1 |
/** |
2 |
* This file is part of SudoBot. |
3 |
* |
4 |
* Copyright (C) 2021-2023 OSN Developers. |
5 |
* |
6 |
* SudoBot is free software; you can redistribute it and/or modify it |
7 |
* under the terms of the GNU Affero General Public License as published by |
8 |
* the Free Software Foundation, either version 3 of the License, or |
9 |
* (at your option) any later version. |
10 |
* |
11 |
* SudoBot is distributed in the hope that it will be useful, but |
12 |
* WITHOUT ANY WARRANTY; without even the implied warranty of |
13 |
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
14 |
* GNU Affero General Public License for more details. |
15 |
* |
16 |
* You should have received a copy of the GNU Affero General Public License |
17 |
* along with SudoBot. If not, see <https://www.gnu.org/licenses/>. |
18 |
*/ |
19 |
|
20 |
import { Snowflake } from "discord.js"; |
21 |
import { existsSync } from "fs"; |
22 |
import fs from "fs/promises"; |
23 |
import path from "path"; |
24 |
import Client from "../core/Client"; |
25 |
import EventListener from "../core/EventListener"; |
26 |
import { Extension } from "../core/Extension"; |
27 |
import Service from "../core/Service"; |
28 |
import type { ClientEvents } from "../types/ClientEvents"; |
29 |
import { log, logError, logInfo, logWarn } from "../utils/logger"; |
30 |
|
31 |
export const name = "extensionService"; |
32 |
|
33 |
type Metadata = { |
34 |
main?: string; |
35 |
commands?: string; |
36 |
services?: string; |
37 |
events?: string; |
38 |
language?: "typescript" | "javascript"; |
39 |
main_directory?: string; |
40 |
build_command?: string; |
41 |
}; |
42 |
|
43 |
const guildIdResolvers: Array<{ |
44 |
events: ReadonlyArray<keyof ClientEvents>; |
45 |
resolver: (args: any) => Snowflake | null | undefined; |
46 |
}> = [ |
47 |
{ |
48 |
events: ["applicationCommandPermissionsUpdate"], |
49 |
resolver: ([data]: ClientEvents["applicationCommandPermissionsUpdate"]) => data.guildId |
50 |
}, |
51 |
{ |
52 |
events: [ |
53 |
"autoModerationActionExecution", |
54 |
"autoModerationRuleCreate", |
55 |
"autoModerationRuleDelete", |
56 |
"autoModerationRuleUpdate" |
57 |
], |
58 |
resolver: ([data]: ClientEvents[ |
59 |
| "autoModerationActionExecution" |
60 |
| "autoModerationRuleCreate" |
61 |
| "autoModerationRuleDelete" |
62 |
| "autoModerationRuleUpdate"]) => data?.guild.id ?? undefined |
63 |
}, |
64 |
{ |
65 |
events: ["messageCreate", "messageDelete", "messageUpdate", "interactionCreate"], |
66 |
resolver: ([data]: ClientEvents["messageCreate" | "messageDelete" | "messageUpdate" | "interactionCreate"]) => |
67 |
data?.guild?.id ?? data?.guildId ?? undefined |
68 |
}, |
69 |
{ |
70 |
events: ["messageDeleteBulk"], |
71 |
resolver: ([data]: ClientEvents["messageDeleteBulk"]) => data.first()?.guildId ?? undefined |
72 |
}, |
73 |
{ |
74 |
events: ["channelCreate", "channelDelete", "channelUpdate", "channelPinsUpdate"], |
75 |
resolver: ([data]: ClientEvents["channelCreate" | "channelDelete" | "channelUpdate" | "channelPinsUpdate"]) => |
76 |
data.isDMBased() ? undefined : data.guildId |
77 |
}, |
78 |
{ |
79 |
events: ["emojiCreate", "emojiDelete", "emojiUpdate"], |
80 |
resolver: ([data]: ClientEvents["emojiCreate" | "emojiDelete" | "emojiUpdate"]) => data?.guild?.id ?? undefined |
81 |
}, |
82 |
{ |
83 |
events: ["messageReactionAdd", "messageReactionRemove", "messageReactionRemoveEmoji"], |
84 |
resolver: ([data]: ClientEvents["messageReactionAdd" | "messageReactionRemove" | "messageReactionRemoveEmoji"]) => |
85 |
data?.message.guildId ?? undefined |
86 |
}, |
87 |
{ |
88 |
events: ["messageReactionRemoveAll"], |
89 |
resolver: ([data]: ClientEvents["messageReactionRemoveAll"]) => data?.guildId ?? undefined |
90 |
}, |
91 |
{ |
92 |
events: ["guildAuditLogEntryCreate", "guildMembersChunk", "threadListSync"], |
93 |
resolver: ([, data]: ClientEvents["guildAuditLogEntryCreate" | "guildMembersChunk" | "threadListSync"]) => |
94 |
data.id ?? undefined |
95 |
}, |
96 |
{ |
97 |
events: ["guildAvailable", "guildCreate", "guildDelete", "guildUpdate", "guildUnavailable", "guildIntegrationsUpdate"], |
98 |
resolver: ([data]: ClientEvents[ |
99 |
| "guildAvailable" |
100 |
| "guildCreate" |
101 |
| "guildUpdate" |
102 |
| "guildUnavailable" |
103 |
| "guildIntegrationsUpdate"]) => data.id ?? undefined |
104 |
}, |
105 |
{ |
106 |
events: [ |
107 |
"guildBanAdd", |
108 |
"guildBanRemove", |
109 |
"guildMemberAdd", |
110 |
"guildMemberRemove", |
111 |
"guildMemberUpdate", |
112 |
"guildMemberAvailable", |
113 |
"inviteCreate", |
114 |
"inviteDelete", |
115 |
"roleCreate", |
116 |
"roleDelete" |
117 |
], |
118 |
resolver: ([data]: ClientEvents[ |
119 |
| "guildBanAdd" |
120 |
| "guildBanRemove" |
121 |
| "guildMemberAdd" |
122 |
| "guildMemberRemove" |
123 |
| "guildMemberUpdate" |
124 |
| "guildMemberAvailable" |
125 |
| "inviteCreate" |
126 |
| "inviteDelete" |
127 |
| "roleCreate" |
128 |
| "roleDelete"]) => data.guild?.id ?? undefined |
129 |
}, |
130 |
{ |
131 |
events: [ |
132 |
"guildScheduledEventCreate", |
133 |
"guildScheduledEventDelete", |
134 |
"guildScheduledEventUserAdd", |
135 |
"guildScheduledEventUserRemove" |
136 |
], |
137 |
resolver: ([data]: ClientEvents[ |
138 |
| "guildScheduledEventCreate" |
139 |
| "guildScheduledEventDelete" |
140 |
| "guildScheduledEventUserAdd" |
141 |
| "guildScheduledEventUserRemove"]) => data.guild?.id ?? data.guildId ?? undefined |
142 |
}, |
143 |
{ |
144 |
events: ["guildScheduledEventUpdate"], |
145 |
resolver: ([data]: ClientEvents["guildScheduledEventUpdate"]) => data?.guild?.id ?? data?.guildId ?? undefined |
146 |
}, |
147 |
{ |
148 |
events: ["presenceUpdate", "roleUpdate", "stageInstanceUpdate", "stickerUpdate", "threadUpdate", "voiceStateUpdate"], |
149 |
resolver: ([data, data2]: ClientEvents[ |
150 |
| "presenceUpdate" |
151 |
| "roleUpdate" |
152 |
| "stageInstanceUpdate" |
153 |
| "threadUpdate" |
154 |
| "voiceStateUpdate"]) => data?.guild?.id ?? data2.guild?.id ?? undefined |
155 |
}, |
156 |
{ |
157 |
events: ["stageInstanceDelete", "stageInstanceCreate", "stickerCreate", "stickerDelete", "threadCreate", "threadDelete"], |
158 |
resolver: ([data]: ClientEvents[ |
159 |
| "stageInstanceDelete" |
160 |
| "stageInstanceCreate" |
161 |
| "stickerCreate" |
162 |
| "stickerDelete" |
163 |
| "threadCreate" |
164 |
| "threadDelete"]) => data?.guild?.id ?? undefined |
165 |
}, |
166 |
{ |
167 |
events: ["threadMemberUpdate"], |
168 |
resolver: ([data, data2]: ClientEvents["threadMemberUpdate"]) => |
169 |
data?.guildMember?.guild.id ?? data2?.guildMember?.guild.id ?? undefined |
170 |
}, |
171 |
{ |
172 |
events: ["typingStart", "webhookUpdate"], |
173 |
resolver: ([data]: ClientEvents["typingStart" | "webhookUpdate"]) => data.guild?.id ?? undefined |
174 |
}, |
175 |
{ |
176 |
events: ["command"], |
177 |
resolver: ([, , , data]: ClientEvents["command"]) => data.guildId ?? undefined |
178 |
}, |
179 |
{ |
180 |
events: [ |
181 |
"cacheSweep", |
182 |
"debug", |
183 |
"error", |
184 |
"warn", |
185 |
"invalidated", |
186 |
"ready", |
187 |
"shardReady", |
188 |
"shardDisconnect", |
189 |
"shardError", |
190 |
"shardReconnecting", |
191 |
"shardResume" |
192 |
], |
193 |
resolver: () => null |
194 |
} |
195 |
]; |
196 |
|
197 |
function getGuildIdResolversMap() { |
198 |
const map = new Map<keyof ClientEvents, Function>(); |
199 |
|
200 |
for (const guildIdResolver of guildIdResolvers) { |
201 |
for (const event of guildIdResolver.events) { |
202 |
if (map.has(event)) { |
203 |
logWarn(`Overlapping Guild ID Resolvers detected: `, event); |
204 |
logWarn("This seems to be an internal bug. Please report this issue to the developers."); |
205 |
} |
206 |
|
207 |
map.set(event, guildIdResolver.resolver); |
208 |
} |
209 |
} |
210 |
|
211 |
return map; |
212 |
} |
213 |
|
214 |
export default class ExtensionService extends Service { |
215 |
protected readonly extensionsPath = path.join(__dirname, "../../extensions"); |
216 |
protected readonly guildIdResolvers = getGuildIdResolversMap(); |
217 |
|
218 |
async bootUp() { |
219 |
if (!existsSync(this.extensionsPath)) { |
220 |
log("No extensions found"); |
221 |
return; |
222 |
} |
223 |
|
224 |
const extensionsIndex = path.join(this.extensionsPath, "index.json"); |
225 |
|
226 |
if (existsSync(extensionsIndex)) { |
227 |
await this.loadExtensionsFromIndex(extensionsIndex); |
228 |
return; |
229 |
} |
230 |
|
231 |
await this.loadExtensions(); |
232 |
} |
233 |
|
234 |
async loadExtensionsFromIndex(extensionsIndex: string) { |
235 |
const { extensions } = JSON.parse(await fs.readFile(extensionsIndex, "utf-8")); |
236 |
|
237 |
for (const { entry, commands, events, name, services } of extensions) { |
238 |
logInfo("Loading extension (cached): ", name); |
239 |
|
240 |
await this.loadExtension({ |
241 |
extensionPath: entry, |
242 |
commands, |
243 |
events, |
244 |
extensionName: name, |
245 |
services |
246 |
}); |
247 |
} |
248 |
} |
249 |
|
250 |
async loadExtensions() { |
251 |
const extensions = await fs.readdir(this.extensionsPath); |
252 |
|
253 |
for (const extensionName of extensions) { |
254 |
const extensionDirectory = path.resolve(this.extensionsPath, extensionName); |
255 |
const isDirectory = (await fs.lstat(extensionDirectory)).isDirectory(); |
256 |
|
257 |
if (!isDirectory) { |
258 |
continue; |
259 |
} |
260 |
|
261 |
logInfo("Loading extension: ", extensionName); |
262 |
const metadataFile = path.join(extensionDirectory, "extension.json"); |
263 |
|
264 |
if (!existsSync(metadataFile)) { |
265 |
logError(`Extension ${extensionName} does not have a "extension.json" file!`); |
266 |
process.exit(-1); |
267 |
} |
268 |
|
269 |
const metadata: Metadata = JSON.parse(await fs.readFile(metadataFile, { encoding: "utf-8" })); |
270 |
const { |
271 |
main_directory = "./build", |
272 |
commands = `./${main_directory}/commands`, |
273 |
events = `./${main_directory}/events`, |
274 |
services = `./${main_directory}/services`, |
275 |
main = `./${main_directory}/index.js` |
276 |
} = metadata; |
277 |
|
278 |
await this.loadExtension({ |
279 |
extensionName, |
280 |
extensionPath: path.join(extensionDirectory, main), |
281 |
commandsDirectory: path.join(extensionDirectory, commands), |
282 |
eventsDirectory: path.join(extensionDirectory, events), |
283 |
servicesDirectory: path.join(extensionDirectory, services) |
284 |
}); |
285 |
} |
286 |
} |
287 |
|
288 |
async loadExtension({ |
289 |
extensionPath, |
290 |
commandsDirectory, |
291 |
eventsDirectory, |
292 |
commands, |
293 |
events, |
294 |
extensionName, |
295 |
services, |
296 |
servicesDirectory |
297 |
}: |
298 |
| { |
299 |
extensionPath: string; |
300 |
commandsDirectory: string; |
301 |
eventsDirectory: string; |
302 |
servicesDirectory: string; |
303 |
extensionName: string; |
304 |
commands?: never; |
305 |
events?: never; |
306 |
services?: never; |
307 |
} |
308 |
| { |
309 |
extensionPath: string; |
310 |
commandsDirectory?: never; |
311 |
eventsDirectory?: never; |
312 |
servicesDirectory?: never; |
313 |
commands: string[]; |
314 |
events: string[]; |
315 |
services: string[]; |
316 |
extensionName: string; |
317 |
}) { |
318 |
const { default: ExtensionClass }: { default: new (client: Client) => Extension } = await import(extensionPath); |
319 |
const extension = new ExtensionClass(this.client); |
320 |
const commandPaths = await extension.commands(); |
321 |
const eventPaths = await extension.events(); |
322 |
const servicePaths = await extension.services(); |
323 |
|
324 |
if (servicePaths === null) { |
325 |
if (servicesDirectory) { |
326 |
if (existsSync(servicesDirectory)) { |
327 |
await this.client.serviceManager.loadServiceFromDirectory(servicesDirectory); |
328 |
} |
329 |
} else if (services) { |
330 |
for (const servicePath of services) { |
331 |
await this.client.serviceManager.loadServiceFromFile(servicePath); |
332 |
} |
333 |
} |
334 |
} else { |
335 |
for (const servicePath of servicePaths) { |
336 |
await this.client.serviceManager.loadServiceFromFile(servicePath); |
337 |
} |
338 |
} |
339 |
|
340 |
if (commandPaths === null) { |
341 |
if (commandsDirectory) { |
342 |
if (existsSync(commandsDirectory)) { |
343 |
await this.client.loadCommands(commandsDirectory); |
344 |
} |
345 |
} else if (commands) { |
346 |
for (const commandPath of commands) { |
347 |
await this.client.loadCommand(commandPath); |
348 |
} |
349 |
} |
350 |
} else { |
351 |
for (const commandPath of commandPaths) { |
352 |
await this.client.loadCommand(commandPath); |
353 |
} |
354 |
} |
355 |
|
356 |
if (eventPaths === null) { |
357 |
if (eventsDirectory) { |
358 |
if (existsSync(eventsDirectory)) { |
359 |
await this.loadEvents(extensionName, eventsDirectory); |
360 |
} |
361 |
} else if (events) { |
362 |
for (const eventPath of events) { |
363 |
await this.loadEvent(extensionName, eventPath); |
364 |
} |
365 |
} |
366 |
} else { |
367 |
for (const eventPath of eventPaths) { |
368 |
await this.loadEvent(extensionName, eventPath); |
369 |
} |
370 |
} |
371 |
} |
372 |
|
373 |
async loadEvents(extensionName: string, directory: string) { |
374 |
const files = await fs.readdir(directory); |
375 |
|
376 |
for (const file of files) { |
377 |
const filePath = path.join(directory, file); |
378 |
const isDirectory = (await fs.lstat(filePath)).isDirectory(); |
379 |
|
380 |
if (isDirectory) { |
381 |
await this.loadEvents(extensionName, filePath); |
382 |
continue; |
383 |
} |
384 |
|
385 |
if ((!file.endsWith(".ts") && !file.endsWith(".js")) || file.endsWith(".d.ts")) { |
386 |
continue; |
387 |
} |
388 |
|
389 |
await this.loadEvent(extensionName, filePath); |
390 |
} |
391 |
} |
392 |
|
393 |
async loadEvent(extensionName: string, filePath: string) { |
394 |
const { default: Event }: { default: new (client: Client) => EventListener<keyof ClientEvents> } = await import(filePath); |
395 |
const event = new Event(this.client); |
396 |
this.client.addEventListener(event.name, this.wrapHandler(extensionName, event.name, event.execute.bind(event))); |
397 |
} |
398 |
|
399 |
wrapHandler<K extends keyof ClientEvents>(extensionName: string, eventName: K, handler: Function, bail?: boolean) { |
400 |
return async (...args: ClientEvents[K]) => { |
401 |
const guildId: Snowflake | null | undefined = this.guildIdResolvers.get(eventName)?.(args); |
402 |
|
403 |
if (guildId === undefined) { |
404 |
logError("Invalid event or failed to fetch guild: ", eventName); |
405 |
return; |
406 |
} |
407 |
|
408 |
if (guildId !== null && !this.isEnabled(extensionName, guildId)) { |
409 |
log("Extension isn't enabled in this guild: ", guildId); |
410 |
return; |
411 |
} |
412 |
|
413 |
logInfo("Running: " + eventName + " [" + extensionName + "]"); |
414 |
|
415 |
try { |
416 |
return await handler(...args); |
417 |
} catch (e) { |
418 |
logError(`Extension error: the extension '${extensionName}' seems to cause this exception`); |
419 |
logError(e); |
420 |
|
421 |
if (bail) { |
422 |
return; |
423 |
} |
424 |
} |
425 |
}; |
426 |
} |
427 |
|
428 |
isEnabled(extensionName: string, guildId: Snowflake) { |
429 |
const { disabled_extensions, enabled } = this.client.configManager.config[guildId]?.extensions ?? {}; |
430 |
const { default_mode } = this.client.configManager.systemConfig.extensions ?? {}; |
431 |
log(default_mode, enabled); |
432 |
return (enabled === undefined ? default_mode === "enable_all" : enabled) && !disabled_extensions?.includes(extensionName); |
433 |
} |
434 |
} |