Aethel Bot OSS repository!
aethel.xyz
bot
fun
ai
discord
discord-bot
aethel
1import * as config from '@/config';
2import initialzeCommands from '@/handlers/initialzeCommands';
3import { SlashCommandProps } from '@/types/command';
4import { Client, Collection, GatewayIntentBits, Partials } from 'discord.js';
5import { Pool } from 'pg';
6import { initializeSocialMediaManager, SocialMediaManager } from './social/SocialMediaManager';
7import { promises, readdirSync } from 'fs';
8import path from 'path';
9import { fileURLToPath } from 'url';
10import { dirname } from 'path';
11import logger from '@/utils/logger';
12
13const __filename = fileURLToPath(import.meta.url);
14const __dirname = dirname(__filename);
15
16export const srcDir = path.join(__dirname, '..');
17
18export default class BotClient extends Client {
19 private static instance: BotClient | null = null;
20 public commands = new Collection<string, SlashCommandProps>();
21 // eslint-disable-next-line @typescript-eslint/no-explicit-any
22 public t = new Collection<string, any>();
23 public socialMediaManager?: SocialMediaManager;
24
25 constructor() {
26 super({
27 intents: [
28 GatewayIntentBits.Guilds,
29 GatewayIntentBits.GuildMessages,
30 GatewayIntentBits.MessageContent,
31 GatewayIntentBits.DirectMessages,
32 ],
33 partials: [Partials.Channel, Partials.Message],
34 presence: {
35 status: 'online',
36 activities: [
37 {
38 name: '/weather | /ai',
39 },
40 ],
41 },
42 });
43 BotClient.instance = this;
44 }
45
46 public static getInstance(): BotClient | null {
47 return BotClient.instance;
48 }
49
50 public async init() {
51 await this.setupLocalization();
52 await initialzeCommands(this);
53 await this.setupEvents();
54 await this.setupDatabase();
55 this.login(config.TOKEN);
56 }
57
58 private async setupEvents() {
59 console.log('Initializing events...');
60 const eventsDir = path.join(srcDir, 'events');
61 for (const event of readdirSync(path.join(eventsDir))) {
62 const filepath = path.join(eventsDir, event);
63 const fileUrl = `file://${filepath.replace(/\\/g, '/')}`;
64 const EventModule = await (await import(fileUrl)).default;
65
66 if (typeof EventModule === 'function') {
67 new EventModule(this);
68 } else if (EventModule && typeof EventModule.execute === 'function') {
69 this.on(EventModule.name, (...args) => EventModule.execute(...args, this));
70 }
71 }
72 }
73
74 private async setupLocalization() {
75 logger.info('Loading localization files...');
76 const localesDir = path.join(srcDir, '..', 'locales');
77
78 try {
79 const localeFiles = (await promises.readdir(localesDir)).filter((f) => f.endsWith('.json'));
80
81 const localePromises = localeFiles.map(async (locale) => {
82 const localeFile = path.join(localesDir, locale);
83 try {
84 const data = await promises.readFile(localeFile, { encoding: 'utf8' });
85 const localeKey = locale.split('.')[0];
86 this.t.set(localeKey, JSON.parse(data));
87 logger.debug(`Loaded locale: ${localeKey}`);
88 } catch (error) {
89 logger.error(`Failed to load locale file ${locale}:`, error);
90 }
91 });
92
93 await Promise.all(localePromises);
94 logger.info(`Loaded ${this.t.size} locale(s)`);
95 } catch (error) {
96 logger.error('Failed to read locales directory:', error);
97 throw new Error('Failed to initialize localization');
98 }
99 }
100 private async setupDatabase() {
101 try {
102 const sslMode = (process.env.PGSSLMODE || process.env.DATABASE_SSL || '').toLowerCase();
103 let ssl: false | { rejectUnauthorized?: boolean; ca?: string } = false;
104 const rootCertPath = process.env.PGSSLROOTCERT || process.env.DATABASE_SSL_CA;
105
106 if (sslMode === 'require') {
107 ssl = { rejectUnauthorized: true };
108 }
109
110 if (rootCertPath) {
111 try {
112 const ca = await promises.readFile(rootCertPath, 'utf8');
113 ssl = { ca, rejectUnauthorized: true };
114 } catch (e) {
115 console.warn('Failed to read CA certificate: unable to access the specified path.', e);
116 }
117 }
118
119 const pool = new Pool({
120 connectionString: process.env.DATABASE_URL,
121 ssl,
122 });
123
124 pool.on('error', (err) => {
125 console.error('Unexpected error on idle PostgreSQL client:', err);
126 });
127
128 const shutdown = async (signal?: NodeJS.Signals) => {
129 try {
130 console.log(`Received ${signal ?? 'shutdown'}: closing services and database pool...`);
131 await this.socialMediaManager?.cleanup();
132 await pool.end();
133 console.log('Database pool closed. Exiting.');
134 } catch (e) {
135 console.error('Error during graceful shutdown:', e);
136 } finally {
137 process.exit(0);
138 }
139 };
140
141 process.on('SIGINT', () => shutdown('SIGINT'));
142 process.on('SIGTERM', () => shutdown('SIGTERM'));
143
144 this.socialMediaManager = initializeSocialMediaManager(this, pool);
145 await this.socialMediaManager.initialize();
146 } catch (error) {
147 console.error('Failed to initialize database and services:', error);
148 throw error;
149 }
150 }
151
152 public async getLocaleText(key: string, locale: string, replaces = {}): Promise<string> {
153 const fallbackLocale = 'en-US';
154
155 if (!locale) {
156 locale = fallbackLocale;
157 }
158
159 let langMap = this.t.get(locale);
160 if (!langMap) {
161 const langOnly = locale.split('-')[0];
162 langMap = this.t.get(langOnly);
163 if (!langMap) {
164 const fuzzyLocale = Array.from(this.t.keys()).find((k) => k.startsWith(langOnly + '-'));
165 if (fuzzyLocale) {
166 langMap = this.t.get(fuzzyLocale);
167 } else {
168 langMap = this.t.get(fallbackLocale);
169 }
170 }
171 }
172
173 // eslint-disable-next-line @typescript-eslint/no-explicit-any
174 const getValueFromMap = (map: any, keyPath: string): any => {
175 return keyPath
176 .split('.')
177 .reduce((prev, cur) => (prev && prev[cur] !== undefined ? prev[cur] : undefined), map);
178 };
179
180 let text = getValueFromMap(langMap, key);
181
182 if (text === undefined && locale !== fallbackLocale) {
183 langMap = this.t.get(fallbackLocale);
184 text = getValueFromMap(langMap, key);
185 }
186
187 if (text === undefined) {
188 text = `Missing translation for key: ${key}`;
189 }
190
191 for (const [varName, value] of Object.entries(replaces)) {
192 const regex = new RegExp(`{${varName}}`, 'g');
193 text = text.replace(regex, value);
194 }
195
196 return text;
197 }
198}