Yet another Fluxer bot built with TypeScript and Bun
at develop 195 lines 6.0 kB view raw
1import type { Message } from "@fluxerjs/core"; 2import { COMMAND_META_KEY, type CommandMeta } from "@/decorators/command"; 3import { GUARDS_KEY, type Guard } from "@/decorators/guards"; 4import { parseArgs, type ArgSchema } from "@/args"; 5import type { BotContext, ICommand } from "@/types"; 6import type { CommandCtor } from "@/types"; 7import { Logger } from "tslog"; 8import { MODULE_META_KEY, MODULE_COMMANDS_KEY, type ModuleCommand } from "@/base/ModuleBase"; 9 10interface FileRecord { 11 filePath: string; 12 triggers: string[]; 13} 14 15interface ModuleRecord { 16 name: string; 17 triggers: string[]; 18 instance: object; 19 onUnload?: () => void | Promise<void>; 20} 21 22type AnyCommand = ICommand<any>; 23 24interface ModuleEntry { 25 type: "module"; 26 instance: object; 27 methodName: string; 28 args?: ArgSchema; 29} 30 31interface ClassEntry { 32 type: "class"; 33 instance: AnyCommand; 34 args?: ArgSchema; 35} 36 37type Entry = ModuleEntry | ClassEntry; 38 39export class CommandRegistry { 40 private map = new Map<string, Entry>(); 41 private guards = new Map<string, Guard[]>(); 42 private fileSources = new Map<string, FileRecord>(); 43 private modules = new Map<string, ModuleRecord>(); 44 private logger: Logger<CommandRegistry>; 45 46 constructor() { 47 this.logger = new Logger({ 48 type: "pretty", 49 name: "CommandRegistry", 50 }); 51 } 52 53 register(prefix: string, filePath: string | null, ...ctors: CommandCtor[]): void { 54 for (const ctor of ctors) { 55 try { 56 const meta: CommandMeta = Reflect.getMetadata(COMMAND_META_KEY, ctor); 57 if (!meta) throw new Error(`${ctor.name} has missing @Command decorators`); 58 59 const instance = new ctor(); 60 const instanceGuards: Guard[] = Reflect.getMetadata(GUARDS_KEY, ctor) ?? []; 61 const triggers = [meta.name, ...(meta.aliases ?? [])].map((n) => `${prefix}${n}`); 62 63 for (const trigger of triggers) { 64 this.map.set(trigger, { type: "class", instance, args: instance.args }); 65 this.guards.set(trigger, instanceGuards); 66 } 67 68 if (filePath) { 69 this.fileSources.set(meta.name, { filePath, triggers }); 70 } 71 } catch (e) { 72 if (e instanceof Error) 73 this.logger.error(`Something went wrong while registring: ${e.message}`); 74 } 75 } 76 } 77 78 registerModule( 79 prefix: string, 80 instance: object, 81 options?: { namespace?: string; guards?: Guard[] }, 82 ): void { 83 const meta = Reflect.getMetadata(MODULE_META_KEY, instance.constructor); 84 if (!meta) { 85 this.logger.error(`${instance.constructor.name} has missing @Module decorator`); 86 return; 87 } 88 89 const commands: ModuleCommand[] = 90 Reflect.getMetadata(MODULE_COMMANDS_KEY, instance.constructor) ?? []; 91 if (commands.length === 0) { 92 this.logger.warn(`Module ${meta.name} has no commands`); 93 return; 94 } 95 96 const ns = options?.namespace ?? meta.name; 97 const moduleGuards: Guard[] = options?.guards ?? []; 98 const triggers: string[] = []; 99 100 for (const cmd of commands) { 101 const commandTriggers = [cmd.meta.name, ...(cmd.meta.aliases ?? [])].map( 102 (n) => `${prefix}${ns}:${n}`, 103 ); 104 triggers.push(...commandTriggers); 105 106 for (const trigger of commandTriggers) { 107 this.map.set(trigger, { 108 type: "module", 109 instance, 110 methodName: cmd.methodName, 111 args: cmd.meta.args, 112 }); 113 this.guards.set(trigger, [...moduleGuards]); 114 } 115 } 116 117 this.modules.set(meta.name, { 118 name: meta.name, 119 triggers, 120 instance, 121 onUnload: (instance as { onUnload?: () => void | Promise<void> }).onUnload?.bind(instance), 122 }); 123 124 (instance as { onLoad?: (registry: CommandRegistry) => void | Promise<void> }).onLoad?.(this); 125 126 this.logger.info(`Registered module "${meta.name}" with ${commands.length} commands`); 127 } 128 129 unregisterModule(name: string): void { 130 const record = this.modules.get(name); 131 if (!record) return; 132 133 for (const trigger of record.triggers) { 134 this.map.delete(trigger); 135 this.guards.delete(trigger); 136 } 137 138 record.onUnload?.(); 139 this.modules.delete(name); 140 this.logger.info(`Unregistered module "${name}"`); 141 } 142 143 unregisterFile(filePath: string): void { 144 for (const [, record] of this.fileSources) { 145 if (record.filePath === filePath) { 146 for (const trigger of record.triggers) { 147 this.map.delete(trigger); 148 this.guards.delete(trigger); 149 } 150 this.fileSources.delete(record.triggers[0]!); 151 } 152 } 153 } 154 155 async handle(ctx: BotContext, prefix: string, message: Message): Promise<void> { 156 if (!message.content.startsWith(prefix)) return; 157 158 try { 159 const [rawCmd, ...args] = message.content.slice(prefix.length).trim().split(/\s+/); 160 const key = `${prefix}${rawCmd?.toLowerCase()}`; 161 162 const entry = this.map.get(key); 163 if (!entry) return; 164 165 const guards = this.guards.get(key) ?? []; 166 for (const guard of guards) { 167 if (!(await guard(message))) return; 168 } 169 170 const parsedArgs = entry.args ? await parseArgs(entry.args, args) : ({} as never); 171 172 this.logger.info( 173 `Executing ${rawCmd} ${args.length ? `with arguments: ${args.join(", ")}` : "without arguments"}`, 174 ); 175 176 if (entry.type === "class") { 177 await entry.instance.execute(ctx, message, parsedArgs); 178 } else { 179 const method = (entry.instance as Record<string, unknown>)[entry.methodName]; 180 if (typeof method === "function") { 181 await ( 182 method as (ctx: BotContext, message: Message, args: unknown) => Promise<void> 183 ).call(entry.instance, ctx, message, parsedArgs); 184 } 185 } 186 } catch (err) { 187 if (err instanceof Error) { 188 this.logger.error(`Something went wrong while executing this command: ${err.message}`); 189 message.reply({ 190 content: `Something went wrong while executing this command:\n\`\`\`\n${err.message}\n\`\`\``, 191 }); 192 } 193 } 194 } 195}