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
-47
View File
@@ -1,47 +0,0 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import {
MessageFlags,
type ChatInputCommandInteraction,
} from "discord.js";
import { isNaomiInteraction } from "../utils/isNaomi.js";
import { logger } from "../utils/logger.js";
import { replyToError } from "../utils/replyToError.js";
/**
* Sends a clear message in the DMs.
* @param interaction -- The interaction payload from Discord.
*/
export const clear = async(
interaction: ChatInputCommandInteraction,
): Promise<void> => {
try {
await interaction.deferReply({ flags: [ MessageFlags.Ephemeral ] });
const isNaomi = await isNaomiInteraction(interaction);
if (!isNaomi) {
return;
}
const sent = await interaction.user.send({
content: "<Clear History>",
}).catch(() => {
return null;
});
await interaction.editReply({
content: sent
? "I have added a clear history marker to your DMs."
// eslint-disable-next-line stylistic/max-len -- This is a long string.
: "I was unable to send you a DM. Please ensure your privacy settings allow direct messages.",
});
} catch (error) {
await replyToError(interaction);
if (error instanceof Error) {
await logger.error("about command", error);
}
}
};
-39
View File
@@ -1,39 +0,0 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { MessageFlags, type ChatInputCommandInteraction } from "discord.js";
import { isNaomiInteraction } from "../utils/isNaomi.js";
import { logger } from "../utils/logger.js";
import { replyToError } from "../utils/replyToError.js";
/**
* Sends a DM to start a conversation in case the DM channel is lost.
* @param interaction -- The interaction payload from Discord.
*/
export const dm = async(
interaction: ChatInputCommandInteraction,
): Promise<void> => {
try {
await interaction.deferReply({ flags: [ MessageFlags.Ephemeral ] });
const isNaomi = await isNaomiInteraction(interaction);
if (!isNaomi) {
return;
}
await interaction.user.send(
"Hello Naomi. How may I serve you today?",
);
await interaction.reply({
content: "I've sent you a DM!",
ephemeral: true,
});
} catch (error) {
await replyToError(interaction);
if (error instanceof Error) {
await logger.error("about command", error);
}
}
};
+49
View File
@@ -0,0 +1,49 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { ChannelType, type Client } from "discord.js";
import { channelConfig } from "../config/channels.js";
import { activityMessage } from "../utils/components.js";
import { logger } from "../utils/logger.js";
interface ActivityData {
readonly client: Client;
readonly emoji: string;
readonly fields: string;
readonly title: string;
}
/**
* Posts an activity entry to the configured activity log channel.
* @param data - The activity data to log.
* @returns A promise that resolves when the log entry has been posted.
*/
const logActivity = async(data: ActivityData): Promise<void> => {
try {
const rawChannel = data.client.channels.cache.get(
channelConfig.activityLog,
);
if (rawChannel?.type !== ChannelType.GuildText) {
await logger.error(
"Activity log channel not found",
new Error(`Channel ID: ${channelConfig.activityLog}`),
);
return;
}
await rawChannel.send(activityMessage(data.emoji, data.title, data.fields));
} catch (error) {
await logger.error(
"Failed to log activity",
error instanceof Error
? error
: new Error(String(error)),
);
}
};
export { logActivity };
+73
View File
@@ -0,0 +1,73 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
/* eslint-disable unicorn/prevent-abbreviations -- File name kept short for convention; logModAction is the established name */
import { ChannelType, type Client } from "discord.js";
import { channelConfig } from "../config/channels.js";
import { modLogMessage } from "../utils/components.js";
import { logger } from "../utils/logger.js";
interface ModuleActionData {
readonly action: string;
readonly emoji: string;
readonly moderatorTag: string;
readonly reason: string;
readonly sanctionNumber: number | null;
readonly source: "Audit Log" | "Command";
readonly targetId: string;
readonly targetTag: string;
}
/**
* Posts a moderation action entry to the configured mod log channel.
* @param client - The Discord client instance.
* @param data - The moderation action data to log.
* @returns A promise that resolves when the log entry has been posted.
*/
const logModerationAction = async(
client: Client,
data: ModuleActionData,
): Promise<void> => {
try {
const rawChannel = client.channels.cache.get(channelConfig.modLog);
if (rawChannel?.type !== ChannelType.GuildText) {
await logger.error(
"Mod log channel not found",
new Error(`Channel ID: ${channelConfig.modLog}`),
);
return;
}
const sanctionLine
= data.sanctionNumber === null
? ""
: `\n**Case**: #${data.sanctionNumber.toString()}`;
const fields = [
`**Moderator**: ${data.moderatorTag}`,
`**Target**: ${data.targetTag} (\`${data.targetId}\`)`,
`**Reason**: ${data.reason}`,
sanctionLine,
].
filter(Boolean).
join("\n");
await rawChannel.send(
modLogMessage(`${data.emoji} ${data.action}`, fields, data.source),
);
} catch (error) {
await logger.error(
"Failed to log mod action",
error instanceof Error
? error
: new Error(String(error)),
);
}
};
export type { ModuleActionData };
export { logModerationAction };
-79
View File
@@ -1,79 +0,0 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { personality } from "../config/personality.js";
import { ai } from "../utils/ai.js";
import type {
MessageParam,
Usage,
} from "@anthropic-ai/sdk/resources/messages.js";
/**
* Makes an AI request to the Anthropic API.
* @param context - The message context to send to the API.
* @param username - The username of the user making the request.
* @returns The content of the response and the usage.
*/
// eslint-disable-next-line max-lines-per-function -- The formatting ruins it.
export const makeAiRequest = async(
context: Array<MessageParam>,
username: string,
): Promise<{ content: Array<string>; usage: Usage }> => {
const response = await ai.messages.create({
// eslint-disable-next-line @typescript-eslint/naming-convention -- Required key format for SDK.
max_tokens: 3000,
messages: context,
model: "claude-sonnet-4-5-20250929",
system: `${personality} The user's name is ${username}`,
temperature: 1,
tools: [ {
// eslint-disable-next-line @typescript-eslint/naming-convention -- Required key format for SDK.
max_uses: 5,
name: "web_search",
type: "web_search_20250305",
} ],
});
const { usage } = response;
const content = response.content.map((message) => {
if (message.type === "text") {
if (
message.citations?.length !== undefined
&& message.citations.length > 0
) {
return `**${message.text}**\n\n-# ${message.citations.
filter((citation) => {
return citation.type === "web_search_result_location";
}).
map((citation) => {
return `${citation.title ?? "Unknown Title"}\n${citation.url}`;
}).
join(", ")}`;
}
return message.text;
}
if (message.type === "server_tool_use") {
return `Searching for: ${JSON.stringify(message.input)}`;
}
if (message.type === "web_search_tool_result") {
if (!Array.isArray(message.content)) {
return `-# Found: ${JSON.stringify(message.content)}`;
}
return `-# Found: ${message.content.
map((entry) => {
return `[${entry.title}](<${entry.url}>)`;
}).
join(", ")}`;
}
if (message.type === "thinking") {
return `-# Thinking: ${message.thinking}`;
}
if (message.type === "redacted_thinking") {
return `-# Thinking: [Redacted]`;
}
return `-# Tool use: ${message.name}`;
});
return { content, usage };
};
-35
View File
@@ -1,35 +0,0 @@
/* eslint-disable no-await-in-loop -- This is necessary so we can send the responses sequentially.*/
/**
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { sleep } from "../utils/sleep.js";
import type { DMChannel, GuildTextBasedChannel, Message } from "discord.js";
/**
* Sends an AI response to a channel.
* @param content - The content to send.
* @param send - The send or reply function to use.
* @param type - The sendTyping function to use.
*/
export const sendAiResponse = async(
content: Array<string>,
send: GuildTextBasedChannel["send"] | DMChannel["send"] | Message["reply"],
type: GuildTextBasedChannel["sendTyping"],
): Promise<void> => {
const joined = content.join("\n\n");
if (joined.length < 2000) {
await send(joined);
return;
}
const chunks = joined.match(/[\S\s]{1,2000}/g);
if (chunks) {
for (const chunk of chunks) {
await send(chunk);
await type();
await sleep(2500);
}
}
};
+70
View File
@@ -0,0 +1,70 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { logger } from "../utils/logger.js";
type SanctionType = "ban" | "kick" | "mute" | "warning";
interface SanctionPayload {
readonly reason: string;
readonly type: SanctionType;
readonly username: string;
readonly uuid: string;
}
interface SanctionResponse {
readonly message: string;
}
/**
* Sends a sanction record to the Hikari sanction API.
* Returns the assigned sanction number, or null if the request failed.
* @param payload - The sanction payload to send.
* @returns The assigned sanction number, or null if the request failed.
*/
const sendSanction = async(
payload: SanctionPayload,
): Promise<number | null> => {
try {
const response = await fetch("https://hikari.nhcarrigan.com/api/sanction", {
body: JSON.stringify({ ...payload, platform: "discord" }),
headers: {
/* eslint-disable @typescript-eslint/naming-convention -- HTTP header names are not camelCase */
"Authorization": process.env.SANCTION_TOKEN ?? "",
"Content-Type": "application/json",
/* eslint-enable @typescript-eslint/naming-convention -- HTTP header names are not camelCase */
},
method: "POST",
});
if (response.ok) {
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Response shape is validated by the API contract */
const data = (await response.json()) as SanctionResponse;
const match = /(?<id>\d+)/.exec(data.message);
return match?.groups?.id === undefined
? null
: Number.parseInt(match.groups.id, 10);
}
const text = await response.text();
await logger.error(
"Sanction API error",
new Error(`HTTP ${response.status.toString()}: ${text}`),
);
return null;
} catch (error) {
await logger.error(
"Failed to reach sanction API",
error instanceof Error
? error
: new Error(String(error)),
);
return null;
}
};
export type { SanctionType };
export { sendSanction };