Yet another Fluxer bot built with TypeScript and Bun
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}