Skip to content
Draft
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
127 changes: 113 additions & 14 deletions packages/discord.js/src/managers/GuildInviteManager.js
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
'use strict';

const { Buffer } = require('node:buffer');
const { Collection } = require('@discordjs/collection');
const { Routes } = require('discord-api-types/v10');
const { DiscordjsError, ErrorCodes } = require('../errors/index.js');
const { GuildInvite } = require('../structures/GuildInvite.js');
const { resolveInviteCode } = require('../util/DataResolver.js');
const { resolveInviteCode, resolveFile } = require('../util/DataResolver.js');
const { CachedManager } = require('./CachedManager.js');

/**
* Manages API methods for GuildInvites and stores their cache.
*
Expand Down Expand Up @@ -104,6 +104,18 @@ class GuildInviteManager extends CachedManager {
* @property {boolean} [cache=true] Whether or not to cache the fetched invites
*/

/**
* Job status for target users of an invite
*
* @typedef {Object} TargetUsersJobStatusForInvite
* @property {InviteTargetUsersJobStatus} status The status of job processing the users
* @property {number} totalUsers The total number of users provided in the list
* @property {number} processedUsers The total number of users processed so far
* @property {Date|null} createdAt The time when the job was created
* @property {Date|null} completedAt The time when the job was successfully completed
* @property {string|null} errorMessage The error message if the job failed
*/

/**
* Fetches invite(s) from Discord.
*
Expand Down Expand Up @@ -191,7 +203,7 @@ class GuildInviteManager extends CachedManager {
/**
* Create an invite to the guild from the provided channel.
*
* @param {GuildInvitableChannelResolvable} channel The options for creating the invite from a channel.
* @param {GuildInvitableChannelResolvable} channel The channel where invite should be created.
* @param {InviteCreateOptions} [options={}] The options for creating the invite from a channel.
* @returns {Promise<GuildInvite>}
* @example
Expand All @@ -202,23 +214,38 @@ class GuildInviteManager extends CachedManager {
*/
async create(
channel,
{ temporary, maxAge, maxUses, unique, targetUser, targetApplication, targetType, reason } = {},
{
temporary,
maxAge,
maxUses,
unique,
targetUser,
targetApplication,
targetType,
roles,
targetUsersFile,
reason,
} = {},
) {
const id = this.guild.channels.resolveId(channel);
if (!id) throw new DiscordjsError(ErrorCodes.GuildChannelResolve);

const options = {
temporary,
max_age: maxAge,
max_uses: maxUses,
unique,
target_user_id: this.client.users.resolveId(targetUser),
target_application_id: targetApplication?.id ?? targetApplication?.applicationId ?? targetApplication,
role_ids: roles?.map(role => this.guild.roles.resolveId(role)),
target_type: targetType,
Comment on lines +232 to +240
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Avoid serializing unresolved role IDs.

roles?.map(...resolveId) can yield null, which becomes null entries in role_ids and can trigger 400s from the API. Filter (or fail fast) before POST.

Proposed fix (filter unresolved IDs)
-      role_ids: roles?.map(role => this.guild.roles.resolveId(role)),
+      role_ids: roles
+        ?.map(role => this.guild.roles.resolveId(role))
+        .filter(Boolean),
🤖 Prompt for AI Agents
In `@packages/discord.js/src/managers/GuildInviteManager.js` around lines 232 -
240, The current construction of options for invite creation (in
GuildInviteManager.js where options.role_ids is built from roles?.map(role =>
this.guild.roles.resolveId(role))) can include null entries which cause API
400s; update the logic that builds role_ids to either filter out falsy/null
resolved IDs (e.g., roles?.map(...).filter(Boolean)) or validate/throw if any
role fails to resolve so you never send nulls to the API, keeping references to
role_ids and this.guild.roles.resolveId in your change.

};
const invite = await this.client.rest.post(Routes.channelInvites(id), {
body: {
temporary,
max_age: maxAge,
max_uses: maxUses,
unique,
target_user_id: this.client.users.resolveId(targetUser),
target_application_id: targetApplication?.id ?? targetApplication?.applicationId ?? targetApplication,
target_type: targetType,
},
body: targetUsersFile ? await this._createInviteFormData({ targetUsersFile, ...options }) : options,
// This is necessary otherwise rest stringifies the body
passThroughBody: Boolean(targetUsersFile),
reason,
});

return new GuildInvite(this.client, invite);
}

