Yet another Fluxer bot built with TypeScript and Bun
at better-command-system 301 lines 10 kB view raw
1import 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 10export interface OptionalStandardSchema<S extends StandardSchemaV1> { 11 readonly _tag: "optional"; 12 readonly schema: S; 13 readonly _rest?: true; 14} 15 16// --------------------------------------------------------------------------- 17// Our native ArgDef — implements StandardSchemaV1 so it's interoperable 18// with any tooling that accepts Standard Schema. 19// 20// Input is always `string` (a raw positional arg from the message). 21// Output is whatever the parser produces. 22// --------------------------------------------------------------------------- 23 24export interface ArgDef<T> extends StandardSchemaV1<string, T> { 25 readonly required: true; 26 optional(): OptionalArgDef<T>; 27} 28 29export interface OptionalArgDef<T> extends StandardSchemaV1<string, T | undefined> { 30 readonly required: false; 31} 32 33// --------------------------------------------------------------------------- 34// What can appear as a value in an arg schema 35// --------------------------------------------------------------------------- 36 37export type ArgEntry = 38 | ArgDef<unknown> 39 | OptionalArgDef<unknown> 40 | StandardSchemaV1 // any external schema — required by default 41 | OptionalStandardSchema<StandardSchemaV1>; // external schema wrapped as optional 42 43export type ArgSchema = Record<string, ArgEntry>; 44 45// Infer the parsed output type for a single entry 46type InferEntry<E extends ArgEntry> = 47 E extends OptionalStandardSchema<infer S> 48 ? StandardSchemaV1.InferOutput<S> | undefined 49 : E extends StandardSchemaV1 50 ? StandardSchemaV1.InferOutput<E> 51 : never; 52 53// Infer the fully parsed object type from a schema 54export type ParsedArgs<S extends ArgSchema> = { 55 [K in keyof S]: InferEntry<S[K]>; 56}; 57 58// --------------------------------------------------------------------------- 59// Internal helpers 60// --------------------------------------------------------------------------- 61 62function makeStandardProps<T>( 63 required: boolean, 64 parser: (raw: string) => T, 65): StandardSchemaV1<string, T>["~standard"] { 66 return { 67 version: 1 as const, 68 vendor: "hydro-args", 69 validate(value: unknown) { 70 if (value === undefined || value === "") { 71 if (required) { 72 return { issues: [{ message: "Missing required argument" }] }; 73 } 74 return { value: undefined as T }; 75 } 76 try { 77 return { value: parser(value as string) }; 78 } catch (e) { 79 return { issues: [{ message: e instanceof Error ? e.message : String(e) }] }; 80 } 81 }, 82 types: undefined, 83 }; 84} 85 86function makeOptional<T>(parser: (raw: string) => T): OptionalArgDef<T> { 87 return { 88 required: false as const, 89 "~standard": makeStandardProps(false, parser) as StandardSchemaV1< 90 string, 91 T | undefined 92 >["~standard"], 93 }; 94} 95 96function makeRequired<T>(parser: (raw: string) => T): ArgDef<T> { 97 return { 98 required: true as const, 99 "~standard": makeStandardProps(true, parser), 100 optional(): OptionalArgDef<T> { 101 return makeOptional(parser); 102 }, 103 }; 104} 105 106// --------------------------------------------------------------------------- 107// Arg factories — the public API 108// --------------------------------------------------------------------------- 109 110export const arg = { 111 /** Raw string — returned as-is */ 112 string(): ArgDef<string> { 113 return makeRequired((raw) => raw); 114 }, 115 116 /** Parsed as a float — throws if not a valid number */ 117 number(): ArgDef<number> { 118 return makeRequired((raw) => { 119 const n = Number(raw); 120 if (Number.isNaN(n)) throw new Error(`Expected a number, got "${raw}"`); 121 return n; 122 }); 123 }, 124 125 /** Parses "true"/"yes"/"1" as true, "false"/"no"/"0" as false */ 126 boolean(): ArgDef<boolean> { 127 return makeRequired((raw) => { 128 const lower = raw.toLowerCase(); 129 if (lower === "true" || lower === "yes" || lower === "1") return true; 130 if (lower === "false" || lower === "no" || lower === "0") return false; 131 throw new Error(`Expected a boolean (true/false/yes/no/1/0), got "${raw}"`); 132 }); 133 }, 134 135 /** 136 * Greedily consumes all remaining positional args into a single string. 137 * Must be the last entry in the schema. 138 * !ban @user being rude in chat → reason = "being rude in chat" 139 */ 140 rest(): ArgDef<string> & { readonly _rest: true } { 141 return { ...makeRequired((raw) => raw), _rest: true as const }; 142 }, 143 144 /** 145 * Wraps any Standard Schema compatible schema (Zod, Valibot, ArkType, etc.) 146 * as an optional arg. Without this wrapper, external schemas are required. 147 * 148 * @example 149 * const schema = { 150 * tag: z.string().regex(/^[a-z]+$/), // required, validated by Zod 151 * note: arg.optional(z.string().max(100)), // optional, validated by Zod 152 * }; 153 */ 154 optional<S extends StandardSchemaV1>(schema: S): OptionalStandardSchema<S> { 155 return { _tag: "optional", schema }; 156 }, 157 158 /** 159 * Like arg.optional(schema), but greedily consumes all remaining tokens 160 * as a single string before passing to the schema. Must be the last entry. 161 * 162 * @example 163 * const schema = { 164 * target: arg.string(), 165 * reason: arg.restOptional(z.string().min(1).max(500)), // "Being rude in chat" 166 * }; 167 */ 168 restOptional<S extends StandardSchemaV1>(schema: S): OptionalStandardSchema<S> { 169 return { _tag: "optional", schema, _rest: true as const }; 170 }, 171 172 /** 173 * Like a required external schema arg, but greedily consumes all remaining 174 * tokens as a single string before passing to the schema. Must be the last entry. 175 * 176 * Use this when you want Zod (or another library) to validate a required 177 * multi-word value, e.g. a message that must be at least 1 character. 178 * 179 * @example 180 * const schema = { 181 * content: arg.restRequired(z.string().min(1)), // "Hello world from chat" 182 * }; 183 */ 184 restRequired<S extends StandardSchemaV1>(schema: S): S & { readonly _rest: true } { 185 return { ...schema, _rest: true as const }; 186 }, 187} as const; 188 189// --------------------------------------------------------------------------- 190// Type guards 191// --------------------------------------------------------------------------- 192 193function isOptionalWrapper(entry: ArgEntry): entry is OptionalStandardSchema<StandardSchemaV1> { 194 return "_tag" in entry && (entry as OptionalStandardSchema<StandardSchemaV1>)._tag === "optional"; 195} 196 197function isNativeArgDef(entry: ArgEntry): entry is ArgDef<unknown> | OptionalArgDef<unknown> { 198 return "required" in entry; 199} 200 201function isRest(entry: ArgEntry): boolean { 202 return ( 203 "_rest" in entry && (entry as { _rest?: boolean })._rest === true && !isOptionalWrapper(entry) 204 ); 205} 206 207function isRestOptionalWrapper( 208 entry: ArgEntry, 209): entry is OptionalStandardSchema<StandardSchemaV1> & { _rest: true } { 210 return isOptionalWrapper(entry) && entry._rest === true; 211} 212 213// --------------------------------------------------------------------------- 214// Parser — called by the registry before execute() 215// --------------------------------------------------------------------------- 216 217async function validateEntry( 218 key: string, 219 entry: ArgEntry, 220 rawValue: string | undefined, 221): Promise<unknown> { 222 // Optional wrapper (including restOptional) around an external schema 223 if (isOptionalWrapper(entry) || isRestOptionalWrapper(entry)) { 224 if (rawValue === undefined || rawValue === "") return undefined; 225 return runValidate(key, entry.schema, rawValue); 226 } 227 228 // Our native ArgDef / OptionalArgDef (also StandardSchemaV1) 229 if (isNativeArgDef(entry)) { 230 // Delegate to the ~standard.validate() we set up — it handles required/optional internally 231 return runValidate(key, entry, rawValue ?? ""); 232 } 233 234 // Plain external Standard Schema — treated as required 235 if (rawValue === undefined || rawValue === "") { 236 throw new Error(`Missing required argument: ${key}`); 237 } 238 return runValidate(key, entry, rawValue); 239} 240 241async function runValidate( 242 key: string, 243 schema: StandardSchemaV1, 244 value: unknown, 245): Promise<unknown> { 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; 267 } 268 269 throw new Error(`Argument "${key}": Schema does not implement StandardSchemaV1 or safeParse`); 270} 271 272export async function parseArgs<S extends ArgSchema>( 273 schema: S, 274 raw: string[], 275): Promise<ParsedArgs<S>> { 276 const keys = Object.keys(schema); 277 const result: Record<string, unknown> = {}; 278 279 for (let i = 0; i < keys.length; i++) { 280 const key = keys[i]!; 281 const entry = schema[key]!; 282 const isLastEntry = i === keys.length - 1; 283 284 let rawValue: string | undefined; 285 286 const isRestEntry = 287 isRest(entry) || 288 isRestOptionalWrapper(entry) || 289 ("_rest" in entry && (entry as { _rest?: boolean })._rest === true); 290 if (isRestEntry && isLastEntry) { 291 // Consume all remaining tokens as one string 292 rawValue = raw.slice(i).join(" ") || undefined; 293 } else { 294 rawValue = raw[i]; 295 } 296 297 result[key] = await validateEntry(key, entry, rawValue); 298 } 299 300 return result as ParsedArgs<S>; 301}