feat: Community Invites (#4685)

* api-docs: Community Invites

Add support for invites that gives roles to users.
Add support for target users on invites.

Sort-of unrleated change required: `restManager.makeRequest` `resolve` function had to changed or else the `getTargetUsers` would hang forever due to a JSON parsing issue.

* fix type error & add bot helpers
This commit is contained in:
Fleny
2026-01-27 19:08:08 +01:00
committed by GitHub
parent 4eccbea89f
commit bd01ded6bf
11 changed files with 154 additions and 2 deletions

View File

@@ -493,6 +493,7 @@ export function createDesiredPropertiesObject<T extends RecursivePartial<Transfo
guildScheduledEvent: defaultValue,
expiresAt: defaultValue,
flags: defaultValue,
roles: defaultValue,
...desiredProperties.invite,
},
member: {

View File

@@ -46,6 +46,7 @@ import type {
DiscordInviteMetadata,
DiscordListArchivedThreads,
DiscordPrunedCount,
DiscordTargetUsersJobStatus,
DiscordTokenExchange,
DiscordTokenRevocation,
DiscordVanityUrl,
@@ -626,6 +627,15 @@ export function createBotHelpers<TProps extends TransformersDesiredProperties, T
unlinkChannelToLobby: async (lobbyId, bearerToken) => {
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<TProps extends TransformersDesiredProperties, TBehavior e
addMemberToLobby: (lobbyId: BigString, userId: BigString, options: AddLobbyMember) => Promise<SetupDesiredProps<LobbyMember, TProps, TBehavior>>;
linkChannelToLobby: (lobbyId: BigString, bearerToken: string, options: LinkChannelToLobby) => Promise<SetupDesiredProps<Lobby, TProps, TBehavior>>;
unlinkChannelToLobby: (lobbyId: BigString, bearerToken: string) => Promise<SetupDesiredProps<Lobby, TProps, TBehavior>>;
getTargetUsers: (inviteCode: string) => Promise<string>;
updateTargetUsers: (inviteCode: string, targetUsersFile: Blob) => Promise<void>;
getTargetUsersJobStatus: (inviteCode: string) => Promise<Camelize<DiscordTargetUsersJobStatus>>;
// functions return Void so dont need any special handling
addReaction: (channelId: BigString, messageId: BigString, reaction: string) => Promise<void>;
addReactions: (channelId: BigString, messageId: BigString, reactions: string[], ordered?: boolean) => Promise<void>;

View File

@@ -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);

View File

@@ -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 {

View File

@@ -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<typeof resolve>[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<DiscordInvite>(rest.routes.channels.invites(channelId), { body, reason });
if (!body.targetUsersFile) {
return await rest.post<DiscordInvite>(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<DiscordInvite>(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<string>(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<DiscordTargetUsersJobStatus>(rest.routes.guilds.inviteTargetUsersJobStatus(inviteCode));
},
async deleteMessage(channelId, messageId, reason) {
await rest.delete(rest.routes.channels.message(channelId, messageId), { reason });
},

View File

@@ -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`;
},

View File

@@ -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<void>;
/**
* 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<string>;
/**
* 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<void>;
/**
* 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<Camelize<DiscordTargetUsersJobStatus>>;
/**
* Deletes a message from a channel.
*

View File

@@ -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. */

View File

@@ -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,
}

View File

@@ -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,

View File

@@ -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 */