Expand All @@ -234,6 +261,78 @@ class GuildInviteManager extends CachedManager {

await this.client.rest.delete(Routes.invite(code), { reason });
}

/**
* Get target users for an invite
*
* @param {InviteResolvable} invite The invite to get the target users
* @returns {Buffer} The csv file containing target users
*/
async fetchTargetUsers(invite) {
const code = resolveInviteCode(invite);
const arrayBuff = await this.client.rest.get(Routes.inviteTargetUsers(code));

return Buffer.from(arrayBuff);
}

/**
* Updates target users for an invite
*
* @param {InviteResolvable} invite The invite to update the target users
* @param {UserResolvable[]|BufferResolvable} targetUsersFile An array of users or a csv file with a single column of user IDs
* for all the users able to accept this invite
* @returns {Promise<unknown>}
*/
async updateTargetUsers(invite, targetUsersFile) {
const code = resolveInviteCode(invite);

return this.client.rest.put(Routes.inviteTargetUsers(code), {
body: await this._createInviteFormData({ targetUsersFile }),
// This is necessary otherwise rest stringifies the body
passThroughBody: true,
});
}

/**
* Get status of the job processing target users of an invite
*
* @param {InviteResolvable} invite The invite to get the target users for
* @returns {TargetUsersJobStatusForInvite[]} The target users
*/
async fetchTargetUsersJobStatus(invite) {
const code = resolveInviteCode(invite);
const job = await this.client.rest.get(Routes.inviteTargetUsersJobStatus(code));
return {
status: job.status,
totalUsers: job.total_users,
processedUsers: job.processed_users,
createdAt: job.created_at ? new Date(job.created_at) : null,
completedAt: job.completed_at ? new Date(job.completed_at) : null,
errorMessage: job.error_message ?? null,
};
}

