diff --git a/packages/bot/src/desiredProperties.ts b/packages/bot/src/desiredProperties.ts index 91efbaeaa..501441dc9 100644 --- a/packages/bot/src/desiredProperties.ts +++ b/packages/bot/src/desiredProperties.ts @@ -493,6 +493,7 @@ export function createDesiredPropertiesObject { return bot.transformers.lobby(bot, snakelize(await bot.rest.unlinkChannelToLobby(lobbyId, bearerToken))); }, + getTargetUsers: async (inviteCode) => { + return await bot.rest.getTargetUsers(inviteCode); + }, + updateTargetUsers: async (inviteCode, targetUsersFile) => { + await bot.rest.updateTargetUsers(inviteCode, targetUsersFile); + }, + getTargetUsersJobStatus: async (inviteCode) => { + return await bot.rest.getTargetUsersJobStatus(inviteCode); + }, // All useless void return functions here addReaction: async (channelId, messageId, reaction) => { return await bot.rest.addReaction(channelId, messageId, reaction); @@ -1119,6 +1129,9 @@ export type BotHelpers Promise>; linkChannelToLobby: (lobbyId: BigString, bearerToken: string, options: LinkChannelToLobby) => Promise>; unlinkChannelToLobby: (lobbyId: BigString, bearerToken: string) => Promise>; + getTargetUsers: (inviteCode: string) => Promise; + updateTargetUsers: (inviteCode: string, targetUsersFile: Blob) => Promise; + getTargetUsersJobStatus: (inviteCode: string) => Promise>; // functions return Void so dont need any special handling addReaction: (channelId: BigString, messageId: BigString, reaction: string) => Promise; addReactions: (channelId: BigString, messageId: BigString, reactions: string[], ordered?: boolean) => Promise; diff --git a/packages/bot/src/transformers/invite.ts b/packages/bot/src/transformers/invite.ts index 42655e7c1..bb9049e40 100644 --- a/packages/bot/src/transformers/invite.ts +++ b/packages/bot/src/transformers/invite.ts @@ -35,6 +35,7 @@ export function transformInvite(bot: Bot, payload: DiscordInviteCreate | Discord invite.expiresAt = Date.parse(payload.expires_at); } if (props.flags && payload.flags) invite.flags = new ToggleBitfield(payload.flags); + if (props.roles && payload.roles) invite.roles = payload.roles.map((role) => bot.transformers.role(bot, role)); } else { if (props.channelId && payload.channel_id) invite.channelId = bot.transformers.snowflake(payload.channel_id); if (props.guildId && payload.guild_id) invite.guildId = bot.transformers.snowflake(payload.guild_id); diff --git a/packages/bot/src/transformers/types.ts b/packages/bot/src/transformers/types.ts index b5b5e9a91..9942a1c74 100644 --- a/packages/bot/src/transformers/types.ts +++ b/packages/bot/src/transformers/types.ts @@ -1142,6 +1142,10 @@ export interface Invite { approximatePresenceCount?: number; /** Guild invite flags for guild invites. */ flags?: ToggleBitfield; + /** + * The roles assigned to the user upon accepting the invite + */ + roles?: Role[]; } export interface Member { diff --git a/packages/rest/src/manager.ts b/packages/rest/src/manager.ts index e463463e4..694aa89f3 100644 --- a/packages/rest/src/manager.ts +++ b/packages/rest/src/manager.ts @@ -48,6 +48,7 @@ import type { DiscordSticker, DiscordStickerPack, DiscordSubscription, + DiscordTargetUsersJobStatus, DiscordTemplate, DiscordThreadMember, DiscordUser, @@ -647,7 +648,7 @@ export function createRestManager(options: CreateRestManagerOptions): RestManage await rest.processRequest(payload); }, resolve: (data) => { - resolve(data.status !== 204 ? (typeof data.body === 'string' ? JSON.parse(data.body) : data.body) : undefined); + resolve(data.status !== 204 ? (data.body as Parameters[0]) : undefined!); }, reject: (reason) => { let errorText: string; @@ -810,7 +811,23 @@ export function createRestManager(options: CreateRestManagerOptions): RestManage }, async createInvite(channelId, body = {}, reason) { - return await rest.post(rest.routes.channels.invites(channelId), { body, reason }); + if (!body.targetUsersFile) { + return await rest.post(rest.routes.channels.invites(channelId), { body, reason }); + } + + // When we have to upload a file, we need to use FormData, and each field has to be appended individually + const form = new FormData(); + + for (const [key, value] of Object.entries(body)) { + if (key !== 'targetUsersFile' && key !== 'roleIds') { + form.append(camelToSnakeCase(key), rest.changeToDiscordFormat(value).toString()); + } + } + + form.append('target_users_file', body.targetUsersFile); + if (body.roleIds) form.append('role_ids', body.roleIds.map((x) => x.toString()).join(',')); + + return await rest.post(rest.routes.channels.invites(channelId), { body: form, reason }); }, async getGuildRoleMemberCounts(guildId) { @@ -889,6 +906,21 @@ export function createRestManager(options: CreateRestManagerOptions): RestManage await rest.delete(rest.routes.guilds.invite(inviteCode), { reason }); }, + async getTargetUsers(inviteCode) { + return await rest.get(rest.routes.guilds.inviteTargetUsers(inviteCode)); + }, + + async updateTargetUsers(inviteCode, targetUsersFile) { + const form = new FormData(); + form.append('target_users_file', targetUsersFile); + + await rest.put(rest.routes.guilds.inviteTargetUsers(inviteCode), { body: form }); + }, + + async getTargetUsersJobStatus(inviteCode) { + return await rest.get(rest.routes.guilds.inviteTargetUsersJobStatus(inviteCode)); + }, + async deleteMessage(channelId, messageId, reason) { await rest.delete(rest.routes.channels.message(channelId, messageId), { reason }); }, diff --git a/packages/rest/src/routes.ts b/packages/rest/src/routes.ts index 78cee44ff..3c574e331 100644 --- a/packages/rest/src/routes.ts +++ b/packages/rest/src/routes.ts @@ -373,6 +373,12 @@ export function createRoutes(disableURIEncode: boolean = false): RestRoutes { return url; }, + inviteTargetUsers(inviteCode) { + return `/invites/${encode(inviteCode)}/target-users`; + }, + inviteTargetUsersJobStatus(inviteCode) { + return `/invites/${encode(inviteCode)}/target-users/job-status`; + }, invites: (guildId) => { return `/guilds/${encode(guildId)}/invites`; }, diff --git a/packages/rest/src/types.ts b/packages/rest/src/types.ts index b7c95d8d8..f2a260823 100644 --- a/packages/rest/src/types.ts +++ b/packages/rest/src/types.ts @@ -79,6 +79,7 @@ import type { DiscordSticker, DiscordStickerPack, DiscordSubscription, + DiscordTargetUsersJobStatus, DiscordTemplate, DiscordThreadMember, DiscordTokenExchange, @@ -809,6 +810,44 @@ export interface RestManager { * @see {@link https://discord.com/developers/docs/resources/channel#delete-channel-invite} */ deleteInvite: (inviteCode: string, reason?: string) => Promise; + /** + * Gets the users allowed to see and accept this invite. + * + * @param inviteCode - The invite code of the invite to update. + * @returns CSV file with a single column `Users` containing the user IDs. + * + * @remarks + * Requires the `MANAGE_GUILD` permission. + * + * @see {@link https://discord.com/developers/docs/resources/invite#get-target-users} + */ + getTargetUsers: (inviteCode: string) => Promise; + /** + * Updates the users allowed to see and accept this invite. + * + * @param inviteCode - The invite code of the invite to update. + * @param targetUsersFile - A CSV file with a single column of user IDs for all the users able to accept this invite + * + * @remarks + * Requires the `MANAGE_GUILD` permission. + * + * Uploading a file with invalid user IDs will result in a 400 with the invalid IDs described. + * + * @see {@link https://discord.com/developers/docs/resources/invite#update-target-users} + */ + updateTargetUsers: (inviteCode: string, targetUsersFile: Blob) => Promise; + /** + * Processing target users from a CSV when creating or updating an invite is done asynchronously. This endpoint allows you to check the status of that job. + * + * @param inviteCode - The invite code of the invite to check the target users job status for. + * @returns An object containing the status of the target users job. + * + * @remarks + * Requires the `MANAGE_GUILD` permission. + * + * @see {@link https://discord.com/developers/docs/resources/invite#get-target-users-job-status} + */ + getTargetUsersJobStatus: (inviteCode: string) => Promise>; /** * Deletes a message from a channel. * diff --git a/packages/rest/src/typings/routes.ts b/packages/rest/src/typings/routes.ts index e693bd779..0990b2092 100644 --- a/packages/rest/src/typings/routes.ts +++ b/packages/rest/src/typings/routes.ts @@ -157,6 +157,10 @@ export interface RestRoutes { integrations: (guildId: BigString) => string; /** Route for handling a specific guild invite. */ invite: (inviteCode: string, options?: GetInvite) => string; + /** Route for a specific invite's target users */ + inviteTargetUsers: (inviteCode: string) => string; + /** Route for a specific invite's target users */ + inviteTargetUsersJobStatus: (inviteCode: string) => string; /** Route for handling non-specific invites in a guild. */ invites: (guildId: BigString) => string; /** Route for handling a bot leaving a guild. */ diff --git a/packages/types/src/discord/invite.ts b/packages/types/src/discord/invite.ts index c64a738dd..dc2e49844 100644 --- a/packages/types/src/discord/invite.ts +++ b/packages/types/src/discord/invite.ts @@ -4,6 +4,7 @@ import type { DiscordApplication } from './application.js'; import type { DiscordChannel } from './channel.js'; import type { DiscordGuild, DiscordMember } from './guild.js'; import type { DiscordScheduledEvent } from './guildScheduledEvent.js'; +import type { DiscordRole } from './permissions.js'; import type { DiscordUser } from './user.js'; /** https://discord.com/developers/docs/resources/invite#invite-object-invite-structure */ @@ -38,6 +39,10 @@ export interface DiscordInvite { * @see {@link DiscordGuildInviteFlags} */ flags?: number; + /** + * The roles assigned to the user upon accepting the invite + */ + roles?: DiscordRole[]; } /** https://discord.com/developers/docs/resources/invite#invite-object-invite-types */ @@ -87,3 +92,30 @@ export interface DiscordInviteStageInstance { /** The topic of the Stage instance (1-120 characters) */ topic: string; } + +/** + * https://discord.com/developers/docs/resources/invite#get-target-users-job-status-example-response + * + * @remarks + * Discord does not seem to actually document the type for this response, so this is based on the example provided + */ +export interface DiscordTargetUsersJobStatus { + status: DiscordGetTargetUsersJobStatusErrorCodes; + total_users: number; + processed_users: number; + created_at: string; + completed_at: string | null; + error_message: string | null; +} + +/** https://discord.com/developers/docs/resources/invite#get-target-users-job-status-error-codes */ +export enum DiscordGetTargetUsersJobStatusErrorCodes { + /** The default value. */ + Unspecified, + /** The job is still being processed. */ + Processing, + /** The job has been completed successfully. */ + Completed, + /** The job has failed, see `error_message` field for more details. */ + Failed, +} diff --git a/packages/types/src/discord/opcodes.ts b/packages/types/src/discord/opcodes.ts index 3629dba87..c2dbd91fe 100644 --- a/packages/types/src/discord/opcodes.ts +++ b/packages/types/src/discord/opcodes.ts @@ -159,6 +159,10 @@ export enum HTTPJsonErrorCodes { UnknownTag = 10087, /** Unknown sound */ UnknownSound = 10097, + /** Unknown invite target users job (invite exists but has no target users) */ + UnknownInviteTargetUsersJob = 10124, + /** Unknown invite target users (invite exists but has no target users) */ + UnknownInviteTargetUsers = 10129, /** Bots cannot use this endpoint */ BotsCannotUseThis = 20001, diff --git a/packages/types/src/discordeno/channel.ts b/packages/types/src/discordeno/channel.ts index aa21ff127..142f353bb 100644 --- a/packages/types/src/discordeno/channel.ts +++ b/packages/types/src/discordeno/channel.ts @@ -300,6 +300,22 @@ export interface CreateChannelInvite { targetUserId?: BigString; /** The id of the embedded application to open for this invite, required if `target_type` is 2, the application must have the `EMBEDDED` flag */ targetApplicationId?: BigString; + /** + * A csv file with a single column of user IDs for all the users able to accept this invite + * + * @remarks + * Requires the `MANAGE_GUILD` permission. + * + * Uploading a file with invalid user IDs will result in a 400 with the invalid IDs described. + */ + targetUsersFile?: Blob; + /** + * The role ID(s) for roles in the guild given to the users that accept this invite + * + * @remarks + * Requires the `MANAGE_ROLES` permission and cannot assign roles with higher permissions than the sender. + */ + roleIds?: BigString[]; } /** https://discord.com/developers/docs/resources/channel#group-dm-add-recipient-json-params */