Skip to content

Commit

Permalink
refactor: improve member logs and add flag checking
Browse files Browse the repository at this point in the history
  • Loading branch information
kyranet committed Feb 13, 2024
1 parent e7f4bb4 commit a419783
Show file tree
Hide file tree
Showing 8 changed files with 181 additions and 109 deletions.
6 changes: 3 additions & 3 deletions src/languages/en-US/events/guilds-members.json
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
{
"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",
"guildMemberBanned": "User Banned",
"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",
Expand Down
38 changes: 19 additions & 19 deletions src/lib/i18n/languageKeys/keys/events/guilds/Members.ts
Original file line number Diff line number Diff line change
@@ -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');
9 changes: 2 additions & 7 deletions src/lib/util/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
1 change: 1 addition & 0 deletions src/lib/util/functions/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
35 changes: 35 additions & 0 deletions src/lib/util/functions/users.ts
Original file line number Diff line number Diff line change
@@ -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;
}
91 changes: 62 additions & 29 deletions src/listeners/guilds/members/guildMemberAdd.ts
Original file line number Diff line number Diff line change
@@ -1,52 +1,40 @@
import { GuildSettings, readSettings, writeSettings } from '#lib/database';
import { LanguageKeys } from '#lib/i18n/languageKeys';
import { Events } from '#lib/types';
import { floatPromise } from '#utils/common';
import { floatPromise, 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;
}
Expand All @@ -55,4 +43,49 @@ export class UserListener extends Listener {

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();
}
});
}
}
43 changes: 24 additions & 19 deletions src/listeners/guilds/members/notMutedMemberAddNotify.ts
Original file line number Diff line number Diff line change
@@ -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();
}
});
}
}
67 changes: 35 additions & 32 deletions src/listeners/guilds/members/rawMemberRemoveNotify.ts
Original file line number Diff line number Diff line change
@@ -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();

Expand All @@ -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;
}
}

Expand Down

0 comments on commit a419783

Please sign in to comment.