/**
* Creates form data body payload for invite
*
* @param {InviteCreateOptions} options The options for creating invite
* @returns {Promise<FormData>}
* @private
*/
async _createInviteFormData({ targetUsersFile, ...rest } = {}) {
const formData = new FormData();
let usersCsv;
if (Array.isArray(targetUsersFile)) {
usersCsv = targetUsersFile.map(user => this.client.users.resolveId(user)).join('\n');
} else {
const resolved = await resolveFile(targetUsersFile);
usersCsv = resolved.data.toString('utf8');
}

formData.append('target_users_file', new Blob([usersCsv], { type: 'text/csv' }), 'users.csv');
formData.append('payload_json', JSON.stringify(rest));
return formData;
Comment on lines +322 to +334
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check Node engine requirement
echo "=== Node Engine Requirement ==="
rg -n "engines" packages/discord.js/package.json

echo -e "\n=== FormData/Blob usage in discord.js src ==="
rg -n "FormData\|Blob" packages/discord.js/src

echo -e "\n=== FormData/Blob imports ==="
rg -n "import.*FormData\|import.*Blob\|require.*FormData\|require.*Blob" packages/discord.js/src

echo -e "\n=== undici or polyfill usage ==="
rg -n "undici\|formdata" packages/discord.js/src

Repository: discordjs/discord.js

Length of output: 226


🏁 Script executed:

#!/bin/bash
# Locate and examine resolveId function
echo "=== Finding resolveId implementation ==="
rg -n "resolveId" packages/discord.js/src -A 5 -B 2 | head -100

Repository: discordjs/discord.js

Length of output: 9356


🏁 Script executed:

#!/bin/bash
# Check package.json dependencies for any polyfills
echo "=== Checking for polyfill dependencies ==="
cat packages/discord.js/package.json | jq '.dependencies, .devDependencies' | grep -i "form\|blob\|fetch\|undici"

Repository: discordjs/discord.js

Length of output: 248


🏁 Script executed:

#!/bin/bash
# Get the full engines object from package.json
echo "=== Full engines requirement ==="
cat packages/discord.js/package.json | jq '.engines'

echo -e "\n=== Check GuildInviteManager.js for imports ==="
head -50 packages/discord.js/src/managers/GuildInviteManager.js

Repository: discordjs/discord.js

Length of output: 1479


🏁 Script executed:

#!/bin/bash
# Find BaseManager to understand resolveId behavior
echo "=== BaseManager resolveId implementation ==="
rg -n "class BaseManager" packages/discord.js/src -A 50 | head -100

echo -e "\n=== resolveId implementation in BaseManager ==="
rg -n "resolveId.*{" packages/discord.js/src/managers/BaseManager.js -A 5

Repository: discordjs/discord.js

Length of output: 1265


🏁 Script executed:

#!/bin/bash
# Get the full context of _createInviteFormData method
echo "=== Full _createInviteFormData method ==="
sed -n '322,334p' packages/discord.js/src/managers/GuildInviteManager.js

Repository: discordjs/discord.js

Length of output: 668


🏁 Script executed:

#!/bin/bash
# Find resolveId implementation in BaseManager or UserManager
echo "=== Find resolveId in BaseManager.js ==="
cat packages/discord.js/src/managers/BaseManager.js

echo -e "\n=== Find resolveId in UserManager.js ==="
rg -n "resolveId" packages/discord.js/src/managers/UserManager.js -A 8

Repository: discordjs/discord.js

Length of output: 1807


🏁 Script executed:

#!/bin/bash
# Search for FormData usage to see how it's imported/used across the codebase
echo "=== FormData usage in GuildInviteManager and vicinity ==="
grep -n "FormData\|new Blob" packages/discord.js/src/managers/GuildInviteManager.js

echo -e "\n=== Check if there are any imports for FormData at top of file ==="
head -20 packages/discord.js/src/managers/GuildInviteManager.js | grep -i "form\|blob\|import\|require"

Repository: discordjs/discord.js

Length of output: 1048


🏁 Script executed:

#!/bin/bash
# Get a better understanding of how resolveId behaves in UserManager
echo "=== UserManager resolve/resolveId methods ==="
rg -n "resolve\(" packages/discord.js/src/managers/UserManager.js -B 2 -A 8 | head -80

Repository: discordjs/discord.js

Length of output: 558


🏁 Script executed:

#!/bin/bash
# Check CachedManager and parent classes for the actual resolveId implementation
echo "=== CachedManager.js ==="
cat packages/discord.js/src/managers/CachedManager.js

echo -e "\n=== Check parent class chain in managers ==="
head -30 packages/discord.js/src/managers/CachedManager.js

Repository: discordjs/discord.js

Length of output: 2474


🏁 Script executed:

#!/bin/bash
# See how resolveId is actually implemented in the full chain
rg -n "class.*Manager extends" packages/discord.js/src/managers/CachedManager.js

echo -e "\n=== Look for the actual resolveId implementation ==="
rg -n "resolveId" packages/discord.js/src/managers/CachedManager.js -B 2 -A 6

Repository: discordjs/discord.js

Length of output: 162


🏁 Script executed:

#!/bin/bash
# Check DataManager.js for resolveId implementation
echo "=== DataManager.js ==="
cat packages/discord.js/src/managers/DataManager.js

Repository: discordjs/discord.js

Length of output: 1736


Filter out unresolved user IDs to prevent invalid entries in the CSV.

When resolveId returns null for unresolved users, the mapped array contains null values. Joining these with \n produces the string "null" in the CSV, which is not a valid user ID and can cause the API to reject or silently drop the request. Filter out falsy values to ensure only valid IDs are included.

FormData and Blob are available as stable globals in Node 22.12.0+ (the required minimum version), so no polyfill is needed.

Proposed fix
-      usersCsv = targetUsersFile.map(user => this.client.users.resolveId(user)).join('\n');
+      const userIds = targetUsersFile
+        .map(user => this.client.users.resolveId(user))
+        .filter(Boolean);
+      usersCsv = userIds.join('\n');
🤖 Prompt for AI Agents
In `@packages/discord.js/src/managers/GuildInviteManager.js` around lines 322 -
334, In _createInviteFormData, filter out unresolved user IDs returned by
this.client.users.resolveId when handling an array targetUsersFile so the CSV
doesn't contain "null" entries; replace the current map(...).join with a map ->
filter(Boolean) -> join('\n') pipeline and if the resulting usersCsv is empty
either throw a clear error or handle it consistently (e.g., reject the request)
before appending the Blob and payload_json to the FormData.

}
}

