Files
keiko/src/commands/mute.ts
T
hikari 453ebd0f15
Node.js CI / CI (push) Successful in 28s
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 2m12s
feat: sanction DM links and per-event colour coding (#13)
## Summary

- Adds resource links (appeal form, sanction logs, contact page, community invite) to all sanction DMs, separated from the sanction text by a Components v2 separator
- Adds a unique accent colour for every mod log and activity log event type, giving each action a distinct visual identity at a glance

## Changes

- `src/utils/components.ts` — Added `sanctionDmMessage` helper with two-section container (sanction text + links); added full `Colours` palette covering all sanction and activity event types; added `ColourKey` export
- `src/commands/{ban,kick,mute,softban,warn}.ts` — Updated DMs to use `sanctionDmMessage` with the appropriate colour
- `src/modules/logModAction.ts` / `logActivity.ts` — Thread `colour` parameter through to message builders
- All event and command files updated with their respective colours

 This PR was created with help from Hikari~ 🌸

Reviewed-on: #13
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
2026-03-31 17:33:35 -07:00

195 lines
5.5 KiB
TypeScript

/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
/* eslint-disable max-lines-per-function -- Command handlers have many validation and action steps */
/* eslint-disable max-statements -- Command handlers have many validation and action steps */
/* eslint-disable complexity -- Command handlers have multiple validation branches */
import {
InteractionContextType,
PermissionFlagsBits,
SlashCommandBuilder,
} from "discord.js";
import { logModerationAction } from "../modules/logModAction.js";
import { sendSanction } from "../modules/sendSanction.js";
import {
errorReply,
sanctionDmMessage,
successReply,
} from "../utils/components.js";
import { logger } from "../utils/logger.js";
import type { Command } from "../interfaces/command.js";
/**
* Duration choices in seconds mapped to human-readable labels.
*/
const durationChoices = [
{ name: "1 Minute", value: "60" },
{ name: "5 Minutes", value: "300" },
{ name: "10 Minutes", value: "600" },
{ name: "30 Minutes", value: "1800" },
{ name: "1 Hour", value: "3600" },
{ name: "6 Hours", value: "21600" },
{ name: "12 Hours", value: "43200" },
{ name: "1 Day", value: "86400" },
{ name: "3 Days", value: "259200" },
{ name: "7 Days", value: "604800" },
{ name: "14 Days", value: "1209600" },
{ name: "28 Days", value: "2419200" },
] as const;
const muteCommand: Command = {
data: new SlashCommandBuilder().
setName("mute").
setDescription("Apply a timeout to a member.").
setDefaultMemberPermissions(PermissionFlagsBits.ModerateMembers).
setContexts([ InteractionContextType.Guild ]).
addUserOption((option) => {
return option.
setName("user").
setDescription("The member to mute.").
setRequired(true);
}).
addStringOption((option) => {
return option.
setName("duration").
setDescription("How long to mute the member.").
setRequired(true).
addChoices(...durationChoices);
}).
addStringOption((option) => {
return option.
setName("reason").
setDescription("The reason for the mute.").
setRequired(true).
setMaxLength(512);
}),
execute: async(interaction) => {
await interaction.deferReply({ ephemeral: true });
const hasPermission = interaction.memberPermissions?.has(
PermissionFlagsBits.ModerateMembers,
);
if (hasPermission !== true) {
await interaction.editReply(
errorReply(
"Insufficient Permissions",
"You do not have permission to use this command.",
),
);
return;
}
const target = interaction.options.getUser("user", true);
const durationSeconds = Number.parseInt(
interaction.options.getString("duration", true),
10,
);
const reason = interaction.options.getString("reason", true);
const durationLabel
= durationChoices.find((choice) => {
return choice.value === String(durationSeconds);
})?.
name ?? `${durationSeconds.toString()}s`;
if (target.bot) {
await interaction.editReply(
errorReply("Invalid Target", "You cannot mute a bot."),
);
return;
}
if (target.id === interaction.user.id) {
await interaction.editReply(
errorReply("Invalid Target", "You cannot mute yourself."),
);
return;
}
const member = interaction.guild?.members.cache.get(target.id);
if (!member) {
await interaction.editReply(
errorReply("Member Not Found", "That user is not in this server."),
);
return;
}
const selfMember = interaction.guild?.members.cache.get(
interaction.user.id,
);
if (
selfMember
&& member.roles.highest.position >= selfMember.roles.highest.position
) {
await interaction.editReply(
errorReply(
"Insufficient Permissions",
"You cannot mute someone with an equal or higher role.",
),
);
return;
}
const milliseconds = durationSeconds * 1000;
const timeoutUntil = new Date(Date.now() + milliseconds);
try {
await member.disableCommunicationUntil(timeoutUntil, reason);
} catch {
await interaction.editReply(
errorReply(
"Action Failed",
"Failed to apply the timeout. "
+ "Check my permissions and role hierarchy.",
),
);
return;
}
const sanctionNumber = await sendSanction({
reason: reason,
type: "mute",
username: target.username,
uuid: target.id,
});
try {
await target.send(
sanctionDmMessage(
`You have been muted in **${interaction.guild?.name ?? "the server"}** for **${durationLabel}**.\n**Reason:** ${reason}`,
"mute",
),
);
} catch {
// DMs may be closed; continue without failing the command.
}
await logModerationAction(interaction.client, {
action: "Member Muted",
colour: "mute",
emoji: "🔇",
moderatorTag: interaction.user.username,
reason: reason,
sanctionNumber: sanctionNumber,
source: "Command",
targetId: target.id,
targetTag: target.username,
});
await interaction.editReply(
successReply(
"Member Muted",
`**User**: ${target.username} (\`${target.id}\`)\n**Duration**: ${durationLabel}\n**Reason**: ${reason}`,
),
);
},
};
void logger.log("debug", "Mute command loaded.");
export { muteCommand };