Aethel Bot OSS repository!
aethel.xyz
bot
fun
ai
discord
discord-bot
aethel
1import { Message } from 'discord.js';
2import BotClient from '@/services/Client';
3import logger from '@/utils/logger';
4import fetch from 'node-fetch';
5
6interface WikipediaPage {
7 pageid?: number;
8 ns?: number;
9 title: string;
10 extract?: string;
11 thumbnail?: {
12 source: string;
13 width: number;
14 height: number;
15 };
16 pageimage?: string;
17 missing?: boolean;
18}
19
20interface _WikipediaResponse {
21 query: {
22 pages: Record<string, WikipediaPage>;
23 };
24}
25
26interface _WeatherResponse {
27 main: {
28 temp: number;
29 feels_like: number;
30 humidity: number;
31 };
32 weather: Array<{
33 description: string;
34 icon: string;
35 }>;
36 wind: {
37 speed: number;
38 };
39 name: string;
40 sys: {
41 country: string;
42 };
43}
44
45export interface MessageToolCall {
46 name: string;
47 args: Record<string, unknown>;
48}
49
50export interface ToolResult {
51 content: Array<{
52 type: string;
53 text?: string;
54 image_url?: {
55 url: string;
56 detail?: 'low' | 'high' | 'auto';
57 };
58 }>;
59 metadata: {
60 type: string;
61 url?: string;
62 title?: string;
63 subreddit?: string;
64 source?: string;
65 isSystem?: boolean;
66 [key: string]: unknown;
67 };
68}
69
70function formatToolResponse(
71 content: string,
72 metadata: Record<string, unknown> = {},
73 showSystemMessage = true,
74): ToolResult {
75 if (showSystemMessage) {
76 return {
77 content: [
78 {
79 type: 'text',
80 text: `[SYSTEM] ${content}`,
81 },
82 ],
83 metadata: {
84 ...metadata,
85 type: 'tool_response',
86 isSystem: true,
87 },
88 };
89 }
90
91 return {
92 content: [],
93 metadata: {
94 ...metadata,
95 type: 'tool_response',
96 isSystem: false,
97 },
98 };
99}
100
101async function catTool(): Promise<ToolResult> {
102 try {
103 const res = await fetch('https://api.pur.cat/random-cat');
104 if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
105
106 const data = (await res.json()) as { url: string; title?: string; subreddit?: string };
107 return formatToolResponse(`Here's a cute cat for you! 🐱\n\nImage URL: ${data.url}`, {
108 type: 'cat',
109 url: data.url,
110 title: data.title,
111 subreddit: data.subreddit,
112 source: 'pur.cat',
113 });
114 } catch (error) {
115 const errorMessage = error instanceof Error ? error.message : 'Unknown error';
116 logger.error('Error in cat tool:', error);
117 throw new Error(`Failed to fetch cat image: ${errorMessage}`);
118 }
119}
120
121async function dogTool(): Promise<ToolResult> {
122 try {
123 const headers = {
124 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
125 Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
126 };
127
128 const res = await fetch('https://api.erm.dog/random-dog', { headers });
129 if (!res.ok) throw new Error(`API request failed with status ${res.status}`);
130
131 const data = (await res.json()) as { url: string; title?: string; subreddit?: string };
132
133 if (!data || !data.url) {
134 throw new Error('Invalid response format from dog API');
135 }
136
137 return formatToolResponse(`Here's a cute dog for you! 🐶\n\nImage URL: ${data.url}`, {
138 type: 'dog',
139 url: data.url,
140 title: data.title || 'Random Dog',
141 subreddit: data.subreddit || 'dogpictures',
142 source: 'erm.dog',
143 });
144 } catch (error) {
145 const errorMessage = error instanceof Error ? error.message : 'Unknown error';
146 logger.error('Error in dog tool:', error);
147 throw new Error(`Failed to fetch dog image: ${errorMessage}`);
148 }
149}
150
151interface WikipediaSummary {
152 title: string;
153 extract: string;
154 content_urls?: {
155 desktop?: {
156 page: string;
157 };
158 };
159}
160
161async function wikiTool(args: Record<string, unknown>): Promise<ToolResult> {
162 const query = args.query as string;
163 if (!query) {
164 throw new Error('Query is required for wiki tool');
165 }
166
167 try {
168 const url = `https://en.wikipedia.org/api/rest_v1/page/summary/${encodeURIComponent(query)}`;
169 const res = await fetch(url);
170
171 if (!res.ok) {
172 throw new Error(`Wikipedia API error: ${res.status} ${res.statusText}`);
173 }
174
175 const data = (await res.json()) as WikipediaSummary;
176 const title = data.title || query;
177 const extract = data.extract || 'No summary available.';
178 const pageUrl =
179 data.content_urls?.desktop?.page ||
180 `https://en.wikipedia.org/wiki/${encodeURIComponent(query)}`;
181
182 return formatToolResponse(`${title}\n\n${extract}\n\nSource: ${pageUrl}`, {
183 type: 'wiki',
184 title,
185 extract,
186 url: pageUrl,
187 });
188 } catch (error) {
189 const errorMessage = error instanceof Error ? error.message : 'Unknown error';
190 logger.error('Error in wiki tool:', error);
191 throw new Error(`Failed to search Wikipedia: ${errorMessage}`);
192 }
193}
194
195interface WeatherData {
196 main: {
197 temp: number;
198 feels_like: number;
199 humidity: number;
200 pressure: number;
201 };
202 weather: Array<{
203 description: string;
204 }>;
205 wind: {
206 speed: number;
207 };
208 name: string;
209}
210
211async function weatherTool(args: Record<string, unknown>): Promise<ToolResult> {
212 const location = args.location as string;
213 if (!location) {
214 throw new Error('Location is required for weather tool');
215 }
216
217 const apiKey = process.env.OPENWEATHER_API_KEY;
218 if (!apiKey) {
219 throw new Error('OpenWeather API key not configured');
220 }
221
222 try {
223 const res = await fetch(
224 `https://api.openweathermap.org/data/2.5/weather?q=${encodeURIComponent(location)}&appid=${apiKey}&units=imperial`,
225 );
226
227 if (!res.ok) {
228 throw new Error(`Weather API error: ${res.status} ${res.statusText}`);
229 }
230
231 const data = (await res.json()) as WeatherData;
232 const temp = Math.round(data.main.temp);
233 const feels = Math.round(data.main.feels_like);
234 const conditions = data.weather[0]?.description || 'Unknown';
235 const humidity = data.main.humidity;
236 const wind = Math.round(data.wind.speed);
237 const pressure = data.main.pressure;
238 const city = data.name || location;
239
240 return formatToolResponse(
241 `Weather for ${city}: ${temp}°F (feels ${feels}°F), ${conditions}. ` +
242 `Humidity ${humidity}%, Wind ${wind} mph, Pressure ${pressure} hPa.`,
243 {
244 type: 'weather',
245 location: city,
246 temperature: temp,
247 feels_like: feels,
248 conditions,
249 humidity,
250 wind_speed: wind,
251 pressure,
252 },
253 );
254 } catch (error) {
255 const errorMessage = error instanceof Error ? error.message : 'Unknown error';
256 logger.error('Error in weather tool:', error);
257 throw new Error(`Failed to get weather: ${errorMessage}`);
258 }
259}
260
261function resolveEmoji(input: string): string {
262 const shortcode = input.match(/^:([a-z0-9_+-]+):$/i)?.[1];
263 if (shortcode) {
264 const map: Record<string, string> = {
265 thumbsup: '👍',
266 thumbsdown: '👎',
267 '+1': '👍',
268 '-1': '👎',
269 thumbs_up: '👍',
270 thumbs_down: '👎',
271 heart: '❤️',
272 smile: '😄',
273 grin: '😁',
274 joy: '😂',
275 cry: '😢',
276 sob: '😭',
277 clap: '👏',
278 fire: '🔥',
279 star: '⭐',
280 eyes: '👀',
281 tada: '🎉',
282 };
283 const key = shortcode.toLowerCase();
284 return map[key] || input;
285 }
286 return input;
287}
288
289async function reactionTool(
290 args: Record<string, unknown>,
291 message: Message,
292 client: BotClient,
293 opts?: { originalMessage?: Message; botMessage?: Message },
294): Promise<ToolResult> {
295 const emoji = (args.emoji || args.query || '') as string;
296 const target = ((args.target as string) || 'user').toLowerCase();
297 const targetMessage = target === 'bot' && opts?.botMessage ? opts.botMessage : message;
298
299 if (!emoji) {
300 throw new Error('Emoji is required for reaction tool');
301 }
302
303 const resolvedEmoji = resolveEmoji(emoji);
304
305 try {
306 await targetMessage.react(resolvedEmoji);
307
308 return {
309 content: [],
310 metadata: {
311 type: 'reaction',
312 emoji: resolvedEmoji,
313 target,
314 success: true,
315 handled: true,
316 isSystem: false,
317 },
318 };
319 } catch (error) {
320 logger.error('Failed to add reaction:', { emoji: resolvedEmoji, error });
321 throw new Error(
322 `Failed to add reaction: ${error instanceof Error ? error.message : 'Unknown error'}`,
323 );
324 }
325}
326
327type ToolFunction = (
328 args: Record<string, unknown>,
329 message: Message,
330 client: BotClient,
331 opts?: { originalMessage?: Message; botMessage?: Message },
332) => Promise<ToolResult>;
333
334const TOOLS: Record<string, ToolFunction> = {
335 cat: catTool,
336 dog: dogTool,
337 wiki: wikiTool,
338 weather: weatherTool,
339 reaction: (args, message, client, opts) => reactionTool(args, message, client, opts),
340 newmessage: () =>
341 Promise.resolve({
342 content: [],
343 metadata: {
344 type: 'newmessage',
345 success: true,
346 handled: true,
347 isSystem: false,
348 },
349 }),
350};
351
352export async function executeMessageToolCall(
353 toolCall: MessageToolCall,
354 message: Message,
355 _client: BotClient,
356 _opts?: { originalMessage?: Message; botMessage?: Message },
357): Promise<{
358 success: boolean;
359 type: string;
360 handled: boolean;
361 error?: string;
362 result?: ToolResult;
363}> {
364 const name = (toolCall.name || '').toLowerCase();
365
366 try {
367 const tool = TOOLS[name];
368 if (!tool) {
369 return {
370 success: false,
371 type: 'error',
372 handled: false,
373 error: `Tool '${name}' not found`,
374 };
375 }
376
377 const result = await tool(toolCall.args || {}, message, _client, _opts);
378
379 return {
380 success: true,
381 type: result.metadata.type || name,
382 handled: true,
383 result,
384 };
385 } catch (error) {
386 const errorMessage = error instanceof Error ? error.message : 'Unknown error';
387 logger.error(`Error in executeMessageToolCall for ${name}:`, error);
388 return {
389 success: false,
390 type: 'error',
391 handled: true,
392 error: errorMessage,
393 };
394 }
395}