Yet another Fluxer bot built with TypeScript and Bun

feat(commands): implement typed arguments

It should make things eaiser without worrying about having wrong types,
also Standard Schema is compatible as well btw

+291 -17
+3
bun.lock
··· 6 6 "name": "hydro", 7 7 "dependencies": { 8 8 "@fluxerjs/core": "^1.2.3", 9 + "@standard-schema/spec": "^1.1.0", 9 10 "@t3-oss/env-core": "^0.13.10", 10 11 "reflect-metadata": "^0.2.2", 11 12 "zod": "^4.3.6", ··· 36 37 "@fluxerjs/util": ["@fluxerjs/util@1.2.3", "", { "dependencies": { "@fluxerjs/types": "1.2.3" } }, "sha512-vxDlxnQV3sTAAdZGrRQrmEx+O8Uzydw+HMx6vF4l0nxeTRwAZgR19CApY6r0tZk2mnHwZO0pqfaXbB4dHL4bGw=="], 37 38 38 39 "@fluxerjs/ws": ["@fluxerjs/ws@1.2.3", "", { "dependencies": { "@fluxerjs/types": "1.2.3", "ws": "^8.18.0" } }, "sha512-0aQn1myqChfO2pABYVdm1bSqWOQrDURt7Bzh42hNsiTUGiJIkyf78r24k6X1JDHXuhUEd9MTBkIDCqQ7LD1K6w=="], 40 + 41 + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], 39 42 40 43 "@t3-oss/env-core": ["@t3-oss/env-core@0.13.10", "", { "peerDependencies": { "arktype": "^2.1.0", "typescript": ">=5.0.0", "valibot": "^1.0.0-beta.7 || ^1.0.0", "zod": "^3.24.0 || ^4.0.0" }, "optionalPeers": ["arktype", "typescript", "valibot", "zod"] }, "sha512-NNFfdlJ+HmPHkLi2HKy7nwuat9SIYOxei9K10lO2YlcSObDILY7mHZNSHsieIM3A0/5OOzw/P/b+yLvPdaG52g=="], 41 44
+1
package.json
··· 11 11 }, 12 12 "dependencies": { 13 13 "@fluxerjs/core": "^1.2.3", 14 + "@standard-schema/spec": "^1.1.0", 14 15 "@t3-oss/env-core": "^0.13.10", 15 16 "reflect-metadata": "^0.2.2", 16 17 "zod": "^4.3.6"
+241
src/args.ts
··· 1 + import type { StandardSchemaV1 } from "@standard-schema/spec"; 2 + 3 + // --------------------------------------------------------------------------- 4 + // Optional wrapper for external Standard Schema schemas 5 + // 6 + // External schemas (Zod, Valibot, etc.) are treated as required by default. 7 + // Wrap with arg.optional(schema) to make them optional. 8 + // --------------------------------------------------------------------------- 9 + 10 + export interface OptionalStandardSchema<S extends StandardSchemaV1> { 11 + readonly _tag: "optional"; 12 + readonly schema: S; 13 + } 14 + 15 + // --------------------------------------------------------------------------- 16 + // Our native ArgDef — implements StandardSchemaV1 so it's interoperable 17 + // with any tooling that accepts Standard Schema. 18 + // 19 + // Input is always `string` (a raw positional arg from the message). 20 + // Output is whatever the parser produces. 21 + // --------------------------------------------------------------------------- 22 + 23 + export interface ArgDef<T> extends StandardSchemaV1<string, T> { 24 + readonly required: true; 25 + optional(): OptionalArgDef<T>; 26 + } 27 + 28 + export interface OptionalArgDef<T> extends StandardSchemaV1<string, T | undefined> { 29 + readonly required: false; 30 + } 31 + 32 + // --------------------------------------------------------------------------- 33 + // What can appear as a value in an arg schema 34 + // --------------------------------------------------------------------------- 35 + 36 + export type ArgEntry = 37 + | ArgDef<unknown> 38 + | OptionalArgDef<unknown> 39 + | StandardSchemaV1 // any external schema — required by default 40 + | OptionalStandardSchema<StandardSchemaV1>; // external schema wrapped as optional 41 + 42 + export type ArgSchema = Record<string, ArgEntry>; 43 + 44 + // Infer the parsed output type for a single entry 45 + type InferEntry<E extends ArgEntry> = 46 + E extends OptionalStandardSchema<infer S> 47 + ? StandardSchemaV1.InferOutput<S> | undefined 48 + : E extends StandardSchemaV1 49 + ? StandardSchemaV1.InferOutput<E> 50 + : never; 51 + 52 + // Infer the fully parsed object type from a schema 53 + export type ParsedArgs<S extends ArgSchema> = { 54 + [K in keyof S]: InferEntry<S[K]>; 55 + }; 56 + 57 + // --------------------------------------------------------------------------- 58 + // Internal helpers 59 + // --------------------------------------------------------------------------- 60 + 61 + function makeStandardProps<T>( 62 + required: boolean, 63 + parser: (raw: string) => T, 64 + ): StandardSchemaV1<string, T>["~standard"] { 65 + return { 66 + version: 1 as const, 67 + vendor: "hydro-args", 68 + validate(value: unknown) { 69 + if (value === undefined || value === "") { 70 + if (required) { 71 + return { issues: [{ message: "Missing required argument" }] }; 72 + } 73 + return { value: undefined as T }; 74 + } 75 + try { 76 + return { value: parser(value as string) }; 77 + } catch (e) { 78 + return { issues: [{ message: e instanceof Error ? e.message : String(e) }] }; 79 + } 80 + }, 81 + types: undefined, 82 + }; 83 + } 84 + 85 + function makeOptional<T>(parser: (raw: string) => T): OptionalArgDef<T> { 86 + return { 87 + required: false as const, 88 + "~standard": makeStandardProps(false, parser) as StandardSchemaV1< 89 + string, 90 + T | undefined 91 + >["~standard"], 92 + }; 93 + } 94 + 95 + function makeRequired<T>(parser: (raw: string) => T): ArgDef<T> { 96 + return { 97 + required: true as const, 98 + "~standard": makeStandardProps(true, parser), 99 + optional(): OptionalArgDef<T> { 100 + return makeOptional(parser); 101 + }, 102 + }; 103 + } 104 + 105 + // --------------------------------------------------------------------------- 106 + // Arg factories — the public API 107 + // --------------------------------------------------------------------------- 108 + 109 + export const arg = { 110 + /** Raw string — returned as-is */ 111 + string(): ArgDef<string> { 112 + return makeRequired((raw) => raw); 113 + }, 114 + 115 + /** Parsed as a float — throws if not a valid number */ 116 + number(): ArgDef<number> { 117 + return makeRequired((raw) => { 118 + const n = Number(raw); 119 + if (Number.isNaN(n)) throw new Error(`Expected a number, got "${raw}"`); 120 + return n; 121 + }); 122 + }, 123 + 124 + /** Parses "true"/"yes"/"1" as true, "false"/"no"/"0" as false */ 125 + boolean(): ArgDef<boolean> { 126 + return makeRequired((raw) => { 127 + const lower = raw.toLowerCase(); 128 + if (lower === "true" || lower === "yes" || lower === "1") return true; 129 + if (lower === "false" || lower === "no" || lower === "0") return false; 130 + throw new Error(`Expected a boolean (true/false/yes/no/1/0), got "${raw}"`); 131 + }); 132 + }, 133 + 134 + /** 135 + * Greedily consumes all remaining positional args into a single string. 136 + * Must be the last entry in the schema. 137 + * !ban @user being rude in chat → reason = "being rude in chat" 138 + */ 139 + rest(): ArgDef<string> & { readonly _rest: true } { 140 + return { ...makeRequired((raw) => raw), _rest: true as const }; 141 + }, 142 + 143 + /** 144 + * Wraps any Standard Schema compatible schema (Zod, Valibot, ArkType, etc.) 145 + * as an optional arg. Without this wrapper, external schemas are required. 146 + * 147 + * @example 148 + * const schema = { 149 + * tag: z.string().regex(/^[a-z]+$/), // required, validated by Zod 150 + * note: arg.optional(z.string().max(100)), // optional, validated by Zod 151 + * }; 152 + */ 153 + optional<S extends StandardSchemaV1>(schema: S): OptionalStandardSchema<S> { 154 + return { _tag: "optional", schema }; 155 + }, 156 + } as const; 157 + 158 + // --------------------------------------------------------------------------- 159 + // Type guards 160 + // --------------------------------------------------------------------------- 161 + 162 + function isOptionalWrapper(entry: ArgEntry): entry is OptionalStandardSchema<StandardSchemaV1> { 163 + return "_tag" in entry && (entry as OptionalStandardSchema<StandardSchemaV1>)._tag === "optional"; 164 + } 165 + 166 + function isNativeArgDef(entry: ArgEntry): entry is ArgDef<unknown> | OptionalArgDef<unknown> { 167 + return "required" in entry; 168 + } 169 + 170 + function isRest(entry: ArgEntry): boolean { 171 + return "_rest" in entry && (entry as { _rest?: boolean })._rest === true; 172 + } 173 + 174 + // --------------------------------------------------------------------------- 175 + // Parser — called by the registry before execute() 176 + // --------------------------------------------------------------------------- 177 + 178 + async function validateEntry( 179 + key: string, 180 + entry: ArgEntry, 181 + rawValue: string | undefined, 182 + ): Promise<unknown> { 183 + // Optional wrapper around an external schema 184 + if (isOptionalWrapper(entry)) { 185 + if (rawValue === undefined || rawValue === "") return undefined; 186 + return runValidate(key, entry.schema, rawValue); 187 + } 188 + 189 + // Our native ArgDef / OptionalArgDef (also StandardSchemaV1) 190 + if (isNativeArgDef(entry)) { 191 + // Delegate to the ~standard.validate() we set up — it handles required/optional internally 192 + return runValidate(key, entry, rawValue ?? ""); 193 + } 194 + 195 + // Plain external Standard Schema — treated as required 196 + if (rawValue === undefined || rawValue === "") { 197 + throw new Error(`Missing required argument: ${key}`); 198 + } 199 + return runValidate(key, entry, rawValue); 200 + } 201 + 202 + async function runValidate( 203 + key: string, 204 + schema: StandardSchemaV1, 205 + value: unknown, 206 + ): Promise<unknown> { 207 + let result = schema["~standard"].validate(value); 208 + if (result instanceof Promise) result = await result; 209 + if (result.issues) { 210 + const messages = result.issues.map((i) => i.message).join(", "); 211 + throw new Error(`Argument "${key}": ${messages}`); 212 + } 213 + return result.value; 214 + } 215 + 216 + export async function parseArgs<S extends ArgSchema>( 217 + schema: S, 218 + raw: string[], 219 + ): Promise<ParsedArgs<S>> { 220 + const keys = Object.keys(schema); 221 + const result: Record<string, unknown> = {}; 222 + 223 + for (let i = 0; i < keys.length; i++) { 224 + const key = keys[i]!; 225 + const entry = schema[key]!; 226 + const isLastEntry = i === keys.length - 1; 227 + 228 + let rawValue: string | undefined; 229 + 230 + if (isRest(entry) && isLastEntry) { 231 + // Consume all remaining tokens as one string 232 + rawValue = raw.slice(i).join(" ") || undefined; 233 + } else { 234 + rawValue = raw[i]; 235 + } 236 + 237 + result[key] = await validateEntry(key, entry, rawValue); 238 + } 239 + 240 + return result as ParsedArgs<S>; 241 + }
+4 -4
src/bot.ts
··· 17 17 18 18 registerEvents() { 19 19 this.client.on(Events.MessageCreate, async (msg: Message) => { 20 - await this.registry.handle(env.PREFIX, msg); 21 - }) 20 + await this.registry.handle({ client: this.client }, env.PREFIX, msg); 21 + }); 22 22 } 23 23 24 24 async start() { ··· 32 32 this.registerEvents(); 33 33 34 34 this.client.on(Events.Ready, () => { 35 - console.log("The bot is now ready") 36 - }) 35 + console.log("The bot is now ready"); 36 + }); 37 37 38 38 // After loading commands and registering events, we can now login our bot 39 39 await this.client.login(env.FLUXER_BOT_TOKEN);
+13 -5
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 type { ICommand } from "../types"; 4 + import { parseArgs } from "../args"; 5 + import type { BotContext, ICommand } from "../types"; 5 6 import type { CommandCtor } from "../types"; 6 7 7 8 interface FileRecord { ··· 9 10 triggers: string[]; 10 11 } 11 12 13 + // eslint-disable-next-line @typescript-eslint/no-explicit-any 14 + type AnyCommand = ICommand<any>; 15 + 12 16 export class CommandRegistry { 13 - private map = new Map<string, ICommand>(); 17 + private map = new Map<string, AnyCommand>(); 14 18 private guards = new Map<string, Guard[]>(); 15 19 private fileSources = new Map<string, FileRecord>(); 16 20 ··· 30 34 } 31 35 32 36 if (filePath) { 33 - this.fileSources.set(meta.name, { filePath, triggers }) 37 + this.fileSources.set(meta.name, { filePath, triggers }); 34 38 } 35 39 } 36 40 } ··· 47 51 } 48 52 } 49 53 50 - async handle(prefix: string, message: Message): Promise<void> { 54 + async handle(ctx: BotContext, prefix: string, message: Message): Promise<void> { 51 55 if (!message.content.startsWith(prefix)) return; 52 56 const [rawCmd, ...args] = message.content.slice(prefix.length).trim().split(/\s+/); 53 57 const key = `${prefix}${rawCmd?.toLowerCase()}`; ··· 60 64 if (!(await guard(message))) return; 61 65 } 62 66 63 - await command.execute(message, args); 67 + // Parse raw string args through the command's schema if it has one, 68 + // otherwise pass the raw array cast to the expected type. 69 + const parsedArgs = command.args ? await parseArgs(command.args, args) : ({} as never); 70 + 71 + await command.execute(ctx, message, parsedArgs); 64 72 } 65 73 }
+1 -1
src/registry/loadCommands.ts
··· 5 5 export async function loadCommands( 6 6 registry: CommandRegistry, 7 7 prefix: string, 8 - pattern = "src/commands/**/*.ts" 8 + pattern = "src/commands/**/*.ts", 9 9 ): Promise<void> { 10 10 const glob = new Bun.Glob(pattern); 11 11
+3 -3
src/registry/watchCommands.ts
··· 13 13 async function reloadFile( 14 14 registry: CommandRegistry, 15 15 prefix: string, 16 - absPath: string 16 + absPath: string, 17 17 ): Promise<void> { 18 18 try { 19 19 delete _require.cache[absPath]; ··· 36 36 export function watchCommands( 37 37 registry: CommandRegistry, 38 38 prefix: string, 39 - dir = "src/commands" 39 + dir = "src/commands", 40 40 ): void { 41 41 // We intentionally ignore the event type here. 42 42 // On Linux, editors do atomic saves (write tmp → rename into place), ··· 61 61 registry.unregisterFile(absPath); 62 62 console.log(`[hot-reload] Unloaded: ${absPath}`); 63 63 } 64 - }, 50) 64 + }, 50), 65 65 ); 66 66 }); 67 67
+25 -4
src/types.ts
··· 1 - import type { Message } from "@fluxerjs/core"; 1 + import type { Client, Message } from "@fluxerjs/core"; 2 + import type { ArgSchema, ParsedArgs } from "./args"; 3 + 4 + export type { ParsedArgs }; 5 + 6 + /** Injected into every command's execute() call by the registry. */ 7 + export interface BotContext { 8 + client: Client; 9 + } 10 + 11 + export interface ICommand<TArgs extends ArgSchema = Record<string, never>> { 12 + /** 13 + * Declare your arg schema here. The registry reads this to parse and 14 + * validate raw string args before calling execute(). 15 + * 16 + * Entries can be: 17 + * - Our built-in helpers: arg.string(), arg.number(), arg.boolean(), arg.rest() 18 + * - Any Standard Schema compatible schema (Zod, Valibot, ArkType, etc.) — required by default 19 + * - arg.optional(zodSchema) — to make an external schema optional 20 + * 21 + * Omit entirely for commands that take no args. 22 + */ 23 + readonly args?: TArgs; 2 24 3 - export interface ICommand { 4 - execute(message: Message, args: string[]): Promise<void> 25 + execute(ctx: BotContext, message: Message, args: ParsedArgs<TArgs>): Promise<void>; 5 26 } 6 27 7 - export type CommandCtor = new () => ICommand; 28 + export type CommandCtor = new () => ICommand<ArgSchema>;