import {
PERMISSIONS,
MEMBER_FLAGS,
TO_JSON_TYPES_ENUM,
CDN_BASE_URL,
} from "../constants.js";
import User from "./User.js";
import checkPermission from "../util/discord/checkPermission.js";
import checkMemberPermissions from "../util/discord/checkMemberPermissions.js";
import GluonCacheOptions from "../managers/GluonCacheOptions.js";
import GuildCacheOptions from "../managers/GuildCacheOptions.js";
import Role from "./Role.js";
import util from "util";
import encryptStructure from "../util/gluon/encryptStructure.js";
import decryptStructure from "../util/gluon/decryptStructure.js";
import structureHashName from "../util/general/structureHashName.js";
import Client from "../Client.js";
import GuildManager from "../managers/GuildManager.js";
/**
* Represents a guild member.
* @see {@link https://discord.com/developers/docs/resources/guild#guild-member-object-guild-member-structure}
*/
class Member {
#_client;
#_guild_id;
#_id;
#nick;
#joined_at;
#communication_disabled_until;
#flags;
#_attributes;
#_avatar;
#_roles;
#user;
/**
* Creates the structure for a guild member.
* @param {Client} client The client instance.
* @param {Object} data The raw member data from Discord.
* @param {Object} options Additional options for the member.
* @param {String} options.userId The id of the member.
* @param {String} options.guildId The id of the guild that the member belongs to.
* @param {User?} options.user A user object for this member.
* @param {Boolean?} options.nocache Whether this member should be cached.
*/
constructor(
client,
data,
{ userId, guildId, user, nocache = false } = {
nocache: false,
},
) {
if (!(client instanceof Client))
throw new TypeError("GLUON: Client must be an instance of Client");
if (typeof data !== "object")
throw new TypeError("GLUON: Data must be an object");
if (typeof guildId !== "string")
throw new TypeError("GLUON: Guild ID must be a string");
if (typeof userId !== "string")
throw new TypeError("GLUON: User ID must be a string");
if (typeof user !== "undefined" && typeof user !== "object")
throw new TypeError("GLUON: User must be an object");
if (typeof nocache !== "boolean")
throw new TypeError("GLUON: No cache must be a boolean");
/**
* The client instance.
* @type {Client}
* @private
*/
this.#_client = client;
/**
* The id of the guild that this member belongs to.
* @type {BigInt}
* @private
*/
this.#_guild_id = BigInt(guildId);
const existing = this.guild?.members.get(userId) || null;
/**
* The id of the member.
* @type {BigInt}
* @private
*/
this.#_id = BigInt(userId);
if (data.user)
/**
* The user object for this member.
* @type {User?}
* @private
*/
this.#user = new User(this.#_client, data.user, { nocache });
else if (existing?.user) this.#user = existing.user;
else if (user) this.#user = user;
else this.#user = this.#_client.users.get(userId) || null;
if (data.nick !== undefined)
/**
* The nickname of this member.
* @type {String?}
* @private
*/
this.#nick = data.nick;
else if (data.nick !== null && existing && existing.nick != undefined)
this.#nick = existing.nick;
if (data.joined_at)
/**
* The UNIX timestamp for when this member joined the guild.
* @type {Number?}
* @private
*/
this.#joined_at = (new Date(data.joined_at).getTime() / 1000) | 0;
else if (existing?.joinedAt) this.#joined_at = existing.joinedAt;
/**
* The UNIX timestamp for when this member's timeout expires, if applicable.
* @type {Number?}
* @private
*/
this.#communication_disabled_until = data.communication_disabled_until
? (new Date(data.communication_disabled_until).getTime() / 1000) | 0
: null;
if (typeof data.flags == "number")
/**
* The flags for this user.
* @type {Number}
* @private
* @see {@link https://discord.com/developers/docs/resources/guild#guild-member-object-guild-member-flags}
*/
this.#flags = data.flags;
else if (existing && typeof existing.flags == "number")
this.#flags = existing.flags;
else this.#flags = 0;
/**
* The attributes for this member.
* @type {Number}
* @private
*/
this.#_attributes = data._attributes ?? 0;
if (data.pending !== undefined && data.pending == true)
this.#_attributes |= 0b1 << 0;
else if (data.pending === undefined && existing && existing.pending == true)
this.#_attributes |= 0b1 << 0;
if (data.avatar && data.avatar.startsWith("a_") == true)
this.#_attributes |= 0b1 << 1;
/**
* The hash of the member's avatar.
* @type {BigInt?}
* @private
*/
if (data.avatar !== undefined)
this.#_avatar = data.avatar
? BigInt(`0x${data.avatar.replace("a_", "")}`)
: null;
else if (data.avatar === undefined && existing && existing._avatar)
this.#_avatar = existing._avatar;
/**
* The roles for this member.
* @type {Array<BigInt>?}
* @private
*/
if (
data.roles &&
this.guild &&
Role.shouldCache(this.#_client._cacheOptions, this.guild._cacheOptions)
) {
this.#_roles = [];
for (let i = 0; i < data.roles.length; i++)
if (data.roles[i] != guildId) this.#_roles.push(BigInt(data.roles[i]));
}
if (
this.id === this.#_client.user.id ||
(nocache === false &&
Member.shouldCache(
this.#_client._cacheOptions,
this.guild._cacheOptions,
))
) {
this.guild.members.set(userId, this);
}
}
/**
* The id of the member.
* @type {String}
* @readonly
* @public
*/
get id() {
return String(this.#_id);
}
/**
* The id of the guild that this member belongs to.
* @type {String}
* @readonly
* @public
*/
get guildId() {
return String(this.#_guild_id);
}
/**
* The guild that this member belongs to.
* @type {Guild?}
* @readonly
* @public
*/
get guild() {
return this.#_client.guilds.get(this.guildId) || null;
}
/**
* The nickname of the member.
* @type {String?}
* @readonly
* @public
*/
get nick() {
return this.#nick;
}
/**
* The UNIX timestamp for when this member joined the guild.
* @type {Number?}
* @readonly
* @public
*/
get joinedAt() {
return this.#joined_at;
}
/**
* The UNIX timestamp for when this member's timeout expires, if applicable.
* @type {Number?}
* @readonly
* @public
*/
get timeoutUntil() {
return this.#communication_disabled_until;
}
/**
* The flags for this user.
* @type {Number}
* @readonly
* @public
*/
get flags() {
return this.#flags;
}
/**
* The member's roles.
* @readonly
* @type {Array<Role>}
* @public
*/
get roles() {
if (
Role.shouldCache(
this.#_client._cacheOptions,
this.guild._cacheOptions,
) === false
)
return null;
const roles = [];
const everyoneRole = this.guild.roles.get(this.guildId);
if (everyoneRole) roles.push(everyoneRole);
if (!this.#_roles) return roles;
for (let i = 0; i < this.#_roles.length; i++) {
const role = this.guild.roles.get(this.#_roles[i].toString());
if (role) roles.push(role);
}
return roles;
}
/**
* The position of the member's highest role.
* @readonly
* @type {Number}
* @public
*/
get highestRolePosition() {
let highestPosition = 0;
const roles = this.roles;
for (let i = 0; i < roles.length; i++)
if (roles[i].position > highestPosition)
highestPosition = roles[i].position;
return highestPosition;
}
/**
* The overall calculated permissions for this member.
* @readonly
* @type {BigInt}
* @public
*/
get permissions() {
if (this.id == this.guild.ownerId) return PERMISSIONS.ADMINISTRATOR;
return checkMemberPermissions(this.roles);
}
/**
* Whether the member has joined the guild before.
* @readonly
* @type {Boolean}
* @public
*/
get rejoined() {
return (this.#flags & MEMBER_FLAGS.DID_REJOIN) == MEMBER_FLAGS.DID_REJOIN;
}
/**
* The user object for this member.
* @type {User}
* @readonly
* @public
*/
get user() {
return this.#user;
}
/**
* The hash of the member's avatar, as it was received from Discord.
* @readonly
* @type {String?}
* @private
*/
get #_originalAvatarHash() {
return this.#_avatar
? // eslint-disable-next-line quotes
`${this.avatarIsAnimated ? "a_" : ""}${this.#_formattedAvatarHash}`
: null;
}
/**
* The hash of the member's avatar as a string.
* @readonly
* @type {String}
* @private
*/
get #_formattedAvatarHash() {
if (!this.#_avatar) return null;
let formattedHash = this.#_avatar.toString(16);
while (formattedHash.length != 32)
// eslint-disable-next-line quotes
formattedHash = `0${formattedHash}`;
return formattedHash;
}
/**
* The url of the member's avatar.
* @readonly
* @type {String}
* @public
*/
get displayAvatarURL() {
return (
Member.getAvatarUrl(this.id, this.guildId, this.#_originalAvatarHash) ??
this.user.displayAvatarURL
);
}
/**
* Whether the user has not yet passed the guild's membership screening requirements.
* @readonly
* @type {Boolean}
* @public
*/
get pending() {
return (this.#_attributes & (0b1 << 0)) == 0b1 << 0;
}
/**
* Whether the user has an animated avatar or not.
* @readonly
* @type {Boolean}
* @public
*/
get avatarIsAnimated() {
return (this.#_attributes & (0b1 << 1)) == 0b1 << 1;
}
/**
* The mention string for the member.
* @type {String}
* @readonly
* @public
*/
get mention() {
return Member.getMention(this.id);
}
/**
* The hash name for the member.
* @type {String}
* @readonly
* @public
*/
get hashName() {
return Member.getHashName(this.guildId, this.id);
}
/**
* Returns the mention string for the member.
* @param {String} userId The id of the user to mention.
* @returns {String}
* @public
* @static
* @method
*/
static getMention(userId) {
if (typeof userId !== "string")
throw new TypeError("GLUON: User ID must be a string.");
return `<@${userId}>`;
}
/**
* Returns the avatar url for the member.
* @param {String} id The id of the user.
* @param {String} guild_id The id of the guild the user belongs to.
* @param {String?} hash The avatar hash of the user.
* @returns {String}
* @public
* @static
* @method
*/
static getAvatarUrl(id, guildId, hash) {
if (typeof id !== "string")
throw new TypeError("GLUON: Member id must be a string.");
if (typeof guildId !== "string")
throw new TypeError("GLUON: Guild id must be a string.");
if (hash && typeof hash !== "string")
throw new TypeError("GLUON: Member avatar hash must be a string.");
return hash
? `${CDN_BASE_URL}/guilds/${guildId}/users/${id}/avatars/${hash}.${
hash.startsWith("a_") ? "gif" : "png"
}`
: null;
}
/**
* Adds a role to the member.
* @param {String} role_id The id of the role to add to the member.
* @param {Object?} options The options for adding the role to the member.
* @param {String?} options.reason The reason for adding the role to the member.
* @returns {Promise<void>}
* @public
* @async
* @method
* @throws {TypeError | Error}
*/
async addRole(role_id, { reason } = {}) {
await Member.addRole(this.#_client, this.guildId, this.id, role_id, {
reason,
});
}
/**
* Removes a role from the member.
* @param {String} role_id The id of the role to remove from the member.
* @param {Object?} options The options for removing the role from the member.
* @param {String?} options.reason The reason for removing the role from the member.
* @returns {Promise<void>}
* @public
* @async
* @method
* @throws {TypeError | Error}
*/
async removeRole(role_id, { reason } = {}) {
await Member.removeRole(this.#_client, this.guildId, this.id, role_id, {
reason,
});
}
/**
* Adds a timeout to the member.
* @param {Number} timeout_until The UNIX timestamp for when the member's timeout should end.
* @param {Object?} options The options for timing out the member.
* @param {String?} options.reason The reason for timing out the member.
* @returns {Promise<void>}
* @public
* @async
* @method
* @throws {TypeError | Error}
*/
async timeoutAdd(timeout_until, { reason } = {}) {
if (
!checkPermission(
(await this.guild.me()).permissions,
PERMISSIONS.MODERATE_MEMBERS,
)
)
throw new Error("MISSING PERMISSIONS: MODERATE_MEMBERS");
if (typeof timeout_until !== "number")
throw new TypeError("GLUON: Timeout until must be a UNIX timestamp.");
if (typeof reason !== "undefined" && typeof reason !== "string")
throw new TypeError("GLUON: Reason must be a string.");
const body = {};
if (reason) body["X-Audit-Log-Reason"] = reason;
body.communication_disabled_until = timeout_until;
await this.#_client.request.makeRequest(
"patchGuildMember",
[this.guildId, this.id],
body,
);
}
/**
* Removes a timeout from the member.
* @param {Object?} options The options for untiming out the member.
* @param {String?} options.reason The reason for removing the time out from the member.
* @returns {Promise<void>}
* @public
* @async
* @method
* @throws {TypeError | Error}
*/
async timeoutRemove({ reason } = {}) {
if (
!checkPermission(
(await this.guild.me()).permissions,
PERMISSIONS.MODERATE_MEMBERS,
)
)
throw new Error("MISSING PERMISSIONS: MODERATE_MEMBERS");
if (typeof reason !== "undefined" && typeof reason !== "string")
throw new TypeError("GLUON: Reason must be a string.");
const body = {};
if (reason) body["X-Audit-Log-Reason"] = reason;
body.communication_disabled_until = null;
await this.#_client.request.makeRequest(
"patchGuildMember",
[this.guildId, this.id],
body,
);
}
/**
* Updates the member's roles.
* @param {Array<String>} roles An array of role ids for the roles the member should be updated with.
* @param {Object?} options The options for updating the member's roles.
* @returns {Promise<void>}
* @public
* @async
* @method
* @throws {TypeError | Error}
*/
async massUpdateRoles(roles, { reason } = {}) {
if (
!checkPermission(
(await this.guild.me()).permissions,
PERMISSIONS.MANAGE_ROLES,
)
)
throw new Error("MISSING PERMISSIONS: MANAGE_ROLES");
if (
!Array.isArray(roles) ||
!roles.every((role) => typeof role === "string")
)
throw new TypeError("GLUON: Roles must be an array of role ids.");
if (typeof reason !== "undefined" && typeof reason !== "string")
throw new TypeError("GLUON: Reason must be a string.");
const body = {};
if (reason) body["X-Audit-Log-Reason"] = reason;
body.roles = roles.map((role) => role.toString());
await this.#_client.request.makeRequest(
"patchGuildMember",
[this.guildId, this.id],
body,
);
}
/**
* Determines whether the member should be cached.
* @param {GluonCacheOptions} gluonCacheOptions The cache options for the client.
* @param {GuildCacheOptions} guildCacheOptions The cache options for the guild.
* @returns {Boolean}
* @public
* @static
* @method
*/
static shouldCache(gluonCacheOptions, guildCacheOptions) {
if (!(gluonCacheOptions instanceof GluonCacheOptions))
throw new TypeError(
"GLUON: Gluon cache options must be a GluonCacheOptions.",
);
if (!(guildCacheOptions instanceof GuildCacheOptions))
throw new TypeError(
"GLUON: Guild cache options must be a GuildCacheOptions.",
);
if (gluonCacheOptions.cacheMembers === false) return false;
if (guildCacheOptions.memberCaching === false) return false;
return true;
}
/**
* Returns the hash name for the message.
* @param {String} guildId The id of the guild that the message belongs to.
* @param {String} memberId The id of the member.
* @returns {String}
*/
static getHashName(guildId, memberId) {
if (typeof guildId !== "string")
throw new TypeError("GLUON: Guild ID must be a string.");
if (typeof memberId !== "string")
throw new TypeError("GLUON: Member ID must be a string.");
return structureHashName(guildId, memberId);
}
/**
* Decrypts a member.
* @param {Client} client The client instance.
* @param {String} data The encrypted message data.
* @param {String} guildId The id of the guild that the message belongs to.
* @param {String} userId The id of the member.
* @returns {Member}
*/
static decrypt(client, data, guildId, userId) {
if (!(client instanceof Client))
throw new TypeError("GLUON: Client must be a Client instance.");
if (typeof data !== "string")
throw new TypeError("GLUON: Data must be a string.");
if (typeof guildId !== "string")
throw new TypeError("GLUON: Guild ID must be a string.");
if (typeof userId !== "string")
throw new TypeError("GLUON: User ID must be a string.");
return new Member(client, decryptStructure(data, userId, guildId), {
userId: userId,
guildId: guildId,
});
}
/**
* Adds a role to a member.
* @param {Client} client The client instance.
* @param {String} guildId The guild id the member belongs to.
* @param {String} userId The id of the member who the action is occuring on.
* @param {String} roleId The id of the role to add.
* @param {Object} options The options for adding the role.
* @param {String} options.reason The reason for adding the role.
* @returns {Promise<void>}
* @public
* @method
* @async
* @throws {TypeError}
*/
static async addRole(client, guildId, userId, roleId, { reason } = {}) {
if (typeof guildId !== "string")
throw new TypeError("GLUON: Guild ID is not a string.");
if (typeof userId !== "string")
throw new TypeError("GLUON: User ID is not a string.");
if (typeof roleId !== "string")
throw new TypeError("GLUON: Role ID is not a string.");
if (typeof reason !== "undefined" && typeof reason !== "string")
throw new TypeError("GLUON: Reason is not a string.");
if (
!checkPermission(
(await GuildManager.getGuild(client, guildId).me()).permissions,
PERMISSIONS.MANAGE_ROLES,
)
)
throw new Error("MISSING PERMISSIONS: MANAGE_ROLES");
const body = {};
if (reason) body["X-Audit-Log-Reason"] = reason;
await client.request.makeRequest(
"putAddGuildMemberRole",
[guildId, userId, roleId],
body,
);
}
/**
* Removes a role from a member.
* @param {Client} client The client instance.
* @param {String} guildId The guild id the member belongs to.
* @param {String} userId The id of the member who the action is occuring on.
* @param {String} roleId The id of the role to remove.
* @param {Object} options The options for removing the role.
* @param {String} options.reason The reason for removing the role.
* @returns {Promise<void>}
* @public
* @method
* @async
* @throws {TypeError}
*/
static async removeRole(client, guildId, userId, roleId, { reason } = {}) {
if (typeof guildId !== "string")
throw new TypeError("GLUON: Guild ID is not a string.");
if (typeof userId !== "string")
throw new TypeError("GLUON: User ID is not a string.");
if (typeof roleId !== "string")
throw new TypeError("GLUON: Role ID is not a string.");
if (typeof reason !== "undefined" && typeof reason !== "string")
throw new TypeError("GLUON: Reason is not a string.");
if (
!checkPermission(
(await GuildManager.getGuild(client, guildId).me()).permissions,
PERMISSIONS.MANAGE_ROLES,
)
)
throw new Error("MISSING PERMISSIONS: MANAGE_ROLES");
const body = {};
if (reason) body["X-Audit-Log-Reason"] = reason;
await client.request.makeRequest(
"deleteRemoveMemberRole",
[guildId, userId, roleId],
body,
);
}
/**
* Encrypts the member.
* @returns {String}
* @public
* @method
*/
encrypt() {
return encryptStructure(this, this.id, this.guildId);
}
/**
* @method
* @public
*/
toString() {
return `<Member: ${this.id}>`;
}
/**
* @method
* @public
*/
[util.inspect.custom]() {
return this.toString();
}
/**
* Returns the JSON representation of this structure.
* @param {Number} format The format to return the data in.
* @returns {Object}
* @public
* @method
*/
toJSON(format) {
switch (format) {
case TO_JSON_TYPES_ENUM.CACHE_FORMAT:
case TO_JSON_TYPES_ENUM.STORAGE_FORMAT: {
return {
user: this.user.toJSON(format),
nick: this.nick,
joined_at: this.joinedAt ? this.joinedAt * 1000 : undefined,
avatar: this.#_originalAvatarHash,
permissions: String(this.permissions),
roles: Array.isArray(this.#_roles)
? this.#_roles
.filter((r) => String(r) !== this.guildId)
.map((r) => String(r))
: undefined,
communication_disabled_until: this.timeoutUntil
? this.timeoutUntil * 1000
: undefined,
flags: this.flags,
_attributes: this.#_attributes,
};
}
case TO_JSON_TYPES_ENUM.DISCORD_FORMAT:
default: {
return {
user: this.user.toJSON(format),
nick: this.nick,
joined_at: this.joinedAt
? new Date(this.joinedAt * 1000).toISOString()
: undefined,
avatar: this.#_originalAvatarHash,
permissions: String(this.permissions),
roles: Array.isArray(this.#_roles)
? this.#_roles
.filter((r) => String(r) !== this.guildId)
.map((r) => String(r))
: undefined,
communication_disabled_until: this.timeoutUntil
? this.timeoutUntil * 1000
: undefined,
flags: this.flags,
pending: this.pending,
};
}
}
}
}
export default Member;