From d05907c16e3f224fa0e634d5be9e26ff6fd98887 Mon Sep 17 00:00:00 2001 From: Fuwn Date: Fri, 3 Oct 2025 15:51:51 -0700 Subject: feat(gateway:aiCommandHandler): Add purge command --- .../listeners/messageCreate/aiCommandHandler.ts | 174 ------------------ .../messageCreate/aiCommandHandler/index.ts | 148 ++++++++++++++++ .../messageCreate/aiCommandHandler/purge.ts | 197 +++++++++++++++++++++ .../messageCreate/aiCommandHandler/slowmode.ts | 47 +++++ 4 files changed, 392 insertions(+), 174 deletions(-) delete mode 100644 packages/gateway/src/listeners/messageCreate/aiCommandHandler.ts create mode 100644 packages/gateway/src/listeners/messageCreate/aiCommandHandler/index.ts create mode 100644 packages/gateway/src/listeners/messageCreate/aiCommandHandler/purge.ts create mode 100644 packages/gateway/src/listeners/messageCreate/aiCommandHandler/slowmode.ts (limited to 'packages/gateway') diff --git a/packages/gateway/src/listeners/messageCreate/aiCommandHandler.ts b/packages/gateway/src/listeners/messageCreate/aiCommandHandler.ts deleted file mode 100644 index 6f91e3c..0000000 --- a/packages/gateway/src/listeners/messageCreate/aiCommandHandler.ts +++ /dev/null @@ -1,174 +0,0 @@ -import { Message } from "discord.js"; - -const MODERATOR_ROLE_IDS = [ - "1406422617765712095", - "1406422617765712094", - "1406422617765712093", - "1406422617724026910", -]; - -interface AICommandResponse { - command: string; - action: string; - value?: number; -} - -export const handleAICommand = async (message: Message) => { - if (message.author.bot) return; - - if (!message.mentions.has(message.client.user!)) return; - - if (!message.member) return; - - const hasModeratorRole = message.member.roles.cache.some((role) => - MODERATOR_ROLE_IDS.includes(role.id), - ); - const application = await message.client.application?.fetch(); - const ownerId = application?.owner?.id; - const isOwner = message.author.id === ownerId; - - if (!hasModeratorRole && !isOwner) return; - - const content = message.content.replace(/<@!?\d+>/g, "").trim(); - - if (!content) return; - - try { - const response = await fetch( - "https://openrouter.ai/api/v1/chat/completions", - { - method: "POST", - headers: { - Authorization: `Bearer ${process.env.OPENROUTER_API_KEY}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ - model: "mistralai/mistral-nemo", - messages: [ - { - role: "system", - content: `You are a Discord bot command interpreter. Parse user input into JSON commands. - -CRITICAL: Respond with ONLY valid JSON. No explanations, no markdown, no other text. - -Available commands: -- slowmode: Toggle, enable, or disable slowmode in the current channel - -Respond with ONLY a JSON object in this exact format: -{"command": "slowmode", "action": "toggle|enable|disable", "value": number} - -Actions for slowmode: -- "toggle" = switch current state -- "enable" = turn on (default 5 seconds) -- "disable" = turn off (0 seconds) -- "value" = seconds (optional, defaults to 5 for enable) - -Examples: -"toggle slowmode" → {"command": "slowmode", "action": "toggle"} -"enable slowmode" → {"command": "slowmode", "action": "enable", "value": 5} -"set slowmode to 10" → {"command": "slowmode", "action": "enable", "value": 10} -"disable slowmode" → {"command": "slowmode", "action": "disable", "value": 0} - -If input doesn't match available commands, respond with: -{"command": "unknown", "action": "none", "value": 0} - -RESPONSE MUST BE ONLY JSON. NO OTHER TEXT.`, - }, - { - role: "user", - content: content, - }, - ], - max_tokens: 100, - temperature: 0.1, - }), - }, - ); - - if (!response.ok) { - console.error( - "OpenRouter API error:", - response.status, - response.statusText, - ); - - return; - } - - const data = await response.json(); - const aiResponse = data.choices?.[0]?.message?.content?.trim(); - - if (!aiResponse) { - console.error("No response from OpenRouter"); - - return; - } - - let commandData: AICommandResponse; - - try { - commandData = JSON.parse(aiResponse); - } catch { - console.error("Failed to parse AI response as JSON:", aiResponse); - - return; - } - - await executeAICommand(message, commandData); - } catch (error) { - console.error("Error in AI command handler:", error); - } -}; - -const executeAICommand = async ( - message: Message, - commandData: AICommandResponse, -) => { - if (commandData.command === "slowmode") - await handleSlowmodeCommand(message, commandData); -}; - -const handleSlowmodeCommand = async ( - message: Message, - commandData: AICommandResponse, -) => { - if (!message.guild || !message.channel.isTextBased()) return; - - const channel = message.channel; - - if (!("rateLimitPerUser" in channel)) { - await message.react("❌"); - - return; - } - - const currentSlowmode = channel.rateLimitPerUser || 0; - let newSlowmode = 0; - - switch (commandData.action) { - case "toggle": - newSlowmode = currentSlowmode > 0 ? 0 : commandData.value || 5; - - break; - case "enable": - newSlowmode = commandData.value || 5; - - break; - case "disable": - newSlowmode = 0; - - break; - default: - await message.react("❌"); - - return; - } - - try { - await (channel as any).setRateLimitPerUser(newSlowmode); - await message.react("✅"); - } catch (error) { - console.error("Error setting slowmode:", error); - await message.react("❌"); - } -}; diff --git a/packages/gateway/src/listeners/messageCreate/aiCommandHandler/index.ts b/packages/gateway/src/listeners/messageCreate/aiCommandHandler/index.ts new file mode 100644 index 0000000..ef30716 --- /dev/null +++ b/packages/gateway/src/listeners/messageCreate/aiCommandHandler/index.ts @@ -0,0 +1,148 @@ +import { Message } from "discord.js"; +import { handleSlowmodeCommand } from "./slowmode.js"; +import { handlePurgeCommand } from "./purge.js"; + +const MODERATOR_ROLE_IDS = [ + "1406422617765712095", + "1406422617765712094", + "1406422617765712093", + "1406422617724026910", +]; + +export interface AICommandResponse { + command: string; + action: string; + value?: number; + user?: string; +} + +export const handleAICommand = async (message: Message) => { + if (message.author.bot) return; + + if (!message.mentions.has(message.client.user!)) return; + + if (!message.member) return; + + const hasModeratorRole = message.member.roles.cache.some((role) => + MODERATOR_ROLE_IDS.includes(role.id), + ); + const application = await message.client.application?.fetch(); + const ownerId = application?.owner?.id; + const isOwner = message.author.id === ownerId; + + if (!hasModeratorRole && !isOwner) return; + + const mentionMatches = message.content.match(/<@!?(\d+)>/g); + const mentionedUserIds = mentionMatches ? mentionMatches.map(match => match.match(/<@!?(\d+)>/)?.[1]).filter(Boolean) : []; + const botMention = message.content.match(/<@!?\d+>/); + const content = botMention ? message.content.replace(botMention[0], "").trim() : message.content.trim(); + + if (!content) return; + + try { + const response = await fetch( + "https://openrouter.ai/api/v1/chat/completions", + { + method: "POST", + headers: { + Authorization: `Bearer ${process.env.OPENROUTER_API_KEY}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + model: "mistralai/mistral-nemo", + messages: [ + { + role: "system", + content: `You are a Discord bot command interpreter. Parse user input into JSON commands. + +CRITICAL: Respond with ONLY valid JSON. No explanations, no markdown, no other text. + +Available commands: +- slowmode: Toggle, enable, or disable slowmode in the current channel +- purge: Delete messages from the current channel + +Respond with ONLY a JSON object in this exact format: +{"command": "slowmode|purge", "action": "toggle|enable|disable|last|from|lastfrom", "value": number, "user": "userid"} + +Actions for slowmode: +- "toggle" = switch current state +- "enable" = turn on (default 5 seconds) +- "disable" = turn off (0 seconds) +- "value" = seconds (optional, defaults to 5 for enable) + +Actions for purge: +- "last" = purge last N messages (value = number of messages) +- "from" = purge all messages from specific user (user = user ID) +- "lastfrom" = purge last N messages from specific user (value = number, user = user ID) + +IMPORTANT: When user mentions someone with @username, use the provided user ID from the context. + +Examples: +"toggle slowmode" → {"command": "slowmode", "action": "toggle"} +"enable slowmode" → {"command": "slowmode", "action": "enable", "value": 5} +"set slowmode to 10" → {"command": "slowmode", "action": "enable", "value": 10} +"disable slowmode" → {"command": "slowmode", "action": "disable", "value": 0} +"purge the last 5 messages" → {"command": "purge", "action": "last", "value": 5} +"purge all messages from @user" → {"command": "purge", "action": "from", "user": "userid"} +"purge the last 5 messages from @user" → {"command": "purge", "action": "lastfrom", "value": 5, "user": "userid"} + +If input doesn't match available commands, respond with: +{"command": "unknown", "action": "none", "value": 0} + +RESPONSE MUST BE ONLY JSON. NO OTHER TEXT.`, + }, + { + role: "user", + content: content + (mentionedUserIds.length > 0 ? `\n\nMentioned user IDs: ${mentionedUserIds.join(', ')}` : ''), + }, + ], + max_tokens: 100, + temperature: 0.1, + }), + }, + ); + + if (!response.ok) { + console.error( + "OpenRouter API error:", + response.status, + response.statusText, + ); + + return; + } + + const data = await response.json(); + const aiResponse = data.choices?.[0]?.message?.content?.trim(); + + if (!aiResponse) { + console.error("No response from OpenRouter"); + + return; + } + + let commandData: AICommandResponse; + + try { + commandData = JSON.parse(aiResponse); + } catch { + console.error("Failed to parse AI response as JSON:", aiResponse); + + return; + } + + await executeAICommand(message, commandData); + } catch (error) { + console.error("Error in AI command handler:", error); + } +}; + +const executeAICommand = async ( + message: Message, + commandData: AICommandResponse, +) => { + if (commandData.command === "slowmode") + await handleSlowmodeCommand(message, commandData); + else if (commandData.command === "purge") + await handlePurgeCommand(message, commandData); +}; diff --git a/packages/gateway/src/listeners/messageCreate/aiCommandHandler/purge.ts b/packages/gateway/src/listeners/messageCreate/aiCommandHandler/purge.ts new file mode 100644 index 0000000..bbbf89a --- /dev/null +++ b/packages/gateway/src/listeners/messageCreate/aiCommandHandler/purge.ts @@ -0,0 +1,197 @@ +import { Message, Collection } from "discord.js"; +import { AICommandResponse } from "./index"; + +const safeReact = async (message: Message, emoji: string) => { + try { + await message.react(emoji); + } catch (reactError) { + console.log("Could not react to message (likely deleted):", reactError); + } +}; + +export const handlePurgeCommand = async ( + message: Message, + commandData: AICommandResponse, +) => { + if (!message.guild || !message.channel.isTextBased()) return; + + const channel = message.channel; + const PURGE_LIMIT = 25; + + try { + let messagesToDelete: Message[] = []; + let totalCount = 0; + + switch (commandData.action) { + case "last": { + const count = commandData.value || 1; + + if (count > PURGE_LIMIT) { + await safeReact(message, "❌"); + + return; + } + + const allMessages: Message[] = []; + let lastMessageId: string | undefined; + const targetCount = count + 1; + + while (allMessages.length < targetCount) { + const fetchLimit = Math.min(100, targetCount - allMessages.length); + const fetchOptions: any = { limit: fetchLimit }; + + if (lastMessageId) + fetchOptions.before = lastMessageId; + + const messages = await channel.messages.fetch(fetchOptions); + + if (messages instanceof Collection) { + if (messages.size === 0) break; + + const messageArray = Array.from(messages.values()); + + allMessages.push(...messageArray); + + lastMessageId = messageArray[messageArray.length - 1]?.id; + } else { + allMessages.push(messages); + + lastMessageId = messages.id; + } + } + + messagesToDelete = allMessages.filter(msg => msg.id !== message.id).slice(0, count); + totalCount = messagesToDelete.length; + + break; + } + + case "from": { + if (!commandData.user) { + await safeReact(message, "❌"); + + return; + } + + const allMessages: Message[] = []; + let lastMessageId: string | undefined; + + while (true) { + const fetchOptions: any = { limit: 100 }; + + if (lastMessageId) + fetchOptions.before = lastMessageId; + + const messages = await channel.messages.fetch(fetchOptions); + + if (messages instanceof Collection) { + if (messages.size === 0) break; + + const messageArray = Array.from(messages.values()); + + allMessages.push(...messageArray); + + lastMessageId = messageArray[messageArray.length - 1]?.id; + } else { + allMessages.push(messages); + + lastMessageId = messages.id; + } + } + + const userMessages = allMessages.filter(msg => msg.author.id === commandData.user && msg.id !== message.id); + + if (userMessages.length > PURGE_LIMIT) { + await safeReact(message, "❌"); + + return; + } + + messagesToDelete = userMessages; + totalCount = messagesToDelete.length; + + break; + } + + case "lastfrom": { + if (!commandData.user || !commandData.value) { + await safeReact(message, "❌"); + + return; + } + + const count = commandData.value; + + if (count > PURGE_LIMIT) { + await safeReact(message, "❌"); + + return; + } + + const allMessages: Message[] = []; + let lastMessageId: string | undefined; + + while (allMessages.length < count * 2) { + const fetchOptions: any = { limit: 100 }; + + if (lastMessageId) + fetchOptions.before = lastMessageId; + + const messages = await channel.messages.fetch(fetchOptions); + + if (messages instanceof Collection) { + if (messages.size === 0) break; + + const messageArray = Array.from(messages.values()); + + allMessages.push(...messageArray); + + lastMessageId = messageArray[messageArray.length - 1]?.id; + } else { + allMessages.push(messages); + + lastMessageId = messages.id; + } + } + + const userMessages = allMessages.filter(msg => msg.author.id === commandData.user && msg.id !== message.id); + + messagesToDelete = userMessages.slice(0, count); + totalCount = messagesToDelete.length; + + break; + } + + default: + await safeReact(message, "❌"); + + return; + } + + if (messagesToDelete.length === 0) { + await safeReact(message, "❌"); + + return; + } + + const batches = []; + + for (let i = 0; i < messagesToDelete.length; i += 100) + batches.push(messagesToDelete.slice(i, i + 100)); + + for (const batch of batches) + if (batch.length === 1) { + await batch[0].delete(); + } else if ('bulkDelete' in channel) { + await (channel as any).bulkDelete(batch); + } else { + for (const msg of batch) + await msg.delete(); + } + + await safeReact(message, "✅"); + } catch (error) { + console.error("Error purging messages:", error); + await safeReact(message, "❌"); + } +}; diff --git a/packages/gateway/src/listeners/messageCreate/aiCommandHandler/slowmode.ts b/packages/gateway/src/listeners/messageCreate/aiCommandHandler/slowmode.ts new file mode 100644 index 0000000..d23e70c --- /dev/null +++ b/packages/gateway/src/listeners/messageCreate/aiCommandHandler/slowmode.ts @@ -0,0 +1,47 @@ +import { Message } from "discord.js"; +import { AICommandResponse } from "./index"; + +export const handleSlowmodeCommand = async ( + message: Message, + commandData: AICommandResponse, +) => { + if (!message.guild || !message.channel.isTextBased()) return; + + const channel = message.channel; + + if (!("rateLimitPerUser" in channel)) { + await message.react("❌"); + + return; + } + + const currentSlowmode = channel.rateLimitPerUser || 0; + let newSlowmode = 0; + + switch (commandData.action) { + case "toggle": + newSlowmode = currentSlowmode > 0 ? 0 : commandData.value || 5; + + break; + case "enable": + newSlowmode = commandData.value || 5; + + break; + case "disable": + newSlowmode = 0; + + break; + default: + await message.react("❌"); + + return; + } + + try { + await (channel as any).setRateLimitPerUser(newSlowmode); + await message.react("✅"); + } catch (error) { + console.error("Error setting slowmode:", error); + await message.react("❌"); + } +}; -- cgit v1.2.3