exports.GuildInviteManager = GuildInviteManager;
3 changes: 3 additions & 0 deletions packages/discord.js/src/structures/BaseGuildTextChannel.js
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,9 @@ class BaseGuildTextChannel extends GuildChannel {
* required if `targetType` is {@link InviteTargetType.Stream}, the application must have the
* {@link InviteTargetType.EmbeddedApplication} flag
* @property {InviteTargetType} [targetType] The type of the target for this voice channel invite
* @property {UserResolvable[]|BufferResolvable} [targetUsersFile] An array of users or a csv file with a single column of user IDs
* for all the users able to accept this invite
* @property {RoleResolvable[]} [roles] The roles in the guild given to users that accept this invite
* @property {string} [reason] The reason for creating the invite
*/

Expand Down
43 changes: 43 additions & 0 deletions packages/discord.js/src/structures/GuildInvite.js
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
'use strict';

const { Collection } = require('@discordjs/collection');
const { Routes, PermissionFlagsBits, InviteType } = require('discord-api-types/v10');
const { DiscordjsError, ErrorCodes } = require('../errors/index.js');
const { InviteFlagsBitField } = require('../util/InviteFlagsBitField.js');
const { BaseInvite } = require('./BaseInvite.js');
const { GuildScheduledEvent } = require('./GuildScheduledEvent.js');
const { IntegrationApplication } = require('./IntegrationApplication.js');
const { InviteGuild } = require('./InviteGuild.js');
const { Role } = require('./Role.js');

/**
* A channel invite leading to a guild.
Expand Down Expand Up @@ -178,6 +180,17 @@ class GuildInvite extends BaseInvite {
} else {
this.approximatePresenceCount ??= null;
}

if ('roles' in data) {
/**
* The roles assigned to the user upon accepting the invite.
*
* @type {Collection|null}
*/
this.roles = new Collection(data.roles.map(role => [role.id, new Role(this.client, data, this.guild)]));
} else {
this.roles ??= null;
}
}

/**
Expand Down Expand Up @@ -206,6 +219,35 @@ class GuildInvite extends BaseInvite {
await this.client.rest.delete(Routes.invite(this.code), { reason });
}

/**
* Update target users of this invite.
*
* @param {UserResolvable[]|BufferResolvable} targetUsersFile An array of users or a csv file with a single column of user IDs
* for all the users able to accept this invite
* @returns {Promes<unknown>}
*/
updateTargetUsers(targetUsersFile) {
return this.guild.invites.updateTargetUsers(this.code, targetUsersFile);
}

/**
* Get target users of this invite
*
* @returns {Promise<Buffer>}
*/
fetchTargetUsers() {
return this.guild.invites.fetchTargetUsers(this.code);
}

/**
* Get status of the job processing target users of this invite
*
* @returns {Promise<TargetUsersJobStatusForInvite>}
*/
fetchTargetUsersJobStatus() {
return this.guild.invites.fetchTargetUsersJobStatus(this.code);
}

