+46
-1
src/commands/utilities/ai.ts
+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
+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) {