Yet another Fluxer bot built with TypeScript and Bun

feat!: Module-based command system rewrite #2

merged opened by spring.furrest.net targeting develop from better-command-system

Since our current command system only allows one command at time, but it is better to start rewriting it to allow to be using @Module (similar on how Discord.Net/Fluxer.Net actually handles).

Since we're using decorators in TypeScript, it should be less painless and clean. But for this pull request, I don't think it's going to be easy anyway. But we'll see for now.

Labels

None yet.

assignee

None yet.

Participants 1
AT URI
at://did:plc:rqpfcwwpcadmlnqxuncywcif/sh.tangled.repo.pull/3mhqm7nd6m522
+497 -361
Diff #1
+1 -13
README.md
··· 9 9 10 10 ## Roadmap 11 11 12 - - [ ] Add basic moderation stuff 12 + - [X] Add basic moderation stuff 13 13 - [ ] Some fun command I guess (?) 14 14 - [ ] Idk what to write here but you can go ahead and write an suggestion 15 - 16 - To install dependencies: 17 - 18 - ```bash 19 - bun install 20 - ``` 21 - 22 - To run: 23 - 24 - ```bash 25 - bun run index.ts 26 - ```
+23 -6
src/args.ts
··· 243 243 schema: StandardSchemaV1, 244 244 value: unknown, 245 245 ): Promise<unknown> { 246 - let result = schema["~standard"].validate(value); 247 - if (result instanceof Promise) result = await result; 248 - if (result.issues) { 249 - const messages = result.issues.map((i) => i.message).join(", "); 250 - throw new Error(`Argument "${key}": ${messages}`); 246 + const standard = schema["~standard"]; 247 + 248 + if (standard && typeof standard.validate === "function") { 249 + let result = standard.validate(value); 250 + if (result instanceof Promise) result = await result; 251 + if (result.issues) { 252 + const messages = result.issues.map((i) => i.message).join(", "); 253 + throw new Error(`Argument "${key}": ${messages}`); 254 + } 255 + return result.value; 256 + } 257 + 258 + if ("safeParse" in schema && typeof schema.safeParse === "function") { 259 + const result = await schema.safeParse(value); 260 + if (!result.success) { 261 + const messages = (result as { error?: { issues?: Array<{ message: string }> } }).error?.issues 262 + ?.map((i) => i.message) 263 + .join(", "); 264 + throw new Error(`Argument "${key}": ${messages ?? "Validation failed"}`); 265 + } 266 + return (result as { data: unknown }).data; 251 267 } 252 - return result.value; 268 + 269 + throw new Error(`Argument "${key}": Schema does not implement StandardSchemaV1 or safeParse`); 253 270 } 254 271 255 272 export async function parseArgs<S extends ArgSchema>(
+31
src/base/ModuleBase.ts
··· 1 + import "reflect-metadata"; 2 + import { Message } from "@fluxerjs/core"; 3 + import type { ArgSchema } from "@/args"; 4 + 5 + export interface ModuleMeta { 6 + name: string; 7 + description?: string; 8 + } 9 + 10 + export const MODULE_META_KEY = Symbol("fluxer:module"); 11 + export const MODULE_COMMANDS_KEY = Symbol("fluxer:module-commands"); 12 + 13 + export interface CommandMethodMeta { 14 + name: string; 15 + aliases?: string[]; 16 + description: string; 17 + args?: ArgSchema; 18 + } 19 + 20 + export interface ModuleCommand { 21 + methodName: string; 22 + meta: CommandMethodMeta; 23 + } 24 + 25 + export type Guard = (msg: Message) => boolean | Promise<boolean>; 26 + 27 + export function Module(meta: ModuleMeta & { guards?: Guard[] }): ClassDecorator { 28 + return (target) => { 29 + Reflect.defineMetadata(MODULE_META_KEY, meta, target); 30 + }; 31 + }
+2 -4
src/bot.ts
··· 1 1 import { Client, Events, Message } from "@fluxerjs/core"; 2 2 import { env } from "@/env"; 3 3 import { CommandRegistry } from "@/registry/commandRegistry"; 4 - import { loadCommands } from "@/registry/loadCommands"; 4 + import { loadModules } from "@/registry/loadModules"; 5 5 import { watchCommands } from "@/registry/watchCommands"; 6 6 import { Logger } from "tslog"; 7 7 ··· 34 34 async start() { 35 35 this.logger.info("Starting Hydro..."); 36 36 37 - await loadCommands(this.registry, env.PREFIX); 37 + await loadModules(this.registry, env.PREFIX); 38 38 39 39 if (IS_DEV) { 40 40 watchCommands(this.registry, env.PREFIX); 41 41 } 42 42 43 - // Registers events before logging in. 44 43 this.registerEvents(); 45 44 46 - // After loading commands and registering events, we can now login our bot 47 45 await this.client.login(env.FLUXER_BOT_TOKEN); 48 46 } 49 47 }
-89
src/commands/ban.ts
··· 1 - import { z } from "zod"; 2 - import { EmbedBuilder, type Message } from "@fluxerjs/core"; 3 - import { arg } from "@/args"; 4 - import { Command } from "@/decorators/command"; 5 - import { RequireNonBot } from "@/decorators/guards"; 6 - import type { BotContext, ICommand, ParsedArgs } from "@/types"; 7 - import { ColorPalette } from "@/utils"; 8 - 9 - const banArgs = { 10 - target: arg.string(), 11 - /** Specifies the ban duration in seconds (max one whole week: 604800) */ 12 - duration: z.coerce.number().int().positive().max(604800), 13 - /** Number of days of messages to delete (0-7) */ 14 - delete_messages_day: arg.optional(z.coerce.number().int().min(0).max(7)), 15 - /** 16 - * Reason for the ban โ€” greedily consumes all remaining tokens. 17 - * Must be last. e.g. !ban @user 86400 1 Being rude in chat 18 - */ 19 - reason: arg.restOptional(z.string().min(1).max(500)), 20 - } as const; 21 - 22 - @Command({ name: "ban", description: "Bans a user" }) 23 - @RequireNonBot() 24 - export class BanCommand implements ICommand<typeof banArgs> { 25 - readonly args = banArgs; 26 - 27 - async execute( 28 - _ctx: BotContext, 29 - message: Message, 30 - args: ParsedArgs<typeof banArgs>, 31 - ): Promise<void> { 32 - try { 33 - const palette = new ColorPalette(); 34 - const guild = message.guild; 35 - if (!guild) throw new Error("This command can only be used in a server"); 36 - 37 - const mentionedUser = message.mentions[0]; 38 - if (!mentionedUser) throw new Error("Please mention a user to ban"); 39 - 40 - const member = await guild.fetchMember(mentionedUser.id); 41 - if (!member) throw new Error("I couldn't find a member to ban :("); 42 - 43 - const embed = new EmbedBuilder() 44 - .setTitle(`You've been banned from "${message.guild?.name}"`) 45 - .addFields( 46 - { 47 - name: "Reason", 48 - value: args.reason || "No reason provided", 49 - }, 50 - { 51 - name: "Ban Duration", 52 - value: `${args.duration > 0 ? args.duration : "Permament"}`, 53 - }, 54 - { 55 - name: "By", 56 - value: 57 - message.author.globalName || 58 - `${message.author.username}#${message.author.discriminator}`, 59 - }, 60 - ) 61 - .setColor(palette.get("red")) 62 - .setFooter({ 63 - text: "This message is automated, you will receive when someone takes action against you", 64 - }); 65 - 66 - await member.user.send({ 67 - embeds: [embed], 68 - }); 69 - 70 - await guild.ban(member.id, { 71 - ban_duration_seconds: args.duration, 72 - reason: args.reason ?? undefined, 73 - delete_message_days: args.delete_messages_day ?? undefined, 74 - }); 75 - 76 - const successMessage = await message.reply({ 77 - content: `Successfully banned **${member.displayName}**`, 78 - }); 79 - 80 - if (successMessage) setTimeout(() => successMessage.delete().catch(() => {}), 5000); 81 - } catch (e) { 82 - if (e instanceof Error) { 83 - await message.reply({ 84 - content: `Failed to ban this user\n\`\`\`\n${e.message}\n\`\`\``, 85 - }); 86 - } 87 - } 88 - } 89 - }
-48
src/commands/bulkdelete.ts
··· 1 - import { z } from "zod"; 2 - import { type Message, Client } from "@fluxerjs/core"; 3 - import { Command } from "@/decorators/command"; 4 - import { RequireNonBot } from "@/decorators/guards"; 5 - import type { BotContext, ICommand, ParsedArgs } from "@/types"; 6 - 7 - const bulkDeleteArgs = { 8 - count: z.coerce.number().int().min(2).max(100), 9 - } as const; 10 - 11 - @Command({ 12 - name: "bulkdelete", 13 - aliases: ["purge", "clear"], 14 - description: "Bulk deletes recent messages", 15 - }) 16 - @RequireNonBot() 17 - export class BulkDeleteCommand implements ICommand<typeof bulkDeleteArgs> { 18 - readonly args = bulkDeleteArgs; 19 - 20 - async execute( 21 - ctx: BotContext, 22 - message: Message, 23 - args: ParsedArgs<typeof bulkDeleteArgs>, 24 - ): Promise<void> { 25 - const { client } = ctx; 26 - 27 - const messages = await client.rest.get<Array<{ id: string }>>( 28 - `${Client.Routes.channelMessages(message.channelId)}?limit=${args.count}`, 29 - ); 30 - 31 - const messageIds = messages.map((m) => m.id); 32 - 33 - if (messageIds.length < 2) { 34 - await message.reply("Need at least 2 messages to bulk delete."); 35 - return; 36 - } 37 - 38 - const channel = await client.channels.resolve(message.channelId); 39 - await channel.bulkDeleteMessages(messageIds); 40 - 41 - const confirm = await message.channel?.send({ 42 - content: `Deleted ${args.count}`, 43 - }); 44 - 45 - // Auto-delete the confirmation after 5 seconds 46 - if (confirm) setTimeout(() => confirm.delete().catch(() => {}), 5000); 47 - } 48 - }
-28
src/commands/echo.ts
··· 1 - import { z } from "zod"; 2 - import type { Message } from "@fluxerjs/core"; 3 - import { arg } from "@/args"; 4 - import { Command } from "@/decorators/command"; 5 - import { RequireNonBot } from "@/decorators/guards"; 6 - import type { BotContext, ICommand, ParsedArgs } from "../types"; 7 - 8 - const echoArgs = { 9 - content: arg.restRequired(z.string().min(1).max(2000)), 10 - } as const; 11 - 12 - @Command({ 13 - name: "echo", 14 - aliases: ["say", "print"], 15 - description: "Repeats the content of the message", 16 - }) 17 - @RequireNonBot() 18 - export class EchoCommand implements ICommand<typeof echoArgs> { 19 - readonly args = echoArgs; 20 - 21 - async execute( 22 - _ctx: BotContext, 23 - message: Message, 24 - args: ParsedArgs<typeof echoArgs>, 25 - ): Promise<void> { 26 - await message.channel?.send({ content: args.content }); 27 - } 28 - }
-65
src/commands/kick.ts
··· 1 - import { Command } from "@/decorators/command"; 2 - import { arg } from "@/args"; 3 - import z from "zod"; 4 - import { RequireNonBot, RequireUserPermission } from "@/decorators/guards"; 5 - import type { BotContext, ICommand, ParsedArgs } from "@/types"; 6 - import { EmbedBuilder, type Message } from "@fluxerjs/core"; 7 - import { ColorPalette } from "@/utils"; 8 - 9 - const kickArgs = { 10 - target: arg.string(), 11 - reason: arg.restOptional(z.coerce.string().max(2000)), 12 - } as const; 13 - 14 - @Command({ 15 - name: "warn", 16 - aliases: ["w"], 17 - description: "Warns to a speficied user", 18 - }) 19 - @RequireNonBot() 20 - @RequireUserPermission({ 21 - permissions: ["Administrator", "KickMembers", "BanMembers"], 22 - mode: "any", 23 - }) 24 - export class WarnCommand implements ICommand<typeof kickArgs> { 25 - readonly args = kickArgs; 26 - 27 - async execute(_: BotContext, message: Message, args: ParsedArgs<typeof kickArgs>): Promise<void> { 28 - const palette = new ColorPalette(); 29 - const guild = message.guild; 30 - if (!guild) throw new Error("This command can only be used in a server"); 31 - 32 - const mentionedUser = message.mentions[0]; 33 - if (!mentionedUser) throw new Error("Please mention a user to warn"); 34 - 35 - const member = await message.guild?.fetchMember(mentionedUser.id); 36 - if (!member) throw new Error("I couldn't find a member to warn :("); 37 - 38 - const embed = new EmbedBuilder() 39 - .setTitle(`You've been kicked from "${message.guild?.name}"`) 40 - .addFields( 41 - { 42 - name: "Reason", 43 - value: args.reason || "No reason provided", 44 - }, 45 - { 46 - name: "By", 47 - value: 48 - message.author.globalName || 49 - `${message.author.username}#${message.author.discriminator}`, 50 - }, 51 - ) 52 - .setColor(palette.get("red")) 53 - .setFooter({ 54 - text: "This message is automated, you will receive when someone takes action against you", 55 - }); 56 - 57 - await member.user.send({ 58 - embeds: [embed], 59 - }); 60 - 61 - await guild.kick(member.id); 62 - 63 - message.reply(`Successfully warned ${member.displayName} for: ${args.reason}`); 64 - } 65 - }
+37
src/commands/misc.ts
··· 1 + import { RequireNonBot } from "@/decorators/guards"; 2 + import { Module } from "@/base/ModuleBase"; 3 + import { Command } from "@/decorators/command"; 4 + import type { BotContext, ParsedArgs } from "@/types"; 5 + import type { Message } from "@fluxerjs/core"; 6 + import { arg } from "@/args"; 7 + import { z } from "zod"; 8 + 9 + // Types 10 + const echoArgs = { 11 + content: arg.restRequired(z.string().min(1).max(2000)), 12 + } as const; 13 + 14 + // Module 15 + @Module({ 16 + name: "misc", 17 + description: "Miscellaneous commands", 18 + guards: [RequireNonBot()], 19 + }) 20 + export class MiscCommands { 21 + @Command({ 22 + name: "echo", 23 + description: "Echoes the provided content", 24 + args: echoArgs, 25 + }) 26 + async echo(_ctx: BotContext, message: Message, args: ParsedArgs<typeof echoArgs>): Promise<void> { 27 + await message.channel?.send({ content: args.content }); 28 + } 29 + 30 + @Command({ 31 + name: "ping", 32 + description: "Pings the bot", 33 + }) 34 + async ping(_ctx: BotContext, message: Message): Promise<void> { 35 + await message.channel?.send({ content: "Pong!" }); 36 + } 37 + }
+211
src/commands/moderation.ts
··· 1 + import { arg } from "@/args"; 2 + import { Module } from "@/base/ModuleBase"; 3 + import { db } from "@/db"; 4 + import { warnsTable } from "@/db/schemas"; 5 + import { Command } from "@/decorators/command"; 6 + import { RequireGuild, RequireNonBot, RequireUserPermission } from "@/decorators/guards"; 7 + import type { BotContext, ParsedArgs } from "@/types"; 8 + import { ColorPalette } from "@/utils"; 9 + import { Client, EmbedBuilder, type Message } from "@fluxerjs/core"; 10 + import z from "zod"; 11 + 12 + // Types 13 + 14 + const banArgs = { 15 + target: arg.string(), 16 + duration: z.coerce.number().int().positive().max(604800), 17 + delete_messages_day: arg.optional(z.coerce.number().int().min(0).max(7)), 18 + reason: arg.restOptional(z.string().min(1).max(500)), 19 + } as const; 20 + 21 + const warnArgs = { 22 + target: arg.string(), 23 + reason: arg.restOptional(z.coerce.string().max(2000)), 24 + } as const; 25 + 26 + const purgeArgs = { 27 + count: z.coerce.number().int().min(2).max(100), 28 + } as const; 29 + 30 + type WarnsInsertType = typeof warnsTable.$inferInsert; 31 + 32 + // Module 33 + 34 + @Module({ 35 + name: "mod", 36 + description: "Moderation stuff", 37 + guards: [ 38 + RequireNonBot(), 39 + RequireUserPermission({ 40 + mode: "any", 41 + permissions: ["Administrator", "KickMembers", "BanMembers"], 42 + }), 43 + RequireGuild(), 44 + ], 45 + }) 46 + export class ModerationModule { 47 + private readonly palette = new ColorPalette(); 48 + 49 + @Command({ 50 + name: "ban", 51 + description: "Ban a member", 52 + args: banArgs, 53 + }) 54 + async ban(_: BotContext, msg: Message, args: ParsedArgs<typeof banArgs>) { 55 + const guild = msg.guild; 56 + const mentionedUser = msg.mentions[0]; 57 + if (!mentionedUser) throw new Error("Please mention an user to ban."); 58 + 59 + const member = await guild?.fetchMember(mentionedUser.id); 60 + if (!member) throw new Error("I couldn't find a member to ban :("); 61 + 62 + const embed = new EmbedBuilder() 63 + .setTitle(`You've been banned from "${msg.guild?.name}"`) 64 + .addFields( 65 + { 66 + name: "Reason", 67 + value: args.reason || "No reason provided", 68 + }, 69 + { 70 + name: "Ban Duration", 71 + value: `${args.duration > 0 ? args.duration : "Permament"}`, 72 + }, 73 + { 74 + name: "By", 75 + value: msg.author.globalName || `${msg.author.username}#${msg.author.discriminator}`, 76 + }, 77 + ) 78 + .setColor(this.palette.get("red")) 79 + .setFooter({ 80 + text: "This message is automated, you will receive when someone takes action against you", 81 + }); 82 + 83 + await member.user.send({ 84 + embeds: [embed], 85 + }); 86 + 87 + await guild?.ban(member.id, { 88 + ban_duration_seconds: args.duration, 89 + reason: args.reason ?? undefined, 90 + delete_message_days: args.delete_messages_day ?? undefined, 91 + }); 92 + 93 + const successMessage = await msg.reply({ 94 + content: `Successfully banned **${member.displayName}**`, 95 + }); 96 + 97 + if (successMessage) setTimeout(() => successMessage.delete().catch(() => {}), 5000); 98 + } 99 + 100 + @Command({ 101 + name: "kick", 102 + description: "Kicks a member", 103 + args: warnArgs, 104 + }) 105 + async kick(_: BotContext, msg: Message, args: ParsedArgs<typeof warnArgs>) { 106 + const guild = msg.guild; 107 + 108 + const mentionedUser = msg.mentions[0]; 109 + if (!mentionedUser) throw new Error("Please mention a user to kick"); 110 + 111 + const member = await msg.guild?.fetchMember(mentionedUser.id); 112 + if (!member) throw new Error("I couldn't find a member to kick :("); 113 + 114 + const embed = new EmbedBuilder() 115 + .setTitle(`You've been kicked from "${msg.guild?.name}"`) 116 + .addFields( 117 + { 118 + name: "Reason", 119 + value: args.reason || "No reason provided", 120 + }, 121 + { 122 + name: "By", 123 + value: msg.author.globalName || `${msg.author.username}#${msg.author.discriminator}`, 124 + }, 125 + ) 126 + .setColor(this.palette.get("red")) 127 + .setFooter({ 128 + text: "This message is automated, you will receive when someone takes action against you", 129 + }); 130 + 131 + await member.user.send({ 132 + embeds: [embed], 133 + }); 134 + 135 + await guild?.kick(member.id); 136 + 137 + msg.reply(`Successfully kicked ${member.displayName} for: ${args.reason}`); 138 + } 139 + 140 + @Command({ 141 + name: "warn", 142 + description: "Warn a member", 143 + args: warnArgs, 144 + }) 145 + async warn(_: BotContext, msg: Message, args: ParsedArgs<typeof warnArgs>) { 146 + const guild = msg.guild; 147 + if (!guild) throw new Error("This command can only be used in a server"); 148 + 149 + const mentionedUser = msg.mentions[0]; 150 + if (!mentionedUser) throw new Error("Please mention a user to warn"); 151 + 152 + const member = await msg.guild?.fetchMember(mentionedUser.id); 153 + if (!member) throw new Error("I couldn't find a member to warn :("); 154 + 155 + const embed = new EmbedBuilder() 156 + .setTitle(`You've been warned from "${msg.guild?.name}"`) 157 + .setDescription( 158 + `Reason: ${args.reason}\nBy ${msg.author.globalName || `${msg.author.username}#${msg.author.discriminator}`}`, 159 + ) 160 + .setColor(this.palette.get("yellow")) 161 + .setFooter({ 162 + text: "This message is automated, you will receive when someone takes action against you.", 163 + }); 164 + 165 + await member.user.send({ 166 + embeds: [embed], 167 + }); 168 + 169 + const insertValues: WarnsInsertType = { 170 + guildId: guild.id, 171 + userId: member.id, 172 + moderator: msg.author.id, 173 + reason: args.reason ?? "", 174 + createdAt: new Date(Date.now()), 175 + }; 176 + 177 + await db.insert(warnsTable).values(insertValues); 178 + 179 + msg.reply(`Successfully warned ${member.displayName} for: ${args.reason}`); 180 + } 181 + 182 + @Command({ 183 + name: "purge", 184 + description: "Purges a number of messages", 185 + args: purgeArgs, 186 + }) 187 + async purge(ctx: BotContext, msg: Message, args: ParsedArgs<typeof purgeArgs>) { 188 + const { client } = ctx; 189 + 190 + const messages = await client.rest.get<Array<{ id: string }>>( 191 + `${Client.Routes.channelMessages(msg.channelId)}?limit=${args.count}`, 192 + ); 193 + 194 + const messageIds = messages.map((m) => m.id); 195 + 196 + if (messageIds.length < 2) { 197 + await msg.reply("Need at least 2 messages to bulk delete."); 198 + return; 199 + } 200 + 201 + const channel = await client.channels.resolve(msg.channelId); 202 + await channel.bulkDeleteMessages(messageIds); 203 + 204 + const confirm = await msg.channel?.send({ 205 + content: `Deleted ${args.count}`, 206 + }); 207 + 208 + // Auto-delete the confirmation after 5 seconds 209 + if (confirm) setTimeout(() => confirm.delete().catch(() => {}), 5000); 210 + } 211 + }
-12
src/commands/ping.ts
··· 1 - import type { Message } from "@fluxerjs/core"; 2 - import { Command } from "@/decorators/command"; 3 - import { RequireNonBot } from "@/decorators/guards"; 4 - import type { BotContext, ICommand } from "@/types"; 5 - 6 - @Command({ name: "ping", aliases: ["p"], description: 'Replies with "Pong!"' }) 7 - @RequireNonBot() 8 - export class PingCommand implements ICommand { 9 - async execute(_: BotContext, message: Message): Promise<void> { 10 - await message.reply("Pong!"); 11 - } 12 - }
-68
src/commands/warn.ts
··· 1 - import { Command } from "@/decorators/command"; 2 - import { arg } from "@/args"; 3 - import z from "zod"; 4 - import { RequireNonBot, RequireUserPermission } from "@/decorators/guards"; 5 - import type { BotContext, ICommand, ParsedArgs } from "@/types"; 6 - import { EmbedBuilder, type Message } from "@fluxerjs/core"; 7 - import { ColorPalette } from "@/utils"; 8 - import { warnsTable } from "@/db/schemas"; 9 - import { db } from "@/db"; 10 - 11 - const warnArgs = { 12 - target: arg.string(), 13 - reason: arg.restOptional(z.coerce.string().max(2000)), 14 - } as const; 15 - 16 - type WarnsInsertType = typeof warnsTable.$inferInsert; 17 - 18 - @Command({ 19 - name: "warn", 20 - aliases: ["w"], 21 - description: "Warns to a speficied user", 22 - }) 23 - @RequireNonBot() 24 - @RequireUserPermission({ 25 - permissions: ["Administrator", "KickMembers", "BanMembers"], 26 - mode: "any", 27 - }) 28 - export class WarnCommand implements ICommand<typeof warnArgs> { 29 - readonly args = warnArgs; 30 - 31 - async execute(_: BotContext, message: Message, args: ParsedArgs<typeof warnArgs>): Promise<void> { 32 - const palette = new ColorPalette(); 33 - const guild = message.guild; 34 - if (!guild) throw new Error("This command can only be used in a server"); 35 - 36 - const mentionedUser = message.mentions[0]; 37 - if (!mentionedUser) throw new Error("Please mention a user to warn"); 38 - 39 - const member = await message.guild?.fetchMember(mentionedUser.id); 40 - if (!member) throw new Error("I couldn't find a member to warn :("); 41 - 42 - const embed = new EmbedBuilder() 43 - .setTitle(`You've been warned from "${message.guild?.name}"`) 44 - .setDescription( 45 - `Reason: ${args.reason}\nBy ${message.author.globalName || `${message.author.username}#${message.author.discriminator}`}`, 46 - ) 47 - .setColor(palette.get("yellow")) 48 - .setFooter({ 49 - text: "This message is automated, you will receive when someone takes action against you.", 50 - }); 51 - 52 - await member.user.send({ 53 - embeds: [embed], 54 - }); 55 - 56 - const insertValues: WarnsInsertType = { 57 - guildId: guild.id, 58 - userId: member.id, 59 - moderator: message.author.id, 60 - reason: args.reason ?? "", 61 - createdAt: new Date(Date.now()), 62 - }; 63 - 64 - await db.insert(warnsTable).values(insertValues); 65 - 66 - message.reply(`Successfully warned ${member.displayName} for: ${args.reason}`); 67 - } 68 - }
+10 -3
src/decorators/command.ts
··· 1 1 import "reflect-metadata"; 2 + import type { ArgSchema } from "@/args"; 3 + import { MODULE_COMMANDS_KEY } from "@/base/ModuleBase"; 2 4 3 5 export interface CommandMeta { 4 6 name: string; 5 7 aliases?: string[]; 6 8 description: string; 9 + args?: ArgSchema; 7 10 } 8 11 9 12 export const COMMAND_META_KEY = Symbol("fluxer:command"); 10 13 11 - export function Command(meta: CommandMeta): ClassDecorator { 12 - return (target) => { 13 - Reflect.defineMetadata(COMMAND_META_KEY, meta, target); 14 + export function Command(meta: CommandMeta): MethodDecorator { 15 + return (_target, methodName) => { 16 + if (typeof methodName !== "string") return; 17 + const commands: Array<{ methodName: string; meta: CommandMeta }> = 18 + Reflect.getMetadata(MODULE_COMMANDS_KEY, _target.constructor) ?? []; 19 + commands.push({ methodName, meta }); 20 + Reflect.defineMetadata(MODULE_COMMANDS_KEY, commands, _target.constructor); 14 21 }; 15 22 }
+35 -11
src/decorators/guards.ts
··· 10 10 export const GUARDS_KEY = Symbol("fluxer:guards"); 11 11 export type Guard = (msg: Message) => boolean | Promise<boolean>; 12 12 13 - export function RequireNonBot(): ClassDecorator { 13 + export function RequireNonBot(): Guard { 14 + return (msg) => !msg.author.bot; 15 + } 16 + 17 + export function RequirePrefix(prefix: string): Guard { 18 + return (msg) => msg.content.startsWith(prefix); 19 + } 20 + 21 + export function RequireUserPermission(meta: RequireUserPermissionMeta): Guard { 22 + return async (msg) => { 23 + const permission = await GetMemeberPermission(msg); 24 + if (permission === null) return false; 25 + const check = meta.mode === "any" ? "some" : "every"; 26 + return meta.permissions[check]((flag) => permission.has(PermissionFlags[flag])); 27 + }; 28 + } 29 + 30 + export function RequireGuild(): Guard { 31 + return (msg) => msg.guild !== null; 32 + } 33 + 34 + export function RequireNonBotDecorator(): ClassDecorator { 14 35 return (target) => { 15 36 const guards: Guard[] = Reflect.getMetadata(GUARDS_KEY, target) ?? []; 16 - guards.push((msg) => !msg.author.bot); 37 + guards.push(RequireNonBot()); 17 38 Reflect.defineMetadata(GUARDS_KEY, guards, target); 18 39 }; 19 40 } 20 41 21 - export function RequirePrefix(prefix: string): ClassDecorator { 42 + export function RequirePrefixDecorator(prefix: string): ClassDecorator { 43 + return (target) => { 44 + const guards: Guard[] = Reflect.getMetadata(GUARDS_KEY, target) ?? []; 45 + guards.push(RequirePrefix(prefix)); 46 + Reflect.defineMetadata(GUARDS_KEY, guards, target); 47 + }; 48 + } 49 + 50 + export function RequireUserPermissionDecorator(meta: RequireUserPermissionMeta): ClassDecorator { 22 51 return (target) => { 23 52 const guards: Guard[] = Reflect.getMetadata(GUARDS_KEY, target) ?? []; 24 - guards.push((msg) => msg.content.startsWith(prefix)); 53 + guards.push(RequireUserPermission(meta)); 25 54 Reflect.defineMetadata(GUARDS_KEY, guards, target); 26 55 }; 27 56 } 28 57 29 - export function RequireUserPermission(meta: RequireUserPermissionMeta): ClassDecorator { 58 + export function RequireGuildDecorator(): ClassDecorator { 30 59 return (target) => { 31 60 const guards: Guard[] = Reflect.getMetadata(GUARDS_KEY, target) ?? []; 32 - const check = meta.mode === "any" ? "some" : "every"; 33 - guards.push(async (msg) => { 34 - const permission = await GetMemeberPermission(msg); 35 - if (permission === null) return false; 36 - return meta.permissions[check]((flag) => permission.has(PermissionFlags[flag])); 37 - }); 61 + guards.push(RequireGuild()); 38 62 Reflect.defineMetadata(GUARDS_KEY, guards, target); 39 63 }; 40 64 }
+1 -1
src/index.ts
··· 1 + import "reflect-metadata"; 1 2 import { Bot } from "@/bot"; 2 3 3 - // Main entry, while it looks stupidly short, it only takes few lines anyway. 4 4 new Bot().start();
+109 -13
src/registry/commandRegistry.ts
··· 1 1 import type { Message } from "@fluxerjs/core"; 2 2 import { COMMAND_META_KEY, type CommandMeta } from "@/decorators/command"; 3 3 import { GUARDS_KEY, type Guard } from "@/decorators/guards"; 4 - import { parseArgs } from "@/args"; 4 + import { parseArgs, type ArgSchema } from "@/args"; 5 5 import type { BotContext, ICommand } from "@/types"; 6 6 import type { CommandCtor } from "@/types"; 7 7 import { Logger } from "tslog"; 8 + import { MODULE_META_KEY, MODULE_COMMANDS_KEY, type ModuleCommand } from "@/base/ModuleBase"; 8 9 9 10 interface FileRecord { 10 11 filePath: string; 11 12 triggers: string[]; 12 13 } 13 14 15 + interface ModuleRecord { 16 + name: string; 17 + triggers: string[]; 18 + instance: object; 19 + onUnload?: () => void | Promise<void>; 20 + } 21 + 14 22 type AnyCommand = ICommand<any>; 15 23 24 + interface ModuleEntry { 25 + type: "module"; 26 + instance: object; 27 + methodName: string; 28 + args?: ArgSchema; 29 + } 30 + 31 + interface ClassEntry { 32 + type: "class"; 33 + instance: AnyCommand; 34 + args?: ArgSchema; 35 + } 36 + 37 + type Entry = ModuleEntry | ClassEntry; 38 + 16 39 export class CommandRegistry { 17 - private map = new Map<string, AnyCommand>(); 40 + private map = new Map<string, Entry>(); 18 41 private guards = new Map<string, Guard[]>(); 19 42 private fileSources = new Map<string, FileRecord>(); 43 + private modules = new Map<string, ModuleRecord>(); 20 44 private logger: Logger<CommandRegistry>; 21 45 22 46 constructor() { ··· 30 54 for (const ctor of ctors) { 31 55 try { 32 56 const meta: CommandMeta = Reflect.getMetadata(COMMAND_META_KEY, ctor); 33 - // Checks if command class has @Command decorators 34 57 if (!meta) throw new Error(`${ctor.name} has missing @Command decorators`); 35 58 36 59 const instance = new ctor(); ··· 38 61 const triggers = [meta.name, ...(meta.aliases ?? [])].map((n) => `${prefix}${n}`); 39 62 40 63 for (const trigger of triggers) { 41 - this.map.set(trigger, instance); 64 + this.map.set(trigger, { type: "class", instance, args: instance.args }); 42 65 this.guards.set(trigger, instanceGuards); 43 66 } 44 67 ··· 52 75 } 53 76 } 54 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 + 55 143 unregisterFile(filePath: string): void { 56 - for (const [name, record] of this.fileSources) { 144 + for (const [, record] of this.fileSources) { 57 145 if (record.filePath === filePath) { 58 146 for (const trigger of record.triggers) { 59 147 this.map.delete(trigger); 60 148 this.guards.delete(trigger); 61 149 } 62 - this.fileSources.delete(name); 150 + this.fileSources.delete(record.triggers[0]!); 63 151 } 64 152 } 65 153 } ··· 71 159 const [rawCmd, ...args] = message.content.slice(prefix.length).trim().split(/\s+/); 72 160 const key = `${prefix}${rawCmd?.toLowerCase()}`; 73 161 74 - const command = this.map.get(key); 75 - if (!command) return; 162 + const entry = this.map.get(key); 163 + if (!entry) return; 76 164 77 165 const guards = this.guards.get(key) ?? []; 78 166 for (const guard of guards) { 79 167 if (!(await guard(message))) return; 80 168 } 81 169 82 - // Parse raw string args through the command's schema if it has one, 83 - // otherwise pass the raw array cast to the expected type. 84 - const parsedArgs = command.args ? await parseArgs(command.args, args) : ({} as never); 170 + const parsedArgs = entry.args ? await parseArgs(entry.args, args) : ({} as never); 85 171 86 172 this.logger.info( 87 - `Executing ${rawCmd} ${args ? `with arguments: ${args.join(", ")}` : "without arguments"}`, 173 + `Executing ${rawCmd} ${args.length ? `with arguments: ${args.join(", ")}` : "without arguments"}`, 88 174 ); 89 - await command.execute(ctx, message, parsedArgs); 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 + } 90 186 } catch (err) { 91 187 if (err instanceof Error) { 92 188 this.logger.error(`Something went wrong while executing this command: ${err.message}`);
+37
src/registry/loadModules.ts
··· 1 + import path from "node:path"; 2 + import type { CommandRegistry } from "./commandRegistry"; 3 + import { Logger } from "tslog"; 4 + import { MODULE_META_KEY } from "@/base/ModuleBase"; 5 + 6 + const logger = new Logger({ 7 + type: "pretty", 8 + name: "loadModules", 9 + }); 10 + 11 + export async function loadModules( 12 + registry: CommandRegistry, 13 + prefix: string, 14 + pattern = "src/commands/**/*.ts", 15 + options?: { namespace?: string }, 16 + ): Promise<void> { 17 + const glob = new Bun.Glob(pattern); 18 + 19 + for await (const file of glob.scan(".")) { 20 + const absPath = path.resolve(file); 21 + 22 + logger.debug(`Loading module from "${absPath}"`); 23 + 24 + const mod = await import(Bun.pathToFileURL(absPath).href); 25 + 26 + for (const exported of Object.values(mod)) { 27 + if (isModuleCtor(exported)) { 28 + const instance = new exported(); 29 + registry.registerModule(prefix, instance, options); 30 + } 31 + } 32 + } 33 + } 34 + 35 + function isModuleCtor(val: unknown): val is new () => object { 36 + return typeof val === "function" && Reflect.hasMetadata(MODULE_META_KEY, val); 37 + }

History

2 rounds 1 comment
sign up or login to add to the discussion
4 commits
expand
feat(modules/commands): rewrite to use ModuleBase
chore(modules/commands): migrate commands to ModuleBase
chore: run bun fmt
docs: small cleanup + smol writing and updating roadmap
expand 1 comment

fyi: the new module-based command system were written by AI. I could've disclosed that... :/

pull request successfully merged
2 commits
expand
feat(modules/commands): rewrite to use ModuleBase
chore(modules/commands): migrate commands to ModuleBase
expand 0 comments