feat: rewrite as moderation bot (#11)
Node.js CI / CI (push) Successful in 29s
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 55s

## Summary

- Replaces the old AI companion bot with a full Discord moderation system
- Adds 8 slash commands: `warn`, `mute`, `unmute`, `kick`, `softban`, `ban`, `unban`, `prune`
- Adds logging for member join/leave, activity (messages, threads, voice), and moderation actions
- Audit log integration captures manual bans, kicks, timeouts, and unbans
- All applicable actions post sanctions to the Hikari sanction API
- All commands are ephemeral, use Components v2, and enforce permission + role hierarchy checks

## Test plan

- [ ] Run `pnpm register` to register all 8 commands to the guild
- [ ] Verify each command appears in Discord and is only visible to members with the appropriate permissions
- [ ] Test each command against a valid target and confirm mod log entry, DM notification, and sanction record
- [ ] Test each command against an invalid target (equal/higher role, self, bot) and confirm correct error response
- [ ] Perform a manual ban, kick, and timeout in the Discord UI and confirm audit log handler picks them up
- [ ] Perform a manual unban and confirm it logs correctly without creating a sanction
- [ ] Verify join/leave messages appear in the welcome log channel
- [ ] Verify message edits, deletes, thread events, and voice state changes appear in the activity log channel

 This issue was created with help from Hikari~ 🌸

Reviewed-on: #11
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
This commit was merged in pull request #11.
This commit is contained in:
2026-03-24 20:35:26 -07:00
committed by Naomi Carrigan
parent d44be4880e
commit 1c31a49bc4
53 changed files with 2884 additions and 1456 deletions
+125
View File
@@ -0,0 +1,125 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
/* eslint-disable max-lines-per-function -- Command handlers have many validation and action steps */
import {
InteractionContextType,
PermissionFlagsBits,
SlashCommandBuilder,
type GuildTextBasedChannel,
} from "discord.js";
import { logModerationAction } from "../modules/logModAction.js";
import { errorReply, successReply } from "../utils/components.js";
import { logger } from "../utils/logger.js";
import type { Command } from "../interfaces/command.js";
/**
* Performs a bulk delete on a text channel and returns the number deleted,
* or null if the bulk delete fails.
* Wrapped to handle the type narrowing gap after isTextBased() checks.
* @param channel - The text channel to delete messages from.
* @param amount - The number of messages to delete.
* @returns The number of messages actually deleted, or null on failure.
*/
const bulkDeleteMessages = async(
channel: GuildTextBasedChannel,
amount: number,
): Promise<number | null> => {
try {
const deleted = await channel.bulkDelete(amount, true);
return deleted.size;
} catch {
return null;
}
};
const pruneCommand: Command = {
data: new SlashCommandBuilder().
setName("prune").
setDescription("Delete the last N messages in this channel.").
setDefaultMemberPermissions(PermissionFlagsBits.ManageMessages).
setContexts([ InteractionContextType.Guild ]).
addIntegerOption((option) => {
return option.
setName("amount").
setDescription("Number of messages to delete (1100).").
setRequired(true).
setMinValue(1).
setMaxValue(100);
}),
execute: async(interaction) => {
await interaction.deferReply({ ephemeral: true });
const hasPermission = interaction.memberPermissions?.has(
PermissionFlagsBits.ManageMessages,
);
if (hasPermission !== true) {
await interaction.editReply(
errorReply(
"Insufficient Permissions",
"You do not have permission to use this command.",
),
);
return;
}
const amount = interaction.options.getInteger("amount", true);
const rawChannel = interaction.channel;
if (rawChannel?.isTextBased() !== true) {
await interaction.editReply(
errorReply(
"Invalid Channel",
"This command can only be used in text channels.",
),
);
return;
}
const channelName = "name" in rawChannel
? String(rawChannel.name)
: rawChannel.id;
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- rawChannel is narrowed by isTextBased() but the type union is not fully resolved */
const textChannel = rawChannel as GuildTextBasedChannel;
const deletedCount = await bulkDeleteMessages(textChannel, amount);
if (deletedCount === null) {
await interaction.editReply(
errorReply(
"Action Failed",
"Failed to delete messages. "
+ "Messages older than 14 days cannot be bulk deleted.",
),
);
return;
}
await logModerationAction(interaction.client, {
action: "Messages Pruned",
emoji: "🗑️",
moderatorTag: interaction.user.username,
reason: `Bulk delete of ${deletedCount.toString()} messages in <#${rawChannel.id}>`,
sanctionNumber: null,
source: "Command",
targetId: rawChannel.id,
targetTag: `#${channelName}`,
});
await interaction.editReply(
successReply(
"Messages Pruned",
`Deleted **${deletedCount.toString()}** messages from <#${rawChannel.id}>.`,
),
);
},
};
void logger.log("debug", "Prune command loaded.");
export { pruneCommand };