Aethel Bot OSS repository!
aethel.xyz
bot
fun
ai
discord
discord-bot
aethel
1import {
2 ChatInputCommandInteraction,
3 InteractionReplyOptions,
4 MessagePayload,
5 EmbedBuilder,
6} from 'discord.js';
7import { SlashCommandProps } from '@/types/command';
8import BotClient from '@/services/Client';
9import logger from '@/utils/logger';
10
11export interface ToolCall {
12 name: string;
13 args: Record<string, unknown>;
14}
15
16type EmbedInput = Record<string, unknown> & {
17 data?: Record<string, unknown>;
18 toJSON?: () => unknown;
19 title?: unknown;
20 description?: unknown;
21 fields?: unknown;
22 color?: unknown;
23 timestamp?: unknown;
24 footer?: unknown;
25};
26
27function toPlainEmbedObject(embed: unknown): Record<string, unknown> | unknown {
28 if (embed && typeof embed === 'object') {
29 const embedObj = embed as EmbedInput;
30 if ('data' in embedObj && embedObj.data) {
31 return embedObj.data as Record<string, unknown>;
32 }
33 if (typeof embedObj.toJSON === 'function') {
34 return embedObj.toJSON();
35 }
36 const e = embedObj as Record<string, unknown>;
37 if ('title' in e || 'fields' in e || 'description' in e) {
38 return e;
39 }
40 return {
41 title: embedObj.title,
42 description: embedObj.description,
43 fields: embedObj.fields,
44 color: embedObj.color,
45 timestamp: embedObj.timestamp,
46 footer: embedObj.footer,
47 } as Record<string, unknown>;
48 }
49 return embed as Record<string, unknown>;
50}
51
52function testEmbedBuilderStructure() {
53 const testEmbed = new EmbedBuilder()
54 .setTitle('Test Title')
55 .setDescription('Test Description')
56 .addFields({ name: 'Test Field', value: 'Test Value', inline: true });
57
58 logger.debug(`[Command Executor] Test EmbedBuilder structure:`, {
59 embed: testEmbed,
60 hasData: 'data' in testEmbed,
61 data: testEmbed.data,
62 hasToJSON: typeof testEmbed.toJSON === 'function',
63 toJSON: testEmbed.toJSON ? testEmbed.toJSON() : 'N/A',
64 keys: Object.keys(testEmbed),
65 prototype: Object.getPrototypeOf(testEmbed)?.constructor?.name,
66 });
67
68 if (testEmbed.data) {
69 logger.debug(`[Command Executor] Test embed data fields:`, {
70 fields: testEmbed.data.fields,
71 fieldsLength: testEmbed.data.fields?.length,
72 allDataKeys: Object.keys(testEmbed.data),
73 });
74 }
75}
76
77export function extractToolCalls(content: string): { cleanContent: string; toolCalls: ToolCall[] } {
78 const toolCallRegex = /{([^{}\s:]+):({[^{}]*}|[^{}]*)?}/g;
79 const toolCalls: ToolCall[] = [];
80 let cleanContent = content;
81 let match;
82
83 while ((match = toolCallRegex.exec(content)) !== null) {
84 try {
85 if (!match[1]) {
86 continue;
87 }
88
89 const toolName = match[1].trim();
90 const argsString = match[2] ? match[2].trim() : '';
91
92 if (!toolName) {
93 continue;
94 }
95
96 let args: Record<string, unknown> = {};
97
98 if (argsString.startsWith('{') && argsString.endsWith('}')) {
99 try {
100 args = JSON.parse(argsString);
101 } catch (_error) {
102 args = { query: argsString };
103 }
104 } else if (argsString) {
105 if (argsString.startsWith('"') && argsString.endsWith('"')) {
106 const unquoted = argsString.slice(1, -1);
107 if (toolName === 'reaction') {
108 args = { emoji: unquoted };
109 } else {
110 args = { query: unquoted };
111 }
112 } else {
113 args = { query: argsString };
114 }
115 } else {
116 args = {};
117 }
118
119 toolCalls.push({
120 name: toolName,
121 args,
122 });
123
124 cleanContent = cleanContent.replace(match[0], '').trim();
125 } catch (error) {
126 logger.error(`Error parsing tool call: ${error}`);
127 }
128 }
129
130 return { cleanContent, toolCalls };
131}
132
133export async function executeToolCall(
134 toolCall: ToolCall,
135 interaction: ChatInputCommandInteraction,
136 client: BotClient,
137): Promise<string> {
138 let { name, args } = toolCall;
139
140 if (name.includes(':')) {
141 const parts = name.split(':');
142 name = parts[0];
143 if (!args || Object.keys(args).length === 0) {
144 args = { search: parts[1] };
145 }
146 }
147
148 try {
149 const validCommands = ['cat', 'dog', 'joke', '8ball', 'weather', 'wiki'];
150
151 if (!validCommands.includes(name.toLowerCase())) {
152 throw new Error(
153 `Command '${name}' is not a valid command. Available commands: ${validCommands.join(', ')}`,
154 );
155 }
156
157 if (['cat', 'dog'].includes(name)) {
158 const commandName = name.charAt(0).toUpperCase() + name.slice(1);
159 logger.debug(`[${commandName}] Starting ${name} command execution`);
160
161 try {
162 const commandDir = 'fun';
163 const commandModule = await import(`../commands/${commandDir}/${name}`);
164
165 const imageData =
166 name === 'cat'
167 ? await commandModule.fetchCatImage()
168 : await commandModule.fetchDogImage();
169
170 return JSON.stringify({
171 success: true,
172 type: name,
173 title: imageData.title || `Random ${commandName}`,
174 url: imageData.url,
175 subreddit: imageData.subreddit,
176 source: name === 'cat' ? 'pur.cat' : 'erm.dog',
177 handled: true,
178 });
179 } catch (error) {
180 const errorMessage =
181 error instanceof Error ? error.message : `Unknown error in ${name} command`;
182 logger.error(`[${commandName}] Error: ${errorMessage}`, { error });
183 return JSON.stringify({
184 success: false,
185 error: `Failed to execute ${name} command: ${errorMessage}`,
186 handled: false,
187 });
188 }
189 }
190
191 if (name === 'wiki') {
192 logger.debug('[Wiki] Starting wiki command execution');
193
194 try {
195 const wikiModule = await import('../commands/utilities/wiki');
196
197 let searchQuery = '';
198 if (typeof args === 'object' && args !== null) {
199 const argsObj = args as Record<string, unknown>;
200 searchQuery = (argsObj.search as string) || (argsObj.query as string) || '';
201 }
202
203 if (!searchQuery) {
204 return JSON.stringify({
205 error: true,
206 message: 'Missing search query',
207 status: 400,
208 });
209 }
210
211 try {
212 logger.debug(
213 `[Wiki] Searching Wikipedia for: ${searchQuery} (locale: ${interaction.locale || 'en'})`,
214 );
215
216 const searchResult = await wikiModule.searchWikipedia(
217 searchQuery,
218 interaction.locale || 'en',
219 );
220 logger.debug(`[Wiki] Search result:`, {
221 pageid: searchResult.pageid,
222 title: searchResult.title,
223 });
224
225 const article = await wikiModule.getArticleSummary(
226 searchResult.pageid,
227 searchResult.wikiLang,
228 );
229 logger.debug(`[Wiki] Retrieved article:`, {
230 title: article.title,
231 extractLength: article.extract?.length,
232 });
233
234 const maxLength = 1500;
235 const truncated = article.extract && article.extract.length > maxLength;
236 const extract = truncated
237 ? article.extract.substring(0, maxLength)
238 : article.extract || 'No summary available for this article.';
239
240 const response = {
241 title: article.title,
242 content: extract,
243 url: `https://${searchResult.wikiLang}.wikipedia.org/wiki/${encodeURIComponent(article.title.replace(/ /g, '_'))}`,
244 truncated: truncated,
245 };
246
247 return JSON.stringify(response);
248 } catch (error) {
249 const errorMessage = error instanceof Error ? error.message : String(error);
250 const stack = error instanceof Error ? error.stack : undefined;
251 const responseStatus =
252 error instanceof Error && 'response' in error
253 ? (error as { response?: { status?: number } }).response?.status
254 : undefined;
255
256 logger.error('[Wiki] Error executing wiki command:', {
257 error: errorMessage,
258 stack,
259 responseStatus,
260 searchQuery,
261 locale: interaction.locale,
262 });
263
264 return JSON.stringify({
265 error: true,
266 message:
267 responseStatus === 404
268 ? 'No Wikipedia article found for that search query. Please try a different search term.'
269 : `Error searching Wikipedia: ${errorMessage}`,
270 status: responseStatus || 500,
271 });
272 }
273 } catch (error) {
274 const errorMessage = error instanceof Error ? error.message : String(error);
275 const stack = error instanceof Error ? error.stack : undefined;
276 logger.error('[Wiki] Error in wiki command execution:', { error: errorMessage, stack });
277
278 return JSON.stringify({
279 error: true,
280 message: `Error in wiki command execution: ${errorMessage}`,
281 status: 500,
282 });
283 }
284 }
285
286 let commandModule;
287 const isDev = process.env.NODE_ENV !== 'production';
288 const ext = isDev ? '.ts' : '.js';
289
290 try {
291 let commandDir = 'fun';
292 if (
293 ['weather', 'wiki', 'ai', 'cobalt', 'remind', 'social', 'time', 'todo', 'whois'].includes(
294 name,
295 )
296 ) {
297 commandDir = 'utilities';
298 }
299
300 const commandPath = `../commands/${commandDir}/${name}${ext}`;
301 logger.debug(`[Command Executor] Trying to import command from: ${commandPath}`);
302 commandModule = await import(commandPath).catch((e) => {
303 logger.error(`[Command Executor] Error importing command '${name}':`, e);
304 throw e;
305 });
306
307 if (name === 'cat' || name === 'dog') {
308 try {
309 const imageData =
310 name === 'cat'
311 ? await commandModule.fetchCatImage()
312 : await commandModule.fetchDogImage();
313
314 return JSON.stringify({
315 success: true,
316 type: name,
317 title: imageData.title || `Random ${name === 'cat' ? 'Cat' : 'Dog'}`,
318 url: imageData.url,
319 subreddit: imageData.subreddit,
320 source: name === 'cat' ? 'pur.cat' : 'erm.dog',
321 });
322 } catch (error) {
323 const errorMessage =
324 error instanceof Error ? error.message : `Unknown error fetching ${name} image`;
325 logger.error(`[${name}] Error: ${errorMessage}`, { error });
326 return JSON.stringify({
327 success: false,
328 error: `Failed to fetch ${name} image: ${errorMessage}`,
329 });
330 }
331 }
332 } catch (error) {
333 logger.error(`[Command Executor] Error importing command '${name}':`, error);
334 throw new Error(`Command '${name}' not found`);
335 }
336
337 const command = commandModule.default as SlashCommandProps;
338
339 if (!command) {
340 throw new Error(`Command '${name}' not found`);
341 }
342
343 let capturedResponse: unknown = null;
344
345 const mockInteraction = {
346 ...interaction,
347 options: {
348 getString: (param: string) => {
349 if (typeof args === 'object' && args !== null) {
350 const argsObj = args as Record<string, unknown>;
351 return (argsObj[param] as string) || '';
352 }
353 return '';
354 },
355 getNumber: (param: string) => {
356 if (typeof args === 'object' && args !== null) {
357 const argsObj = args as Record<string, unknown>;
358 const value = argsObj[param];
359 return value !== null && value !== undefined ? Number(value) : null;
360 }
361 return null;
362 },
363 getBoolean: (param: string) => {
364 if (typeof args === 'object' && args !== null) {
365 const argsObj = args as Record<string, unknown>;
366 const value = argsObj[param];
367 return typeof value === 'boolean' ? value : null;
368 }
369 return null;
370 },
371 },
372 deferReply: async () => {
373 return Promise.resolve();
374 },
375 reply: async (options: InteractionReplyOptions | MessagePayload) => {
376 if ('embeds' in options && options.embeds && options.embeds.length > 0) {
377 const processedEmbeds = options.embeds.map((e) => toPlainEmbedObject(e));
378 capturedResponse = { ...(options as Record<string, unknown>), embeds: processedEmbeds };
379 } else {
380 capturedResponse = options;
381 }
382 if ('embeds' in options && options.embeds && options.embeds.length > 0) {
383 return JSON.stringify({
384 success: true,
385 embeds:
386 'embeds' in (capturedResponse as Record<string, unknown>)
387 ? (capturedResponse as { embeds?: unknown[] }).embeds
388 : options.embeds,
389 });
390 }
391 return JSON.stringify({
392 success: true,
393 content: 'content' in options ? options.content : undefined,
394 });
395 },
396 editReply: async (options: InteractionReplyOptions | MessagePayload) => {
397 if ('embeds' in options && options.embeds && options.embeds.length > 0) {
398 const processedEmbeds = options.embeds.map((e) => toPlainEmbedObject(e));
399 capturedResponse = { ...(options as Record<string, unknown>), embeds: processedEmbeds };
400 } else {
401 capturedResponse = options;
402 }
403 logger.debug(`[Command Executor] editReply called with options:`, {
404 hasEmbeds: 'embeds' in options && options.embeds && options.embeds.length > 0,
405 hasContent: 'content' in options,
406 options: options,
407 });
408
409 if ('embeds' in options && options.embeds && options.embeds.length > 0) {
410 logger.debug(`[Command Executor] Raw embeds before processing:`, options.embeds);
411
412 testEmbedBuilderStructure();
413
414 const processedEmbeds = options.embeds.map((embed) => {
415 const embedObj = embed as EmbedInput;
416 logger.debug(`[Command Executor] Processing embed:`, {
417 hasData: !!(embedObj && typeof embedObj === 'object' && 'data' in embedObj),
418 embedKeys: embedObj ? Object.keys(embedObj) : [],
419 embedType: (embed as { constructor?: { name?: string } })?.constructor?.name,
420 embedPrototype: Object.getPrototypeOf(embedObj as object)?.constructor?.name,
421 });
422 const plain = toPlainEmbedObject(embedObj);
423 return plain;
424 });
425
426 logger.debug(`[Command Executor] Processed embeds:`, processedEmbeds);
427
428 if (processedEmbeds.length > 0) {
429 const firstEmbed = processedEmbeds[0];
430 const firstEmbedObj = firstEmbed as Record<string, unknown>;
431 logger.debug(`[Command Executor] First processed embed details:`, {
432 title: (firstEmbedObj as { title?: unknown })?.title,
433 description: (firstEmbedObj as { description?: unknown })?.description,
434 fields: (firstEmbedObj as { fields?: unknown })?.fields,
435 fieldsLength: (firstEmbedObj as { fields?: Array<unknown> })?.fields?.length,
436 allKeys: firstEmbedObj ? Object.keys(firstEmbedObj) : [],
437 });
438
439 logger.debug(
440 `[Command Executor] Full embed structure:`,
441 JSON.stringify(firstEmbedObj, null, 2),
442 );
443 }
444
445 const response = {
446 success: true,
447 embeds: processedEmbeds,
448 };
449 logger.debug(`[Command Executor] Returning embed response:`, response);
450 return JSON.stringify(response);
451 }
452 const response = {
453 success: true,
454 content: 'content' in options ? options.content : undefined,
455 };
456 logger.debug(`[Command Executor] Returning content response:`, response);
457 return JSON.stringify(response);
458 },
459 followUp: async (options: InteractionReplyOptions | MessagePayload) => {
460 capturedResponse = options;
461 return JSON.stringify({
462 success: true,
463 content: 'content' in options ? options.content : undefined,
464 });
465 },
466 } as unknown as ChatInputCommandInteraction;
467
468 try {
469 const result = await command.execute(client, mockInteraction);
470
471 const responseToProcess = capturedResponse || result;
472
473 logger.debug(`[Command Executor] Processing response for ${name}:`, {
474 hasCapturedResponse: !!capturedResponse,
475 hasResult: result !== undefined,
476 responseType: typeof responseToProcess,
477 });
478
479 if (!responseToProcess) {
480 return JSON.stringify({
481 success: true,
482 message: 'Command executed successfully',
483 });
484 }
485
486 if (typeof responseToProcess === 'string') {
487 return JSON.stringify({
488 success: true,
489 content: responseToProcess,
490 });
491 }
492
493 if (typeof responseToProcess === 'object') {
494 const response = responseToProcess as Record<string, unknown>;
495
496 if (Array.isArray(response.embeds) && response.embeds.length > 0) {
497 const embeds = response.embeds as Array<{
498 title?: string;
499 description?: string;
500 fields?: Array<{ name: string; value: string; inline?: boolean }>;
501 color?: number;
502 timestamp?: string | number | Date;
503 footer?: { text: string; icon_url?: string };
504 }>;
505
506 logger.debug(`[Command Executor] Processing embeds for ${name}:`, {
507 embedCount: embeds.length,
508 firstEmbed: embeds[0],
509 embedFields: embeds[0]?.fields,
510 embedTitle: embeds[0]?.title,
511 });
512
513 if (name === 'weather') {
514 const embed = embeds[0];
515 logger.debug(`[Command Executor] Weather embed details:`, {
516 hasEmbed: !!embed,
517 hasFields: !!(embed && embed.fields),
518 fieldsCount: embed?.fields?.length || 0,
519 embedData: embed,
520 });
521
522 if (embed && embed.fields && embed.fields.length > 0) {
523 const f = embed.fields;
524 const title = embed.title || '';
525 let locationFromTitle = '';
526 try {
527 const m = title.match(/([A-Za-zÀ-ÖØ-öø-ÿ' .-]+,\s*[A-Z]{2})/u);
528 if (m && m[1]) {
529 locationFromTitle = m[1].trim();
530 }
531 } catch (_err) {
532 /* ignore */
533 }
534 let locationArg = '';
535 if (args && typeof args === 'object') {
536 const a = args as Record<string, unknown>;
537 locationArg = String(a.location || a.query || a.search || '').trim();
538 }
539 const weatherResponse = {
540 success: true,
541 type: 'weather',
542 location: locationFromTitle || locationArg || 'Unknown location',
543 temperature: f[0]?.value || 'N/A',
544 feels_like: f[1]?.value || 'N/A',
545 conditions: f[2]?.value || 'N/A',
546 humidity: f[3]?.value || 'N/A',
547 wind_speed: f[4]?.value || 'N/A',
548 pressure: f[5]?.value || 'N/A',
549 handled: true,
550 };
551 logger.debug(`[Command Executor] Weather response:`, weatherResponse);
552 return JSON.stringify(weatherResponse);
553 }
554 logger.debug(`[Command Executor] Weather embed has no fields, using fallback`);
555 logger.debug(`[Command Executor] Weather embed fallback data:`, {
556 title: embed?.title,
557 description: embed?.description,
558 fields: embed?.fields,
559 allProperties: embed ? Object.keys(embed) : [],
560 });
561
562 const fallbackResponse = {
563 success: true,
564 type: 'weather',
565 location:
566 embed?.title ||
567 (args && typeof args === 'object'
568 ? String(
569 (args as Record<string, unknown>).location ||
570 (args as Record<string, unknown>).query ||
571 (args as Record<string, unknown>).search ||
572 '',
573 )
574 : '') ||
575 'Unknown location',
576 description: embed?.description || 'Weather data unavailable',
577 rawEmbed: embed,
578 handled: true,
579 };
580
581 return JSON.stringify(fallbackResponse);
582 }
583
584 if (name === 'joke') {
585 const embed = embeds[0];
586 return JSON.stringify({
587 success: true,
588 type: 'joke',
589 title: embed.title || 'Random Joke',
590 setup: embed.description || 'No joke available',
591 handled: true,
592 });
593 }
594
595 return JSON.stringify({
596 success: true,
597 embeds: embeds.map((embed) => ({
598 title: embed.title,
599 description: embed.description,
600 fields:
601 embed.fields?.map((f) => ({
602 name: f.name,
603 value: f.value,
604 inline: f.inline,
605 })) || [],
606 color: embed.color,
607 timestamp: embed.timestamp,
608 footer: embed.footer,
609 })),
610 });
611 }
612
613 if (Array.isArray(response.components) && response.components.length > 0) {
614 if (name === '8ball') {
615 const components = response.components as Array<{
616 components?: Array<{
617 data?: {
618 components?: Array<{
619 data?: {
620 content?: string;
621 };
622 }>;
623 };
624 }>;
625 }>;
626
627 let question = '';
628 let answer = '';
629
630 for (const component of components) {
631 if (component.components) {
632 for (const subComponent of component.components) {
633 if (subComponent.data?.components) {
634 for (const textComponent of subComponent.data.components) {
635 const content = textComponent.data?.content || '';
636 if (content.includes('**Question**') || content.includes('**Pregunta**')) {
637 question = content
638 .replace(/.*?\*\*(.*?)\*\*\s*>\s*(.*?)\n\n.*/s, '$2')
639 .trim();
640 } else if (
641 content.includes('**Answer**') ||
642 content.includes('**Respuesta**') ||
643 content.includes('✨')
644 ) {
645 answer = content.replace(/.*?✨\s*(.*?)$/s, '$1').trim();
646 }
647 }
648 }
649 }
650 }
651 }
652
653 return JSON.stringify({
654 success: true,
655 type: '8ball',
656 question: question || 'Unknown question',
657 answer: answer || 'Unknown answer',
658 handled: true,
659 });
660 }
661 }
662
663 if (typeof response.content === 'string') {
664 return JSON.stringify({
665 success: true,
666 content: response.content,
667 });
668 }
669 }
670
671 return JSON.stringify({
672 success: true,
673 message: 'Command executed successfully',
674 });
675 } catch (error) {
676 const errorMessage = error instanceof Error ? error.message : 'Unknown error';
677 logger.error(`Error executing command '${name}':`, errorMessage);
678 return JSON.stringify({
679 success: false,
680 error: errorMessage,
681 status: 500,
682 });
683 }
684 } catch (error) {
685 logger.error(`Error executing tool call '${name}':`, error);
686 return JSON.stringify({
687 success: false,
688 error: `Error executing tool call '${name}': ${error}`,
689 status: 500,
690 });
691 }
692}