diff --git a/src/languages/en-US/events/guilds-members.json b/src/languages/en-US/events/guilds-members.json index 3a8e6af2d1b..ac993520d44 100644 --- a/src/languages/en-US/events/guilds-members.json +++ b/src/languages/en-US/events/guilds-members.json @@ -1,6 +1,6 @@ { "guildMemberAdd": "User Joined", - "guildMemberAddDescription": "{{mention}} | **Joined Discord**: {{time, duration}} ago.", + "guildMemberAddDescription": "{{user}} joined Discord {{relativeTime}}", "guildMemberAddedRoles_other": "**Added roles**: {{addedRoles, list(conjunction)}}", "guildMemberAddedRoles_one": "**Added role**: {{addedRoles}}", "guildMemberAddMute": "Muted User joined", @@ -8,8 +8,8 @@ "guildMemberKicked": "User Kicked", "guildMemberNoUpdate": "No update detected", "guildMemberRemove": "User Left", - "guildMemberRemoveDescription": "{{mention}} | **Joined Server**: Unknown.", - "guildMemberRemoveDescriptionWithJoinedAt": "{{mention}} | **Joined Server**: {{time, duration}} ago.", + "guildMemberRemoveDescription": "{{user}} joined this server *an unknown time ago*", + "guildMemberRemoveDescriptionWithJoinedAt": "{{user}} joined this server {{relativeTime}}", "guildMemberRemovedRoles_other": "**Removed roles**: {{removedRoles, list(conjunction)}}", "guildMemberRemovedRoles_one": "**Removed role**: {{removedRoles}}", "guildMemberSoftBanned": "User Softbanned", diff --git a/src/lib/i18n/languageKeys/keys/events/guilds/Members.ts b/src/lib/i18n/languageKeys/keys/events/guilds/Members.ts index a487d89b061..4a2b96d5200 100644 --- a/src/lib/i18n/languageKeys/keys/events/guilds/Members.ts +++ b/src/lib/i18n/languageKeys/keys/events/guilds/Members.ts @@ -1,23 +1,23 @@ import { FT, T } from '#lib/types'; -export const GuildMemberAdd = T<string>('events/guilds-members:guildMemberAdd'); -export const GuildMemberAddDescription = FT<{ mention: string; time: number }, string>('events/guilds-members:guildMemberAddDescription'); -export const GuildMemberAddedRoles = FT<{ addedRoles: string; count: number }, string>('events/guilds-members:guildMemberAddedRoles'); -export const GuildMemberAddMute = T<string>('events/guilds-members:guildMemberAddMute'); -export const GuildMemberBanned = T<string>('events/guilds-members:guildMemberBanned'); -export const GuildMemberKicked = T<string>('events/guilds-members:guildMemberKicked'); -export const GuildMemberNoUpdate = T<string>('events/guilds-members:guildMemberNoUpdate'); -export const GuildMemberRemove = T<string>('events/guilds-members:guildMemberRemove'); -export const GuildMemberRemoveDescription = FT<{ mention: string; time: number }, string>('events/guilds-members:guildMemberRemoveDescription'); -export const GuildMemberRemoveDescriptionWithJoinedAt = FT<{ mention: string; time: number }, string>( +export const GuildMemberAdd = T('events/guilds-members:guildMemberAdd'); +export const GuildMemberAddDescription = FT<{ user: string; relativeTime: string }>('events/guilds-members:guildMemberAddDescription'); +export const GuildMemberAddedRoles = FT<{ addedRoles: string; count: number }>('events/guilds-members:guildMemberAddedRoles'); +export const GuildMemberAddMute = T('events/guilds-members:guildMemberAddMute'); +export const GuildMemberBanned = T('events/guilds-members:guildMemberBanned'); +export const GuildMemberKicked = T('events/guilds-members:guildMemberKicked'); +export const GuildMemberNoUpdate = T('events/guilds-members:guildMemberNoUpdate'); +export const GuildMemberRemove = T('events/guilds-members:guildMemberRemove'); +export const GuildMemberRemoveDescription = FT<{ user: string; relativeTime: string }>('events/guilds-members:guildMemberRemoveDescription'); +export const GuildMemberRemoveDescriptionWithJoinedAt = FT<{ user: string; relativeTime: string }>( 'events/guilds-members:guildMemberRemoveDescriptionWithJoinedAt' ); -export const GuildMemberRemovedRoles = FT<{ removedRoles: string; count: number }, string>('events/guilds-members:guildMemberRemovedRoles'); -export const GuildMemberSoftBanned = T<string>('events/guilds-members:guildMemberSoftBanned'); -export const NameUpdateNextWasNotSet = FT<{ nextName: string | null }, string>('events/guilds-members:nameUpdateNextWasNotSet'); -export const NameUpdateNextWasSet = FT<{ nextName: string | null }, string>('events/guilds-members:nameUpdateNextWasSet'); -export const NameUpdatePreviousWasNotSet = FT<{ previousName: string | null }, string>('events/guilds-members:nameUpdatePreviousWasNotSet'); -export const NameUpdatePreviousWasSet = FT<{ previousName: string | null }, string>('events/guilds-members:nameUpdatePreviousWasSet'); -export const NicknameUpdate = T<string>('events/guilds-members:nicknameUpdate'); -export const RoleUpdate = T<string>('events/guilds-members:roleUpdate'); -export const UsernameUpdate = T<string>('events/guilds-members:usernameUpdate'); +export const GuildMemberRemovedRoles = FT<{ removedRoles: string; count: number }>('events/guilds-members:guildMemberRemovedRoles'); +export const GuildMemberSoftBanned = T('events/guilds-members:guildMemberSoftBanned'); +export const NameUpdateNextWasNotSet = FT<{ nextName: string | null }>('events/guilds-members:nameUpdateNextWasNotSet'); +export const NameUpdateNextWasSet = FT<{ nextName: string | null }>('events/guilds-members:nameUpdateNextWasSet'); +export const NameUpdatePreviousWasNotSet = FT<{ previousName: string | null }>('events/guilds-members:nameUpdatePreviousWasNotSet'); +export const NameUpdatePreviousWasSet = FT<{ previousName: string | null }>('events/guilds-members:nameUpdatePreviousWasSet'); +export const NicknameUpdate = T('events/guilds-members:nicknameUpdate'); +export const RoleUpdate = T('events/guilds-members:roleUpdate'); +export const UsernameUpdate = T('events/guilds-members:usernameUpdate'); diff --git a/src/lib/util/constants.ts b/src/lib/util/constants.ts index 4e6ed7ff0b7..7de9f9a0446 100644 --- a/src/lib/util/constants.ts +++ b/src/lib/util/constants.ts @@ -38,13 +38,8 @@ export const enum Emojis { GreenTickSerialized = 's637706251253317669', Loading = '<a:sloading:656988867403972629>', RedCross = '<:redCross:637706251257511973>', - Star = '<:Star:736337719982030910>', - StarEmpty = '<:StarEmpty:736337232738254849>', - StarHalf = '<:StarHalf:736337529900499034>', - /** This is the default Twemoji, uploaded as a custom emoji because iOS and Android do not render the emoji properly */ - MaleSignEmoji = '<:2642:845772713770614874>', - /** This is the default Twemoji, uploaded as a custom emoji because iOS and Android do not render the emoji properly */ - FemaleSignEmoji = '<:2640:845772713729720320>' + SpammerIcon = '<:spammer:1206893298292232245>', + QuarantinedIcon = '<:quarantined:1206899526447923210>' } export const enum BrandingColors { diff --git a/src/lib/util/functions/index.ts b/src/lib/util/functions/index.ts index 2875a8b6a62..87a297cd5cd 100644 --- a/src/lib/util/functions/index.ts +++ b/src/lib/util/functions/index.ts @@ -4,3 +4,4 @@ export * from '#lib/util/functions/guild'; export * from '#lib/util/functions/messages'; export * from '#lib/util/functions/permissions'; export * from '#lib/util/functions/pieces'; +export * from '#lib/util/functions/users'; diff --git a/src/lib/util/functions/users.ts b/src/lib/util/functions/users.ts new file mode 100644 index 00000000000..4ce3a5b1df0 --- /dev/null +++ b/src/lib/util/functions/users.ts @@ -0,0 +1,35 @@ +import { Emojis } from '#utils/constants'; +import { userMention } from '@discordjs/builders'; +import { BitField } from '@sapphire/bitfield'; +import { UserFlags, type Snowflake } from 'discord.js'; + +const ExtendedUserFlagBits = new BitField({ + Quarantined: getExtendedBits(UserFlags.Quarantined), + Collaborator: getExtendedBits(UserFlags.Collaborator), + RestrictedCollaborator: getExtendedBits(UserFlags.RestrictedCollaborator) +}); + +export function getModerationFlags(bitfield: number) { + return { + spammer: (bitfield & UserFlags.Spammer) === UserFlags.Spammer, + quarantined: ExtendedUserFlagBits.has(getExtendedBits(bitfield), ExtendedUserFlagBits.flags.Quarantined) + }; +} + +export function getModerationFlagsString(bitfield: number) { + const { spammer, quarantined } = getModerationFlags(bitfield); + if (spammer && quarantined) return Emojis.SpammerIcon + Emojis.QuarantinedIcon; + if (spammer) return Emojis.SpammerIcon; + if (quarantined) return Emojis.QuarantinedIcon; + return ''; +} + +export function getUserMentionWithFlagsString(bitfield: number, userId: Snowflake) { + const flags = getModerationFlagsString(bitfield); + const mention = userMention(userId); + return flags ? `${mention} ${flags}` : mention; +} + +function getExtendedBits(bitfield: number) { + return (bitfield / (1 << 30)) | 0; +} diff --git a/src/listeners/guilds/members/guildMemberAdd.ts b/src/listeners/guilds/members/guildMemberAdd.ts index c367e961760..67f86a955ee 100644 --- a/src/listeners/guilds/members/guildMemberAdd.ts +++ b/src/listeners/guilds/members/guildMemberAdd.ts @@ -1,58 +1,106 @@ import { GuildSettings, readSettings, writeSettings } from '#lib/database'; import { LanguageKeys } from '#lib/i18n/languageKeys'; import { Events } from '#lib/types'; -import { floatPromise } from '#utils/common'; +import { seconds, toErrorCodeResult } from '#utils/common'; import { Colors } from '#utils/constants'; -import { getStickyRoles } from '#utils/functions'; +import { getLogPrefix, getLogger, getStickyRoles, getUserMentionWithFlagsString } from '#utils/functions'; import { getFullEmbedAuthor } from '#utils/util'; import { EmbedBuilder } from '@discordjs/builders'; import { Listener } from '@sapphire/framework'; -import { PermissionFlagsBits, type GuildMember } from 'discord.js'; +import type { TFunction } from '@sapphire/plugin-i18next'; +import { isNullish, type Nullish } from '@sapphire/utilities'; +import { Guild, PermissionFlagsBits, RESTJSONErrorCodes, TimestampStyles, time, type GuildMember, type Snowflake } from 'discord.js'; + +const Root = LanguageKeys.Events.Guilds.Members; +const ChannelSettingsKey = GuildSettings.Channels.Logs.MemberAdd; export class UserListener extends Listener { public async run(member: GuildMember) { - if (await this.handleStickyRoles(member)) return; + if (await this.#handleStickyRoles(member)) return; this.container.client.emit(Events.NotMutedMemberAdd, member); } - private async handleStickyRoles(member: GuildMember) { + async #handleStickyRoles(member: GuildMember) { if (!member.guild.members.me!.permissions.has(PermissionFlagsBits.ManageRoles)) return false; const stickyRoles = await getStickyRoles(member).fetch(member.id); if (stickyRoles.length === 0) return false; // Handle the case the user is muted - const key = GuildSettings.Channels.Logs.MemberAdd; - const [logChannelId, roleId, t] = await readSettings(member, (settings) => [ - settings[key], - settings[GuildSettings.Roles.Muted], - settings.getLanguage() + const [t, targetChannelId, mutedRoleId] = await readSettings(member, (settings) => [ + settings.getLanguage(), + settings[ChannelSettingsKey], + settings[GuildSettings.Roles.Muted] ]); - if (roleId && stickyRoles.includes(roleId)) { - // Handle mute - const role = member.guild.roles.cache.get(roleId); - floatPromise(role ? member.roles.add(role) : writeSettings(member, [[GuildSettings.Roles.Muted, null]])); - - // Handle log - this.container.client.emit(Events.GuildMessageLog, member.guild, logChannelId, key, () => - new EmbedBuilder() - .setColor(Colors.Amber) - .setAuthor(getFullEmbedAuthor(member.user)) - .setDescription( - t(LanguageKeys.Events.Guilds.Members.GuildMemberAddDescription, { - mention: member.toString(), - time: Date.now() - member.user.createdTimestamp - }) - ) - .setFooter({ text: t(LanguageKeys.Events.Guilds.Members.GuildMemberAddMute) }) - .setTimestamp() - ); + if (mutedRoleId && stickyRoles.includes(mutedRoleId)) { + void this.#handleMutedMemberAddRole(member, mutedRoleId); + void this.#handleMutedMemberNotify(t, member, targetChannelId); return true; } - floatPromise(member.roles.add(stickyRoles)); + void this.#handleStickyRolesAddRoles(member, stickyRoles); return false; } + + async #handleMutedMemberAddRole(member: GuildMember, mutedRoleId: Snowflake) { + const { guild } = member; + const role = guild.roles.cache.get(mutedRoleId); + if (isNullish(role)) { + await writeSettings(member, [[GuildSettings.Roles.Muted, null]]); + } else { + const result = await toErrorCodeResult(member.roles.add(role)); + await result.inspectErrAsync((code) => this.#handleMutedMemberAddRoleErr(guild, code)); + } + } + + async #handleMutedMemberAddRoleErr(guild: Guild, code: RESTJSONErrorCodes) { + // The member left the guild before we could add the role, ignore: + if (code === RESTJSONErrorCodes.UnknownMember) return; + + // The role was deleted, remove it from the settings: + if (code === RESTJSONErrorCodes.UnknownRole) { + await writeSettings(guild, [[GuildSettings.Roles.Muted, null]]); + return; + } + + // Otherwise, log the error: + this.container.logger.error(`${getLogPrefix(this)} Failed to add the muted role to a member.`); + } + + async #handleMutedMemberNotify(t: TFunction, member: GuildMember, targetChannelId: Snowflake | Nullish) { + await getLogger(member.guild).send({ + key: ChannelSettingsKey, + channelId: targetChannelId, + makeMessage: () => { + const { user } = member; + const description = t(Root.GuildMemberAddDescription, { + user: getUserMentionWithFlagsString(user.flags?.bitfield ?? 0, user.id), + relativeTime: time(seconds.fromMilliseconds(user.createdTimestamp), TimestampStyles.RelativeTime) + }); + return new EmbedBuilder() + .setColor(Colors.Amber) + .setAuthor(getFullEmbedAuthor(member.user)) + .setDescription(description) + .setFooter({ text: t(Root.GuildMemberAddMute) }) + .setTimestamp(); + } + }); + } + + async #handleStickyRolesAddRoles(member: GuildMember, stickyRoles: readonly Snowflake[]) { + const guildRoles = member.guild.roles; + const roles = stickyRoles.filter((role) => guildRoles.cache.has(role)); + const result = await toErrorCodeResult(member.roles.add(roles)); + await result.inspectErrAsync((code) => this.#handleStickyRolesAddRolesErr(code)); + } + + #handleStickyRolesAddRolesErr(code: RESTJSONErrorCodes) { + // The member left the guild before we could add the roles, ignore: + if (code === RESTJSONErrorCodes.UnknownMember) return; + + // Otherwise, log the error: + this.container.logger.error(`${getLogPrefix(this)} Failed to add the muted role to a member.`); + } } diff --git a/src/listeners/guilds/members/notMutedMemberAddNotify.ts b/src/listeners/guilds/members/notMutedMemberAddNotify.ts index afec1d7cd68..ec361629ea6 100644 --- a/src/listeners/guilds/members/notMutedMemberAddNotify.ts +++ b/src/listeners/guilds/members/notMutedMemberAddNotify.ts @@ -1,33 +1,38 @@ import { GuildSettings, readSettings } from '#lib/database'; import { LanguageKeys } from '#lib/i18n/languageKeys'; import { Events } from '#lib/types'; +import { seconds } from '#utils/common'; import { Colors } from '#utils/constants'; +import { getLogger, getUserMentionWithFlagsString } from '#utils/functions'; import { getFullEmbedAuthor } from '#utils/util'; -import { EmbedBuilder } from '@discordjs/builders'; +import { EmbedBuilder, TimestampStyles, time } from '@discordjs/builders'; import { ApplyOptions } from '@sapphire/decorators'; import { Listener } from '@sapphire/framework'; -import { isNullish } from '@sapphire/utilities'; import type { GuildMember } from 'discord.js'; +const Root = LanguageKeys.Events.Guilds.Members; +const ChannelSettingsKey = GuildSettings.Channels.Logs.MemberAdd; + @ApplyOptions<Listener.Options>({ event: Events.NotMutedMemberAdd }) export class UserListener extends Listener { public async run(member: GuildMember) { - const key = GuildSettings.Channels.Logs.MemberAdd; - const [logChannelId, t] = await readSettings(member, (settings) => [settings[key], settings.getLanguage()]); - if (isNullish(logChannelId)) return; - - this.container.client.emit(Events.GuildMessageLog, member.guild, logChannelId, key, () => - new EmbedBuilder() - .setColor(Colors.Green) - .setAuthor(getFullEmbedAuthor(member.user)) - .setDescription( - t(LanguageKeys.Events.Guilds.Members.GuildMemberAddDescription, { - mention: member.toString(), - time: Date.now() - member.user.createdTimestamp - }) - ) - .setFooter({ text: t(LanguageKeys.Events.Guilds.Members.GuildMemberAdd) }) - .setTimestamp() - ); + const [t, logChannelId] = await readSettings(member, (settings) => [settings.getLanguage(), settings[ChannelSettingsKey]]); + await getLogger(member.guild).send({ + key: ChannelSettingsKey, + channelId: logChannelId, + makeMessage: () => { + const { user } = member; + const description = t(Root.GuildMemberAddDescription, { + user: getUserMentionWithFlagsString(user.flags?.bitfield ?? 0, user.id), + relativeTime: time(seconds.fromMilliseconds(user.createdTimestamp), TimestampStyles.RelativeTime) + }); + return new EmbedBuilder() + .setColor(Colors.Green) + .setAuthor(getFullEmbedAuthor(member.user)) + .setDescription(description) + .setFooter({ text: t(Root.GuildMemberAdd) }) + .setTimestamp(); + } + }); } } diff --git a/src/listeners/guilds/members/rawMemberRemoveNotify.ts b/src/listeners/guilds/members/rawMemberRemoveNotify.ts index ba2a061d664..6499ee3b3d9 100644 --- a/src/listeners/guilds/members/rawMemberRemoveNotify.ts +++ b/src/listeners/guilds/members/rawMemberRemoveNotify.ts @@ -1,55 +1,58 @@ import { GuildSettings, readSettings } from '#lib/database'; import { LanguageKeys } from '#lib/i18n/languageKeys'; import { Events } from '#lib/types'; +import { seconds } from '#utils/common'; import { Colors } from '#utils/constants'; -import { getModeration } from '#utils/functions'; +import { getLogger, getModeration, getUserMentionWithFlagsString } from '#utils/functions'; import { TypeVariation } from '#utils/moderationConstants'; import { getFullEmbedAuthor } from '#utils/util'; -import { EmbedBuilder } from '@discordjs/builders'; +import { EmbedBuilder, TimestampStyles, time } from '@discordjs/builders'; import { ApplyOptions } from '@sapphire/decorators'; import { Listener } from '@sapphire/framework'; import { isNullish } from '@sapphire/utilities'; -import type { GatewayGuildMemberRemoveDispatch, Guild, GuildMember } from 'discord.js'; +import type { GatewayGuildMemberRemoveDispatchData, Guild, GuildMember } from 'discord.js'; + +const Root = LanguageKeys.Events.Guilds.Members; +const ChannelSettingsKey = GuildSettings.Channels.Logs.MemberRemove; @ApplyOptions<Listener.Options>({ event: Events.RawMemberRemove }) export class UserListener extends Listener { - public async run(guild: Guild, member: GuildMember | null, { user }: GatewayGuildMemberRemoveDispatch['d']) { - const key = GuildSettings.Channels.Logs.MemberRemove; - const [logChannelId, t] = await readSettings(guild, (settings) => [settings[key], settings.getLanguage()]); - if (isNullish(logChannelId)) return; + public async run(guild: Guild, member: GuildMember | null, { user }: GatewayGuildMemberRemoveDispatchData) { + const [t, targetChannelId] = await readSettings(guild, (settings) => [settings.getLanguage(), settings[ChannelSettingsKey]]); + if (isNullish(targetChannelId)) return; const isModerationAction = await this.isModerationAction(guild, user); const footer = isModerationAction.kicked - ? t(LanguageKeys.Events.Guilds.Members.GuildMemberKicked) + ? t(Root.GuildMemberKicked) : isModerationAction.banned - ? t(LanguageKeys.Events.Guilds.Members.GuildMemberBanned) + ? t(Root.GuildMemberBanned) : isModerationAction.softbanned - ? t(LanguageKeys.Events.Guilds.Members.GuildMemberSoftBanned) - : t(LanguageKeys.Events.Guilds.Members.GuildMemberRemove); + ? t(Root.GuildMemberSoftBanned) + : t(Root.GuildMemberRemove); + + const joinedTimestamp = this.processJoinedTimestamp(member); + await getLogger(guild).send({ + key: ChannelSettingsKey, + channelId: targetChannelId, + makeMessage: () => { + const key = joinedTimestamp === -1 ? Root.GuildMemberRemoveDescription : Root.GuildMemberRemoveDescriptionWithJoinedAt; + const description = t(key, { + user: getUserMentionWithFlagsString(user.flags ?? 0, user.id), + relativeTime: time(seconds.fromMilliseconds(joinedTimestamp), TimestampStyles.RelativeTime) + }); - const time = this.processJoinedTimestamp(member); - this.container.client.emit(Events.GuildMessageLog, guild, logChannelId, key, () => - new EmbedBuilder() - .setColor(Colors.Red) - .setAuthor(getFullEmbedAuthor(user)) - .setDescription( - t( - time === -1 - ? LanguageKeys.Events.Guilds.Members.GuildMemberRemoveDescription - : LanguageKeys.Events.Guilds.Members.GuildMemberRemoveDescriptionWithJoinedAt, - { - mention: `<@${user.id}>`, - time - } - ) - ) - .setFooter({ text: footer }) - .setTimestamp() - ); + return new EmbedBuilder() + .setColor(Colors.Red) + .setAuthor(getFullEmbedAuthor(user)) + .setDescription(description) + .setFooter({ text: footer }) + .setTimestamp(); + } + }); } - private async isModerationAction(guild: Guild, user: GatewayGuildMemberRemoveDispatch['d']['user']): Promise<IsModerationAction> { + private async isModerationAction(guild: Guild, user: GatewayGuildMemberRemoveDispatchData['user']): Promise<IsModerationAction> { const moderation = getModeration(guild); await moderation.waitLock(); @@ -73,7 +76,7 @@ export class UserListener extends Listener { private processJoinedTimestamp(member: GuildMember | null) { if (member === null) return -1; if (member.joinedTimestamp === null) return -1; - return Date.now() - member.joinedTimestamp; + return member.joinedTimestamp; } }