Aethel Bot OSS repository! aethel.xyz
bot fun ai discord discord-bot aethel

feat: Enhanced AI integration for discord servers with multi-context

Changed files
+203 -49
src
commands
utilities
events
+46 -1
src/commands/utilities/ai.ts
··· 34 detail?: 'low' | 'high' | 'auto'; 35 }; 36 }>; 37 } 38 39 interface AIResponse { ··· 139 model?: string, 140 username?: string, 141 interaction?: ChatInputCommandInteraction, 142 ): string { 143 const now = new Date(); 144 const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone; ··· 171 172 const currentModel = model || (usingDefaultKey ? 'moonshotai/kimi-k2 (default)' : 'custom model'); 173 174 const baseInstructions = `You are a helpful, accurate, and privacy-respecting AI assistant for the /ai command of the Aethel Discord User Bot. Your primary goal is to provide clear, concise, and friendly answers to user questions, adapting your tone to be conversational and approachable. 175 176 **USER INFORMATION:** 177 - Username: ${username || 'Discord User'} ··· 190 - NEVER format, modify, or alter URLs in any way. Leave them exactly as they are. 191 - Format your responses using Discord markdown where appropriate, but NEVER format URLs. 192 - Only greet the user at the start of a new conversation, not in every message. 193 194 **BOT FACTS (use only if asked about the bot):** 195 - Name: Aethel ··· 289 await pool.query( 290 `INSERT INTO users (user_id, api_key_encrypted, custom_model, custom_api_url, updated_at) 291 VALUES ($1, $2, $3, $4, now()) 292 - ON CONFLICT (user_id) DO UPDATE SET 293 api_key_encrypted = $2, custom_model = $3, custom_api_url = $4, updated_at = now()`, 294 [userId, encrypted, model?.trim() || null, apiUrl?.trim() || null], 295 ); ··· 352 `INSERT INTO ai_usage (user_id, usage_date, count) VALUES ($1, $2, 1) 353 ON CONFLICT (user_id, usage_date) DO UPDATE SET count = ai_usage.count + 1 RETURNING count`, 354 [userId, today], 355 ); 356 await client.query('COMMIT'); 357 return res.rows[0].count <= limit; ··· 566 sendAIResponse, 567 getUserCredentials, 568 incrementAndCheckDailyLimit, 569 splitResponseIntoChunks, 570 }; 571
··· 34 detail?: 'low' | 'high' | 'auto'; 35 }; 36 }>; 37 + username?: string; 38 } 39 40 interface AIResponse { ··· 140 model?: string, 141 username?: string, 142 interaction?: ChatInputCommandInteraction, 143 + isServer?: boolean, 144 + serverName?: string, 145 ): string { 146 const now = new Date(); 147 const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone; ··· 174 175 const currentModel = model || (usingDefaultKey ? 'moonshotai/kimi-k2 (default)' : 'custom model'); 176 177 + const contextInfo = isServer 178 + ? `**CONTEXT:** 179 + - You are responding in the Discord server: "${serverName || 'Unknown Server'}" 180 + - CURRENT USER TALKING TO YOU: ${username || 'Discord User'} 181 + - IMPORTANT: This is a SERVER conversation where MULTIPLE DIFFERENT USERS can talk to you 182 + - Previous messages may be from different users - always check the username before each message 183 + - When you see "**Username**: message", that means a different user said that message 184 + - The current message is from ${username || 'Discord User'} specifically 185 + - Always be aware of WHO is talking to you in each message 186 + - If you reference previous messages, make sure you know which user said what 187 + - Users may respond to your messages or ping you to continue conversations 188 + - When users reply to your previous responses, treat it as part of the ongoing conversation` 189 + : `**CONTEXT:** 190 + - You are in a direct message conversation 191 + - User: ${username || 'Discord User'}`; 192 + 193 const baseInstructions = `You are a helpful, accurate, and privacy-respecting AI assistant for the /ai command of the Aethel Discord User Bot. Your primary goal is to provide clear, concise, and friendly answers to user questions, adapting your tone to be conversational and approachable. 194 + 195 + ${contextInfo} 196 197 **USER INFORMATION:** 198 - Username: ${username || 'Discord User'} ··· 211 - NEVER format, modify, or alter URLs in any way. Leave them exactly as they are. 212 - Format your responses using Discord markdown where appropriate, but NEVER format URLs. 213 - Only greet the user at the start of a new conversation, not in every message. 214 + - DO NOT hallucinate, make up facts, or provide false information. If you don't know something, say so clearly. 215 + - Be accurate and truthful in all responses. Do not invent details, statistics, or information that you're not certain about. 216 + - If asked about current events, real-time data, or information beyond your knowledge cutoff, clearly state your limitations. 217 218 **BOT FACTS (use only if asked about the bot):** 219 - Name: Aethel ··· 313 await pool.query( 314 `INSERT INTO users (user_id, api_key_encrypted, custom_model, custom_api_url, updated_at) 315 VALUES ($1, $2, $3, $4, now()) 316 + ON CONFLICT (user_id) DO UPDATE SET 317 api_key_encrypted = $2, custom_model = $3, custom_api_url = $4, updated_at = now()`, 318 [userId, encrypted, model?.trim() || null, apiUrl?.trim() || null], 319 ); ··· 376 `INSERT INTO ai_usage (user_id, usage_date, count) VALUES ($1, $2, 1) 377 ON CONFLICT (user_id, usage_date) DO UPDATE SET count = ai_usage.count + 1 RETURNING count`, 378 [userId, today], 379 + ); 380 + await client.query('COMMIT'); 381 + return res.rows[0].count <= limit; 382 + } catch (err) { 383 + await client.query('ROLLBACK'); 384 + throw err; 385 + } finally { 386 + client.release(); 387 + } 388 + } 389 + 390 + async function incrementAndCheckServerDailyLimit(serverId: string, limit = 20): Promise<boolean> { 391 + const today = new Date().toISOString().slice(0, 10); 392 + const client = await pool.connect(); 393 + try { 394 + await client.query('BEGIN'); 395 + const res = await client.query( 396 + `INSERT INTO server_ai_usage (server_id, usage_date, count) VALUES ($1, $2, 1) 397 + ON CONFLICT (server_id, usage_date) DO UPDATE SET count = server_ai_usage.count + 1 RETURNING count`, 398 + [serverId, today], 399 ); 400 await client.query('COMMIT'); 401 return res.rows[0].count <= limit; ··· 610 sendAIResponse, 611 getUserCredentials, 612 incrementAndCheckDailyLimit, 613 + incrementAndCheckServerDailyLimit, 614 splitResponseIntoChunks, 615 }; 616
+157 -48
src/events/messageCreate.ts
··· 8 buildConversation, 9 getUserCredentials, 10 incrementAndCheckDailyLimit, 11 splitResponseIntoChunks, 12 processUrls, 13 } from '@/commands/utilities/ai'; 14 import type { ConversationMessage, AIResponse } from '@/commands/utilities/ai'; 15 import { createMemoryManager } from '@/utils/memoryManager'; 16 17 - const conversations = createMemoryManager<string, ConversationMessage[]>({ 18 maxSize: 2000, 19 maxAge: 2 * 60 * 60 * 1000, 20 cleanupInterval: 10 * 60 * 1000, 21 }); 22 23 - function getConversationKey(message: Message): string { 24 - if (message.channel.type === ChannelType.DM) { 25 - return `dm:${message.author.id}`; 26 - } else if (message.guildId) { 27 - return `guild:${message.guildId}:${message.author.id}`; 28 - } 29 - return `channel:${message.channelId}`; 30 } 31 32 export default class MessageCreateEvent { ··· 47 const isMentioned = 48 message.mentions.users.has(this.client.user!.id) && !message.mentions.everyone; 49 50 if (!isDM && !isMentioned) { 51 logger.debug( 52 - `Ignoring message - not a DM and bot not mentioned (channel type: ${message.channel.type})`, 53 ); 54 return; 55 } ··· 61 `${isDM ? 'DM' : 'Message'} received (${message.content.length} characters) - content hidden for privacy`, 62 ); 63 64 - const conversationKey = getConversationKey(message); 65 - const conversation = conversations.get(conversationKey) || []; 66 - 67 const hasImageAttachments = message.attachments.some( 68 (att) => 69 att.contentType?.startsWith('image/') || 70 att.name?.match(/\.(jpg|jpeg|png|gif|webp|bmp|svg)$/i), 71 ); 72 73 - const hasImageUrls = false; 74 75 - if (message.attachments.size > 0) { 76 - logger.debug(`Found ${message.attachments.size} attachment(s)`); 77 - message.attachments.forEach((att) => { 78 - logger.debug(`Attachment: type=${att.contentType}, size=${att.size}bytes`); 79 - }); 80 } else { 81 - logger.debug('No attachments found in message'); 82 - } 83 - 84 - logger.debug(`hasImageAttachments: ${hasImageAttachments}, hasImageUrls: ${hasImageUrls}`); 85 - const hasImages = hasImageAttachments; 86 - const { model: userCustomModel } = await getUserCredentials(conversationKey); 87 88 - const selectedModel = hasImages 89 - ? 'google/gemma-3-4b-it' 90 - : userCustomModel || 'moonshotai/kimi-k2'; 91 92 logger.info( 93 - `Using model: ${selectedModel} for message with images: ${hasImages}${userCustomModel ? ' (user custom model)' : ' (default model)'}`, 94 ); 95 96 const systemPrompt = buildSystemPrompt( 97 - isDM, 98 this.client, 99 selectedModel, 100 message.author.username, 101 ); 102 103 let messageContent: ··· 150 messageContent = contentArray; 151 } 152 153 let filteredConversation = conversation; 154 if (selectedModel === 'moonshotai/kimi-k2') { 155 filteredConversation = conversation.map((msg) => { ··· 170 systemPrompt, 171 ); 172 173 - const { apiKey: userApiKey, apiUrl: userApiUrl } = await getUserCredentials(conversationKey); 174 - const config = getApiConfiguration(userApiKey ?? null, selectedModel, userApiUrl ?? null); 175 - 176 if (config.usingDefaultKey) { 177 const exemptUserId = process.env.AI_EXEMPT_USER_ID; 178 const actorId = message.author.id; 179 180 if (actorId !== exemptUserId) { 181 - const allowed = await incrementAndCheckDailyLimit(actorId, 10); 182 - if (!allowed) { 183 - await message.reply( 184 - "❌ You've reached your daily limit of AI requests. Please try again tomorrow or set up your own API key using the `/ai` command.", 185 - ); 186 - return; 187 } 188 } 189 } else if (!config.finalApiKey) { ··· 229 return msg; 230 }); 231 232 - const fallbackModel = userCustomModel || 'moonshotai/kimi-k2'; 233 234 const fallbackConversation = buildConversation( 235 cleanedConversation, 236 fallbackContent, 237 - buildSystemPrompt(isDM, this.client, fallbackModel, message.author.username), 238 ); 239 240 - const fallbackConfig = getApiConfiguration( 241 - userApiKey ?? null, 242 - fallbackModel, 243 - userApiUrl ?? null, 244 - ); 245 aiResponse = await makeAIRequest(fallbackConfig, fallbackConversation); 246 247 if (aiResponse) { ··· 274 275 await this.sendResponse(message, aiResponse); 276 277 - updatedConversation.push({ 278 role: 'assistant', 279 content: aiResponse.content, 280 - }); 281 - conversations.set(conversationKey, updatedConversation); 282 283 logger.info(`${isDM ? 'DM' : 'Server'} response sent successfully`); 284 } catch (error) {
··· 8 buildConversation, 9 getUserCredentials, 10 incrementAndCheckDailyLimit, 11 + incrementAndCheckServerDailyLimit, 12 splitResponseIntoChunks, 13 processUrls, 14 } from '@/commands/utilities/ai'; 15 import type { ConversationMessage, AIResponse } from '@/commands/utilities/ai'; 16 + 17 + type ApiConfiguration = ReturnType<typeof getApiConfiguration>; 18 import { createMemoryManager } from '@/utils/memoryManager'; 19 20 + const serverConversations = createMemoryManager<string, ConversationMessage[]>({ 21 + maxSize: 1000, 22 + maxAge: 2 * 60 * 60 * 1000, 23 + cleanupInterval: 10 * 60 * 1000, 24 + }); 25 + 26 + const serverMessageContext = createMemoryManager< 27 + string, 28 + Array<{ 29 + username: string; 30 + content: string; 31 + timestamp: number; 32 + }> 33 + >({ 34 + maxSize: 1000, 35 + maxAge: 2 * 60 * 60 * 1000, 36 + cleanupInterval: 10 * 60 * 1000, 37 + }); 38 + 39 + const userConversations = createMemoryManager<string, ConversationMessage[]>({ 40 maxSize: 2000, 41 maxAge: 2 * 60 * 60 * 1000, 42 cleanupInterval: 10 * 60 * 1000, 43 }); 44 45 + function getServerConversationKey(guildId: string): string { 46 + return `server:${guildId}`; 47 + } 48 + 49 + function getUserConversationKey(userId: string): string { 50 + return `dm:${userId}`; 51 } 52 53 export default class MessageCreateEvent { ··· 68 const isMentioned = 69 message.mentions.users.has(this.client.user!.id) && !message.mentions.everyone; 70 71 + if (!isDM && message.guildId) { 72 + const contextKey = getServerConversationKey(message.guildId); 73 + const existingContext = serverMessageContext.get(contextKey) || []; 74 + 75 + const newMessage = { 76 + username: message.author.username, 77 + content: message.content, 78 + timestamp: Date.now(), 79 + }; 80 + 81 + const updatedContext = [...existingContext, newMessage].slice(-10); 82 + serverMessageContext.set(contextKey, updatedContext); 83 + } 84 + 85 if (!isDM && !isMentioned) { 86 logger.debug( 87 + `Storing message for context but not responding - not a DM and bot not mentioned (channel type: ${message.channel.type})`, 88 ); 89 return; 90 } ··· 96 `${isDM ? 'DM' : 'Message'} received (${message.content.length} characters) - content hidden for privacy`, 97 ); 98 99 const hasImageAttachments = message.attachments.some( 100 (att) => 101 att.contentType?.startsWith('image/') || 102 att.name?.match(/\.(jpg|jpeg|png|gif|webp|bmp|svg)$/i), 103 ); 104 105 + logger.debug(`hasImageAttachments: ${hasImageAttachments}`); 106 + const hasImages = hasImageAttachments; 107 + 108 + let selectedModel: string; 109 + let config: ApiConfiguration; 110 + let usingDefaultKey = true; 111 112 + if (isDM) { 113 + const { 114 + model: userCustomModel, 115 + apiKey: userApiKey, 116 + apiUrl: userApiUrl, 117 + } = await getUserCredentials(`user:${message.author.id}`); 118 + 119 + selectedModel = hasImages 120 + ? 'google/gemma-3-4b-it' 121 + : userCustomModel || 'moonshotai/kimi-k2'; 122 + 123 + config = getApiConfiguration(userApiKey ?? null, selectedModel, userApiUrl ?? null); 124 + usingDefaultKey = config.usingDefaultKey; 125 } else { 126 + selectedModel = hasImages ? 'google/gemma-3-4b-it' : 'google/gemini-2.5-flash-lite'; 127 128 + config = getApiConfiguration(null, selectedModel, null); 129 + } 130 131 logger.info( 132 + `Using model: ${selectedModel} for message with images: ${hasImages}${ 133 + isDM && !usingDefaultKey ? ' (user custom model)' : ' (default model)' 134 + }`, 135 ); 136 137 const systemPrompt = buildSystemPrompt( 138 + usingDefaultKey, 139 this.client, 140 selectedModel, 141 message.author.username, 142 + undefined, 143 + !isDM, 144 + !isDM ? message.guild?.name : undefined, 145 ); 146 147 let messageContent: ··· 194 messageContent = contentArray; 195 } 196 197 + let conversation: ConversationMessage[] = []; 198 + 199 + if (isDM) { 200 + const conversationKey = getUserConversationKey(message.author.id); 201 + conversation = userConversations.get(conversationKey) || []; 202 + } else { 203 + const serverKey = getServerConversationKey(message.guildId!); 204 + const serverConversation = serverConversations.get(serverKey) || []; 205 + const recentMessages = serverMessageContext.get(serverKey) || []; 206 + 207 + const contextMessages = recentMessages.slice(-6, -1).map((msg) => ({ 208 + role: 'user' as const, 209 + content: `**${msg.username}**: ${msg.content}`, 210 + username: msg.username, 211 + })); 212 + 213 + const aiHistory = serverConversation.slice(-3); 214 + 215 + const formattedAiHistory = aiHistory.map((msg) => { 216 + if (msg.role === 'user' && msg.username) { 217 + const content = Array.isArray(msg.content) 218 + ? msg.content.map((c) => (c.type === 'text' ? c.text : '[Image]')).join(' ') 219 + : msg.content; 220 + return { 221 + ...msg, 222 + content: `**${msg.username}**: ${content}`, 223 + }; 224 + } 225 + return msg; 226 + }); 227 + 228 + conversation = [...contextMessages, ...formattedAiHistory]; 229 + } 230 + 231 let filteredConversation = conversation; 232 if (selectedModel === 'moonshotai/kimi-k2') { 233 filteredConversation = conversation.map((msg) => { ··· 248 systemPrompt, 249 ); 250 251 if (config.usingDefaultKey) { 252 const exemptUserId = process.env.AI_EXEMPT_USER_ID; 253 const actorId = message.author.id; 254 255 if (actorId !== exemptUserId) { 256 + if (isDM) { 257 + const allowed = await incrementAndCheckDailyLimit(actorId, 10); 258 + if (!allowed) { 259 + await message.reply( 260 + "❌ You've reached your daily limit of AI requests. Please try again tomorrow or set up your own API key using the `/ai` command.", 261 + ); 262 + return; 263 + } 264 + } else { 265 + const serverAllowed = await incrementAndCheckServerDailyLimit(message.guildId!, 150); 266 + if (!serverAllowed) { 267 + await message.reply( 268 + '❌ This server has reached its daily limit of 150 AI requests. Please try again tomorrow or have someone set up their own API key using the `/ai` command.', 269 + ); 270 + return; 271 + } 272 } 273 } 274 } else if (!config.finalApiKey) { ··· 314 return msg; 315 }); 316 317 + const fallbackModel = 318 + isDM && !usingDefaultKey 319 + ? 'moonshotai/kimi-k2' 320 + : isDM 321 + ? 'moonshotai/kimi-k2' 322 + : 'google/gemini-2.5-flash-lite'; 323 324 const fallbackConversation = buildConversation( 325 cleanedConversation, 326 fallbackContent, 327 + buildSystemPrompt( 328 + usingDefaultKey, 329 + this.client, 330 + fallbackModel, 331 + message.author.username, 332 + undefined, 333 + !isDM, 334 + !isDM ? message.guild?.name : undefined, 335 + ), 336 ); 337 338 + const fallbackConfig = isDM ? config : getApiConfiguration(null, fallbackModel, null); 339 aiResponse = await makeAIRequest(fallbackConfig, fallbackConversation); 340 341 if (aiResponse) { ··· 368 369 await this.sendResponse(message, aiResponse); 370 371 + const userMessage: ConversationMessage = { 372 + role: 'user', 373 + content: messageContent, 374 + username: message.author.username, 375 + }; 376 + const assistantMessage: ConversationMessage = { 377 role: 'assistant', 378 content: aiResponse.content, 379 + }; 380 + 381 + if (isDM) { 382 + const conversationKey = getUserConversationKey(message.author.id); 383 + const newConversation = [...filteredConversation, userMessage, assistantMessage]; 384 + userConversations.set(conversationKey, newConversation); 385 + } else { 386 + const serverKey = getServerConversationKey(message.guildId!); 387 + const serverConversation = serverConversations.get(serverKey) || []; 388 + const newServerConversation = [...serverConversation, userMessage, assistantMessage]; 389 + serverConversations.set(serverKey, newServerConversation); 390 + } 391 392 logger.info(`${isDM ? 'DM' : 'Server'} response sent successfully`); 393 } catch (error) {