Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(Interactions): member objects for uncached guilds #10195

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
5 changes: 4 additions & 1 deletion packages/discord.js/src/structures/BaseInteraction.js
Expand Up @@ -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');

Expand Down Expand Up @@ -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
Expand Down
7 changes: 6 additions & 1 deletion packages/discord.js/src/structures/CommandInteraction.js
Expand Up @@ -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');

/**
Expand Down Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion packages/discord.js/src/structures/GuildMember.js
Expand Up @@ -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<DMChannel>}
*/
deleteDM() {
Expand Down
328 changes: 328 additions & 0 deletions 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);
Copy link
Contributor

Choose a reason for hiding this comment

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

Isn’t this a breaking change? APIInteractionGuildMember#user is APIUser and thus has snakecase properties. Now it doesn’t anymore.

Copy link
Member

Choose a reason for hiding this comment

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

Yep, that is a breaking change indeed... @advaith1 can you solve that too?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

ugh, good catch. so I think the options here are

  • add (deprecated, maybe undocumented) getters for global_name, accent_color, and public_flags to User
  • make a new class extending User that adds those, and make UncachedGuildMember construct one of those instead

for simplicity I'd prefer to do the former tbh... what do people think


/**
* 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
Comment on lines +71 to +72
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
* @deprecated Use {@link UncachedGuildMember#premiumSinceTimestamp}
* or {@link UncachedGuildMember#premiumSince} instead
* @deprecated Use {@link UncachedGuildMember#premiumSinceTimestamp} or {@link UncachedGuildMember#premiumSince} instead

This comment was marked as spam.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

this is what I had originally, but it fails prettier due to the line length, so I split it up.

*/
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<GuildMemberFlagsBitField>}
*/
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<PermissionsBitField>}
*/
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;
}
vladfrangu marked this conversation as resolved.
Show resolved Hide resolved

/**
* 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}
Copy link
Contributor

Choose a reason for hiding this comment

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

would they not always have a display name, or is this something to do with partials?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

hm yeah, why is this nullable in GuildMember? also user is nullable in GuildMember but id (which just returns this.user.id) isn't
are both of those just incorrectly marked as nullable

Copy link
Contributor

Choose a reason for hiding this comment

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

it would seem so

Copy link
Contributor

Choose a reason for hiding this comment

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

Is the user being nullable related to the

The field user won't be included in the member object attached to MESSAGE_CREATE and MESSAGE_UPDATE gateway events.

note that is under the Guild Member object table in the DAPI docs?

Copy link
Contributor

Choose a reason for hiding this comment

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

this sounds like something we need to investigate.... possibly seperately
but that would mean the docs on here could be wrong until investigation
complete

re posted because email replies dont work properly

Copy link
Contributor

Choose a reason for hiding this comment

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

In those two events the message.author field contains the user and is patched into the GuildMember accordingly so won’t be null either

this.guild.members._add(Object.assign(data.member, { user: this.author }));

Copy link
Contributor

Choose a reason for hiding this comment

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

so in conclusion: the docs are wrong

* @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;
}
Jiralite marked this conversation as resolved.
Show resolved Hide resolved

/**
* 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<DMChannel>}
*/
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<DMChannel>}
*/
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 &&
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
this.parsedFlags.bitfield === member.parsedFlags.bitfield &&
this.flags === member.flags &&

Copy link
Contributor Author

Choose a reason for hiding this comment

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

.flags is deprecated so I didn't want to use it; I want to rename parsedFlags to flags in v15

(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<Message>}
* @example
* // Send a direct message
* UncachedGuildMember.send('Hello!')
* .then(message => console.log(`Sent message: ${message.content} to ${guildMember.displayName}`))
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
* .then(message => console.log(`Sent message: ${message.content} to ${guildMember.displayName}`))
* .then(message => console.log(`Sent message: ${message.content} to ${uncachedGuildMember.displayName}`))

Feels a bit silly but eh

* .catch(console.error);
*/

TextBasedChannel.applyToClass(UncachedGuildMember);
Copy link
Contributor

Choose a reason for hiding this comment

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

This won’t work without additional changes in TextBasedChannel#send(), because that method uses a (granted very hacky) check to find out if it‘s called on a User/GuildMember or a Channel. Without changes it would currently treat UncachedGuildMember like it was a channel and fail miserably.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

ah thanks, will look into that


module.exports = UncachedGuildMember;
Expand Up @@ -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);
}
Expand Down