feat: sanction DM links and per-event colour coding #13

Merged
naomi merged 2 commits from feat/sanction-dm-links into main 2026-03-31 17:33:35 -07:00
20 changed files with 140 additions and 23 deletions
+10 -2
View File
@@ -14,7 +14,11 @@ import {
} from "discord.js"; } from "discord.js";
import { logModerationAction } from "../modules/logModAction.js"; import { logModerationAction } from "../modules/logModAction.js";
import { sendSanction } from "../modules/sendSanction.js"; import { sendSanction } from "../modules/sendSanction.js";
import { errorReply, successReply } from "../utils/components.js"; import {
errorReply,
sanctionDmMessage,
successReply,
} from "../utils/components.js";
import { logger } from "../utils/logger.js"; import { logger } from "../utils/logger.js";
import type { Command } from "../interfaces/command.js"; import type { Command } from "../interfaces/command.js";
@@ -103,7 +107,10 @@ const banCommand: Command = {
try { try {
await target.send( await target.send(
`You have been banned from **${interaction.guild?.name ?? "the server"}**.\n**Reason:** ${reason}`, sanctionDmMessage(
`You have been banned from **${interaction.guild?.name ?? "the server"}**.\n**Reason:** ${reason}`,
"ban",
),
); );
} catch { } catch {
// DMs may be closed; continue without failing the command. // DMs may be closed; continue without failing the command.
@@ -134,6 +141,7 @@ const banCommand: Command = {
await logModerationAction(interaction.client, { await logModerationAction(interaction.client, {
action: "Member Banned", action: "Member Banned",
colour: "ban",
emoji: "🔨", emoji: "🔨",
moderatorTag: interaction.user.username, moderatorTag: interaction.user.username,
reason: reason, reason: reason,
+10 -2
View File
@@ -14,7 +14,11 @@ import {
} from "discord.js"; } from "discord.js";
import { logModerationAction } from "../modules/logModAction.js"; import { logModerationAction } from "../modules/logModAction.js";
import { sendSanction } from "../modules/sendSanction.js"; import { sendSanction } from "../modules/sendSanction.js";
import { errorReply, successReply } from "../utils/components.js"; import {
errorReply,
sanctionDmMessage,
successReply,
} from "../utils/components.js";
import { logger } from "../utils/logger.js"; import { logger } from "../utils/logger.js";
import type { Command } from "../interfaces/command.js"; import type { Command } from "../interfaces/command.js";
@@ -98,7 +102,10 @@ const kickCommand: Command = {
try { try {
await target.send( await target.send(
`You have been kicked from **${interaction.guild?.name ?? "the server"}**.\n**Reason:** ${reason}`, sanctionDmMessage(
`You have been kicked from **${interaction.guild?.name ?? "the server"}**.\n**Reason:** ${reason}`,
"kick",
),
); );
} catch { } catch {
// DMs may be closed; continue without failing the command. // DMs may be closed; continue without failing the command.
@@ -125,6 +132,7 @@ const kickCommand: Command = {
await logModerationAction(interaction.client, { await logModerationAction(interaction.client, {
action: "Member Kicked", action: "Member Kicked",
colour: "kick",
emoji: "👢", emoji: "👢",
moderatorTag: interaction.user.username, moderatorTag: interaction.user.username,
reason: reason, reason: reason,
+10 -2
View File
@@ -14,7 +14,11 @@ import {
} from "discord.js"; } from "discord.js";
import { logModerationAction } from "../modules/logModAction.js"; import { logModerationAction } from "../modules/logModAction.js";
import { sendSanction } from "../modules/sendSanction.js"; import { sendSanction } from "../modules/sendSanction.js";
import { errorReply, successReply } from "../utils/components.js"; import {
errorReply,
sanctionDmMessage,
successReply,
} from "../utils/components.js";
import { logger } from "../utils/logger.js"; import { logger } from "../utils/logger.js";
import type { Command } from "../interfaces/command.js"; import type { Command } from "../interfaces/command.js";
@@ -155,7 +159,10 @@ const muteCommand: Command = {
try { try {
await target.send( await target.send(
`You have been muted in **${interaction.guild?.name ?? "the server"}** for **${durationLabel}**.\n**Reason:** ${reason}`, sanctionDmMessage(
`You have been muted in **${interaction.guild?.name ?? "the server"}** for **${durationLabel}**.\n**Reason:** ${reason}`,
"mute",
),
); );
} catch { } catch {
// DMs may be closed; continue without failing the command. // DMs may be closed; continue without failing the command.
@@ -163,6 +170,7 @@ const muteCommand: Command = {
await logModerationAction(interaction.client, { await logModerationAction(interaction.client, {
action: "Member Muted", action: "Member Muted",
colour: "mute",
emoji: "🔇", emoji: "🔇",
moderatorTag: interaction.user.username, moderatorTag: interaction.user.username,
reason: reason, reason: reason,
+1
View File
@@ -102,6 +102,7 @@ const pruneCommand: Command = {
await logModerationAction(interaction.client, { await logModerationAction(interaction.client, {
action: "Messages Pruned", action: "Messages Pruned",
colour: "prune",
emoji: "🗑️", emoji: "🗑️",
moderatorTag: interaction.user.username, moderatorTag: interaction.user.username,
reason: `Bulk delete of ${deletedCount.toString()} messages in <#${rawChannel.id}>`, reason: `Bulk delete of ${deletedCount.toString()} messages in <#${rawChannel.id}>`,
+10 -2
View File
@@ -14,7 +14,11 @@ import {
} from "discord.js"; } from "discord.js";
import { logModerationAction } from "../modules/logModAction.js"; import { logModerationAction } from "../modules/logModAction.js";
import { sendSanction } from "../modules/sendSanction.js"; import { sendSanction } from "../modules/sendSanction.js";
import { errorReply, successReply } from "../utils/components.js"; import {
errorReply,
sanctionDmMessage,
successReply,
} from "../utils/components.js";
import { logger } from "../utils/logger.js"; import { logger } from "../utils/logger.js";
import type { Command } from "../interfaces/command.js"; import type { Command } from "../interfaces/command.js";
@@ -94,7 +98,10 @@ const softbanCommand: Command = {
try { try {
await target.send( await target.send(
`You have been softbanned from **${interaction.guild?.name ?? "the server"}** (your recent messages have been removed).\n**Reason:** ${reason}`, sanctionDmMessage(
`You have been softbanned from **${interaction.guild?.name ?? "the server"}** (your recent messages have been removed).\n**Reason:** ${reason}`,
"softban",
),
); );
} catch { } catch {
// DMs may be closed; continue without failing the command. // DMs may be closed; continue without failing the command.
@@ -130,6 +137,7 @@ const softbanCommand: Command = {
await logModerationAction(interaction.client, { await logModerationAction(interaction.client, {
action: "Member Softbanned", action: "Member Softbanned",
colour: "softban",
emoji: "đź§ą", emoji: "đź§ą",
moderatorTag: interaction.user.username, moderatorTag: interaction.user.username,
reason: reason, reason: reason,
+1
View File
@@ -84,6 +84,7 @@ const unbanCommand: Command = {
await logModerationAction(interaction.client, { await logModerationAction(interaction.client, {
action: "Member Unbanned", action: "Member Unbanned",
colour: "unban",
emoji: "🔓", emoji: "🔓",
moderatorTag: interaction.user.username, moderatorTag: interaction.user.username,
reason: reason, reason: reason,
+1
View File
@@ -103,6 +103,7 @@ const unmuteCommand: Command = {
await logModerationAction(interaction.client, { await logModerationAction(interaction.client, {
action: "Member Unmuted", action: "Member Unmuted",
colour: "unmute",
emoji: "🔊", emoji: "🔊",
moderatorTag: interaction.user.username, moderatorTag: interaction.user.username,
reason: reason, reason: reason,
+10 -2
View File
@@ -14,7 +14,11 @@ import {
} from "discord.js"; } from "discord.js";
import { logModerationAction } from "../modules/logModAction.js"; import { logModerationAction } from "../modules/logModAction.js";
import { sendSanction } from "../modules/sendSanction.js"; import { sendSanction } from "../modules/sendSanction.js";
import { errorReply, successReply } from "../utils/components.js"; import {
errorReply,
sanctionDmMessage,
successReply,
} from "../utils/components.js";
import { logger } from "../utils/logger.js"; import { logger } from "../utils/logger.js";
import type { Command } from "../interfaces/command.js"; import type { Command } from "../interfaces/command.js";
@@ -99,7 +103,10 @@ const warnCommand: Command = {
try { try {
await target.send( await target.send(
`You have received a warning in **${interaction.guild?.name ?? "the server"}**.\n**Reason:** ${reason}`, sanctionDmMessage(
`You have received a warning in **${interaction.guild?.name ?? "the server"}**.\n**Reason:** ${reason}`,
"warn",
),
); );
} catch { } catch {
// DMs may be closed; continue without failing the command. // DMs may be closed; continue without failing the command.
@@ -107,6 +114,7 @@ const warnCommand: Command = {
await logModerationAction(interaction.client, { await logModerationAction(interaction.client, {
action: "Warning Issued", action: "Warning Issued",
colour: "warn",
emoji: "⚠️", emoji: "⚠️",
moderatorTag: interaction.user.username, moderatorTag: interaction.user.username,
reason: reason, reason: reason,
+4
View File
@@ -57,6 +57,7 @@ const handleManualUnban = async(
await logModerationAction(client, { await logModerationAction(client, {
action: "Member Unbanned", action: "Member Unbanned",
colour: "unban",
emoji: "🔓", emoji: "🔓",
moderatorTag: moderatorTag, moderatorTag: moderatorTag,
reason: resolvedReason, reason: resolvedReason,
@@ -98,6 +99,7 @@ const handleManualBan = async(
await logModerationAction(client, { await logModerationAction(client, {
action: "Member Banned", action: "Member Banned",
colour: "ban",
emoji: "🔨", emoji: "🔨",
moderatorTag: moderatorTag, moderatorTag: moderatorTag,
reason: resolvedReason, reason: resolvedReason,
@@ -139,6 +141,7 @@ const handleManualKick = async(
await logModerationAction(client, { await logModerationAction(client, {
action: "Member Kicked", action: "Member Kicked",
colour: "kick",
emoji: "👢", emoji: "👢",
moderatorTag: moderatorTag, moderatorTag: moderatorTag,
reason: resolvedReason, reason: resolvedReason,
@@ -191,6 +194,7 @@ const handleManualTimeout = async(
await logModerationAction(client, { await logModerationAction(client, {
action: "Member Muted", action: "Member Muted",
colour: "mute",
emoji: "🔇", emoji: "🔇",
moderatorTag: moderatorTag, moderatorTag: moderatorTag,
reason: resolvedReason, reason: resolvedReason,
+4
View File
@@ -41,6 +41,7 @@ export const onGuildMemberUpdate = async(
await logActivity({ await logActivity({
client: newMember.client, client: newMember.client,
colour: "nicknameChange",
emoji: "📝", emoji: "📝",
fields: [ fields: [
`**User**: ${username} (\`${userId}\`)`, `**User**: ${username} (\`${userId}\`)`,
@@ -64,6 +65,7 @@ export const onGuildMemberUpdate = async(
await logActivity({ await logActivity({
client: newMember.client, client: newMember.client,
colour: "rolesAdded",
emoji: "âž•", emoji: "âž•",
fields: [ fields: [
`**User**: ${username} (\`${userId}\`)`, `**User**: ${username} (\`${userId}\`)`,
@@ -86,6 +88,7 @@ export const onGuildMemberUpdate = async(
await logActivity({ await logActivity({
client: newMember.client, client: newMember.client,
colour: "rolesRemoved",
emoji: "âž–", emoji: "âž–",
fields: [ fields: [
`**User**: ${username} (\`${userId}\`)`, `**User**: ${username} (\`${userId}\`)`,
@@ -98,6 +101,7 @@ export const onGuildMemberUpdate = async(
if (oldMember.avatar !== newMember.avatar) { if (oldMember.avatar !== newMember.avatar) {
await logActivity({ await logActivity({
client: newMember.client, client: newMember.client,
colour: "serverAvatar",
emoji: "🖼️", emoji: "🖼️",
fields: [ fields: [
`**User**: ${username} (\`${userId}\`)`, `**User**: ${username} (\`${userId}\`)`,
+1
View File
@@ -54,6 +54,7 @@ export const onMessageDelete = async(
await logActivity({ await logActivity({
client: message.client, client: message.client,
colour: "messageDeleted",
emoji: "🗑️", emoji: "🗑️",
fields: fields, fields: fields,
title: "Message Deleted", title: "Message Deleted",
+1
View File
@@ -53,6 +53,7 @@ export const onMessageUpdate = async(
await logActivity({ await logActivity({
client: newMessage.client, client: newMessage.client,
colour: "messageEdited",
emoji: "✏️", emoji: "✏️",
fields: fields, fields: fields,
title: "Message Edited", title: "Message Edited",
+1
View File
@@ -27,6 +27,7 @@ export const onThreadCreate = async(
await logActivity({ await logActivity({
client: thread.client, client: thread.client,
colour: "threadCreated",
emoji: "đź§µ", emoji: "đź§µ",
fields: fields, fields: fields,
title: "Thread Created", title: "Thread Created",
+1
View File
@@ -24,6 +24,7 @@ export const onThreadDelete = async(
await logActivity({ await logActivity({
client: thread.client, client: thread.client,
colour: "threadDeleted",
emoji: "🗑️", emoji: "🗑️",
fields: fields, fields: fields,
title: "Thread Deleted", title: "Thread Deleted",
+1
View File
@@ -52,6 +52,7 @@ export const onThreadUpdate = async(
await logActivity({ await logActivity({
client: newThread.client, client: newThread.client,
colour: "threadUpdated",
emoji: "🔄", emoji: "🔄",
fields: fields, fields: fields,
title: "Thread Updated", title: "Thread Updated",
+3
View File
@@ -29,6 +29,7 @@ export const onUserUpdate = async(
if (oldUser.username !== newUser.username) { if (oldUser.username !== newUser.username) {
await logActivity({ await logActivity({
client: newUser.client, client: newUser.client,
colour: "usernameChanged",
emoji: "✏️", emoji: "✏️",
fields: [ fields: [
`**User**: ${newUser.username} (\`${newUser.id}\`)`, `**User**: ${newUser.username} (\`${newUser.id}\`)`,
@@ -42,6 +43,7 @@ export const onUserUpdate = async(
if (oldUser.globalName !== newUser.globalName) { if (oldUser.globalName !== newUser.globalName) {
await logActivity({ await logActivity({
client: newUser.client, client: newUser.client,
colour: "displayName",
emoji: "đź“›", emoji: "đź“›",
fields: [ fields: [
`**User**: ${newUser.username} (\`${newUser.id}\`)`, `**User**: ${newUser.username} (\`${newUser.id}\`)`,
@@ -55,6 +57,7 @@ export const onUserUpdate = async(
if (oldUser.avatar !== newUser.avatar) { if (oldUser.avatar !== newUser.avatar) {
await logActivity({ await logActivity({
client: newUser.client, client: newUser.client,
colour: "avatarChanged",
emoji: "🖼️", emoji: "🖼️",
fields: [ fields: [
`**User**: ${newUser.username} (\`${newUser.id}\`)`, `**User**: ${newUser.username} (\`${newUser.id}\`)`,
+1
View File
@@ -60,6 +60,7 @@ export const onVoiceStateUpdate = async(
await logActivity({ await logActivity({
client: user.client, client: user.client,
colour: "voiceState",
emoji: "🔊", emoji: "🔊",
fields: fields, fields: fields,
title: "Voice State Update", title: "Voice State Update",
+5 -2
View File
@@ -6,11 +6,12 @@
import { ChannelType, type Client } from "discord.js"; import { ChannelType, type Client } from "discord.js";
import { channelConfig } from "../config/channels.js"; import { channelConfig } from "../config/channels.js";
import { activityMessage } from "../utils/components.js"; import { activityMessage, type ColourKey } from "../utils/components.js";
import { logger } from "../utils/logger.js"; import { logger } from "../utils/logger.js";
interface ActivityData { interface ActivityData {
readonly client: Client; readonly client: Client;
readonly colour: ColourKey;
readonly emoji: string; readonly emoji: string;
readonly fields: string; readonly fields: string;
readonly title: string; readonly title: string;
@@ -35,7 +36,9 @@ const logActivity = async(data: ActivityData): Promise<void> => {
return; return;
} }
await rawChannel.send(activityMessage(data.emoji, data.title, data.fields)); await rawChannel.send(
activityMessage(data.emoji, data.title, data.fields, data.colour),
);
} catch (error) { } catch (error) {
await logger.error( await logger.error(
"Failed to log activity", "Failed to log activity",
+3 -2
View File
@@ -7,11 +7,12 @@
import { ChannelType, type Client } from "discord.js"; import { ChannelType, type Client } from "discord.js";
import { channelConfig } from "../config/channels.js"; import { channelConfig } from "../config/channels.js";
import { modLogMessage } from "../utils/components.js"; import { modLogMessage, type ColourKey } from "../utils/components.js";
import { logger } from "../utils/logger.js"; import { logger } from "../utils/logger.js";
interface ModuleActionData { interface ModuleActionData {
readonly action: string; readonly action: string;
readonly colour: ColourKey;
readonly emoji: string; readonly emoji: string;
readonly moderatorTag: string; readonly moderatorTag: string;
readonly reason: string; readonly reason: string;
@@ -57,7 +58,7 @@ const logModerationAction = async(
join("\n"); join("\n");
await rawChannel.send( await rawChannel.send(
modLogMessage(`${data.emoji} ${data.action}`, fields, data.source), modLogMessage(`${data.emoji} ${data.action}`, fields, data.source, data.colour),
); );
} catch (error) { } catch (error) {
await logger.error( await logger.error(
+62 -9
View File
@@ -27,13 +27,33 @@ const MessageFlags = {
} as const; } as const;
const Colours = { const Colours = {
error: 0xED_42_45, avatarChanged: 0xEC_40_7A,
info: 0x58_65_F2, ban: 0xE7_4C_3C,
join: 0x57_F2_87, displayName: 0xAB_47_BC,
leave: 0x99_AA_B5, error: 0xED_42_45,
modAction: 0xE6_7E_22, info: 0x58_65_F2,
success: 0x57_F2_87, join: 0x57_F2_87,
warning: 0xFE_E7_5C, kick: 0xE6_7E_22,
leave: 0x99_AA_B5,
messageDeleted: 0xC0_39_2B,
messageEdited: 0x29_80_B9,
mute: 0xF3_9C_12,
nicknameChange: 0x9B_59_B6,
prune: 0x34_98_DB,
rolesAdded: 0x27_AE_60,
rolesRemoved: 0xE7_4C_3C,
serverAvatar: 0x16_A0_85,
softban: 0xD3_54_00,
success: 0x57_F2_87,
threadCreated: 0x00_BC_D4,
threadDeleted: 0x54_6E_7A,
threadUpdated: 0x5D_AD_E2,
unban: 0x2E_CC_71,
unmute: 0x1A_BC_9C,
usernameChanged: 0x7E_57_C2,
voiceState: 0x43_B5_81,
warn: 0xF1_C4_0F,
warning: 0xFE_E7_5C,
} as const; } as const;
/* eslint-enable @typescript-eslint/naming-convention -- Enum-style constants use PascalCase by convention */ /* eslint-enable @typescript-eslint/naming-convention -- Enum-style constants use PascalCase by convention */
@@ -106,18 +126,21 @@ const errorReply = (title: string, body: string): Record<string, unknown> => {
* @param title - The formatted title string (emoji + action combined). * @param title - The formatted title string (emoji + action combined).
* @param fields - The formatted field lines for the log entry. * @param fields - The formatted field lines for the log entry.
* @param source - Whether the action originated from a command or the audit log. * @param source - Whether the action originated from a command or the audit log.
* @param colour - The accent colour for the container.
* @returns A Discord message payload object. * @returns A Discord message payload object.
*/ */
const modLogMessage = ( const modLogMessage = (
title: string, title: string,
fields: string, fields: string,
source: "Command" | "Audit Log", source: "Command" | "Audit Log",
colour: ColourKey,
// eslint-disable-next-line @typescript-eslint/max-params -- Four params is the minimum needed for this helper
): Record<string, unknown> => { ): Record<string, unknown> => {
return { return {
allowedMentions: { parse: [] }, allowedMentions: { parse: [] },
components: [ components: [
buildContainer( buildContainer(
"modAction", colour,
`## ${title}`, `## ${title}`,
fields, fields,
`*Source: ${source}*`, `*Source: ${source}*`,
@@ -132,16 +155,19 @@ const modLogMessage = (
* @param emoji - The emoji to prefix the title with. * @param emoji - The emoji to prefix the title with.
* @param title - The title of the activity entry. * @param title - The title of the activity entry.
* @param fields - The formatted field lines. * @param fields - The formatted field lines.
* @param colour - The accent colour for the container.
* @returns A Discord message payload object. * @returns A Discord message payload object.
*/ */
const activityMessage = ( const activityMessage = (
emoji: string, emoji: string,
title: string, title: string,
fields: string, fields: string,
colour: ColourKey,
// eslint-disable-next-line @typescript-eslint/max-params -- Four params is the minimum needed for this helper
): Record<string, unknown> => { ): Record<string, unknown> => {
return { return {
allowedMentions: { parse: [] }, allowedMentions: { parse: [] },
components: [ buildContainer("info", `## ${emoji} ${title}`, fields) ], components: [ buildContainer(colour, `## ${emoji} ${title}`, fields) ],
flags: MessageFlags.IsComponentsV2, flags: MessageFlags.IsComponentsV2,
}; };
}; };
@@ -173,10 +199,37 @@ const memberMessage = (
}; };
}; };
const sanctionLinks = [
// eslint-disable-next-line stylistic/max-len -- URL cannot be shortened
"**Appeal this sanction:** https://forms.nhcarrigan.com/o/docs/forms/4w5VHsYiEkiS2mewvtuJYL/4",
"**View sanction logs:** https://hikari.nhcarrigan.com/sanctions",
"**Contact us:** https://docs.nhcarrigan.com/about/contact/",
"**Rejoin our community:** https://chat.nhcarrigan.com",
].join("\n");
/**
* Builds a Components v2 DM payload for sanction notifications.
* Includes a separator between the sanction details and the resource links.
* @param sanctionText - The formatted sanction message (action + reason).
* @param colour - The accent colour for the container.
* @returns A Discord message payload object.
*/
const sanctionDmMessage = (
sanctionText: string,
colour: ColourKey,
): Record<string, unknown> => {
return {
components: [ buildContainer(colour, sanctionText, sanctionLinks) ],
flags: MessageFlags.IsComponentsV2,
};
};
export type { ColourKey };
export { export {
activityMessage, activityMessage,
errorReply, errorReply,
memberMessage, memberMessage,
modLogMessage, modLogMessage,
sanctionDmMessage,
successReply, successReply,
}; };