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 { return new Promise((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 { 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 { 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 { 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 { 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 }); } }); } }