/[sudobot]/branches/7.x/src/services/ExtensionService.ts
ViewVC logotype

Annotation of /branches/7.x/src/services/ExtensionService.ts

Parent Directory Parent Directory | Revision Log Revision Log


Revision 577 - (hide annotations)
Mon Jul 29 18:52:37 2024 UTC (8 months ago) by rakinar2
File MIME type: application/typescript
File size: 15801 byte(s)
chore: add old version archive branches (2.x to 9.x-dev)
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     }

[email protected]
ViewVC Help
Powered by ViewVC 1.1.26