diff --git a/packages/discord.js/src/structures/BaseInteraction.js b/packages/discord.js/src/structures/BaseInteraction.js index 09e717bc9ead..6a19d78613c2 100644 --- a/packages/discord.js/src/structures/BaseInteraction.js +++ b/packages/discord.js/src/structures/BaseInteraction.js @@ -5,6 +5,7 @@ const { Collection } = require('@discordjs/collection'); const { DiscordSnowflake } = require('@sapphire/snowflake'); const { InteractionType, ApplicationCommandType, ComponentType } = require('discord-api-types/v10'); const Base = require('./Base'); +const { UncachedGuildMember } = require('./UncachedGuildMember'); const { SelectMenuTypes } = require('../util/Constants'); const PermissionsBitField = require('../util/PermissionsBitField'); @@ -65,7 +66,9 @@ class BaseInteraction extends Base { * If this interaction was sent in a guild, the member which sent it * @type {?(GuildMember|APIInteractionGuildMember)} */ - this.member = data.member ? this.guild?.members._add(data.member) ?? data.member : null; + this.member = data.member + ? this.guild?.members._add(data.member) ?? new UncachedGuildMember(this.client, data.member, this.guildId) + : null; /** * The version diff --git a/packages/discord.js/src/structures/CommandInteraction.js b/packages/discord.js/src/structures/CommandInteraction.js index 0d435deeb446..91c04893a84e 100644 --- a/packages/discord.js/src/structures/CommandInteraction.js +++ b/packages/discord.js/src/structures/CommandInteraction.js @@ -3,6 +3,7 @@ const Attachment = require('./Attachment'); const BaseInteraction = require('./BaseInteraction'); const InteractionWebhook = require('./InteractionWebhook'); +const { UncachedGuildMember } = require('./UncachedGuildMember'); const InteractionResponses = require('./interfaces/InteractionResponses'); /** @@ -129,7 +130,11 @@ class CommandInteraction extends BaseInteraction { if (user) result.user = this.client.users._add(user); const member = resolved.members?.[option.value]; - if (member) result.member = this.guild?.members._add({ user, ...member }) ?? member; + if (member) { + result.member = + this.guild?.members._add({ user, ...member }) ?? + new UncachedGuildMember(this.client, { user, ...member }, this.guildId); + } const channel = resolved.channels?.[option.value]; if (channel) result.channel = this.client.channels._add(channel, this.guild) ?? channel; diff --git a/packages/discord.js/src/structures/GuildMember.js b/packages/discord.js/src/structures/GuildMember.js index 2a39f3f59940..0ed966ac6d2c 100644 --- a/packages/discord.js/src/structures/GuildMember.js +++ b/packages/discord.js/src/structures/GuildMember.js @@ -372,7 +372,7 @@ class GuildMember extends Base { } /** - * Deletes any DMs with this member. + * Deletes a DM channel (if one exists) between the client and the member. Resolves with the channel if successful. * @returns {Promise} */ deleteDM() { diff --git a/packages/discord.js/src/structures/UncachedGuildMember.js b/packages/discord.js/src/structures/UncachedGuildMember.js new file mode 100644 index 000000000000..06b6311a14f7 --- /dev/null +++ b/packages/discord.js/src/structures/UncachedGuildMember.js @@ -0,0 +1,328 @@ +'use strict'; + +const Base = require('./Base'); +const TextBasedChannel = require('./interfaces/TextBasedChannel'); +const { GuildMemberFlagsBitField } = require('../util/GuildMemberFlagsBitField'); +const PermissionsBitField = require('../util/PermissionsBitField'); + +/** + * Represents a member of a guild on Discord. Used in interactions from guilds that aren't cached. + * Backwards compatible with {@link APIGuildMember}. + * @implements {TextBasedChannel} + * @extends {Base} + */ +class UncachedGuildMember extends Base { + constructor(client, data, guildId) { + super(client); + + /** + * The ID of the guild that this member is part of + * @type {string} + */ + this.guildId = guildId; + + /** + * The user that this guild member instance represents + * @type {User} + */ + this.user = this.client.users._add(data.user, true); + + /** + * The nickname of this member, if they have one + * @type {?string} + */ + this.nickname = data.nick; + + /** + * The guild member's avatar hash + * @type {?string} + */ + this.avatar = data.avatar; + + /** + * The role ids of the member + * @name UncachedGuildMember#roleIds + * @type {Snowflake[]} + */ + this.roleIds = data.roles; + + /** + * The timestamp the member joined the guild at + * @type {number} + */ + this.joinedTimestamp = Date.parse(data.joined_at); + + /** + * The timestamp the member joined the guild at, as an ISO8601 timestamp + * @type {string} + * @deprecated Use {@link UncachedGuildMember#joinedTimestamp} or {@link UncachedGuildMember#joinedAt} instead + */ + this.joined_at = data.joined_at; + + /** + * The last timestamp this member started boosting the guild + * @type {?number} + */ + this.premiumSinceTimestamp = data.premium_since ? Date.parse(data.premium_since) : null; + + /** + * The last timestamp this member started boosting the guild, as an ISO8601 timestamp + * @type {?string} + * @deprecated Use {@link UncachedGuildMember#premiumSinceTimestamp} + * or {@link UncachedGuildMember#premiumSince} instead + */ + this.premium_since = data.premium_since; + + /** + * Whether the user is deafened in voice channels + * @type {boolean | undefined} + */ + this.deaf = data.deaf; + + /** + * Whether the user is muted in voice channels + * @type {boolean | undefined} + */ + this.mute = data.mute; + + /** + * The flags of this member + * @type {Readonly} + */ + this.parsedFlags = new GuildMemberFlagsBitField(data.flags).freeze(); + + /** + * The raw flags of this member + * @type {number} + * @deprecated Use {@link UncachedGuildMember#parsedFlags} instead. + * This field will be replaced with parsedFlags in the future. + */ + this.flags = data.flags; + + /** + * Whether this member has yet to pass the guild's membership gate + * @type {?boolean} + */ + this.pending = data.pending; + + /** + * The total permissions of the member in this channel, including overwrites + * @type {Readonly} + */ + this.parsedPermissions = new PermissionsBitField(data.permissions).freeze(); + + /** + * Raw total permissions of the member in this channel, including overwrites + * @type {?string} + * @deprecated Use {@link UncachedGuildMember#parsedPermissions} instead. + * This field will be replaced with parsedPermissions in the future. + */ + this.permissions = data.permissions; + + /** + * The timestamp this member's timeout will be removed + * @type {?number} + */ + this.communicationDisabledUntilTimestamp = + data.communication_disabled_until && Date.parse(data.communication_disabled_until); + + /** + * The timestamp this member's timeout will be removed, as an ISO8601 timestamp + * @type {?string} + * @deprecated Use {@link UncachedGuildMember#communicationDisabledUntilTimestamp} + * or {@link UncachedGuildMember#communicationDisabledUntil} instead + */ + this.communication_disabled_until = data.communication_disabled_until; + + /** + * Whether this UncachedGuildMember is a partial (always true, as it is a partial GuildMember) + * @type {boolean} + * @readonly + */ + this.partial = true; + } + + /** + * A link to the member's guild avatar. + * @param {ImageURLOptions} [options={}] Options for the image URL + * @returns {?string} + */ + avatarURL(options = {}) { + return this.avatar && this.client.rest.cdn.guildMemberAvatar(this.guildId, this.id, this.avatar, options); + } + + /** + * A link to the member's guild avatar if they have one. + * Otherwise, a link to their {@link User#displayAvatarURL} will be returned. + * @param {ImageURLOptions} [options={}] Options for the Image URL + * @returns {string} + */ + displayAvatarURL(options) { + return this.avatarURL(options) ?? this.user.displayAvatarURL(options); + } + + /** + * The time this member joined the guild + * @type {?Date} + * @readonly + */ + get joinedAt() { + return this.joinedTimestamp && new Date(this.joinedTimestamp); + } + + /** + * 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} + * @readonly + */ + get premiumSince() { + return this.premiumSinceTimestamp && new Date(this.premiumSinceTimestamp); + } + + /** + * The member's id + * @type {Snowflake} + * @readonly + */ + get id() { + return this.user.id; + } + + /** + * The DM between the client's user and this member + * @type {?DMChannel} + * @readonly + */ + get dmChannel() { + return this.client.users.dmChannel(this.id); + } + + /** + * The nickname of this member, or their user display name if they don't have one + * @type {?string} + * @readonly + */ + get displayName() { + return this.nickname ?? this.user.displayName; + } + + /** + * The nickname of the member + * @name UncachedGuildMember#nick + * @type {?string} + * @deprecated Use {@link UncachedGuildMember#nickname} instead + */ + get nick() { + return this.nickname; + } + + /** + * The role ids of the member + * @name UncachedGuildMember#roles + * @type {Snowflake[]} + * @deprecated Use {@link UncachedGuildMember#roleIds} instead + */ + get roles() { + return this.roleIds; + } + + /** + * Whether this member is currently timed out + * @returns {boolean} + */ + isCommunicationDisabled() { + return this.communicationDisabledUntilTimestamp > Date.now(); + } + + /** + * Creates a DM channel between the client and this member. + * @param {boolean} [force=false] Whether to skip the cache check and request the API + * @returns {Promise} + */ + createDM(force = false) { + return this.user.createDM(force); + } + + /** + * Deletes a DM channel (if one exists) between the client and the member. Resolves with the channel if successful. + * @returns {Promise} + */ + deleteDM() { + return this.user.deleteDM(); + } + + /** + * Whether this guild member equals another guild member. It compares all properties, so for most + * comparison it is advisable to just compare `member.id === member2.id` as it is significantly faster + * and is often what most users need. + * @param {UncachedGuildMember} member The member to compare with + * @returns {boolean} + */ + equals(member) { + return ( + member instanceof this.constructor && + this.id === member.id && + this.partial === member.partial && + this.guildId === member.guildId && + this.joinedTimestamp === member.joinedTimestamp && + this.nickname === member.nickname && + this.avatar === member.avatar && + this.pending === member.pending && + this.communicationDisabledUntilTimestamp === member.communicationDisabledUntilTimestamp && + this.parsedFlags.bitfield === member.parsedFlags.bitfield && + (this.roleIds === member.roleIds || + (this.roleIds.length === member.roleIds.length && this.roleIds.every((role, i) => role === member.roleIds[i]))) + ); + } + + /** + * When concatenated with a string, this automatically returns the user's mention + * instead of the UncachedGuildMember object. + * @returns {string} + * @example + * // Logs: Hello from <@123456789012345678>! + * console.log(`Hello from ${member}!`); + */ + toString() { + return this.user.toString(); + } + + toJSON() { + const json = super.toJSON({ + guildId: true, + user: 'userId', + displayName: true, + roles: true, + }); + json.avatarURL = this.avatarURL(); + json.displayAvatarURL = this.displayAvatarURL(); + return json; + } +} + +/** + * Sends a message to this user. + * @method send + * @memberof UncachedGuildMember + * @instance + * @param {string|MessagePayload|MessageCreateOptions} options The options to provide + * @returns {Promise} + * @example + * // Send a direct message + * UncachedGuildMember.send('Hello!') + * .then(message => console.log(`Sent message: ${message.content} to ${guildMember.displayName}`)) + * .catch(console.error); + */ + +TextBasedChannel.applyToClass(UncachedGuildMember); + +module.exports = UncachedGuildMember; diff --git a/packages/discord.js/src/structures/interfaces/TextBasedChannel.js b/packages/discord.js/src/structures/interfaces/TextBasedChannel.js index ea914465520b..b960b1dd05d7 100644 --- a/packages/discord.js/src/structures/interfaces/TextBasedChannel.js +++ b/packages/discord.js/src/structures/interfaces/TextBasedChannel.js @@ -141,8 +141,9 @@ class TextBasedChannel { async send(options) { const User = require('../User'); const { GuildMember } = require('../GuildMember'); + const { UncachedGuildMember } = require('../UncachedGuildMember'); - if (this instanceof User || this instanceof GuildMember) { + if (this instanceof User || this instanceof GuildMember || this instanceof UncachedGuildMember) { const dm = await this.createDM(); return dm.send(options); } diff --git a/packages/discord.js/typings/index.d.ts b/packages/discord.js/typings/index.d.ts index aa96d1a9136b..02e47a263bf4 100644 --- a/packages/discord.js/typings/index.d.ts +++ b/packages/discord.js/typings/index.d.ts @@ -1642,6 +1642,46 @@ export class GuildMember extends Base { public valueOf(): string; } +export interface UncachedGuildMember extends PartialTextBasedChannelFields {} +export class UncachedGuildMember extends Base { + private constructor( + client: Client, + data: APIInteractionGuildMember | APIInteractionDataResolvedGuildMember, + guildId: Snowflake, + ); + public avatar: string | null; + public get dmChannel(): DMChannel | null; + public get displayName(): string; + public guildId: string; + public get id(): Snowflake; + public pending: boolean; + public get communicationDisabledUntil(): Date | null; + public communicationDisabledUntilTimestamp: number | null; + public parsedFlags: Readonly; + public get joinedAt(): Date | null; + public joinedTimestamp: number | null; + public nickname: string | null; + public mute?: boolean; + public deaf?: boolean; + public partial: true; + public parsedPermissions: Readonly; + public get premiumSince(): Date | null; + public premiumSinceTimestamp: number | null; + public roleIds: Snowflake[]; + public user: User; + public avatarURL(options?: ImageURLOptions): string | null; + public createDM(force?: boolean): Promise; + public deleteDM(): Promise; + public displayAvatarURL(options?: ImageURLOptions): string; + public isCommunicationDisabled(): this is GuildMember & { + communicationDisabledUntilTimestamp: number; + readonly communicationDisabledUntil: Date; + }; + public toJSON(): unknown; + public toString(): UserMention; + public valueOf(): string; +} + export class GuildOnboarding extends Base { private constructor(client: Client, data: RESTGetAPIGuildOnboardingResult); public get guild(): Guild;