toJSON() {
return super.toJSON({
url: true,
Expand All @@ -216,6 +258,7 @@ class GuildInvite extends BaseInvite {
channel: 'channelId',
inviter: 'inviterId',
guild: 'guildId',
roles: 'roles',
});
}
}
Expand Down
5 changes: 5 additions & 0 deletions packages/discord.js/src/util/APITypes.js
Original file line number Diff line number Diff line change
Expand Up @@ -519,6 +519,11 @@
* @see {@link https://discord-api-types.dev/api/discord-api-types-v10/enum/InviteFlags}
*/

/**
* @external InviteTargetUsersJobStatus
* @see {@link https://discord-api-types.dev/api/discord-api-types-v10/enum/InviteTargetUsersJobStatus}
*/

/**
* @external InviteType
* @see {@link https://discord-api-types.dev/api/discord-api-types-v10/enum/InviteType}
Expand Down
22 changes: 22 additions & 0 deletions packages/discord.js/typings/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,7 @@ import {
InteractionType,
InviteFlags,
InviteTargetType,
InviteTargetUsersJobStatus,
InviteType,
Locale,
LocalizationMap,
Expand Down Expand Up @@ -2060,6 +2061,7 @@ export class GuildInvite<WithCounts extends boolean = boolean> extends BaseInvit
public guild: Guild | InviteGuild | null;
public readonly guildId: Snowflake;
public channel: NonThreadGuildBasedChannel | null;
public roles: Collection<Snowflake, Role> | null;
public targetType: InviteTargetType | null;
public targetUser: User | null;
public targetApplication: IntegrationApplication | null;
Expand All @@ -2070,6 +2072,9 @@ export class GuildInvite<WithCounts extends boolean = boolean> extends BaseInvit
public approximatePresenceCount: WithCounts extends true ? number : null;
public get deletable(): boolean;
public delete(reason?: string): Promise<void>;
public updateTargetUsers(targetUsersFile: BufferResolvable | readonly UserResolvable[]): Promise<unknown>;
public fetchTargetUsersJobStatus(): Promise<TargetUsersJobStatusForInvite>;
public fetchTargetUsers(): Promise<Buffer>;
}

export type InviteFlagsString = keyof typeof InviteFlags;
Expand Down Expand Up @@ -4513,13 +4518,28 @@ export class GuildBanManager extends CachedManager<Snowflake, GuildBan, GuildBan
): Promise<BulkBanResult>;
}

export interface TargetUsersJobStatusForInvite {
completedAt: Date | null;
createdAt: Date | null;
errorMessage: string | null;
processedUsers: number;
status: InviteTargetUsersJobStatus;
totalUsers: number;
}
export class GuildInviteManager extends DataManager<string, GuildInvite, GuildInviteResolvable> {
private constructor(guild: Guild, iterable?: Iterable<unknown>);
public guild: Guild;
public create(channel: GuildInvitableChannelResolvable, options?: InviteCreateOptions): Promise<GuildInvite>;
public updateTargetUsers(
invite: InviteResolvable,
targetUsersFile: BufferResolvable | readonly UserResolvable[],
): Promise<unknown>;
public fetchTargetUsers(invite: InviteResolvable): Promise<Buffer>;
public fetchTargetUsersJobStatus(invite: InviteResolvable): Promise<TargetUsersJobStatusForInvite>;
public fetch(options: FetchInviteOptions | InviteResolvable): Promise<GuildInvite>;
public fetch(options?: FetchInvitesOptions): Promise<Collection<string, GuildInvite>>;
public delete(invite: InviteResolvable, reason?: string): Promise<void>;
private _createInviteFormData(options: InviteCreateOptions): Promise<FormData>;
}

export class GuildScheduledEventManager extends CachedManager<
Expand Down Expand Up @@ -6582,9 +6602,11 @@ export interface InviteCreateOptions {
maxAge?: number;
maxUses?: number;
reason?: string;
roles?: readonly RoleResolvable[];
targetApplication?: ApplicationResolvable;
targetType?: InviteTargetType;
targetUser?: UserResolvable;
targetUsersFile?: BufferResolvable | readonly UserResolvable[];
temporary?: boolean;
unique?: boolean;
}
Expand Down
Loading