188 lines
5.4 KiB
TypeScript
188 lines
5.4 KiB
TypeScript
import {
|
|
Client,
|
|
GatewayIntentBits,
|
|
REST,
|
|
Routes,
|
|
SlashCommandBuilder,
|
|
type ChatInputCommandInteraction,
|
|
type Message,
|
|
type TextChannel,
|
|
} from "discord.js";
|
|
import { logger } from "./logger.js";
|
|
|
|
export interface Prompt {
|
|
text: string;
|
|
channelId: string;
|
|
userId: string;
|
|
guildId: string | null;
|
|
}
|
|
|
|
export function shouldIgnoreMessage(message: { author: { bot: boolean } }): boolean {
|
|
return message.author.bot;
|
|
}
|
|
|
|
export function extractPromptFromMention(content: string, botId: string): string {
|
|
// Remove user mentions (<@ID> or <@!ID>) and role mentions (<@&ID>) for the bot
|
|
return content
|
|
.replace(/<@[!&]?\d+>/g, "")
|
|
.trim();
|
|
}
|
|
|
|
export class DiscordBot {
|
|
private client: Client;
|
|
private promptHandler: ((prompt: Prompt) => void) | null = null;
|
|
private resetHandler: ((channelId: string) => void) | null = null;
|
|
|
|
constructor() {
|
|
this.client = new Client({
|
|
intents: [
|
|
GatewayIntentBits.Guilds,
|
|
GatewayIntentBits.GuildMessages,
|
|
GatewayIntentBits.MessageContent,
|
|
],
|
|
});
|
|
}
|
|
|
|
async start(token: string): Promise<void> {
|
|
return new Promise<void>((resolve, reject) => {
|
|
this.client.once("ready", () => {
|
|
const user = this.client.user;
|
|
logger.info({ tag: user?.tag ?? "unknown" }, "Bot logged in");
|
|
logger.info({ guildCount: this.client.guilds.cache.size }, "Connected to guilds");
|
|
this.setupMessageHandler();
|
|
this.setupInteractionHandler();
|
|
|
|
logger.debug("Message intent enabled, listening for messageCreate events");
|
|
resolve();
|
|
});
|
|
|
|
this.client.once("error", reject);
|
|
this.client.login(token).catch(reject);
|
|
});
|
|
}
|
|
|
|
async registerCommands(): Promise<void> {
|
|
const clientId = this.client.user?.id;
|
|
if (!clientId) {
|
|
throw new Error("Bot must be started before registering commands");
|
|
}
|
|
|
|
const claudeCommand = new SlashCommandBuilder()
|
|
.setName("claude")
|
|
.setDescription("Send a prompt to Claude")
|
|
.addStringOption((option) =>
|
|
option
|
|
.setName("prompt")
|
|
.setDescription("The prompt to send to Claude")
|
|
.setRequired(true)
|
|
);
|
|
|
|
const claudeResetCommand = new SlashCommandBuilder()
|
|
.setName("claude-reset")
|
|
.setDescription("Reset the conversation session in this channel");
|
|
|
|
const commands = [claudeCommand.toJSON(), claudeResetCommand.toJSON()];
|
|
|
|
const rest = new REST({ version: "10" }).setToken(this.client.token!);
|
|
await rest.put(Routes.applicationCommands(clientId), { body: commands });
|
|
logger.info("Registered /claude and /claude-reset slash commands");
|
|
}
|
|
|
|
async sendMessage(channelId: string, content: string): Promise<void> {
|
|
try {
|
|
const channel = await this.client.channels.fetch(channelId);
|
|
if (channel && "send" in channel) {
|
|
await (channel as TextChannel).send(content);
|
|
}
|
|
} catch (error) {
|
|
logger.error(
|
|
{ channelId, contentLength: content.length, err: error },
|
|
"Failed to send message to channel",
|
|
);
|
|
}
|
|
}
|
|
|
|
async sendTyping(channelId: string): Promise<void> {
|
|
try {
|
|
const channel = await this.client.channels.fetch(channelId);
|
|
if (channel && "sendTyping" in channel) {
|
|
await (channel as TextChannel).sendTyping();
|
|
}
|
|
} catch {
|
|
// Typing indicator failures are non-critical
|
|
}
|
|
}
|
|
|
|
async destroy(): Promise<void> {
|
|
await this.client.destroy();
|
|
}
|
|
|
|
onPrompt(handler: (prompt: Prompt) => void): void {
|
|
this.promptHandler = handler;
|
|
}
|
|
|
|
onReset(handler: (channelId: string) => void): void {
|
|
this.resetHandler = handler;
|
|
}
|
|
|
|
private setupMessageHandler(): void {
|
|
this.client.on("messageCreate", (message: Message) => {
|
|
logger.debug({ content: message.content, author: message.author.tag, bot: message.author.bot }, "Message received");
|
|
|
|
if (shouldIgnoreMessage(message)) {
|
|
logger.debug("Ignoring bot message");
|
|
return;
|
|
}
|
|
|
|
const botUser = this.client.user;
|
|
if (!botUser) {
|
|
logger.debug("No bot user available");
|
|
return;
|
|
}
|
|
|
|
if (!message.mentions.has(botUser)) {
|
|
logger.debug("Message does not mention the bot");
|
|
return;
|
|
}
|
|
|
|
const text = extractPromptFromMention(message.content, botUser.id);
|
|
logger.debug({ text }, "Extracted prompt");
|
|
if (!text) {
|
|
logger.debug("Empty prompt after extraction, ignoring");
|
|
return;
|
|
}
|
|
|
|
logger.debug({ text, channelId: message.channelId }, "Forwarding prompt to handler");
|
|
this.promptHandler?.({
|
|
text,
|
|
channelId: message.channelId,
|
|
userId: message.author.id,
|
|
guildId: message.guildId,
|
|
});
|
|
});
|
|
}
|
|
|
|
private setupInteractionHandler(): void {
|
|
this.client.on("interactionCreate", async (interaction) => {
|
|
if (!interaction.isChatInputCommand()) return;
|
|
|
|
const command = interaction as ChatInputCommandInteraction;
|
|
|
|
if (command.commandName === "claude") {
|
|
const promptText = command.options.getString("prompt", true);
|
|
await command.deferReply();
|
|
|
|
this.promptHandler?.({
|
|
text: promptText,
|
|
channelId: command.channelId,
|
|
userId: command.user.id,
|
|
guildId: command.guildId,
|
|
});
|
|
} else if (command.commandName === "claude-reset") {
|
|
this.resetHandler?.(command.channelId);
|
|
await command.reply({ content: "Session reset. Your next message will start a new conversation.", ephemeral: true });
|
|
}
|
|
});
|
|
}
|
|
}
|