Files
keiko/src/events/guildMemberUpdate.ts
T
hikari 1c31a49bc4
Node.js CI / CI (push) Successful in 29s
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 55s
feat: rewrite as moderation bot (#11)
## 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>
2026-03-24 20:35:26 -07:00

118 lines
3.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* @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)),
);
}
};