diff --git a/src/managers/GuildMemberManager.js b/src/managers/GuildMemberManager.js index ef85790d2328..8e8e4ec07b96 100644 --- a/src/managers/GuildMemberManager.js +++ b/src/managers/GuildMemberManager.js @@ -223,6 +223,19 @@ class GuildMemberManager extends CachedManager { return data.reduce((col, member) => col.set(member.user.id, this._add(member, cache)), new Collection()); } + /** + * The data for editing a guild member. + * @typedef {Object} GuildMemberEditData + * @property {?string} [nick] The nickname to set for the member + * @property {Collection|RoleResolvable[]} [roles] The roles or role ids to apply + * @property {boolean} [mute] Whether or not the member should be muted + * @property {boolean} [deaf] Whether or not the member should be deafened + * @property {GuildVoiceChannelResolvable|null} [channel] Channel to move the member to + * (if they are connected to voice), or `null` if you want to disconnect them from voice + * @property {DateResolvable|null} [communicationDisabledUntil] The date or timestamp + * for the member's communication to be disabled until. Provide `null` to enable communication again. + */ + /** * Edits a member of the guild. * The user must be a member of the guild @@ -249,6 +262,10 @@ class GuildMemberManager extends CachedManager { _data.channel = undefined; } _data.roles &&= _data.roles.map(role => (role instanceof Role ? role.id : role)); + + _data.communication_disabled_until = + _data.communicationDisabledUntil && new Date(_data.communicationDisabledUntil).toISOString(); + let endpoint = this.client.api.guilds(this.guild.id); if (id === this.client.user.id) { const keys = Object.keys(_data); diff --git a/src/structures/BaseGuildVoiceChannel.js b/src/structures/BaseGuildVoiceChannel.js index b3ef2f8b8127..e048eb3e6c34 100644 --- a/src/structures/BaseGuildVoiceChannel.js +++ b/src/structures/BaseGuildVoiceChannel.js @@ -68,8 +68,16 @@ class BaseGuildVoiceChannel extends GuildChannel { */ get joinable() { if (!this.viewable) return false; - if (!this.permissionsFor(this.client.user).has(Permissions.FLAGS.CONNECT, false)) return false; - return true; + const permissions = this.permissionsFor(this.client.user); + if (!permissions) return false; + + // This flag allows joining even if timed out + if (permissions.has(Permissions.FLAGS.ADMINISTRATOR, false)) return true; + + return ( + this.guild.me.communicationDisabledUntilTimestamp < Date.now() && + permissions.has(Permissions.FLAGS.CONNECT, false) + ); } /** diff --git a/src/structures/GuildChannel.js b/src/structures/GuildChannel.js index fe1a246fcc86..03e86d3a429a 100644 --- a/src/structures/GuildChannel.js +++ b/src/structures/GuildChannel.js @@ -510,6 +510,11 @@ class GuildChannel extends Channel { if (this.client.user.id === this.guild.ownerId) return true; const permissions = this.permissionsFor(this.client.user); if (!permissions) return false; + + // This flag allows managing even if timed out + if (permissions.has(Permissions.FLAGS.ADMINISTRATOR, false)) return true; + if (this.guild.me.communicationDisabledUntilTimestamp > Date.now()) return false; + const bitfield = VoiceBasedChannelTypes.includes(this.type) ? Permissions.FLAGS.MANAGE_CHANNELS | Permissions.FLAGS.CONNECT : Permissions.FLAGS.VIEW_CHANNEL | Permissions.FLAGS.MANAGE_CHANNELS; diff --git a/src/structures/GuildMember.js b/src/structures/GuildMember.js index b91110a11624..58f1f463f98e 100644 --- a/src/structures/GuildMember.js +++ b/src/structures/GuildMember.js @@ -54,6 +54,12 @@ class GuildMember extends Base { */ this.pending = false; + /** + * The timestamp this member's timeout will be removed + * @type {?number} + */ + this.communicationDisabledUntilTimestamp = null; + this._roles = []; if (data) this._patch(data); } @@ -83,6 +89,11 @@ class GuildMember extends Base { } if ('roles' in data) this._roles = data.roles; this.pending = data.pending ?? false; + + if ('communication_disabled_until' in data) { + this.communicationDisabledUntilTimestamp = + data.communication_disabled_until && Date.parse(data.communication_disabled_until); + } } _clone() { @@ -177,6 +188,15 @@ class GuildMember extends Base { return this.joinedTimestamp ? new Date(this.joinedTimestamp) : null; } + /** + * The time this member's timeout will be removed + * @type {?Date} + * @readonly + */ + get communicationDisabledUntil() { + return this.communicationDisabledUntilTimestamp && new Date(this.communicationDisabledUntilTimestamp); + } + /** * The last time this member started boosting the guild * @type {?Date} @@ -273,6 +293,15 @@ class GuildMember extends Base { return this.manageable && this.guild.me.permissions.has(Permissions.FLAGS.BAN_MEMBERS); } + /** + * Whether this member is moderatable by the client user + * @type {boolean} + * @readonly + */ + get moderatable() { + return this.manageable && (this.guild.me?.permissions.has(Permissions.FLAGS.MODERATE_MEMBERS) ?? false); + } + /** * Returns `channel.permissionsFor(guildMember)`. Returns permissions for a member in a guild channel, * taking into account roles and permission overwrites. @@ -285,17 +314,6 @@ class GuildMember extends Base { return channel.permissionsFor(this); } - /** - * The data for editing a guild member. - * @typedef {Object} GuildMemberEditData - * @property {?string} [nick] The nickname to set for the member - * @property {Collection|RoleResolvable[]} [roles] The roles or role ids to apply - * @property {boolean} [mute] Whether or not the member should be muted - * @property {boolean} [deaf] Whether or not the member should be deafened - * @property {GuildVoiceChannelResolvable|null} [channel] Channel to move the member to - * (if they are connected to voice), or `null` if you want to disconnect them from voice - */ - /** * Edits this member. * @param {GuildMemberEditData} data The data to edit the member with @@ -356,6 +374,38 @@ class GuildMember extends Base { return this.guild.members.ban(this, options); } + /** + * Times this guild member out. + * @param {DateResolvable|null} communicationDisabledUntil The date or timestamp + * for the member's communication to be disabled until. Provide `null` to remove the timeout. + * @param {string} [reason] The reason for this timeout. + * @returns {Promise} + * @example + * // Time a guild member out for 5 minutes + * guildMember.disableCommunicationUntil(Date.now() + (5 * 60 * 1000), 'They deserved it') + * .then(console.log) + * .catch(console.error); + */ + disableCommunicationUntil(communicationDisabledUntil, reason) { + return this.edit({ communicationDisabledUntil }, reason); + } + + /** + * Times this guild member out. + * @param {number|null} timeout The time in milliseconds + * for the member's communication to be disabled until. Provide `null` to remove the timeout. + * @param {string} [reason] The reason for this timeout. + * @returns {Promise} + * @example + * // Time a guild member out for 5 minutes + * guildMember.timeout(5 * 60 * 1000, 'They deserved it') + * .then(console.log) + * .catch(console.error); + */ + timeout(timeout, reason) { + return this.disableCommunicationUntil(timeout && Date.now() + timeout, reason); + } + /** * Fetches this GuildMember. * @param {boolean} [force=true] Whether to skip the cache check and request the API @@ -382,6 +432,7 @@ class GuildMember extends Base { this.nickname === member.nickname && this.avatar === member.avatar && this.pending === member.pending && + this.communicationDisabledUntilTimestamp === member.communicationDisabledUntilTimestamp && (this._roles === member._roles || (this._roles.length === member._roles.length && this._roles.every((role, i) => role === member._roles[i]))) ); diff --git a/src/structures/Message.js b/src/structures/Message.js index dc29d5cbaf82..59dac9894db0 100644 --- a/src/structures/Message.js +++ b/src/structures/Message.js @@ -610,9 +610,16 @@ class Message extends Base { if (!this.channel?.viewable) { return false; } + + const permissions = this.channel?.permissionsFor(this.client.user); + if (!permissions) return false; + // This flag allows deleting even if timed out + if (permissions.has(Permissions.FLAGS.ADMINISTRATOR, false)) return true; + return Boolean( this.author.id === this.client.user.id || - this.channel?.permissionsFor(this.client.user)?.has(Permissions.FLAGS.MANAGE_MESSAGES, false), + (permissions.has(Permissions.FLAGS.MANAGE_MESSAGES, false) && + this.guild.me.communicationDisabledUntilTimestamp < Date.now()), ); } diff --git a/src/structures/ThreadChannel.js b/src/structures/ThreadChannel.js index 9a0f5c634500..7c45cbc327dd 100644 --- a/src/structures/ThreadChannel.js +++ b/src/structures/ThreadChannel.js @@ -439,7 +439,15 @@ class ThreadChannel extends Channel { * @readonly */ get manageable() { - return this.permissionsFor(this.client.user)?.has(Permissions.FLAGS.MANAGE_THREADS, false); + const permissions = this.permissionsFor(this.client.user); + if (!permissions) return false; + // This flag allows managing even if timed out + if (permissions.has(Permissions.FLAGS.ADMINISTRATOR, false)) return true; + + return ( + this.guild.me.communicationDisabledUntilTimestamp < Date.now() && + permissions.has(Permissions.FLAGS.MANAGE_THREADS, false) + ); } /** @@ -460,11 +468,16 @@ class ThreadChannel extends Channel { * @readonly */ get sendable() { + const permissions = this.permissionsFor(this.client.user); + if (!permissions) return false; + // This flag allows sending even if timed out + if (permissions.has(Permissions.FLAGS.ADMINISTRATOR, false)) return true; + return ( - (!(this.archived && this.locked && !this.manageable) && - (this.type !== 'GUILD_PRIVATE_THREAD' || this.joined || this.manageable) && - this.permissionsFor(this.client.user)?.has(Permissions.FLAGS.SEND_MESSAGES_IN_THREADS, false)) ?? - false + !(this.archived && this.locked && !this.manageable) && + (this.type !== 'GUILD_PRIVATE_THREAD' || this.joined || this.manageable) && + permissions.has(Permissions.FLAGS.SEND_MESSAGES_IN_THREADS, false) && + this.guild.me.communicationDisabledUntilTimestamp < Date.now() ); } diff --git a/src/structures/VoiceChannel.js b/src/structures/VoiceChannel.js index 9b5850788b61..7327c4879925 100644 --- a/src/structures/VoiceChannel.js +++ b/src/structures/VoiceChannel.js @@ -46,7 +46,14 @@ class VoiceChannel extends BaseGuildVoiceChannel { * @readonly */ get speakable() { - return this.permissionsFor(this.client.user).has(Permissions.FLAGS.SPEAK, false); + const permissions = this.permissionsFor(this.client.user); + if (!permissions) return false; + // This flag allows speaking even if timed out + if (permissions.has(Permissions.FLAGS.ADMINISTRATOR, false)) return true; + + return ( + this.guild.me.communicationDisabledUntilTimestamp < Date.now() && permissions.has(Permissions.FLAGS.SPEAK, false) + ); } /** diff --git a/src/util/Permissions.js b/src/util/Permissions.js index b59497fd0ba7..b44e5539470d 100644 --- a/src/util/Permissions.js +++ b/src/util/Permissions.js @@ -98,6 +98,7 @@ class Permissions extends BitField { * * `USE_EXTERNAL_STICKERS` (use stickers from different guilds) * * `SEND_MESSAGES_IN_THREADS` * * `START_EMBEDDED_ACTIVITIES` + * * `MODERATE_MEMBERS` * @type {Object} * @see {@link https://discord.com/developers/docs/topics/permissions#permissions-bitwise-permission-flags} */ @@ -144,6 +145,7 @@ Permissions.FLAGS = { USE_EXTERNAL_STICKERS: 1n << 37n, SEND_MESSAGES_IN_THREADS: 1n << 38n, START_EMBEDDED_ACTIVITIES: 1n << 39n, + MODERATE_MEMBERS: 1n << 40n, }; /** diff --git a/typings/index.d.ts b/typings/index.d.ts index 41877c252c44..a48681ce52aa 100644 --- a/typings/index.d.ts +++ b/typings/index.d.ts @@ -1096,10 +1096,13 @@ export class GuildMember extends PartialTextBasedChannel(Base) { public guild: Guild; public readonly id: Snowflake; public pending: boolean; + public readonly communicationDisabledUntil: Date | null; + public communicationDisabledUntilTimestamp: number | null; public readonly joinedAt: Date | null; public joinedTimestamp: number | null; public readonly kickable: boolean; public readonly manageable: boolean; + public readonly moderatable: boolean; public nickname: string | null; public readonly partial: false; public readonly permissions: Readonly; @@ -1111,6 +1114,8 @@ export class GuildMember extends PartialTextBasedChannel(Base) { public readonly voice: VoiceState; public avatarURL(options?: ImageURLOptions): string | null; public ban(options?: BanOptions): Promise; + public disableCommunicationUntil(timeout: DateResolvable | null, reason?: string): Promise; + public timeout(timeout: number | null, reason?: string): Promise; public fetch(force?: boolean): Promise; public createDM(force?: boolean): Promise; public deleteDM(): Promise; @@ -4590,6 +4595,7 @@ export interface GuildMemberEditData { mute?: boolean; deaf?: boolean; channel?: GuildVoiceChannelResolvable | null; + communicationDisabledUntil?: DateResolvable | null; } export type GuildMemberResolvable = GuildMember | UserResolvable; @@ -5080,7 +5086,8 @@ export type PermissionString = | 'CREATE_PRIVATE_THREADS' | 'USE_EXTERNAL_STICKERS' | 'SEND_MESSAGES_IN_THREADS' - | 'START_EMBEDDED_ACTIVITIES'; + | 'START_EMBEDDED_ACTIVITIES' + | 'MODERATE_MEMBERS'; export type RecursiveArray = ReadonlyArray>;