generated from nhcarrigan/template
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>
This commit was merged in pull request #11.
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -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 };
|
||||
Reference in New Issue
Block a user