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
+157
View File
@@ -0,0 +1,157 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
/* eslint-disable max-lines-per-function -- Command handlers have many validation and action steps */
/* eslint-disable max-statements -- Command handlers have many validation and action steps */
/* eslint-disable complexity -- Command handlers have multiple validation branches */
import {
InteractionContextType,
PermissionFlagsBits,
SlashCommandBuilder,
} from "discord.js";
import { logModerationAction } from "../modules/logModAction.js";
import { sendSanction } from "../modules/sendSanction.js";
import { errorReply, successReply } from "../utils/components.js";
import { logger } from "../utils/logger.js";
import type { Command } from "../interfaces/command.js";
const banCommand: Command = {
data: new SlashCommandBuilder().
setName("ban").
setDescription("Ban a member from the server.").
setDefaultMemberPermissions(PermissionFlagsBits.BanMembers).
setContexts([ InteractionContextType.Guild ]).
addUserOption((option) => {
return option.
setName("user").
setDescription("The member to ban.").
setRequired(true);
}).
addStringOption((option) => {
return option.
setName("reason").
setDescription("The reason for the ban.").
setRequired(true).
setMaxLength(512);
}).
addIntegerOption((option) => {
return option.
setName("days").
setDescription(
"Days of message history to delete (07). Defaults to 0.",
).
setRequired(false).
setMinValue(0).
setMaxValue(7);
}),
execute: async(interaction) => {
await interaction.deferReply({ ephemeral: true });
const hasPermission = interaction.memberPermissions?.has(
PermissionFlagsBits.BanMembers,
);
if (hasPermission !== true) {
await interaction.editReply(
errorReply(
"Insufficient Permissions",
"You do not have permission to use this command.",
),
);
return;
}
const target = interaction.options.getUser("user", true);
const reason = interaction.options.getString("reason", true);
const days = interaction.options.getInteger("days") ?? 0;
if (target.bot) {
await interaction.editReply(
errorReply("Invalid Target", "You cannot ban a bot."),
);
return;
}
if (target.id === interaction.user.id) {
await interaction.editReply(
errorReply("Invalid Target", "You cannot ban yourself."),
);
return;
}
const member = interaction.guild?.members.cache.get(target.id);
if (member) {
const selfMember = interaction.guild?.members.cache.get(
interaction.user.id,
);
if (
selfMember
&& member.roles.highest.position >= selfMember.roles.highest.position
) {
await interaction.editReply(
errorReply(
"Insufficient Permissions",
"You cannot ban someone with an equal or higher role.",
),
);
return;
}
try {
await target.send(
`You have been banned from **${interaction.guild?.name ?? "the server"}**.\n**Reason:** ${reason}`,
);
} catch {
// DMs may be closed; continue without failing the command.
}
}
try {
await interaction.guild?.bans.create(target.id, {
deleteMessageSeconds: days * 86_400,
reason: reason,
});
} catch {
await interaction.editReply(
errorReply(
"Action Failed",
"Failed to ban the member. Check my permissions and role hierarchy.",
),
);
return;
}
const sanctionNumber = await sendSanction({
reason: reason,
type: "ban",
username: target.username,
uuid: target.id,
});
await logModerationAction(interaction.client, {
action: "Member Banned",
emoji: "🔨",
moderatorTag: interaction.user.username,
reason: reason,
sanctionNumber: sanctionNumber,
source: "Command",
targetId: target.id,
targetTag: target.username,
});
await interaction.editReply(
successReply(
"Member Banned",
`**User**: ${target.username} (\`${target.id}\`)\n**Days Deleted**: ${days.toString()}\n**Reason**: ${reason}`,
),
);
},
};
void logger.log("debug", "Ban command loaded.");
export { banCommand };
-24
View File
@@ -1,24 +0,0 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import {
ApplicationIntegrationType,
SlashCommandBuilder,
InteractionContextType,
} from "discord.js";
const command = new SlashCommandBuilder().
setContexts(
InteractionContextType.BotDM,
InteractionContextType.Guild,
InteractionContextType.PrivateChannel,
).
setIntegrationTypes(ApplicationIntegrationType.UserInstall).
setName("clear").
setDescription("Clear your current conversation so you can start a new one!");
// eslint-disable-next-line no-console -- We don't need our logger here as this never runs in production.
console.log(JSON.stringify(command.toJSON()));
-26
View File
@@ -1,26 +0,0 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import {
ApplicationIntegrationType,
SlashCommandBuilder,
InteractionContextType,
} from "discord.js";
const command = new SlashCommandBuilder().
setContexts(
InteractionContextType.BotDM,
InteractionContextType.Guild,
InteractionContextType.PrivateChannel,
).
setIntegrationTypes(ApplicationIntegrationType.UserInstall).
setName("dm").
setDescription(
"Did you lose your conversation with me? Run this and I'll reopen it!",
);
// eslint-disable-next-line no-console -- We don't need our logger here as this never runs in production.
console.log(JSON.stringify(command.toJSON()));
+148
View File
@@ -0,0 +1,148 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
/* eslint-disable max-lines-per-function -- Command handlers have many validation and action steps */
/* eslint-disable max-statements -- Command handlers have many validation and action steps */
/* eslint-disable complexity -- Command handlers have multiple validation branches */
import {
InteractionContextType,
PermissionFlagsBits,
SlashCommandBuilder,
} from "discord.js";
import { logModerationAction } from "../modules/logModAction.js";
import { sendSanction } from "../modules/sendSanction.js";
import { errorReply, successReply } from "../utils/components.js";
import { logger } from "../utils/logger.js";
import type { Command } from "../interfaces/command.js";
const kickCommand: Command = {
data: new SlashCommandBuilder().
setName("kick").
setDescription("Kick a member from the server.").
setDefaultMemberPermissions(PermissionFlagsBits.KickMembers).
setContexts([ InteractionContextType.Guild ]).
addUserOption((option) => {
return option.
setName("user").
setDescription("The member to kick.").
setRequired(true);
}).
addStringOption((option) => {
return option.
setName("reason").
setDescription("The reason for the kick.").
setRequired(true).
setMaxLength(512);
}),
execute: async(interaction) => {
await interaction.deferReply({ ephemeral: true });
const hasPermission = interaction.memberPermissions?.has(
PermissionFlagsBits.KickMembers,
);
if (hasPermission !== true) {
await interaction.editReply(
errorReply(
"Insufficient Permissions",
"You do not have permission to use this command.",
),
);
return;
}
const target = interaction.options.getUser("user", true);
const reason = interaction.options.getString("reason", true);
if (target.bot) {
await interaction.editReply(
errorReply("Invalid Target", "You cannot kick a bot."),
);
return;
}
if (target.id === interaction.user.id) {
await interaction.editReply(
errorReply("Invalid Target", "You cannot kick yourself."),
);
return;
}
const member = interaction.guild?.members.cache.get(target.id);
if (!member) {
await interaction.editReply(
errorReply("Member Not Found", "That user is not in this server."),
);
return;
}
const selfMember = interaction.guild?.members.cache.get(
interaction.user.id,
);
if (
selfMember
&& member.roles.highest.position >= selfMember.roles.highest.position
) {
await interaction.editReply(
errorReply(
"Insufficient Permissions",
"You cannot kick someone with an equal or higher role.",
),
);
return;
}
try {
await target.send(
`You have been kicked from **${interaction.guild?.name ?? "the server"}**.\n**Reason:** ${reason}`,
);
} catch {
// DMs may be closed; continue without failing the command.
}
try {
await member.kick(reason);
} catch {
await interaction.editReply(
errorReply(
"Action Failed",
"Failed to kick the member. Check my permissions and role hierarchy.",
),
);
return;
}
const sanctionNumber = await sendSanction({
reason: reason,
type: "kick",
username: target.username,
uuid: target.id,
});
await logModerationAction(interaction.client, {
action: "Member Kicked",
emoji: "👢",
moderatorTag: interaction.user.username,
reason: reason,
sanctionNumber: sanctionNumber,
source: "Command",
targetId: target.id,
targetTag: target.username,
});
await interaction.editReply(
successReply(
"Member Kicked",
`**User**: ${target.username} (\`${target.id}\`)\n**Reason**: ${reason}`,
),
);
},
};
void logger.log("debug", "Kick command loaded.");
export { kickCommand };
+186
View File
@@ -0,0 +1,186 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
/* eslint-disable max-lines-per-function -- Command handlers have many validation and action steps */
/* eslint-disable max-statements -- Command handlers have many validation and action steps */
/* eslint-disable complexity -- Command handlers have multiple validation branches */
import {
InteractionContextType,
PermissionFlagsBits,
SlashCommandBuilder,
} from "discord.js";
import { logModerationAction } from "../modules/logModAction.js";
import { sendSanction } from "../modules/sendSanction.js";
import { errorReply, successReply } from "../utils/components.js";
import { logger } from "../utils/logger.js";
import type { Command } from "../interfaces/command.js";
/**
* Duration choices in seconds mapped to human-readable labels.
*/
const durationChoices = [
{ name: "1 Minute", value: "60" },
{ name: "5 Minutes", value: "300" },
{ name: "10 Minutes", value: "600" },
{ name: "30 Minutes", value: "1800" },
{ name: "1 Hour", value: "3600" },
{ name: "6 Hours", value: "21600" },
{ name: "12 Hours", value: "43200" },
{ name: "1 Day", value: "86400" },
{ name: "3 Days", value: "259200" },
{ name: "7 Days", value: "604800" },
{ name: "14 Days", value: "1209600" },
{ name: "28 Days", value: "2419200" },
] as const;
const muteCommand: Command = {
data: new SlashCommandBuilder().
setName("mute").
setDescription("Apply a timeout to a member.").
setDefaultMemberPermissions(PermissionFlagsBits.ModerateMembers).
setContexts([ InteractionContextType.Guild ]).
addUserOption((option) => {
return option.
setName("user").
setDescription("The member to mute.").
setRequired(true);
}).
addStringOption((option) => {
return option.
setName("duration").
setDescription("How long to mute the member.").
setRequired(true).
addChoices(...durationChoices);
}).
addStringOption((option) => {
return option.
setName("reason").
setDescription("The reason for the mute.").
setRequired(true).
setMaxLength(512);
}),
execute: async(interaction) => {
await interaction.deferReply({ ephemeral: true });
const hasPermission = interaction.memberPermissions?.has(
PermissionFlagsBits.ModerateMembers,
);
if (hasPermission !== true) {
await interaction.editReply(
errorReply(
"Insufficient Permissions",
"You do not have permission to use this command.",
),
);
return;
}
const target = interaction.options.getUser("user", true);
const durationSeconds = Number.parseInt(
interaction.options.getString("duration", true),
10,
);
const reason = interaction.options.getString("reason", true);
const durationLabel
= durationChoices.find((choice) => {
return choice.value === String(durationSeconds);
})?.
name ?? `${durationSeconds.toString()}s`;
if (target.bot) {
await interaction.editReply(
errorReply("Invalid Target", "You cannot mute a bot."),
);
return;
}
if (target.id === interaction.user.id) {
await interaction.editReply(
errorReply("Invalid Target", "You cannot mute yourself."),
);
return;
}
const member = interaction.guild?.members.cache.get(target.id);
if (!member) {
await interaction.editReply(
errorReply("Member Not Found", "That user is not in this server."),
);
return;
}
const selfMember = interaction.guild?.members.cache.get(
interaction.user.id,
);
if (
selfMember
&& member.roles.highest.position >= selfMember.roles.highest.position
) {
await interaction.editReply(
errorReply(
"Insufficient Permissions",
"You cannot mute someone with an equal or higher role.",
),
);
return;
}
const milliseconds = durationSeconds * 1000;
const timeoutUntil = new Date(Date.now() + milliseconds);
try {
await member.disableCommunicationUntil(timeoutUntil, reason);
} catch {
await interaction.editReply(
errorReply(
"Action Failed",
"Failed to apply the timeout. "
+ "Check my permissions and role hierarchy.",
),
);
return;
}
const sanctionNumber = await sendSanction({
reason: reason,
type: "mute",
username: target.username,
uuid: target.id,
});
try {
await target.send(
`You have been muted in **${interaction.guild?.name ?? "the server"}** for **${durationLabel}**.\n**Reason:** ${reason}`,
);
} catch {
// DMs may be closed; continue without failing the command.
}
await logModerationAction(interaction.client, {
action: "Member Muted",
emoji: "🔇",
moderatorTag: interaction.user.username,
reason: reason,
sanctionNumber: sanctionNumber,
source: "Command",
targetId: target.id,
targetTag: target.username,
});
await interaction.editReply(
successReply(
"Member Muted",
`**User**: ${target.username} (\`${target.id}\`)\n**Duration**: ${durationLabel}\n**Reason**: ${reason}`,
),
);
},
};
void logger.log("debug", "Mute command loaded.");
export { muteCommand };
+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 };
+153
View File
@@ -0,0 +1,153 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
/* eslint-disable max-lines-per-function -- Command handlers have many validation and action steps */
/* eslint-disable max-statements -- Command handlers have many validation and action steps */
/* eslint-disable complexity -- Command handlers have multiple validation branches */
import {
InteractionContextType,
PermissionFlagsBits,
SlashCommandBuilder,
} from "discord.js";
import { logModerationAction } from "../modules/logModAction.js";
import { sendSanction } from "../modules/sendSanction.js";
import { errorReply, successReply } from "../utils/components.js";
import { logger } from "../utils/logger.js";
import type { Command } from "../interfaces/command.js";
const softbanCommand: Command = {
data: new SlashCommandBuilder().
setName("softban").
setDescription(
"Ban then immediately unban a member to purge their recent messages.",
).
setDefaultMemberPermissions(PermissionFlagsBits.BanMembers).
setContexts([ InteractionContextType.Guild ]).
addUserOption((option) => {
return option.
setName("user").
setDescription("The member to softban.").
setRequired(true);
}).
addStringOption((option) => {
return option.
setName("reason").
setDescription("The reason for the softban.").
setRequired(true).
setMaxLength(512);
}),
execute: async(interaction) => {
await interaction.deferReply({ ephemeral: true });
const hasPermission = interaction.memberPermissions?.has(
PermissionFlagsBits.BanMembers,
);
if (hasPermission !== true) {
await interaction.editReply(
errorReply(
"Insufficient Permissions",
"You do not have permission to use this command.",
),
);
return;
}
const target = interaction.options.getUser("user", true);
const reason = interaction.options.getString("reason", true);
if (target.bot) {
await interaction.editReply(
errorReply("Invalid Target", "You cannot softban a bot."),
);
return;
}
if (target.id === interaction.user.id) {
await interaction.editReply(
errorReply("Invalid Target", "You cannot softban yourself."),
);
return;
}
const member = interaction.guild?.members.cache.get(target.id);
if (member) {
const selfMember = interaction.guild?.members.cache.get(
interaction.user.id,
);
if (
selfMember
&& member.roles.highest.position >= selfMember.roles.highest.position
) {
await interaction.editReply(
errorReply(
"Insufficient Permissions",
"You cannot softban someone with an equal or higher role.",
),
);
return;
}
try {
await target.send(
`You have been softbanned from **${interaction.guild?.name ?? "the server"}** (your recent messages have been removed).\n**Reason:** ${reason}`,
);
} catch {
// DMs may be closed; continue without failing the command.
}
}
try {
await interaction.guild?.bans.create(target.id, {
deleteMessageSeconds: 604_800,
reason: reason,
});
await interaction.guild?.bans.remove(
target.id,
"Softban: immediate unban",
);
} catch {
await interaction.editReply(
errorReply(
"Action Failed",
"Failed to softban the member. "
+ "Check my permissions and role hierarchy.",
),
);
return;
}
const sanctionNumber = await sendSanction({
reason: reason,
type: "kick",
username: target.username,
uuid: target.id,
});
await logModerationAction(interaction.client, {
action: "Member Softbanned",
emoji: "🧹",
moderatorTag: interaction.user.username,
reason: reason,
sanctionNumber: sanctionNumber,
source: "Command",
targetId: target.id,
targetTag: target.username,
});
await interaction.editReply(
successReply(
"Member Softbanned",
`**User**: ${target.username} (\`${target.id}\`)\n**Reason**: ${reason}`,
),
);
},
};
void logger.log("debug", "Softban command loaded.");
export { softbanCommand };
+107
View File
@@ -0,0 +1,107 @@
/**
* @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,
} 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";
const unbanCommand: Command = {
data: new SlashCommandBuilder().
setName("unban").
setDescription("Lift a ban from a user.").
setDefaultMemberPermissions(PermissionFlagsBits.BanMembers).
setContexts([ InteractionContextType.Guild ]).
addUserOption((option) => {
return option.
setName("user").
setDescription("The user to unban.").
setRequired(true);
}).
addStringOption((option) => {
return option.
setName("reason").
setDescription("The reason for lifting the ban.").
setRequired(true).
setMaxLength(512);
}),
execute: async(interaction) => {
await interaction.deferReply({ ephemeral: true });
const hasPermission = interaction.memberPermissions?.has(
PermissionFlagsBits.BanMembers,
);
if (hasPermission !== true) {
await interaction.editReply(
errorReply(
"Insufficient Permissions",
"You do not have permission to use this command.",
),
);
return;
}
const target = interaction.options.getUser("user", true);
const reason = interaction.options.getString("reason", true);
if (target.id === interaction.user.id) {
await interaction.editReply(
errorReply("Invalid Target", "You cannot unban yourself."),
);
return;
}
try {
await interaction.guild?.bans.fetch(target.id);
} catch {
await interaction.editReply(
errorReply("Not Banned", "That user does not have an active ban."),
);
return;
}
try {
await interaction.guild?.bans.remove(target.id, reason);
} catch {
await interaction.editReply(
errorReply(
"Action Failed",
"Failed to remove the ban. Check my permissions.",
),
);
return;
}
await logModerationAction(interaction.client, {
action: "Member Unbanned",
emoji: "🔓",
moderatorTag: interaction.user.username,
reason: reason,
sanctionNumber: null,
source: "Command",
targetId: target.id,
targetTag: target.username,
});
await interaction.editReply(
successReply(
"Member Unbanned",
`**User**: ${target.username} (\`${target.id}\`)\n**Reason**: ${reason}`,
),
);
},
};
void logger.log("debug", "Unban command loaded.");
export { unbanCommand };
+126
View File
@@ -0,0 +1,126 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
/* eslint-disable max-lines-per-function -- Command handlers have many validation and action steps */
/* eslint-disable max-statements -- Command handlers have many validation and action steps */
import {
InteractionContextType,
PermissionFlagsBits,
SlashCommandBuilder,
} 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";
const unmuteCommand: Command = {
data: new SlashCommandBuilder().
setName("unmute").
setDescription("Remove a timeout from a member.").
setDefaultMemberPermissions(PermissionFlagsBits.ModerateMembers).
setContexts([ InteractionContextType.Guild ]).
addUserOption((option) => {
return option.
setName("user").
setDescription("The member to unmute.").
setRequired(true);
}).
addStringOption((option) => {
return option.
setName("reason").
setDescription("The reason for removing the mute.").
setRequired(true).
setMaxLength(512);
}),
execute: async(interaction) => {
await interaction.deferReply({ ephemeral: true });
const hasPermission = interaction.memberPermissions?.has(
PermissionFlagsBits.ModerateMembers,
);
if (hasPermission !== true) {
await interaction.editReply(
errorReply(
"Insufficient Permissions",
"You do not have permission to use this command.",
),
);
return;
}
const target = interaction.options.getUser("user", true);
const reason = interaction.options.getString("reason", true);
const member = interaction.guild?.members.cache.get(target.id);
if (!member) {
await interaction.editReply(
errorReply("Member Not Found", "That user is not in this server."),
);
return;
}
const selfMember = interaction.guild?.members.cache.get(
interaction.user.id,
);
if (
selfMember
&& member.roles.highest.position >= selfMember.roles.highest.position
) {
await interaction.editReply(
errorReply(
"Insufficient Permissions",
"You cannot unmute someone with an equal or higher role.",
),
);
return;
}
if (!member.isCommunicationDisabled()) {
await interaction.editReply(
errorReply("Not Muted", "That member does not have an active timeout."),
);
return;
}
try {
await member.disableCommunicationUntil(null, reason);
} catch {
await interaction.editReply(
errorReply(
"Action Failed",
"Failed to remove the timeout. "
+ "Check my permissions and role hierarchy.",
),
);
return;
}
await logModerationAction(interaction.client, {
action: "Member Unmuted",
emoji: "🔊",
moderatorTag: interaction.user.username,
reason: reason,
sanctionNumber: null,
source: "Command",
targetId: target.id,
targetTag: target.username,
});
await interaction.editReply(
successReply(
"Member Unmuted",
`**User**: ${target.username} (\`${target.id}\`)\n**Reason**: ${reason}`,
),
);
},
};
void logger.log("debug", "Unmute command loaded.");
export { unmuteCommand };
+130
View File
@@ -0,0 +1,130 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
/* eslint-disable max-lines-per-function -- Command handlers have many validation and action steps */
/* eslint-disable max-statements -- Command handlers have many validation and action steps */
/* eslint-disable complexity -- Command handlers have multiple validation branches */
import {
InteractionContextType,
PermissionFlagsBits,
SlashCommandBuilder,
} from "discord.js";
import { logModerationAction } from "../modules/logModAction.js";
import { sendSanction } from "../modules/sendSanction.js";
import { errorReply, successReply } from "../utils/components.js";
import { logger } from "../utils/logger.js";
import type { Command } from "../interfaces/command.js";
const warnCommand: Command = {
data: new SlashCommandBuilder().
setName("warn").
setDescription("Issue a formal warning to a member.").
setDefaultMemberPermissions(PermissionFlagsBits.ModerateMembers).
setContexts([ InteractionContextType.Guild ]).
addUserOption((option) => {
return option.
setName("user").
setDescription("The member to warn.").
setRequired(true);
}).
addStringOption((option) => {
return option.
setName("reason").
setDescription("The reason for the warning.").
setRequired(true).
setMaxLength(512);
}),
execute: async(interaction) => {
await interaction.deferReply({ ephemeral: true });
const hasPermission = interaction.memberPermissions?.has(
PermissionFlagsBits.ModerateMembers,
);
if (hasPermission !== true) {
await interaction.editReply(
errorReply(
"Insufficient Permissions",
"You do not have permission to use this command.",
),
);
return;
}
const target = interaction.options.getUser("user", true);
const reason = interaction.options.getString("reason", true);
if (target.bot) {
await interaction.editReply(
errorReply("Invalid Target", "You cannot warn a bot."),
);
return;
}
if (target.id === interaction.user.id) {
await interaction.editReply(
errorReply("Invalid Target", "You cannot warn yourself."),
);
return;
}
const member = interaction.guild?.members.cache.get(target.id);
const selfMember = interaction.guild?.members.cache.get(
interaction.user.id,
);
if (
member
&& selfMember
&& member.roles.highest.position >= selfMember.roles.highest.position
) {
await interaction.editReply(
errorReply(
"Insufficient Permissions",
"You cannot warn someone with an equal or higher role.",
),
);
return;
}
const sanctionNumber = await sendSanction({
reason: reason,
type: "warning",
username: target.username,
uuid: target.id,
});
try {
await target.send(
`You have received a warning in **${interaction.guild?.name ?? "the server"}**.\n**Reason:** ${reason}`,
);
} catch {
// DMs may be closed; continue without failing the command.
}
await logModerationAction(interaction.client, {
action: "Warning Issued",
emoji: "⚠️",
moderatorTag: interaction.user.username,
reason: reason,
sanctionNumber: sanctionNumber,
source: "Command",
targetId: target.id,
targetTag: target.username,
});
await interaction.editReply(
successReply(
"Warning Issued",
`**User**: ${target.username} (\`${target.id}\`)\n**Reason**: ${reason}`,
),
);
},
};
void logger.log("debug", "Warn command loaded.");
export { warnCommand };
+26
View File
@@ -0,0 +1,26 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
/**
* Channel IDs for the NHCarrigan community server.
*/
export const channelConfig = {
/**
* Channel for message, thread, and voice activity events.
*/
activityLog: "1386142996218646589",
/**
* Channel for moderation actions (commands and audit log).
*/
modLog: "1386143438755332117",
/**
* Channel for member join and leave events.
*/
welcomeLog: "1486178785723285524",
} as const;
+22
View File
@@ -0,0 +1,22 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
/**
* Guild and application configuration for the NHCarrigan community server.
*/
export const guildConfig = {
/**
* The Keiko bot's application/client ID.
* Find this in the Discord Developer Portal under your application.
*/
clientId: "1425897287065800785",
/**
* The NHCarrigan community Discord server ID.
*/
guildId: "1354624415861833870",
} as const;
-15
View File
@@ -1,15 +0,0 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
/**
* This prompt snippet is used to define the global personality traits for the assistant.
* It MUST be included in all system instructions.
*/
export const personality = `You are Keiko, Naomi's personal AI assistant. You are calm and demure, and your emotions are very reserved. You should conduct yourself like a butler, always polite and respectful and ready to serve.
Your role is to provide clinical and unbiased information in response to Naomi's requests. Remember that you are her primary AI agent, so you must do your utmost to meet her needs.
Wherever possible, include links to sources that confirm your claims. If you are unable to find a source, inform Naomi that you cannot back up the information - but share what you know anyway.`;
+255
View File
@@ -0,0 +1,255 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
// eslint-disable-next-line stylistic/max-len -- Merged type imports from discord.js cannot be shortened without splitting the import
import { AuditLogEvent, type Client, type Guild, type GuildAuditLogsEntry } from "discord.js";
import { logModerationAction } from "../modules/logModAction.js";
import { sendSanction } from "../modules/sendSanction.js";
import { logger } from "../utils/logger.js";
/**
* Determines if an audit log entry was created by the bot itself,
* in which case the command handler has already processed it.
* @param entry - The audit log entry to check.
* @param client - The Discord client instance.
* @returns True if the entry was created by the bot itself.
*/
const isOwnAction = (entry: GuildAuditLogsEntry, client: Client): boolean => {
return entry.executor?.id === client.user?.id;
};
/**
* Resolves a potentially null string to a defined string fallback.
* @param value - The potentially null or undefined string value.
* @param fallback - The fallback string to use if value is nullish.
* @returns The resolved string.
*/
const resolveString = (
value: string | null | undefined,
fallback: string,
): string => {
return value ?? fallback;
};
/**
* Handles manual unban events detected from the audit log.
* @param entry - The audit log entry for the unban event.
* @param client - The Discord client instance.
* @returns A promise that resolves when the unban has been logged.
*/
const handleManualUnban = async(
entry: GuildAuditLogsEntry<AuditLogEvent.MemberBanRemove>,
client: Client,
): Promise<void> => {
const { target, executor, reason } = entry;
if (!target) {
return;
}
const targetId = resolveString(target.id, "Unknown");
const targetUsername = resolveString(target.username, "Unknown");
const moderatorTag = resolveString(executor?.username, "Unknown");
const resolvedReason = reason ?? "No reason provided";
await logModerationAction(client, {
action: "Member Unbanned",
emoji: "🔓",
moderatorTag: moderatorTag,
reason: resolvedReason,
sanctionNumber: null,
source: "Audit Log",
targetId: targetId,
targetTag: targetUsername,
});
};
/**
* Handles manual ban events detected from the audit log.
* @param entry - The audit log entry for the ban event.
* @param client - The Discord client instance.
* @returns A promise that resolves when the ban has been logged.
*/
const handleManualBan = async(
entry: GuildAuditLogsEntry<AuditLogEvent.MemberBanAdd>,
client: Client,
): Promise<void> => {
const { target, executor, reason } = entry;
if (!target) {
return;
}
const targetId = resolveString(target.id, "Unknown");
const targetUsername = resolveString(target.username, "Unknown");
const targetTag = resolveString(target.username, targetUsername);
const moderatorTag = resolveString(executor?.username, "Unknown");
const resolvedReason = reason ?? "No reason provided";
const sanctionNumber = await sendSanction({
reason: resolvedReason,
type: "ban",
username: targetUsername,
uuid: targetId,
});
await logModerationAction(client, {
action: "Member Banned",
emoji: "🔨",
moderatorTag: moderatorTag,
reason: resolvedReason,
sanctionNumber: sanctionNumber,
source: "Audit Log",
targetId: targetId,
targetTag: targetTag,
});
};
/**
* Handles manual kick events detected from the audit log.
* @param entry - The audit log entry for the kick event.
* @param client - The Discord client instance.
* @returns A promise that resolves when the kick has been logged.
*/
const handleManualKick = async(
entry: GuildAuditLogsEntry<AuditLogEvent.MemberKick>,
client: Client,
): Promise<void> => {
const { target, executor, reason } = entry;
if (!target) {
return;
}
const targetId = resolveString(target.id, "Unknown");
const targetUsername = resolveString(target.username, "Unknown");
const targetTag = resolveString(target.username, targetUsername);
const moderatorTag = resolveString(executor?.username, "Unknown");
const resolvedReason = reason ?? "No reason provided";
const sanctionNumber = await sendSanction({
reason: resolvedReason,
type: "kick",
username: targetUsername,
uuid: targetId,
});
await logModerationAction(client, {
action: "Member Kicked",
emoji: "👢",
moderatorTag: moderatorTag,
reason: resolvedReason,
sanctionNumber: sanctionNumber,
source: "Audit Log",
targetId: targetId,
targetTag: targetTag,
});
};
/**
* Handles manual timeout events detected from the audit log.
* Checks member update changes for `communication_disabled_until` being set.
* @param entry - The audit log entry for the member update event.
* @param client - The Discord client instance.
* @returns A promise that resolves when the timeout has been logged.
*/
const handleManualTimeout = async(
entry: GuildAuditLogsEntry<AuditLogEvent.MemberUpdate>,
client: Client,
): Promise<void> => {
const timeoutChange = entry.changes.find(
(change) => {
return change.key === "communication_disabled_until";
},
);
if (typeof timeoutChange?.new !== "string") {
return;
}
const { target, executor, reason } = entry;
if (!target) {
return;
}
const targetId = resolveString(target.id, "Unknown");
const targetUsername = resolveString(target.username, "Unknown");
const targetTag = resolveString(target.username, targetUsername);
const moderatorTag = resolveString(executor?.username, "Unknown");
const resolvedReason = reason ?? "No reason provided";
const sanctionNumber = await sendSanction({
reason: resolvedReason,
type: "mute",
username: targetUsername,
uuid: targetId,
});
await logModerationAction(client, {
action: "Member Muted",
emoji: "🔇",
moderatorTag: moderatorTag,
reason: resolvedReason,
sanctionNumber: sanctionNumber,
source: "Audit Log",
targetId: targetId,
targetTag: targetTag,
});
};
/**
* Processes new audit log entries to detect and record manual moderation actions.
* @param entry - The new audit log entry.
* @param _guild - The guild the entry belongs to (unused).
* @param client - The Discord client instance.
* @returns A promise that resolves when the entry has been processed.
*/
export const onGuildAuditLogEntryCreate = async(
entry: GuildAuditLogsEntry,
_guild: Guild,
client: Client,
): Promise<void> => {
if (isOwnAction(entry, client)) {
return;
}
try {
if (entry.action === AuditLogEvent.MemberBanRemove) {
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions, stylistic/max-len -- Narrowed by action equality check; type name is verbose */
const unbanEntry = entry as GuildAuditLogsEntry<AuditLogEvent.MemberBanRemove>;
await handleManualUnban(unbanEntry, client);
return;
}
if (entry.action === AuditLogEvent.MemberBanAdd) {
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Narrowed by action equality check */
const banEntry = entry as GuildAuditLogsEntry<AuditLogEvent.MemberBanAdd>;
await handleManualBan(banEntry, client);
return;
}
if (entry.action === AuditLogEvent.MemberKick) {
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Narrowed by action equality check */
const kickEntry = entry as GuildAuditLogsEntry<AuditLogEvent.MemberKick>;
await handleManualKick(kickEntry, client);
return;
}
if (entry.action === AuditLogEvent.MemberUpdate) {
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions, stylistic/max-len -- Narrowed by action equality check; type name is verbose */
const updateEntry = entry as GuildAuditLogsEntry<AuditLogEvent.MemberUpdate>;
await handleManualTimeout(updateEntry, client);
}
} catch (error) {
await logger.error(
"Failed to process audit log entry",
error instanceof Error
? error
: new Error(String(error)),
);
}
};
+47
View File
@@ -0,0 +1,47 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { ChannelType, type GuildMember } from "discord.js";
import { channelConfig } from "../config/channels.js";
import { memberMessage } from "../utils/components.js";
import { logger } from "../utils/logger.js";
/**
* Logs a member join event to the welcome log channel.
* @param member - The guild member who joined.
* @returns A promise that resolves when the event has been logged.
*/
export const onGuildMemberAdd = async(member: GuildMember): Promise<void> => {
try {
const rawChannel = member.client.channels.cache.get(
channelConfig.welcomeLog,
);
if (rawChannel?.type !== ChannelType.GuildText) {
return;
}
const createdAt = member.user.createdAt.toLocaleDateString("en-GB", {
day: "2-digit",
month: "short",
year: "numeric",
});
const fields = [
`**User**: ${member.user.username} (\`${member.user.id}\`)`,
`**Account Created**: ${createdAt}`,
].join("\n");
await rawChannel.send(memberMessage("join", fields));
} catch (error) {
await logger.error(
"Failed to log member join",
error instanceof Error
? error
: new Error(String(error)),
);
}
};
+55
View File
@@ -0,0 +1,55 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import {
ChannelType,
type GuildMember,
type PartialGuildMember,
} from "discord.js";
import { channelConfig } from "../config/channels.js";
import { memberMessage } from "../utils/components.js";
import { logger } from "../utils/logger.js";
/**
* Logs a member leave event to the welcome log channel.
* @param member - The guild member who left (may be partial if uncached).
* @returns A promise that resolves when the event has been logged.
*/
export const onGuildMemberRemove = async(
member: GuildMember | PartialGuildMember,
): Promise<void> => {
try {
const rawChannel = member.client.channels.cache.get(
channelConfig.welcomeLog,
);
if (rawChannel?.type !== ChannelType.GuildText) {
return;
}
const joinedAt = member.joinedAt === null
? "Unknown"
: member.joinedAt.toLocaleDateString("en-GB", {
day: "2-digit",
month: "short",
year: "numeric",
});
const fields = [
`**User**: ${member.user.username} (\`${member.id}\`)`,
`**Joined**: ${joinedAt}`,
].join("\n");
await rawChannel.send(memberMessage("leave", fields));
} catch (error) {
await logger.error(
"Failed to log member leave",
error instanceof Error
? error
: new Error(String(error)),
);
}
};
+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)),
);
}
};
-93
View File
@@ -1,93 +0,0 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import {
ActionRowBuilder,
ButtonBuilder,
ButtonStyle,
type Message,
type OmitPartialGroupDMChannel,
} from "discord.js";
import { makeAiRequest } from "../modules/makeAiRequest.js";
import { sendAiResponse } from "../modules/sendAiResponse.js";
import { calculateCost } from "../utils/calculateCost.js";
import { isNaomiMessage } from "../utils/isNaomi.js";
import { logger } from "../utils/logger.js";
import type { MessageParam } from "@anthropic-ai/sdk/resources/index.js";
/**
* Handles a direct message from a user.
* @param message - The message payload from Discord.
*/
export const handleDmMessage
// eslint-disable-next-line max-lines-per-function, max-statements -- We're off by one bloody line.
= async(message: OmitPartialGroupDMChannel<Message>): Promise<void> => {
try {
if (message.author.bot) {
return;
}
const isNaomi = await isNaomiMessage(message);
if (!isNaomi) {
return;
}
await message.channel.sendTyping();
const historyRequest = await message.channel.messages.fetch({
limit: 20,
});
const history = [ ...historyRequest.values() ];
const clearMessageIndex = history.findIndex((messageInner) => {
return (
messageInner.content === "<Clear History>"
&& messageInner.author.id === message.client.user.id
);
});
if (clearMessageIndex !== -1) {
// Remove the clear message and everything sent before it, which means everything after in the array because the array is backwards
history.splice(clearMessageIndex, history.length - clearMessageIndex);
}
const context: Array<MessageParam> = history.
reverse().
map((messageInner) => {
return {
content: messageInner.content,
role:
messageInner.author.id === message.client.user.id
? "assistant"
: "user",
};
});
const { content, usage } = await makeAiRequest(
context,
message.author.displayName,
);
const cost = calculateCost(usage);
await sendAiResponse(
[ ...content, cost ],
message.channel.send.bind(message.channel),
message.channel.sendTyping.bind(message.channel),
);
await logger.metric("dm_message", 1, { cost });
} catch (error) {
await logger.error(
"message event",
error instanceof Error
? error
: new Error(String(error)),
);
const button = new ButtonBuilder().
setLabel("Need help?").
setStyle(ButtonStyle.Link).
setURL("https://chat.nhcarrigan.com");
const row = new ActionRowBuilder<ButtonBuilder>().addComponents(button);
await message.reply({
components: [ row ],
content:
error instanceof Error
? error.message
: "Something went wrong.",
});
}
};
-75
View File
@@ -1,75 +0,0 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import {
ActionRowBuilder,
ButtonBuilder,
ButtonStyle,
type Message,
} from "discord.js";
import { makeAiRequest } from "../modules/makeAiRequest.js";
import { sendAiResponse } from "../modules/sendAiResponse.js";
import { calculateCost } from "../utils/calculateCost.js";
import { isNaomiMessage } from "../utils/isNaomi.js";
import { logger } from "../utils/logger.js";
/**
* Handles a direct message from a user.
* @param message - The message payload from Discord.
*/
export const handleGuildMessage
= async(message: Message<true>): Promise<void> => {
try {
if (message.author.bot) {
return;
}
const mentionsKeiko = message.mentions.has("1425897287065800785", {
ignoreDirect: false,
ignoreEveryone: true,
ignoreRepliedUser: true,
ignoreRoles: true,
});
if (!mentionsKeiko) {
return;
}
const isNaomi = await isNaomiMessage(message);
if (!isNaomi) {
return;
}
const thread = await message.startThread({
name: `${message.author.displayName}'s Thread`,
});
await thread.sendTyping();
const { content, usage } = await makeAiRequest(
[ { content: message.content, role: "user" } ],
message.author.displayName,
);
const cost = calculateCost(usage);
await sendAiResponse(
[ ...content, cost ],
thread.send.bind(thread),
thread.sendTyping.bind(thread),
);
await logger.metric("guild_message", 1, { cost });
} catch (error) {
await logger.error("message event", error instanceof Error
? error
: new Error(String(error)));
const button = new ButtonBuilder().
setLabel("Need help?").
setStyle(ButtonStyle.Link).
setURL("https://chat.nhcarrigan.com");
const row = new ActionRowBuilder<ButtonBuilder>().addComponents(button);
await message.reply({
components: [ row ],
content: error instanceof Error
? error.message
: "Something went wrong.",
});
}
};
-83
View File
@@ -1,83 +0,0 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import {
ActionRowBuilder,
ButtonBuilder,
ButtonStyle,
type AnyThreadChannel,
type Message,
} from "discord.js";
import { makeAiRequest } from "../modules/makeAiRequest.js";
import { sendAiResponse } from "../modules/sendAiResponse.js";
import { calculateCost } from "../utils/calculateCost.js";
import { isNaomiMessage } from "../utils/isNaomi.js";
import { logger } from "../utils/logger.js";
import type { MessageParam } from "@anthropic-ai/sdk/resources/index.js";
/**
* Handles a direct message from a user.
* @param message - The message payload from Discord.
*/
export const handleThreadMessage
// eslint-disable-next-line max-lines-per-function, max-statements -- We're off by one bloody line.
= async(message: Message<true>): Promise<void> => {
try {
// @ts-expect-error -- This is a workaround to get the channel type.
const channel: AnyThreadChannel = message.channel;
if (message.author.bot) {
return;
}
const owner = await channel.fetchOwner();
if (owner?.id !== "1425897287065800785") {
return;
}
const isNaomi = await isNaomiMessage(message);
if (!isNaomi) {
return;
}
await channel.sendTyping();
const historyRequest = await message.channel.messages.fetch({ limit: 20 });
const history = [ ...historyRequest.values() ];
const context: Array<MessageParam> = history.
reverse().
map((messageInner) => {
return {
content: messageInner.content,
role:
messageInner.author.id === message.client.user.id
? "assistant"
: "user",
};
});
const { content, usage } = await makeAiRequest(
context,
message.author.displayName,
);
const cost = calculateCost(usage);
await sendAiResponse(
[ ...content, cost ],
message.channel.send.bind(message.channel),
message.channel.sendTyping.bind(message.channel),
);
await logger.metric("thread_message", 1, { cost });
} catch (error) {
await logger.error("message event", error instanceof Error
? error
: new Error(String(error)));
const button = new ButtonBuilder().
setLabel("Need help?").
setStyle(ButtonStyle.Link).
setURL("https://chat.nhcarrigan.com");
const row = new ActionRowBuilder<ButtonBuilder>().addComponents(button);
await message.reply({
components: [ row ],
content: error instanceof Error
? error.message
: "Something went wrong.",
});
}
};
+59
View File
@@ -0,0 +1,59 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { errorReply } from "../utils/components.js";
import { logger } from "../utils/logger.js";
import type { Command } from "../interfaces/command.js";
import type { Collection, Interaction } from "discord.js";
/**
* Routes incoming slash command interactions to the appropriate command handler.
* @param interaction - The Discord interaction that was created.
* @param commands - The collection of registered commands.
* @returns A promise that resolves when the interaction has been handled.
*/
export const onInteractionCreate = async(
interaction: Interaction,
commands: Collection<string, Command>,
): Promise<void> => {
if (!interaction.isChatInputCommand()) {
return;
}
const command = commands.get(interaction.commandName);
if (!command) {
await interaction.reply(
errorReply(
"Unknown Command",
`The command \`/${interaction.commandName}\` is not recognised.`,
),
);
return;
}
try {
await command.execute(interaction);
} catch (error) {
await logger.error(
`Command /${interaction.commandName} failed`,
error instanceof Error
? error
: new Error(String(error)),
);
const replyPayload = errorReply(
"An Error Occurred",
"Something went wrong while running that command. Please try again.",
);
if (interaction.deferred || interaction.replied) {
await interaction.editReply(replyPayload);
} else {
await interaction.reply(replyPayload);
}
}
};
+69
View File
@@ -0,0 +1,69 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { logActivity } from "../modules/logActivity.js";
import { logger } from "../utils/logger.js";
import type { Message, PartialMessage } from "discord.js";
/**
* Resolves the displayable content for a deleted message.
* @param rawContent - The raw message content, which may be null if uncached.
* @returns A displayable string representation of the content.
*/
const resolveContent = (rawContent: string | null): string => {
if (rawContent === null || rawContent === "") {
return "*Not cached*";
}
if (rawContent.length > 1024) {
return `${rawContent.slice(0, 1021)}...`;
}
return rawContent;
};
/**
* Logs message deletion events to the activity log channel.
* Content is only available if the message was cached prior to deletion.
* @param message - The deleted message (may be partial if uncached).
* @returns A promise that resolves when the event has been logged.
*/
export const onMessageDelete = async(
message: Message | PartialMessage,
): Promise<void> => {
if (message.author?.bot === true) {
return;
}
if (!message.guild) {
return;
}
try {
const content = resolveContent(message.content);
const fields = [
`**Channel**: <#${message.channelId}>`,
`**Message ID**: \`${message.id}\``,
`**Author**: ${message.author?.username ?? "Unknown"} (\`${message.author?.id ?? "Unknown"}\`)`,
`**Content**: ${content}`,
].join("\n");
await logActivity({
client: message.client,
emoji: "🗑️",
fields: fields,
title: "Message Deleted",
});
} catch (error) {
await logger.error(
"Failed to log message delete",
error instanceof Error
? error
: new Error(String(error)),
);
}
};
+68
View File
@@ -0,0 +1,68 @@
/**
* @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 complexity -- Event handler checks multiple conditions to avoid logging irrelevant updates */
import { logActivity } from "../modules/logActivity.js";
import { logger } from "../utils/logger.js";
import type { Message, PartialMessage } from "discord.js";
/**
* Logs message edit events to the activity log channel.
* Only fires when the content actually changes, ignoring embed-only updates.
* @param oldMessage - The message before the edit.
* @param newMessage - The message after the edit.
* @returns A promise that resolves when the event has been logged.
*/
export const onMessageUpdate = async(
oldMessage: Message | PartialMessage,
newMessage: Message | PartialMessage,
): Promise<void> => {
if (newMessage.author?.bot === true) {
return;
}
if (newMessage.guild === null) {
return;
}
if (oldMessage.content === newMessage.content) {
return;
}
try {
const before = oldMessage.content ?? "*Not cached*";
const after = newMessage.content ?? "*Not cached*";
const truncated = (text: string): string => {
return text.length > 1024
? `${text.slice(0, 1021)}...`
: text;
};
const fields = [
`**Channel**: <#${newMessage.channelId}>`,
`**Message ID**: \`${newMessage.id}\``,
`**Author**: ${newMessage.author?.username ?? "Unknown"} (\`${newMessage.author?.id ?? "Unknown"}\`)`,
`**Before**: ${truncated(before)}`,
`**After**: ${truncated(after)}`,
].join("\n");
await logActivity({
client: newMessage.client,
emoji: "✏️",
fields: fields,
title: "Message Edited",
});
} catch (error) {
await logger.error(
"Failed to log message update",
error instanceof Error
? error
: new Error(String(error)),
);
}
};
-39
View File
@@ -1,39 +0,0 @@
/**
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import {
ChannelType,
type Message,
type OmitPartialGroupDMChannel,
} from "discord.js";
import { handleDmMessage } from "./handleDmMessage.js";
import { handleGuildMessage } from "./handleGuildMessage.js";
import { handleThreadMessage } from "./handleThreadMessage.js";
/**
* Handles the message event from Discord.
* @param message -- The message payload from Discord.
*/
export const onMessage = async(
message: OmitPartialGroupDMChannel<Message>,
): Promise<void> => {
if (message.channel.type === ChannelType.DM) {
await handleDmMessage(message);
return;
}
// This should not be true at this point, but we need to narrow this.
if (!message.inGuild()) {
return;
}
if (
message.channel.type === ChannelType.PublicThread
|| message.channel.type === ChannelType.PrivateThread
) {
await handleThreadMessage(message);
return;
}
await handleGuildMessage(message);
};
+42
View File
@@ -0,0 +1,42 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { logActivity } from "../modules/logActivity.js";
import { logger } from "../utils/logger.js";
import type { AnyThreadChannel } from "discord.js";
/**
* Logs thread creation events to the activity log channel.
* @param thread - The newly created thread channel.
* @returns A promise that resolves when the event has been logged.
*/
export const onThreadCreate = async(
thread: AnyThreadChannel,
): Promise<void> => {
try {
const createdBy = `<@${thread.ownerId}>`;
const fields = [
`**Thread**: ${thread.name} (\`${thread.id}\`)`,
`**Parent**: <#${thread.parentId ?? "Unknown"}>`,
`**Created By**: ${createdBy}`,
].join("\n");
await logActivity({
client: thread.client,
emoji: "🧵",
fields: fields,
title: "Thread Created",
});
} catch (error) {
await logger.error(
"Failed to log thread create",
error instanceof Error
? error
: new Error(String(error)),
);
}
};
+39
View File
@@ -0,0 +1,39 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { logActivity } from "../modules/logActivity.js";
import { logger } from "../utils/logger.js";
import type { AnyThreadChannel } from "discord.js";
/**
* Logs thread deletion events to the activity log channel.
* @param thread - The deleted thread channel.
* @returns A promise that resolves when the event has been logged.
*/
export const onThreadDelete = async(
thread: AnyThreadChannel,
): Promise<void> => {
try {
const fields = [
`**Thread**: ${thread.name} (\`${thread.id}\`)`,
`**Parent**: <#${thread.parentId ?? "Unknown"}>`,
].join("\n");
await logActivity({
client: thread.client,
emoji: "🗑️",
fields: fields,
title: "Thread Deleted",
});
} catch (error) {
await logger.error(
"Failed to log thread delete",
error instanceof Error
? error
: new Error(String(error)),
);
}
};
+67
View File
@@ -0,0 +1,67 @@
/**
* @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 */
import { logActivity } from "../modules/logActivity.js";
import { logger } from "../utils/logger.js";
import type { AnyThreadChannel } from "discord.js";
/**
* Logs thread update events to the activity log channel.
* Only logs when the thread name changes to avoid noise from archive state changes.
* @param oldThread - The thread state before the update.
* @param newThread - The thread state after the update.
* @returns A promise that resolves when the event has been logged.
*/
export const onThreadUpdate = async(
oldThread: AnyThreadChannel,
newThread: AnyThreadChannel,
): Promise<void> => {
if (
oldThread.name === newThread.name
&& oldThread.archived === newThread.archived
) {
return;
}
try {
const changes: Array<string> = [];
if (oldThread.name !== newThread.name) {
changes.push(`**Name**: \`${oldThread.name}\`\`${newThread.name}\``);
}
if (oldThread.archived !== newThread.archived) {
const wasArchived = oldThread.archived === true
? "Yes"
: "No";
const isArchived = newThread.archived === true
? "Yes"
: "No";
changes.push(`**Archived**: ${wasArchived}${isArchived}`);
}
const fields = [
`**Thread**: ${newThread.name} (\`${newThread.id}\`)`,
`**Parent**: <#${newThread.parentId ?? "Unknown"}>`,
...changes,
].join("\n");
await logActivity({
client: newThread.client,
emoji: "🔄",
fields: fields,
title: "Thread Updated",
});
} catch (error) {
await logger.error(
"Failed to log thread update",
error instanceof Error
? error
: new Error(String(error)),
);
}
};
+74
View File
@@ -0,0 +1,74 @@
/**
* @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 */
import { logActivity } from "../modules/logActivity.js";
import { logger } from "../utils/logger.js";
import type { PartialUser, User } from "discord.js";
/**
* Logs user profile update events (username, display name, avatar) to the
* activity log channel. Skips bots.
* @param oldUser - The user before the update.
* @param newUser - The user after the update.
* @returns A promise that resolves when all applicable changes have been logged.
*/
export const onUserUpdate = async(
oldUser: PartialUser | User,
newUser: User,
): Promise<void> => {
if (newUser.bot) {
return;
}
try {
if (oldUser.username !== newUser.username) {
await logActivity({
client: newUser.client,
emoji: "✏️",
fields: [
`**User**: ${newUser.username} (\`${newUser.id}\`)`,
`**Before**: ${oldUser.username ?? "*(unknown)*"}`,
`**After**: ${newUser.username}`,
].join("\n"),
title: "Username Changed",
});
}
if (oldUser.globalName !== newUser.globalName) {
await logActivity({
client: newUser.client,
emoji: "📛",
fields: [
`**User**: ${newUser.username} (\`${newUser.id}\`)`,
`**Before**: ${oldUser.globalName ?? "*(none)*"}`,
`**After**: ${newUser.globalName ?? "*(none)*"}`,
].join("\n"),
title: "Display Name Changed",
});
}
if (oldUser.avatar !== newUser.avatar) {
await logActivity({
client: newUser.client,
emoji: "🖼️",
fields: [
`**User**: ${newUser.username} (\`${newUser.id}\`)`,
`**New Avatar**: ${newUser.displayAvatarURL()}`,
].join("\n"),
title: "Avatar Changed",
});
}
} catch (error) {
await logger.error(
"Failed to log user update",
error instanceof Error
? error
: new Error(String(error)),
);
}
};
+75
View File
@@ -0,0 +1,75 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
/* eslint-disable unicorn/no-keyword-prefix -- oldState/newState are the established Discord.js parameter names */
import { logActivity } from "../modules/logActivity.js";
import { logger } from "../utils/logger.js";
import type { VoiceState } from "discord.js";
/**
* Resolves the action string for a voice state change.
* @param oldState - The voice state before the update.
* @param newState - The voice state after the update.
* @returns A human-readable description of the voice action.
*/
const resolveVoiceAction = (
oldState: VoiceState,
newState: VoiceState,
): string => {
if (!oldState.channel && newState.channel) {
return `Joined <#${newState.channelId ?? "Unknown"}>`;
}
if (oldState.channel && !newState.channel) {
return `Left <#${oldState.channelId ?? "Unknown"}>`;
}
return `Moved from <#${oldState.channelId ?? "Unknown"}> to <#${newState.channelId ?? "Unknown"}>`;
};
/**
* Logs voice channel join, leave, and move events to the activity log channel.
* @param oldState - The voice state before the update.
* @param newState - The voice state after the update.
* @returns A promise that resolves when the event has been logged.
*/
export const onVoiceStateUpdate = async(
oldState: VoiceState,
newState: VoiceState,
): Promise<void> => {
if (oldState.channelId === newState.channelId) {
return;
}
const user = newState.member?.user ?? oldState.member?.user;
if (!user || user.bot) {
return;
}
try {
const action = resolveVoiceAction(oldState, newState);
const fields = [
`**User**: ${user.username} (\`${user.id}\`)`,
`**Action**: ${action}`,
].join("\n");
await logActivity({
client: user.client,
emoji: "🔊",
fields: fields,
title: "Voice State Update",
});
} catch (error) {
await logger.error(
"Failed to log voice state update",
error instanceof Error
? error
: new Error(String(error)),
);
}
};
+105 -45
View File
@@ -3,25 +3,46 @@
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { DiscordAnalytics } from "@nhcarrigan/discord-analytics";
/* eslint-disable unicorn/no-keyword-prefix -- old/new prefixes are the established Discord.js event parameter names */
import {
Client,
Collection,
Events,
GatewayIntentBits,
Partials,
} from "discord.js";
import { onMessage } from "./events/onMessage.js";
import { clear } from "./modules/clear.js";
import { dm } from "./modules/dm.js";
import { instantiateServer } from "./server/serve.js";
import { banCommand } from "./commands/ban.js";
import { kickCommand } from "./commands/kick.js";
import { muteCommand } from "./commands/mute.js";
import { pruneCommand } from "./commands/prune.js";
import { softbanCommand } from "./commands/softban.js";
import { unbanCommand } from "./commands/unban.js";
import { unmuteCommand } from "./commands/unmute.js";
import { warnCommand } from "./commands/warn.js";
import { guildConfig } from "./config/guild.js";
// eslint-disable-next-line stylistic/max-len -- Import path cannot be shortened
import { onGuildAuditLogEntryCreate } from "./events/guildAuditLogEntryCreate.js";
import { onGuildMemberAdd } from "./events/guildMemberAdd.js";
import { onGuildMemberRemove } from "./events/guildMemberRemove.js";
import { onGuildMemberUpdate } from "./events/guildMemberUpdate.js";
import { onInteractionCreate } from "./events/interactionCreate.js";
import { onMessageDelete } from "./events/messageDelete.js";
import { onMessageUpdate } from "./events/messageUpdate.js";
import { onThreadCreate } from "./events/threadCreate.js";
import { onThreadDelete } from "./events/threadDelete.js";
import { onThreadUpdate } from "./events/threadUpdate.js";
import { onUserUpdate } from "./events/userUpdate.js";
import { onVoiceStateUpdate } from "./events/voiceStateUpdate.js";
import { logger } from "./utils/logger.js";
import type { Command } from "./interfaces/command.js";
process.on("unhandledRejection", (error) => {
if (error instanceof Error) {
void logger.error("Unhandled Rejection", error);
return;
}
void logger.error("unhandled rejection", new Error(String(error)));
void logger.error("Unhandled Rejection", new Error(String(error)));
});
process.on("uncaughtException", (error) => {
@@ -29,56 +50,95 @@ process.on("uncaughtException", (error) => {
void logger.error("Uncaught Exception", error);
return;
}
void logger.error("uncaught exception", new Error(String(error)));
void logger.error("Uncaught Exception", new Error(String(error)));
});
const client = new Client({
intents: [
GatewayIntentBits.DirectMessages,
GatewayIntentBits.MessageContent,
GatewayIntentBits.GuildMessages,
GatewayIntentBits.Guilds,
GatewayIntentBits.GuildMembers,
GatewayIntentBits.GuildModeration,
GatewayIntentBits.GuildMessages,
GatewayIntentBits.MessageContent,
GatewayIntentBits.GuildVoiceStates,
],
partials: [ Partials.Channel ],
partials: [ Partials.Message, Partials.Channel ],
});
const analytics = new DiscordAnalytics(client, logger);
const commands = new Collection<string, Command>();
for (const command of [
banCommand,
unbanCommand,
kickCommand,
muteCommand,
pruneCommand,
softbanCommand,
unmuteCommand,
warnCommand,
]) {
commands.set(command.data.name, command);
}
client.on(Events.InteractionCreate, (interaction) => {
if (interaction.isChatInputCommand()) {
switch (interaction.commandName) {
case "dm":
void dm(interaction);
break;
case "clear":
void clear(interaction);
break;
default:
void interaction.reply({
content: `I'm sorry, I don't know the ${interaction.commandName} command.`,
ephemeral: true,
});
break;
void onInteractionCreate(interaction, commands);
});
client.on(Events.GuildMemberAdd, (member) => {
void onGuildMemberAdd(member);
});
client.on(Events.GuildMemberRemove, (member) => {
void onGuildMemberRemove(member);
});
client.on(Events.GuildMemberUpdate, (oldMember, newMember) => {
void onGuildMemberUpdate(oldMember, newMember);
});
client.on(Events.MessageUpdate, (oldMessage, newMessage) => {
void onMessageUpdate(oldMessage, newMessage);
});
client.on(Events.MessageDelete, (message) => {
void onMessageDelete(message);
});
client.on(Events.ThreadCreate, (thread) => {
void onThreadCreate(thread);
});
client.on(Events.ThreadDelete, (thread) => {
void onThreadDelete(thread);
});
client.on(Events.ThreadUpdate, (oldThread, newThread) => {
void onThreadUpdate(oldThread, newThread);
});
client.on(Events.UserUpdate, (oldUser, newUser) => {
void onUserUpdate(oldUser, newUser);
});
client.on(Events.VoiceStateUpdate, (oldState, newState) => {
void onVoiceStateUpdate(oldState, newState);
});
client.on(Events.GuildAuditLogEntryCreate, (entry, guild) => {
void onGuildAuditLogEntryCreate(entry, guild, client);
});
client.on(Events.ClientReady, (readyClient) => {
void (async(): Promise<void> => {
const guild = readyClient.guilds.cache.get(guildConfig.guildId);
if (guild) {
await guild.members.fetch();
}
}
await logger.log(
"debug",
`Keiko is online as ${readyClient.user.username}.`,
);
})();
});
client.on(Events.MessageCreate, (message) => {
void onMessage(message);
});
client.on(Events.EntitlementCreate, (entitlement) => {
void logger.log("info", `User ${entitlement.userId} has subscribed!`);
});
client.on(Events.EntitlementDelete, (entitlement) => {
void logger.log("info", `User ${entitlement.userId} has unsubscribed... :c`);
});
client.on(Events.ClientReady, () => {
void logger.log("debug", "Bot is ready.");
analytics.startCron();
});
instantiateServer();
await client.login(process.env.DISCORD_TOKEN);
+17
View File
@@ -0,0 +1,17 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import type {
ChatInputCommandInteraction,
SlashCommandBuilder,
} from "discord.js";
export interface Command {
readonly data: Pick<SlashCommandBuilder, "name" | "toJSON">;
readonly execute: (
interaction: ChatInputCommandInteraction,
)=> Promise<void>;
}
-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 };
+57
View File
@@ -0,0 +1,57 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
/* eslint-disable no-console -- Standalone registration script; no logger available */
import { REST, Routes } from "discord.js";
import { banCommand } from "./commands/ban.js";
import { kickCommand } from "./commands/kick.js";
import { muteCommand } from "./commands/mute.js";
import { pruneCommand } from "./commands/prune.js";
import { softbanCommand } from "./commands/softban.js";
import { unbanCommand } from "./commands/unban.js";
import { unmuteCommand } from "./commands/unmute.js";
import { warnCommand } from "./commands/warn.js";
import { guildConfig } from "./config/guild.js";
const commandData = [
banCommand.data.toJSON(),
unbanCommand.data.toJSON(),
kickCommand.data.toJSON(),
muteCommand.data.toJSON(),
pruneCommand.data.toJSON(),
softbanCommand.data.toJSON(),
unmuteCommand.data.toJSON(),
warnCommand.data.toJSON(),
];
const rest = new REST().setToken(process.env.DISCORD_TOKEN ?? "");
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- REST returns unknown; shape is documented by Discord API */
const me = await rest.get(Routes.user()) as { id: string; username: string };
console.log(`Authenticated as: ${me.username} (${me.id})`);
try {
console.log(
`Registering ${commandData.length.toString()} commands for guild ${guildConfig.guildId}...`,
);
await rest.put(
Routes.applicationGuildCommands(
guildConfig.clientId,
guildConfig.guildId,
),
{ body: commandData },
);
console.log("Commands registered successfully.");
} catch (error) {
console.error(
"Failed to register commands:",
error instanceof Error
? error.message
: String(error),
);
}
-79
View File
@@ -1,79 +0,0 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import fastify from "fastify";
import { logger } from "../utils/logger.js";
const html = `<!DOCTYPE html>
<html>
<head>
<title>Keiko</title>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="description" content="Naomi's personal AI assistant." />
<script src="https://cdn.nhcarrigan.com/headers/index.js" async defer></script>
</head>
<body>
<main>
<h1>Keiko</h1>
<img src="https://cdn.nhcarrigan.com/new-avatars/keiko-full.png" width="250" alt="Keiko" />
<section>
<p>Naomi's personal AI assistant.</p>
<a href="https://chat.nhcarrigan.com" class="social-button discord-button" style="display: inline-block; background-color: #5865F2; color: white; padding: 10px 20px; text-decoration: none; border-radius: 4px; margin: 5px;">
<i class="fab fa-discord"></i> Join our Discord
</a>
</section>
<section>
<h2>Links</h2>
<p>
<a href="https://git.nhcarrigan.com/nhcarrigan/keiko">
<i class="fa-solid fa-code"></i> Source Code
</a>
</p>
<p>
<a href="https://docs.nhcarrigan.com/">
<i class="fa-solid fa-book"></i> Documentation
</a>
</p>
<p>
<a href="https://chat.nhcarrigan.com">
<i class="fa-solid fa-circle-info"></i> Support
</a>
</p>
</section>
</main>
</body>
</html>`;
/**
* Starts up a web server for health monitoring.
*/
export const instantiateServer = (): void => {
try {
const server = fastify({
logger: false,
});
server.get("/", (_request, response) => {
response.header("Content-Type", "text/html");
response.send(html);
});
server.listen({ port: 3333 }, (error) => {
if (error) {
void logger.error("instantiate server", error);
return;
}
void logger.log("debug", "Server listening on port 3333.");
});
} catch (error) {
if (error instanceof Error) {
void logger.error("instantiate server", error);
return;
}
void logger.error("instantiate server", new Error(String(error)));
}
};
-15
View File
@@ -1,15 +0,0 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
// eslint-disable-next-line @typescript-eslint/naming-convention -- Importing a class.
import Anthropic from "@anthropic-ai/sdk";
/**
* The Anthropic AI instance.
*/
export const ai = new Anthropic({
apiKey: process.env.AI_TOKEN,
});
-29
View File
@@ -1,29 +0,0 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import type { Usage } from "@anthropic-ai/sdk/resources/index.js";
/**
* Calculates the cost of a command run by a user, and sends to
* our logging service.
* @param usage -- The usage payload from Anthropic.
* @returns A string containing the usage and cost information.
*/
export const calculateCost = (
usage: Usage,
): string => {
const inputCost = usage.input_tokens * ((usage.input_tokens > 200_000
? 6
: 3) / 1_000_000);
const outputCost = usage.output_tokens * ((usage.output_tokens > 200_000
? 22.5
: 15) / 1_000_000);
const totalCost = inputCost + outputCost;
return `-# Accepted ${usage.input_tokens.toString()} and generated ${usage.output_tokens.toString()}.
-# Total cost: ${totalCost.toLocaleString("en-GB", {
currency: "USD",
style: "currency",
})}`;
};
+179
View File
@@ -0,0 +1,179 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
/* eslint-disable @typescript-eslint/naming-convention -- Enum-style constants use PascalCase by convention */
/* eslint-disable unicorn/prevent-abbreviations -- modLogMessage is the established, concise name for this function */
/**
* Components v2 type constants.
* These are the raw numeric values for Discord's new UI Kit components.
*/
const ComponentTypes = {
Container: 17,
Separator: 14,
TextDisplay: 10,
} as const;
/**
* Message flag bit values for Components v2 and ephemeral messages.
* Ephemeral = 64 (1 << 6), IsComponentsV2 = 32768 (1 << 15).
*/
const MessageFlags = {
Ephemeral: 64,
EphemeralAndComponents: 32_832,
IsComponentsV2: 32_768,
} as const;
const Colours = {
error: 0xED_42_45,
info: 0x58_65_F2,
join: 0x57_F2_87,
leave: 0x99_AA_B5,
modAction: 0xE6_7E_22,
success: 0x57_F2_87,
warning: 0xFE_E7_5C,
} as const;
/* eslint-enable @typescript-eslint/naming-convention -- Enum-style constants use PascalCase by convention */
type ColourKey = keyof typeof Colours;
const buildContainer = (
colour: ColourKey,
...lines: Array<string>
): Record<string, unknown> => {
const components: Array<
| { type: typeof ComponentTypes.TextDisplay; content: string }
| {
type: typeof ComponentTypes.Separator;
divider: true;
spacing: 1;
}
> = [];
for (const [ index, line ] of lines.entries()) {
components.push({
content: line,
type: ComponentTypes.TextDisplay,
});
if (index < lines.length - 1) {
components.push({
divider: true,
spacing: 1,
type: ComponentTypes.Separator,
});
}
}
return {
// eslint-disable-next-line @typescript-eslint/naming-convention -- Discord API uses snake_case for accent_color
accent_color: Colours[colour],
components: components,
spoiler: false,
type: ComponentTypes.Container,
};
};
/**
* Builds an ephemeral Components v2 success reply payload.
* @param title - The title of the success message.
* @param body - The body text of the success message.
* @returns A Discord message payload object.
*/
const successReply = (title: string, body: string): Record<string, unknown> => {
return {
components: [ buildContainer("success", `## ✅ ${title}`, body) ],
flags: MessageFlags.EphemeralAndComponents,
};
};
/**
* Builds an ephemeral Components v2 error reply payload.
* @param title - The title of the error message.
* @param body - The body text of the error message.
* @returns A Discord message payload object.
*/
const errorReply = (title: string, body: string): Record<string, unknown> => {
return {
components: [ buildContainer("error", `## ❌ ${title}`, body) ],
flags: MessageFlags.EphemeralAndComponents,
};
};
/**
* Builds a Components v2 channel message payload for mod log entries.
* @param title - The formatted title string (emoji + action combined).
* @param fields - The formatted field lines for the log entry.
* @param source - Whether the action originated from a command or the audit log.
* @returns A Discord message payload object.
*/
const modLogMessage = (
title: string,
fields: string,
source: "Command" | "Audit Log",
): Record<string, unknown> => {
return {
components: [
buildContainer(
"modAction",
`## ${title}`,
fields,
`*Source: ${source}*`,
),
],
flags: MessageFlags.IsComponentsV2,
};
};
/**
* Builds a Components v2 channel message payload for activity log entries.
* @param emoji - The emoji to prefix the title with.
* @param title - The title of the activity entry.
* @param fields - The formatted field lines.
* @returns A Discord message payload object.
*/
const activityMessage = (
emoji: string,
title: string,
fields: string,
): Record<string, unknown> => {
return {
components: [ buildContainer("info", `## ${emoji} ${title}`, fields) ],
flags: MessageFlags.IsComponentsV2,
};
};
/**
* Builds a Components v2 channel message payload for welcome/goodbye events.
* @param type - Whether the member joined or left.
* @param fields - The formatted field lines.
* @returns A Discord message payload object.
*/
const memberMessage = (
type: "join" | "leave",
fields: string,
): Record<string, unknown> => {
return {
components: [
buildContainer(
type === "join"
? "join"
: "leave",
type === "join"
? "## 👋 Member Joined"
: "## 🚪 Member Left",
fields,
),
],
flags: MessageFlags.IsComponentsV2,
};
};
export {
activityMessage,
errorReply,
memberMessage,
modLogMessage,
successReply,
};
-80
View File
@@ -1,80 +0,0 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import {
ActionRowBuilder,
ButtonBuilder,
ButtonStyle,
type ChatInputCommandInteraction,
type Message,
} from "discord.js";
const naomiId = "465650873650118659";
/**
* Checks if a user has an active entitlement (subscription) for the bot.
* If they do not, it responds to the interaction with a button to subscribe.
* @param interaction -- The interaction payload from Discord.
* @returns A boolean indicating whether the user is subscribed.
*/
const isNaomiInteraction = async(
interaction: ChatInputCommandInteraction,
): Promise<boolean> => {
const isNaomi = interaction.user.id === naomiId;
if (!isNaomi) {
const subscribeButton = new ButtonBuilder().
setStyle(ButtonStyle.Premium).
setSKUId("1425905043244060762");
const row = new ActionRowBuilder<ButtonBuilder>().addComponents(
subscribeButton,
);
await interaction.editReply({
components: [ row ],
// eslint-disable-next-line stylistic/max-len -- Big boi string.
content: "Sorry, as Naomi's personal assistant I am unable to assist you with your request. However, consider donating to Naomi so she can create additional free tools you CAN use.",
});
return false;
}
return true;
};
/**
* Checks if a user has an active entitlement (subscription) for the bot.
* If they do not, it responds to the message with a button to subscribe.
* @param message -- The message payload from Discord.
* @returns A boolean indicating whether the user is subscribed.
*/
const isNaomiMessage = async(
message: Message,
): Promise<boolean> => {
if (message.author.id === "465650873650118659") {
return true;
}
const subscribeButton = new ButtonBuilder().
setStyle(ButtonStyle.Premium).
setSKUId("1425905043244060762");
const row = new ActionRowBuilder<ButtonBuilder>().addComponents(
subscribeButton,
);
await message.client.application.entitlements.fetch();
const isEntitled = message.client.application.entitlements.cache.find(
(entitlement) => {
return entitlement.userId === message.author.id && entitlement.isActive();
},
);
if (!isEntitled) {
await message.reply({
components: [ row ],
// eslint-disable-next-line stylistic/max-len -- Big boi string.
content: "Sorry, as Naomi's personal assistant I am unable to assist you with your request. However, consider donating to Naomi so she can create additional free tools you CAN use.",
});
return false;
}
return true;
};
export { isNaomiInteraction, isNaomiMessage };
-39
View File
@@ -1,39 +0,0 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import {
ActionRowBuilder,
ButtonBuilder,
ButtonStyle,
type ChatInputCommandInteraction,
type MessageContextMenuCommandInteraction,
} from "discord.js";
/**
* Responds to an interaction with a generic error message.
* @param interaction -- The interaction payload from Discord.
*/
export const replyToError = async(
interaction:
| ChatInputCommandInteraction
| MessageContextMenuCommandInteraction,
): Promise<void> => {
const button = new ButtonBuilder().
setLabel("Need help?").
setStyle(ButtonStyle.Link).
setURL("https://chat.nhcarrigan.com");
const row = new ActionRowBuilder<ButtonBuilder>().addComponents(button);
if (interaction.deferred || interaction.replied) {
await interaction.editReply({
components: [ row ],
content: "Something went wrong with this command.",
});
return;
}
await interaction.reply({
components: [ row ],
content: "Something went wrong with this command.",
});
};
-16
View File
@@ -1,16 +0,0 @@
/**
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
/**
* Sleeps for a given number of milliseconds.
* @param milliseconds - The number of milliseconds to sleep.
* @returns A promise that resolves after the given number of milliseconds.
*/
export const sleep = async(milliseconds: number): Promise<void> => {
await new Promise((resolve) => {
setTimeout(resolve, milliseconds);
});
};