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
+117
View File
@@ -0,0 +1,117 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
/* eslint-disable unicorn/no-keyword-prefix -- old/new prefixes are the established Discord.js event parameter names */
/* eslint-disable max-lines-per-function -- Event handler checks multiple independent change types */
/* eslint-disable complexity -- Event handler branches cover distinct member update scenarios */
/* eslint-disable max-statements -- Event handler has many intermediate variables across change types */
import { logActivity } from "../modules/logActivity.js";
import { logger } from "../utils/logger.js";
import type { GuildMember, PartialGuildMember } from "discord.js";
/**
* Logs member update events (roles, nickname, server avatar) to the activity
* log channel. Skips bots and partial old-member states where comparison is
* unreliable.
* @param oldMember - The guild member before the update.
* @param newMember - The guild member after the update.
* @returns A promise that resolves when all applicable changes have been logged.
*/
export const onGuildMemberUpdate = async(
oldMember: GuildMember | PartialGuildMember,
newMember: GuildMember,
): Promise<void> => {
if (newMember.user.bot) {
return;
}
if (oldMember.partial) {
return;
}
const { username, id: userId } = newMember.user;
try {
if (oldMember.nickname !== newMember.nickname) {
const before = oldMember.nickname ?? "*(none)*";
const after = newMember.nickname ?? "*(none)*";
await logActivity({
client: newMember.client,
emoji: "📝",
fields: [
`**User**: ${username} (\`${userId}\`)`,
`**Before**: ${before}`,
`**After**: ${after}`,
].join("\n"),
title: "Nickname Changed",
});
}
const addedRoles = newMember.roles.cache.filter(
(role) => {
return !oldMember.roles.cache.has(role.id);
},
);
if (addedRoles.size > 0) {
const roleList = addedRoles.map((role) => {
return `<@&${role.id}>`;
}).join(", ");
await logActivity({
client: newMember.client,
emoji: "",
fields: [
`**User**: ${username} (\`${userId}\`)`,
`**Roles Added**: ${roleList}`,
].join("\n"),
title: "Roles Added",
});
}
const removedRoles = oldMember.roles.cache.filter(
(role) => {
return !newMember.roles.cache.has(role.id);
},
);
if (removedRoles.size > 0) {
const roleList = removedRoles.map((role) => {
return `<@&${role.id}>`;
}).join(", ");
await logActivity({
client: newMember.client,
emoji: "",
fields: [
`**User**: ${username} (\`${userId}\`)`,
`**Roles Removed**: ${roleList}`,
].join("\n"),
title: "Roles Removed",
});
}
if (oldMember.avatar !== newMember.avatar) {
await logActivity({
client: newMember.client,
emoji: "🖼️",
fields: [
`**User**: ${username} (\`${userId}\`)`,
`**New Server Avatar**: ${newMember.displayAvatarURL()}`,
].join("\n"),
title: "Server Avatar Changed",
});
}
} catch (error) {
await logger.error(
"Failed to log member update",
error instanceof Error
? error
: new Error(String(error)),
);
}
};