1 |
rakinar2 |
577 |
/** |
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.loadService(servicePath); |
332 |
|
|
} |
333 |
|
|
} |
334 |
|
|
} else { |
335 |
|
|
for (const servicePath of servicePaths) { |
336 |
|
|
await this.client.serviceManager.loadService(servicePath); |
337 |
|
|
} |
338 |
|
|
} |
339 |
|
|
|
340 |
|
|
if (commandPaths === null) { |
341 |
|
|
if (commandsDirectory) { |
342 |
|
|
if (existsSync(commandsDirectory)) { |
343 |
|
|
await this.client.dynamicLoader.loadCommands(commandsDirectory); |
344 |
|
|
} |
345 |
|
|
} else if (commands) { |
346 |
|
|
for (const commandPath of commands) { |
347 |
|
|
await this.client.dynamicLoader.loadCommand(commandPath); |
348 |
|
|
} |
349 |
|
|
} |
350 |
|
|
} else { |
351 |
|
|
for (const commandPath of commandPaths) { |
352 |
|
|
await this.client.dynamicLoader.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 |
|
|
} |