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 34 detail?: 'low' | 'high' | 'auto'; 35 35 }; 36 36 }>; 37 + username?: string; 37 38 } 38 39 39 40 interface AIResponse { ··· 139 140 model?: string, 140 141 username?: string, 141 142 interaction?: ChatInputCommandInteraction, 143 + isServer?: boolean, 144 + serverName?: string, 142 145 ): string { 143 146 const now = new Date(); 144 147 const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone; ··· 171 174 172 175 const currentModel = model || (usingDefaultKey ? 'moonshotai/kimi-k2 (default)' : 'custom model'); 173 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 + 174 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} 175 196 176 197 **USER INFORMATION:** 177 198 - Username: ${username || 'Discord User'} ··· 190 211 - NEVER format, modify, or alter URLs in any way. Leave them exactly as they are. 191 212 - Format your responses using Discord markdown where appropriate, but NEVER format URLs. 192 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. 193 217 194 218 **BOT FACTS (use only if asked about the bot):** 195 219 - Name: Aethel ··· 289 313 await pool.query( 290 314 `INSERT INTO users (user_id, api_key_encrypted, custom_model, custom_api_url, updated_at) 291 315 VALUES ($1, $2, $3, $4, now()) 292 - ON CONFLICT (user_id) DO UPDATE SET 316 + ON CONFLICT (user_id) DO UPDATE SET 293 317 api_key_encrypted = $2, custom_model = $3, custom_api_url = $4, updated_at = now()`, 294 318 [userId, encrypted, model?.trim() || null, apiUrl?.trim() || null], 295 319 ); ··· 352 376 `INSERT INTO ai_usage (user_id, usage_date, count) VALUES ($1, $2, 1) 353 377 ON CONFLICT (user_id, usage_date) DO UPDATE SET count = ai_usage.count + 1 RETURNING count`, 354 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], 355 399 ); 356 400 await client.query('COMMIT'); 357 401 return res.rows[0].count <= limit; ··· 566 610 sendAIResponse, 567 611 getUserCredentials, 568 612 incrementAndCheckDailyLimit, 613 + incrementAndCheckServerDailyLimit, 569 614 splitResponseIntoChunks, 570 615 }; 571 616
+157 -48
src/events/messageCreate.ts
··· 8 8 buildConversation, 9 9 getUserCredentials, 10 10 incrementAndCheckDailyLimit, 11 + incrementAndCheckServerDailyLimit, 11 12 splitResponseIntoChunks, 12 13 processUrls, 13 14 } from '@/commands/utilities/ai'; 14 15 import type { ConversationMessage, AIResponse } from '@/commands/utilities/ai'; 16 + 17 + type ApiConfiguration = ReturnType<typeof getApiConfiguration>; 15 18 import { createMemoryManager } from '@/utils/memoryManager'; 16 19 17 - const conversations = createMemoryManager<string, ConversationMessage[]>({ 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[]>({ 18 40 maxSize: 2000, 19 41 maxAge: 2 * 60 * 60 * 1000, 20 42 cleanupInterval: 10 * 60 * 1000, 21 43 }); 22 44 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}`; 45 + function getServerConversationKey(guildId: string): string { 46 + return `server:${guildId}`; 47 + } 48 + 49 + function getUserConversationKey(userId: string): string { 50 + return `dm:${userId}`; 30 51 } 31 52 32 53 export default class MessageCreateEvent { ··· 47 68 const isMentioned = 48 69 message.mentions.users.has(this.client.user!.id) && !message.mentions.everyone; 49 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 + 50 85 if (!isDM && !isMentioned) { 51 86 logger.debug( 52 - `Ignoring message - not a DM and bot not mentioned (channel type: ${message.channel.type})`, 87 + `Storing message for context but not responding - not a DM and bot not mentioned (channel type: ${message.channel.type})`, 53 88 ); 54 89 return; 55 90 } ··· 61 96 `${isDM ? 'DM' : 'Message'} received (${message.content.length} characters) - content hidden for privacy`, 62 97 ); 63 98 64 - const conversationKey = getConversationKey(message); 65 - const conversation = conversations.get(conversationKey) || []; 66 - 67 99 const hasImageAttachments = message.attachments.some( 68 100 (att) => 69 101 att.contentType?.startsWith('image/') || 70 102 att.name?.match(/\.(jpg|jpeg|png|gif|webp|bmp|svg)$/i), 71 103 ); 72 104 73 - const hasImageUrls = false; 105 + logger.debug(`hasImageAttachments: ${hasImageAttachments}`); 106 + const hasImages = hasImageAttachments; 107 + 108 + let selectedModel: string; 109 + let config: ApiConfiguration; 110 + let usingDefaultKey = true; 74 111 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 - }); 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; 80 125 } 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); 126 + selectedModel = hasImages ? 'google/gemma-3-4b-it' : 'google/gemini-2.5-flash-lite'; 87 127 88 - const selectedModel = hasImages 89 - ? 'google/gemma-3-4b-it' 90 - : userCustomModel || 'moonshotai/kimi-k2'; 128 + config = getApiConfiguration(null, selectedModel, null); 129 + } 91 130 92 131 logger.info( 93 - `Using model: ${selectedModel} for message with images: ${hasImages}${userCustomModel ? ' (user custom model)' : ' (default model)'}`, 132 + `Using model: ${selectedModel} for message with images: ${hasImages}${ 133 + isDM && !usingDefaultKey ? ' (user custom model)' : ' (default model)' 134 + }`, 94 135 ); 95 136 96 137 const systemPrompt = buildSystemPrompt( 97 - isDM, 138 + usingDefaultKey, 98 139 this.client, 99 140 selectedModel, 100 141 message.author.username, 142 + undefined, 143 + !isDM, 144 + !isDM ? message.guild?.name : undefined, 101 145 ); 102 146 103 147 let messageContent: ··· 150 194 messageContent = contentArray; 151 195 } 152 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 + 153 231 let filteredConversation = conversation; 154 232 if (selectedModel === 'moonshotai/kimi-k2') { 155 233 filteredConversation = conversation.map((msg) => { ··· 170 248 systemPrompt, 171 249 ); 172 250 173 - const { apiKey: userApiKey, apiUrl: userApiUrl } = await getUserCredentials(conversationKey); 174 - const config = getApiConfiguration(userApiKey ?? null, selectedModel, userApiUrl ?? null); 175 - 176 251 if (config.usingDefaultKey) { 177 252 const exemptUserId = process.env.AI_EXEMPT_USER_ID; 178 253 const actorId = message.author.id; 179 254 180 255 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; 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 + } 187 272 } 188 273 } 189 274 } else if (!config.finalApiKey) { ··· 229 314 return msg; 230 315 }); 231 316 232 - const fallbackModel = userCustomModel || 'moonshotai/kimi-k2'; 317 + const fallbackModel = 318 + isDM && !usingDefaultKey 319 + ? 'moonshotai/kimi-k2' 320 + : isDM 321 + ? 'moonshotai/kimi-k2' 322 + : 'google/gemini-2.5-flash-lite'; 233 323 234 324 const fallbackConversation = buildConversation( 235 325 cleanedConversation, 236 326 fallbackContent, 237 - buildSystemPrompt(isDM, this.client, fallbackModel, message.author.username), 327 + buildSystemPrompt( 328 + usingDefaultKey, 329 + this.client, 330 + fallbackModel, 331 + message.author.username, 332 + undefined, 333 + !isDM, 334 + !isDM ? message.guild?.name : undefined, 335 + ), 238 336 ); 239 337 240 - const fallbackConfig = getApiConfiguration( 241 - userApiKey ?? null, 242 - fallbackModel, 243 - userApiUrl ?? null, 244 - ); 338 + const fallbackConfig = isDM ? config : getApiConfiguration(null, fallbackModel, null); 245 339 aiResponse = await makeAIRequest(fallbackConfig, fallbackConversation); 246 340 247 341 if (aiResponse) { ··· 274 368 275 369 await this.sendResponse(message, aiResponse); 276 370 277 - updatedConversation.push({ 371 + const userMessage: ConversationMessage = { 372 + role: 'user', 373 + content: messageContent, 374 + username: message.author.username, 375 + }; 376 + const assistantMessage: ConversationMessage = { 278 377 role: 'assistant', 279 378 content: aiResponse.content, 280 - }); 281 - conversations.set(conversationKey, updatedConversation); 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 + } 282 391 283 392 logger.info(`${isDM ? 'DM' : 'Server'} response sent successfully`); 284 393 } catch (error) {