spearkit 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,777 @@
1
+ # API reference
2
+
3
+ Every symbol spearkit exports, in addition to the entire re-exported discord.js
4
+ surface. Import any of these from `"spearkit"`.
5
+
6
+ ```ts
7
+ import { SpearClient, command, option, event, button, modal, row /* … */ } from "spearkit";
8
+ ```
9
+
10
+ ---
11
+
12
+ ## Client
13
+
14
+ ### `class SpearClient extends Client`
15
+
16
+ A discord.js `Client` with registries and interaction routing wired up.
17
+
18
+ ```ts
19
+ new SpearClient(options?: SpearClientOptions)
20
+ ```
21
+
22
+ | Member | Type | Description |
23
+ | ------ | ---- | ----------- |
24
+ | `commands` | `CommandRegistry` | Slash command registry + dispatcher. |
25
+ | `events` | `EventRegistry` | Event listener registry. |
26
+ | `components` | `ComponentRegistry` | Button/select/modal router. |
27
+ | `logger` | `Logger` | Structured logger (`client.logger.child(scope)` for sub-scopes). |
28
+ | `cooldowns` | `CooldownManager` | Shared cooldown manager (also used by prefix commands). |
29
+ | `scheduler` | `TaskScheduler` | Cron / interval task scheduler. |
30
+ | `prefix` | `PrefixRegistry` | Prefix (text) command registry. |
31
+ | `usage` | `UsageTracker` | Usage tracker — records who used what. |
32
+ | `embeds` | `Embeds` | Preset embed factory behind `ctx.success/error/...`. |
33
+ | `contextMenus` | `ContextMenuRegistry` | User / message context-menu registry. |
34
+ | `register(...items: Registerable[])` | `this` | Route each item to the matching registry. |
35
+ | `use(...plugins: SpearPlugin[])` | `Promise<this>` | Run each plugin's `setup`. |
36
+ | `load(dir: string, options?: LoadOptions)` | `Promise<number>` | Import a directory and register its exports. Returns count. |
37
+ | `start(token?: string)` | `Promise<this>` | Log in (falls back to `DISCORD_TOKEN`). |
38
+ | `deployCommands(options?: { guildId?: string })` | `Promise<DeployResult>` | Push commands using the client's REST. Call after ready. |
39
+ | `deployAllCommands(options?)` | `Promise<DeployResult \| { skipped: true; reason; body }>` | Deploy slash + context menus together; supports `dryRun` and `strategy: "diff"`. |
40
+ | `schedule(config: TaskConfig)` | `ScheduledTask` | Define and register a scheduled task in one call. |
41
+ | `enableGracefulShutdown(options?: GracefulShutdownOptions)` | `() => void` | Tear down cleanly on `SIGINT`/`SIGTERM`; returns a disposer. |
42
+
43
+ Inherits everything from discord.js `Client` (`on`, `once`, `login`, `ws`, `rest`, `application`, `user`, …).
44
+
45
+ ### `type SpearClientOptions = Partial<ClientOptions> & SpearOptions`
46
+
47
+ discord.js `ClientOptions` (with `intents` optional — it defaults to
48
+ `Intents.default`) intersected with spearkit's own options (`SpearOptions`):
49
+
50
+ | Option | Type | Configures |
51
+ | ------ | ---- | ---------- |
52
+ | `logger` | `Logger \| LoggerOptions` | The `client.logger`. |
53
+ | `dotenv` | `boolean \| LoadEnvOptions` | Auto-load `.env` on `start()` (default `true`). |
54
+ | `cooldown` | `CooldownInput` | Default cooldown applied to every command. |
55
+ | `prefix` | `string \| readonly string[] \| PrefixOptions` | Enable prefix commands. |
56
+ | `usage` | `UsageOptions` | Usage-tracking store and/or channel. |
57
+ | `embeds` | `Embeds \| EmbedsOptions` | Preset embed factory. |
58
+ | `guards` | `readonly Guard[]` | Default guards run before every handler. |
59
+ | `autoDefer` | `AutoDeferInput` | Default auto-defer for slash + context-menu handlers. |
60
+
61
+ ### `const Intents`
62
+
63
+ Ready-made intent presets (arrays of `GatewayIntentBits`).
64
+
65
+ | Key | Contents |
66
+ | --- | -------- |
67
+ | `Intents.none` | `[]` |
68
+ | `Intents.default` | `[Guilds]` |
69
+ | `Intents.guilds` | `[Guilds, GuildMembers]` |
70
+ | `Intents.messages` | `[Guilds, GuildMessages, MessageContent]` |
71
+ | `Intents.all` | Every intent (includes privileged). |
72
+
73
+ ### `type Registerable = SlashCommand | EventDef | ComponentDef | ScheduledTask | PrefixCommand | ContextMenuCommand`
74
+
75
+ The union accepted by `SpearClient.register`.
76
+
77
+ ---
78
+
79
+ ## Commands
80
+
81
+ ### `function command<O, R>(config): SlashCommand`
82
+
83
+ Define a leaf slash command.
84
+
85
+ ```ts
86
+ interface CommandConfig<O extends OptionMap, R> {
87
+ name: string;
88
+ description: string;
89
+ options?: O;
90
+ defaultMemberPermissions?: PermissionResolvable | null;
91
+ nsfw?: boolean;
92
+ guildOnly?: boolean;
93
+ nameLocalizations?: LocalizationMap;
94
+ descriptionLocalizations?: LocalizationMap;
95
+ cooldown?: CooldownInput;
96
+ guards?: readonly Guard[];
97
+ autoDefer?: AutoDeferInput;
98
+ run: (ctx: CommandContext<O>) => Awaitable<R>;
99
+ }
100
+ ```
101
+
102
+ ### `function commandGroup(config: CommandGroupConfig): SlashCommand`
103
+
104
+ Define a command that routes to subcommands and/or subcommand groups.
105
+
106
+ ```ts
107
+ interface CommandGroupConfig {
108
+ name: string;
109
+ description: string;
110
+ subcommands?: Record<string, Subcommand>;
111
+ groups?: Record<string, SubcommandGroup>;
112
+ defaultMemberPermissions?: PermissionResolvable | null;
113
+ nsfw?: boolean;
114
+ guildOnly?: boolean;
115
+ nameLocalizations?: LocalizationMap;
116
+ descriptionLocalizations?: LocalizationMap;
117
+ cooldown?: CooldownInput;
118
+ guards?: readonly Guard[];
119
+ autoDefer?: AutoDeferInput;
120
+ }
121
+ ```
122
+
123
+ ### `function subcommand<O, R>(config): Subcommand`
124
+
125
+ ```ts
126
+ interface SubcommandConfig<O extends OptionMap, R> {
127
+ description: string;
128
+ options?: O;
129
+ nameLocalizations?: LocalizationMap;
130
+ descriptionLocalizations?: LocalizationMap;
131
+ run: (ctx: CommandContext<O>) => Awaitable<R>;
132
+ }
133
+ ```
134
+
135
+ ### `function subcommandGroup(config: SubcommandGroupConfig): SubcommandGroup`
136
+
137
+ ```ts
138
+ interface SubcommandGroupConfig {
139
+ description: string;
140
+ subcommands: Record<string, Subcommand>;
141
+ nameLocalizations?: LocalizationMap;
142
+ descriptionLocalizations?: LocalizationMap;
143
+ }
144
+ ```
145
+
146
+ ### `class SlashCommand`
147
+
148
+ | Member | Type | Description |
149
+ | ------ | ---- | ----------- |
150
+ | `name` | `string` | Top-level command name. |
151
+ | `hasAutocomplete` | `boolean` | True if any option declares autocomplete. |
152
+ | `toJSON()` | `RESTPostAPIChatInputApplicationCommandsJSONBody` | REST payload. |
153
+ | `execute(interaction)` | `Promise<void>` | Run for a chat-input interaction. |
154
+ | `autocomplete(interaction)` | `Promise<void>` | Run autocomplete for the focused option. |
155
+ | `cooldown` | `CooldownConfig \| undefined` | Resolved cooldown, when set. |
156
+ | `guards` | `readonly Guard[] \| undefined` | Guards run before `execute`. |
157
+ | `autoDefer` | `AutoDeferConfig \| undefined` | Resolved auto-defer config, when set. |
158
+
159
+ ### `class CommandContext<O> extends BaseContext<ChatInputCommandInteraction>`
160
+
161
+ | Member | Type | Description |
162
+ | ------ | ---- | ----------- |
163
+ | `options` | `ResolvedOptions<O>` | Resolved, fully-typed option values. |
164
+ | `commandName` | `string` | Invoked command name. |
165
+ | `subcommand` | `string \| null` | Invoked subcommand, if any. |
166
+ | `showModal(modal)` | `Promise<void>` | Present a modal. |
167
+ | `awaitModal(modal, options?)` | `Promise<ModalSubmitInteraction \| null>` | Show a modal and await its submission (scoped to this user). |
168
+
169
+ Plus all `BaseContext` members.
170
+
171
+ ### `class CommandRegistry`
172
+
173
+ | Member | Type | Description |
174
+ | ------ | ---- | ----------- |
175
+ | `add(...commands: SlashCommand[])` | `this` | Register commands (override by name). |
176
+ | `remove(name: string)` | `boolean` | Remove a command. |
177
+ | `get(name: string)` | `SlashCommand \| undefined` | Look up a command. |
178
+ | `all()` | `SlashCommand[]` | All commands. |
179
+ | `names` | `string[]` | All command names. |
180
+ | `size` | `number` | Count. |
181
+ | `onError(handler: CommandErrorHandler)` | `this` | Set the error handler. |
182
+ | `toJSON()` | `RESTPostAPIApplicationCommandsJSONBody[]` | Serialise all commands. |
183
+ | `handle(interaction)` | `Promise<void>` | Dispatch a chat-input interaction. |
184
+ | `handleAutocomplete(interaction)` | `Promise<void>` | Dispatch an autocomplete interaction. |
185
+ | `deploy(options: DeployOptions)` | `Promise<DeployResult>` | Push commands to discord. |
186
+ | `setLogger(logger: Logger)` | `this` | Attach a debug logger for dispatch tracing. |
187
+ | `setCooldowns(manager: CooldownManager, default?: CooldownConfig)` | `this` | Wire a shared cooldown manager and optional default. |
188
+ | `setDefaultGuards(guards: readonly Guard[])` | `this` | Guards run before each command's own guards. |
189
+ | `setUsageHook(hook: (event: UsageEvent) => void)` | `this` | Called after each dispatch (success or error). |
190
+
191
+ ```ts
192
+ type CommandErrorHandler = (error: Error, interaction: ChatInputCommandInteraction) => Awaitable<void>;
193
+ interface DeployOptions { token?: string; applicationId: string; guildId?: string; rest?: REST; }
194
+ type DeployResult = RESTPutAPIApplicationCommandsResult | RESTPutAPIApplicationGuildCommandsResult;
195
+ ```
196
+
197
+ ---
198
+
199
+ ## Options
200
+
201
+ ### `const option`
202
+
203
+ Type-safe option builders. Each returns an `OptionDef` whose resolved value type
204
+ is inferred (required → value, optional → `value | undefined`, `choices` →
205
+ literal union).
206
+
207
+ | Builder | Resolved type | Extra config |
208
+ | ------- | ------------- | ------------ |
209
+ | `option.string(config)` | `string` | `choices?`, `minLength?`, `maxLength?`, `autocomplete?` |
210
+ | `option.integer(config)` | `number` | `choices?`, `minValue?`, `maxValue?`, `autocomplete?` |
211
+ | `option.number(config)` | `number` | `choices?`, `minValue?`, `maxValue?`, `autocomplete?` |
212
+ | `option.boolean(config)` | `boolean` | — |
213
+ | `option.user(config)` | `User` | — |
214
+ | `option.channel(config)` | channel union | `channelTypes?` |
215
+ | `option.role(config)` | `Role \| APIRole` | — |
216
+ | `option.mentionable(config)` | user/role/member | — |
217
+ | `option.attachment(config)` | `Attachment` | — |
218
+
219
+ Common config (`BaseConfig`):
220
+
221
+ ```ts
222
+ {
223
+ description: string;
224
+ required?: boolean; // default false
225
+ nameLocalizations?: LocalizationMap;
226
+ descriptionLocalizations?: LocalizationMap;
227
+ }
228
+ ```
229
+
230
+ `choices` items are `OptionChoice<V>`:
231
+
232
+ ```ts
233
+ interface OptionChoice<V extends string | number = string | number> {
234
+ name: string;
235
+ value: V;
236
+ nameLocalizations?: LocalizationMap;
237
+ }
238
+ ```
239
+
240
+ `autocomplete`:
241
+
242
+ ```ts
243
+ type AutocompleteHandler<V extends string | number> =
244
+ (ctx: AutocompleteContext) => Awaitable<OptionChoice<V>[]>;
245
+ ```
246
+
247
+ ### Option types
248
+
249
+ | Symbol | Description |
250
+ | ------ | ----------- |
251
+ | `interface OptionDef<TValue, TRequired>` | A described option (phantom-typed for inference). |
252
+ | `type AnyOptionDef` | `OptionDef<OptionValue, boolean>`. |
253
+ | `type OptionMap` | `Record<string, AnyOptionDef>`. |
254
+ | `type ResolvedOption<O>` | The handler value for one option. |
255
+ | `type ResolvedOptions<O>` | The handler's `options` object. |
256
+ | `type OptionValue` | Union of all possible resolved values. |
257
+ | `type AllowedChannelType` | Channel types valid for a channel option. |
258
+ | `function toAPIOption(name, def)` | Serialise one option to REST. |
259
+ | `function readOption(resolver, name, def)` | Read a resolved value (null → undefined). |
260
+ | `function optionsHaveAutocomplete(options)` | True if any option has autocomplete. |
261
+
262
+ ### `class AutocompleteContext`
263
+
264
+ | Member | Type | Description |
265
+ | ------ | ---- | ----------- |
266
+ | `interaction` | `AutocompleteInteraction` | Raw interaction. |
267
+ | `client` / `user` / `guild` / `guildId` | — | Convenience accessors. |
268
+ | `commandName` | `string` | Command being completed. |
269
+ | `focusedName` | `string` | Name of the focused option. |
270
+ | `value` | `string` | Current partial value typed by the user. |
271
+ | `respond(choices: OptionChoice[])` | `Promise<void>` | Send up to 25 suggestions. |
272
+
273
+ ---
274
+
275
+ ## Events
276
+
277
+ ### `function event(name, run): EventDef` / `function event(config): EventDef`
278
+
279
+ ```ts
280
+ type EventHandler<E extends keyof ClientEvents> = (...args: ClientEvents[E]) => Awaitable<void>;
281
+ interface EventConfig<E extends keyof ClientEvents> { name: E; once?: boolean; run: EventHandler<E>; }
282
+ interface EventDef { name: keyof ClientEvents; once: boolean; attach(client: Client): void; detach(client: Client): void; }
283
+ ```
284
+
285
+ Thrown errors and rejected promises are routed to the client's `error` event.
286
+
287
+ ### `class EventRegistry`
288
+
289
+ | Member | Type | Description |
290
+ | ------ | ---- | ----------- |
291
+ | `add(...defs: EventDef[])` | `this` | Register listeners. |
292
+ | `size` | `number` | Count. |
293
+ | `attachAll(client: Client)` | `void` | Attach every listener. |
294
+ | `detachAll(client: Client)` | `void` | Detach every listener. |
295
+
296
+ ---
297
+
298
+ ## Components
299
+
300
+ ### Builders
301
+
302
+ | Function | Returns | Notes |
303
+ | -------- | ------- | ----- |
304
+ | `button(config)` | `Button<P>` | Interactive button. |
305
+ | `linkButton(config)` | `ButtonBuilder` | URL button, no handler. |
306
+ | `stringSelect(config)` | `StringSelect<P>` | String select; takes `options`. |
307
+ | `userSelect(config)` | `UserSelect<P>` | User select. |
308
+ | `roleSelect(config)` | `RoleSelect<P>` | Role select. |
309
+ | `channelSelect(config)` | `ChannelSelect<P>` | Channel select; takes `channelTypes?`. |
310
+ | `mentionableSelect(config)` | `MentionableSelect<P>` | User + role select. |
311
+ | `modal(config)` | `Modal<P>` | Modal with `fields`. |
312
+ | `textInput(config)` | `TextInputDef` | A modal text-input field. |
313
+ | `row(...components)` | `ActionRowBuilder<C>` | Wrap components in a row. |
314
+
315
+ Each registrable component (`Button`, `StringSelect`, …, `Modal`) extends its
316
+ routing interface and adds `build(...args: BuildArgs<P>)`, which returns the
317
+ discord.js builder. `build` requires exactly the params declared in the id
318
+ pattern.
319
+
320
+ ```ts
321
+ interface ButtonConfig<P extends string, R> {
322
+ id: P; // pattern: "name" or "name:{param}"
323
+ label?: string;
324
+ style?: ButtonStyleInput; // "Primary" | "Secondary" | "Success" | "Danger" | ButtonStyle.*
325
+ emoji?: ComponentEmojiResolvable;
326
+ disabled?: boolean;
327
+ guards?: readonly Guard[];
328
+ run: (ctx: ButtonContext<Params<P>>) => Awaitable<R>;
329
+ }
330
+
331
+ interface LinkButtonConfig { url: string; label?: string; emoji?: ComponentEmojiResolvable; disabled?: boolean; }
332
+
333
+ interface StringSelectConfig<P extends string, R> {
334
+ id: P;
335
+ options: readonly SelectMenuComponentOptionData[];
336
+ placeholder?: string; minValues?: number; maxValues?: number; disabled?: boolean;
337
+ guards?: readonly Guard[];
338
+ run: (ctx: StringSelectContext<Params<P>>) => Awaitable<R>;
339
+ }
340
+
341
+ interface EntitySelectConfig<P extends string> {
342
+ id: P; placeholder?: string; minValues?: number; maxValues?: number; disabled?: boolean;
343
+ guards?: readonly Guard[];
344
+ }
345
+ // user/role/mentionable selects take EntitySelectConfig & { run };
346
+ // channelSelect additionally takes { channelTypes?: readonly ChannelType[] }.
347
+
348
+ function textInput(config: {
349
+ label: string;
350
+ style?: TextInputStyleInput; // "Short" | "Paragraph" | TextInputStyle
351
+ placeholder?: string; required?: boolean; minLength?: number; maxLength?: number; value?: string;
352
+ }): TextInputDef;
353
+
354
+ interface ModalConfig<P extends string, F extends Record<string, TextInputDef>, R> {
355
+ id: P;
356
+ title: string;
357
+ fields: F;
358
+ guards?: readonly Guard[];
359
+ run: (ctx: ModalContext<Params<P>, keyof F & string>) => Awaitable<R>;
360
+ }
361
+ ```
362
+
363
+ ### Component contexts
364
+
365
+ | Class | Extra members |
366
+ | ----- | ------------- |
367
+ | `MessageComponentContext<P, I>` | `params`, `customId`, `message`, `update(input)`, `deferUpdate()`, `showModal(modal)`, `awaitModal(modal, options?)` (+ BaseContext) |
368
+ | `ButtonContext<P>` | — |
369
+ | `StringSelectContext<P>` | `values: string[]`, `value: string \| undefined` |
370
+ | `UserSelectContext<P>` | `values`, `users`, `members` |
371
+ | `RoleSelectContext<P>` | `values`, `roles` |
372
+ | `ChannelSelectContext<P>` | `values`, `channels` |
373
+ | `MentionableSelectContext<P>` | `values`, `users`, `roles`, `members` |
374
+ | `ModalContext<P, F>` | `params`, `fields: Record<F, string>`, `customId` (+ BaseContext) |
375
+
376
+ ### `class ComponentRegistry`
377
+
378
+ | Member | Type | Description |
379
+ | ------ | ---- | ----------- |
380
+ | `add(...defs: ComponentDef[])` | `this` | Register components (override by namespace). |
381
+ | `onError(handler: ComponentErrorHandler)` | `this` | Set the error handler. |
382
+ | `size` | `number` | Count. |
383
+ | `handle(interaction: Interaction)` | `Promise<boolean>` | Route an interaction; `true` if matched. |
384
+ | `setLogger(logger: Logger)` | `this` | Debug logger for dispatch tracing. |
385
+ | `setUsageHook(hook: (event: UsageEvent) => void)` | `this` | Called after each component run (success or error). |
386
+ | `setDefaultGuards(guards: readonly Guard[])` | `this` | Guards run before each component's own guards. |
387
+
388
+ ```ts
389
+ type ComponentErrorHandler = (error: Error, interaction: RepliableInteraction) => Awaitable<void>;
390
+ type ComponentDef = ButtonRoute | StringSelectRoute | UserSelectRoute | RoleSelectRoute
391
+ | ChannelSelectRoute | MentionableSelectRoute | ModalRoute;
392
+ ```
393
+
394
+ ### Custom-id codec
395
+
396
+ | Symbol | Description |
397
+ | ------ | ----------- |
398
+ | `type ParamNames<S>` | Union of `{param}` names in a pattern. |
399
+ | `type Params<S>` | The params object a pattern resolves to. |
400
+ | `type BuildArgs<S>` | `build()` args (none when no params). |
401
+ | `const MAX_CUSTOM_ID_LENGTH` | `100`. |
402
+ | `function compilePattern(pattern)` | → `CompiledPattern { pattern, namespace, paramNames }`. |
403
+ | `function buildCustomId(compiled, params)` | Encode a concrete id. |
404
+ | `function parseCustomId(customId)` | → `ParsedCustomId { namespace, values }`. |
405
+ | `function paramsFromValues(paramNames, values)` | Map values onto names. |
406
+
407
+ ---
408
+
409
+ ## Contexts (shared)
410
+
411
+ ### `abstract class BaseContext<I>`
412
+
413
+ The base for every interaction context.
414
+
415
+ | Member | Type | Description |
416
+ | ------ | ---- | ----------- |
417
+ | `interaction` | `I` | Raw discord.js interaction. |
418
+ | `client` / `user` / `member` / `guild` / `guildId` / `channel` / `channelId` / `locale` | — | Accessors. |
419
+ | `deferred` / `replied` | `boolean` | Interaction state. |
420
+ | `reply(input)` | `Promise<InteractionResponse>` | Initial response. |
421
+ | `replyEphemeral(input)` | `Promise<InteractionResponse>` | Hidden reply. |
422
+ | `defer({ ephemeral? })` | `Promise<InteractionResponse>` | Acknowledge, respond later. |
423
+ | `editReply(input)` | `Promise<Message>` | Edit the response. |
424
+ | `followUp(input)` | `Promise<Message>` | Additional message. |
425
+ | `send(input)` | `Promise<void>` | State-aware reply/edit/followUp. |
426
+ | `error(input, options?)` | `Promise<void>` | State-aware preset error embed; defaults to ephemeral (pass `{ ephemeral: false }` to override). |
427
+ | `success` / `info` / `warn` `(input, options?)` | `Promise<void>` | State-aware preset embeds (green / blue / yellow). |
428
+ | `replyError(input, options?)` | `Promise<InteractionResponse>` | Initial-reply error embed; defaults to ephemeral. |
429
+ | `replySuccess` / `replyInfo` / `replyWarn` `(input, options?)` | `Promise<InteractionResponse>` | Initial-reply preset embeds. |
430
+ | `botPermissions` | `Readonly<PermissionsBitField>` | The bot's resolved permissions in the channel (zero-fetch). |
431
+ | `botMissing(required)` | `PermissionsString[]` | Permission names the bot is missing here. |
432
+ | `userMissing(required)` | `PermissionsString[]` | Permission names the invoking user is missing here. |
433
+ | `awaitMessageFrom(userId?, options?)` | `Promise<Message \| null>` | Wait for the next message from a user in this channel. |
434
+
435
+ ```ts
436
+ type ReplyData = InteractionReplyOptions & { ephemeral?: boolean };
437
+ type ReplyInput = string | ReplyData;
438
+ function normalizeReply(input: ReplyInput): InteractionReplyOptions;
439
+ function asEphemeral(input: ReplyInput): ReplyData;
440
+ ```
441
+
442
+ ---
443
+
444
+ ## Plugins
445
+
446
+ ```ts
447
+ interface SpearPlugin { name: string; setup(client: SpearClient): Awaitable<void>; }
448
+ function definePlugin(plugin: SpearPlugin): SpearPlugin;
449
+ ```
450
+
451
+ ---
452
+
453
+ ## Loading
454
+
455
+ ```ts
456
+ interface LoadOptions { extensions?: readonly string[]; recursive?: boolean; } // defaults: [.js,.mjs,.cjs], true
457
+ function collectModules(dir: string, options?: LoadOptions): Promise<Registerable[]>;
458
+ function loadInto(client: SpearClient, dir: string, options?: LoadOptions): Promise<number>;
459
+ ```
460
+
461
+ `SpearClient.load(dir, options?)` is the method form of `loadInto`.
462
+
463
+ ---
464
+
465
+ ## Added in 0.2
466
+
467
+ New subsystems, each with a dedicated guide. The `SpearClient` options
468
+ `{ logger?, dotenv?, cooldown?, prefix?, usage?, embeds?, guards? }` configure them.
469
+
470
+ ### Logging — [guide](./logging.md)
471
+
472
+ ```ts
473
+ class Logger { log(level, message, options?): void; debug/info/warn/error(message: string, options?: { error?: Error; data?: Record<string, LogValue> }): void; child(scope: string): Logger; setLevel(level: LogThreshold): this; enabled(level: LogLevel): boolean; addTransport(sink): this; setTransports(sinks): this; }
474
+ type LogLevel = "debug" | "info" | "warn" | "error";
475
+ type LogThreshold = LogLevel | "silent";
476
+ function consoleSink(entry: LogEntry): void;
477
+ function toError(value: unknown): Error;
478
+ // client.logger is a Logger; new SpearClient({ logger: { level: "debug" } })
479
+ ```
480
+
481
+ ### Environment — [guide](./env.md)
482
+
483
+ ```ts
484
+ function parseEnv(content: string): Record<string, string>;
485
+ function loadEnv(options?: { path?: string; override?: boolean }): Record<string, string>;
486
+ const env: { string(k, fallback?); number(k, fallback?); boolean(k, fallback?); require(k): string };
487
+ // client auto-loads .env on start(); disable/configure via the dotenv option
488
+ ```
489
+
490
+ ### Cooldowns — [guide](./cooldown.md)
491
+
492
+ ```ts
493
+ interface CooldownConfig { duration: number; scope?: "user" | "guild" | "channel" | "global"; exempt?: { users?: string[]; roles?: string[] }; overrides?: { users?: Record<string, number>; roles?: Record<string, number> }; message?: string | ((remainingMs: number) => string); }
494
+ class CooldownManager { consume(bucket, input, actor, now?); peek(...); reset(...); clear(); }
495
+ type CooldownInput = number | CooldownConfig; // a bare ms duration, or a full config
496
+ type CooldownScope = "user" | "guild" | "channel" | "global";
497
+ type CooldownResult = { allowed: true } | { allowed: false; remaining: number };
498
+ interface CooldownActor { userId; roleIds; guildId; channelId; } // also: CooldownExemptions, CooldownOverrides
499
+ function normalizeCooldown(input: CooldownInput): CooldownConfig;
500
+ function effectiveDuration(config: CooldownConfig, actor: CooldownActor): number | null; // null = exempt
501
+ function formatCooldownMessage(config: CooldownConfig, remainingMs: number): string;
502
+ // command({ cooldown: number | CooldownConfig }); new SpearClient({ cooldown }); client.cooldowns
503
+ ```
504
+
505
+ ### Scheduled tasks — [guide](./scheduler.md)
506
+
507
+ ```ts
508
+ function task(config: { name: string; cron?: string; interval?: number; runOnStart?: boolean; run: (client: SpearClient) => Awaitable<void> }): ScheduledTask;
509
+ function cron(expression: string): CronExpression; // .next(from?: Date): Date
510
+ class TaskScheduler { add/remove/list/size/active/start/stop/setLogger; delay/followUp/reconcile (see "Scheduler — one-shot + reconcile") }
511
+ // client.register(task(...)); client.schedule(config); client.scheduler
512
+ ```
513
+
514
+ ### Prefix commands — [guide](./prefix.md)
515
+
516
+ ```ts
517
+ function prefixCommand<TArgs, R>(config: { name: string; aliases?: readonly string[]; description?: string; cooldown?: CooldownInput; guards?: readonly Guard[]; args?: (a: PrefixArgsBuilder<{}>) => PrefixArgsBuilder<TArgs>; run: (ctx: PrefixContext<TArgs>) => Awaitable<R> }): PrefixCommand;
518
+ class PrefixContext<TArgs> { message; commandName; args: string[]; rest: string; options: TArgs; client; author; member; guild; guildId; channel; channelId; reply(content); send(content); }
519
+ // new SpearClient({ prefix: "!" | string[] | { prefix, mention?, ignoreBots?, caseInsensitive? } }); client.prefix
520
+ // reading others' content needs the privileged MessageContent intent (Intents.messages)
521
+ ```
522
+
523
+ ### Usage tracking — [guide](./usage.md)
524
+
525
+ ```ts
526
+ interface UsageEvent { type: UsageType; name: string; userId?; userTag?; guildId?; channelId?; detail?; outcome?: UsageOutcome; durationMs?: number; options?: Readonly<Record<string, UsageMetaValue>>; errorMessage?: string; timestamp: Date; }
527
+ type UsageType = "command" | "prefix" | "component" | "event";
528
+ type UsageOutcome = "success" | "error";
529
+ type UsageMetaValue = string | number | boolean | null;
530
+ function formatUsage(event: UsageEvent): string; // default channel-line renderer
531
+ interface UsageStore { record(event): Awaitable<void>; all(): Awaitable<readonly UsageEvent[]>; }
532
+ class MemoryUsageStore { record; all; size; byUser(id); clear; }
533
+ class JsonFileUsageStore { constructor(path: string); record; all; }
534
+ class UsageTracker { setStore(store); reportTo(channelId, format?); track(event); store; enabled; }
535
+ // new SpearClient({ usage: { store?, channel?, format? } }); client.usage
536
+ ```
537
+
538
+ ---
539
+
540
+ ## Added in 0.3
541
+
542
+ Driven by patterns repeated across long-running production bots: the role/
543
+ permission checks, `.catch(() => null)` fetches, embed factories, pagination
544
+ /confirm flows, mention/duration parsing, locks, config loaders and pluggable
545
+ log/usage transports a real Discord bot ends up writing.
546
+
547
+ ### Embeds — preset replies
548
+
549
+ ```ts
550
+ class Embeds { constructor(options?: EmbedsOptions); error(input); success(input); info(input); warn(input); build(level, input); readonly colors: EmbedColors; readonly icons: EmbedIcons; }
551
+ const defaultEmbeds: Embeds; // shared default used when `client.embeds` is unset
552
+ const DEFAULT_EMBED_COLORS: EmbedColors; // red / green / blue / yellow
553
+ const DEFAULT_EMBED_ICONS: EmbedIcons; // ⛔ ✅ ℹ️ ⚠️
554
+ // SpearClient owns one as `client.embeds`; configure via the `embeds` option.
555
+ // BaseContext gains ctx.success/info/warn/error (state-aware send) + replySuccess/replyInfo/replyWarn/replyError.
556
+ ```
557
+
558
+ ### Guards — declarative preconditions — [guide](./guards.md)
559
+
560
+ ```ts
561
+ type Guard<TCtx extends GuardContext = GuardContext> = (ctx: TCtx) => Awaitable<GuardResult>;
562
+ interface GuardContext { client; user; member; guild; guildId; channelId; }
563
+ type GuardResult = boolean | { allowed: false; reason?: string };
564
+ type RunGuardsResult = { allowed: true } | { allowed: false; reason: string | undefined };
565
+ function runGuards<TCtx extends GuardContext>(ctx: TCtx, guards?: readonly Guard<TCtx>[]): Promise<RunGuardsResult>;
566
+ function denied(reason?: string): GuardResult;
567
+ function guildOnly(reason?: string): Guard;
568
+ function dmOnly(reason?: string): Guard;
569
+ function requireAnyRole(roleIds: readonly string[], reason?: string): Guard;
570
+ function requireAllRoles(roleIds: readonly string[], reason?: string): Guard;
571
+ function requireOwner(ownerIds: readonly string[], reason?: string): Guard;
572
+ function requireUserPermissions(permission: PermissionResolvable, reason?: string): Guard;
573
+ function requireBotPermissions(permission: PermissionResolvable, reason?: string): Guard;
574
+ function guard<TCtx>(predicate: Guard<TCtx>): Guard<TCtx>;
575
+ // every built-in guard takes an optional custom `reason`; each has a sensible default message.
576
+ // per-handler: command({ guards: [...] }), prefixCommand({ guards }), button({ guards }), userCommand({ guards }), ...
577
+ // client-wide: new SpearClient({ guards: [...] })
578
+ ```
579
+
580
+ ### Context-menu commands — [guide](./context-menus.md)
581
+
582
+ ```ts
583
+ interface ContextMenuMeta { defaultMemberPermissions?: PermissionResolvable | null; nsfw?: boolean; guildOnly?: boolean; nameLocalizations?: LocalizationMap; cooldown?: CooldownInput; guards?: readonly Guard[]; autoDefer?: AutoDeferInput; }
584
+ function userCommand<R>(config: ContextMenuMeta & { name: string; run: (ctx: UserContextMenuContext) => Awaitable<R> }): UserContextMenu;
585
+ function messageCommand<R>(config: ContextMenuMeta & { name: string; run: (ctx: MessageContextMenuContext) => Awaitable<R> }): MessageContextMenu;
586
+ // UserContextMenuContext adds ctx.targetUser, ctx.targetMember; MessageContextMenuContext adds ctx.targetMessage (+ BaseContext).
587
+ // ContextMenuCommand = UserContextMenu | MessageContextMenu; client.contextMenus is a ContextMenuRegistry.
588
+ // Deploy slash commands + menus together with client.deployAllCommands({ guildId }).
589
+ ```
590
+
591
+ ### Prefix typed arguments
592
+
593
+ ```ts
594
+ function prefixArgs(): PrefixArgsBuilder<{}>;
595
+ // builder methods — each requires a `name` and takes an optional options object:
596
+ // .string(name, { required?, minLength?, maxLength?, default? }) -> string
597
+ // .integer(name, { required?, minValue?, maxValue?, default? }) -> number
598
+ // .number(name, { required?, minValue?, maxValue?, default? }) -> number
599
+ // .boolean(name, { required?, default? }) -> boolean
600
+ // .snowflake(name, { required?, default? }) -> string (accepts raw ids and <@u>/<#c>/<@&r> mentions)
601
+ // .duration(name, { required?, default? }) -> number ("1h30m" parsed to ms)
602
+ // .rest(name, { required?, default? }) -> string (remaining text)
603
+ // prefixCommand({ args: (a) => a.snowflake("target", { required: true }).duration("dur").rest("reason", { default: "No reason" }), run: (ctx) => ctx.options });
604
+ ```
605
+
606
+ ### Pagination + Confirmation
607
+
608
+ ```ts
609
+ function paginate<T>(interaction, items, { render, pageSize?, user?, timeoutMs?, controls?: "prev-next" | "first-prev-next-last", ephemeral?, namespace?, labels?: { first?; prev?; next?; last? } }): Promise<void>;
610
+ function buildPaginatorPage<T>(items, page, options): Promise<{ payload; pages }>;
611
+ function confirm(interaction, { body, title?, confirm?: { label?; style? }, cancel?: { label?; style? }, user?, timeoutMs?, ephemeral?, namespace? }): Promise<{ confirmed: boolean; reason: "confirm" | "cancel" | "timeout"; interaction? }>; // style: "Primary" | "Secondary" | "Success" | "Danger"
612
+ ```
613
+
614
+ ### Primitives
615
+
616
+ ```ts
617
+ class KeyedLock { constructor(options?: { ttl?: number; sweep?: number }); tryAcquire(key, ttl?); run(key, fn, { onBusy?, ttl? }); isHeld(key); forget(key); dispose(); readonly size: number; }
618
+ const safeFetch = { member, channel, message, user, guild, role, try }; // each returns T | null; also exported standalone as fetchMember/fetchChannel/fetchMessage/fetchUser/fetchGuild/fetchRole/safeTry
619
+ function withSafeTimeout<T>(p: Promise<T>, ms): Promise<T | null>;
620
+ function formatDuration(ms, opts?: { locale?: string | UnitLabels; largest?: number; units?: readonly DurationUnit[] }): string; // locale: "en"|"en-US"|"en-GB"|"tr"|"tr-TR" or a custom label set; unknown locales fall back to en
621
+ function parseDuration(input: string): number | null;
622
+ function discordTimestamp(date, style?: "t"|"T"|"d"|"D"|"f"|"F"|"R"): string;
623
+ function relativeTimestamp(date): string;
624
+ interface CacheStore { get; set; delete; has; increment; rateLimit; clear; }
625
+ class MemoryCache implements CacheStore { /* TTL, counter, fixed-window rate limit */ }
626
+ function createCache(): CacheStore; // default in-memory cache
627
+ function loadConfig<T>({ file, parser?, schema?, encoding? }): T;
628
+ function loadConfigAsync<T>(opts): Promise<T>;
629
+ function lookup<K, V>(table, resourceName?): (key: K) => V;
630
+ function lookupOptional<K, V>(table): (key: K) => V | undefined; // non-throwing variant of lookup
631
+ ```
632
+
633
+ ### Logger transports
634
+
635
+ ```ts
636
+ new Logger({ level, transports: [consoleSink, jsonlSink("./logs/bot.jsonl"), webhookSink({ url, minLevel: "error" })] });
637
+ function jsonlSink(path: string, { minLevel? }?): LogSink;
638
+ function webhookSink({ url, minLevel?, username? }): LogSink;
639
+ function consoleSink(entry: LogEntry): void; // default human-readable console transport
640
+ // Logger.addTransport(sink), setTransports([sinks])
641
+ ```
642
+
643
+ ### Scheduler — one-shot + reconcile
644
+
645
+ ```ts
646
+ client.scheduler.delay(name, ms, fn) -> { cancel(): boolean };
647
+ client.scheduler.followUp(name, [10_000, 30_000, 60_000], (i) => ...) -> { cancel(): boolean };
648
+ client.scheduler.reconcile("voice-sessions", async (client) => { /* once on ready */ });
649
+ ```
650
+
651
+ ### Deploy diff + dry run
652
+
653
+ ```ts
654
+ client.deployAllCommands({ guildId, dryRun: true }); // returns { skipped, body, reason: "dry-run" }
655
+ client.deployAllCommands({ guildId, strategy: "diff" }); // skips PUT when remote matches
656
+ client.deployAllCommands({ applicationId: "...", strategy: "diff" }); // explicit app id, no ready required
657
+ ```
658
+ ---
659
+
660
+ ## Added in 0.4
661
+
662
+ Reliability and moderation helpers distilled from production bots: never lose an
663
+ interaction to the 3-second window, shut down cleanly, run permission/hierarchy
664
+ preflights, persist per-guild settings, and await replies without hand-rolled
665
+ collectors.
666
+
667
+ ### Auto-defer — [guide](./auto-defer.md)
668
+
669
+ ```ts
670
+ type AutoDeferInput = boolean | { ephemeral?: boolean; delayMs?: number };
671
+ interface AutoDeferConfig { ephemeral: boolean; delayMs: number; }
672
+ const DEFAULT_AUTO_DEFER_DELAY_MS = 2000;
673
+ function normalizeAutoDefer(input?: AutoDeferInput): AutoDeferConfig | undefined;
674
+ function armAutoDefer(interaction, config: AutoDeferConfig): () => void; // returns a cancel fn
675
+ type AutoDeferrableInteraction = ChatInputCommandInteraction | UserContextMenuCommandInteraction | MessageContextMenuCommandInteraction;
676
+ // Enable per handler: command({ autoDefer: true }), userCommand({ autoDefer }), messageCommand({ autoDefer })
677
+ // Or globally: new SpearClient({ autoDefer: true }). With it on, respond via ctx.send / ctx.editReply.
678
+ // Arms a timer when the handler starts; defers if it hasn't responded by ~2s, preventing "Unknown interaction" (10062).
679
+ ```
680
+
681
+ ### Graceful shutdown
682
+
683
+ ```ts
684
+ interface GracefulShutdownOptions {
685
+ signals?: readonly NodeJS.Signals[]; // default ["SIGINT", "SIGTERM"]
686
+ timeoutMs?: number; // force-exit after this; default 10000
687
+ exit?: boolean; // call process.exit when done; default true
688
+ onShutdown?: (signal: NodeJS.Signals) => Awaitable<void>; // runs before client.destroy()
689
+ logger?: { info?(msg): void; error?(msg, meta?): void };
690
+ }
691
+ interface Destroyable { destroy(): Awaitable<void>; } // a discord.js Client qualifies
692
+ interface ShutdownLogger { info?(message: string): void; error?(message: string, meta?: unknown): void; }
693
+ function gracefulShutdown(client: Destroyable, options?: GracefulShutdownOptions): () => void;
694
+ // SpearClient.enableGracefulShutdown(options?) wires it with client.logger and returns a disposer.
695
+ ```
696
+
697
+ ### Permissions & moderation — [guide](./permissions.md)
698
+
699
+ ```ts
700
+ type PermissionHolder = GuildMember | Role;
701
+ function missingPermissions(channel: GuildBasedChannel, who: PermissionHolder, required: PermissionResolvable): PermissionsString[];
702
+ function botMissingPermissions(channel: GuildBasedChannel, required: PermissionResolvable): PermissionsString[];
703
+ function hasPermissions(channel: GuildBasedChannel, who: PermissionHolder, required: PermissionResolvable): boolean;
704
+ function compareRoles(a: GuildMember, b: GuildMember): number; // by highest-role position
705
+ function canActOn(actor: GuildMember, target: GuildMember): boolean;
706
+ function formatPermissions(permissions: PermissionResolvable): string; // human, comma-separated
707
+
708
+ type ModerationCheckResult = { ok: true } | { ok: false; reason: string };
709
+ interface ModerationCheckOptions { moderator: GuildMember; target: GuildMember; me?: GuildMember | null; action?: string; }
710
+ function moderationCheck(options: ModerationCheckOptions): ModerationCheckResult; // self / owner / role-hierarchy preflight
711
+ ```
712
+
713
+ ### Persistent storage
714
+
715
+ ```ts
716
+ interface KeyValueStore {
717
+ get<T>(key: string): Promise<T | undefined>;
718
+ set<T>(key: string, value: T): Promise<void>;
719
+ has(key: string): Promise<boolean>;
720
+ delete(key: string): Promise<boolean>;
721
+ keys(): Promise<string[]>;
722
+ clear(): Promise<void>;
723
+ }
724
+ class MemoryStore implements KeyValueStore { /* deep-cloned in-memory */ }
725
+ class JsonStore implements KeyValueStore { constructor(path: string); /* atomic JSON file */ }
726
+ function namespaced(store: KeyValueStore, prefix: string): KeyValueStore;
727
+
728
+ interface SettingsManager<T> { readonly defaults: T; readonly store: KeyValueStore; get(id): Promise<T>; set(id, patch: Partial<T>): Promise<T>; reset(id): Promise<void>; }
729
+ interface CreateSettingsOptions<T> { store: KeyValueStore; defaults: T; namespace?: string; } // namespace default "settings"
730
+ function createSettings<T extends Record<string, unknown>>(options: CreateSettingsOptions<T>): SettingsManager<T>;
731
+ ```
732
+
733
+ ### Collectors
734
+
735
+ ```ts
736
+ interface AwaitMessageOptions { filter?: (m: Message) => boolean; time?: number; } // time default 60000
737
+ function awaitMessage(channel: CollectableChannel, options?: AwaitMessageOptions): Promise<Message | null>;
738
+ interface AwaitComponentOptions { filter?; time?; componentType?: ComponentType; } // time default 60000
739
+ function awaitComponent(message: Message, options?: AwaitComponentOptions): Promise<MessageComponentInteraction | null>;
740
+ interface AwaitModalOptions { time?: number; filter?: (i: ModalSubmitInteraction) => boolean; } // time default 120000
741
+ function showAndAwaitModal(interaction: ModalShowingInteraction, modal: ModalLike, options?: AwaitModalOptions): Promise<ModalSubmitInteraction | null>;
742
+ // Context sugar: ctx.awaitMessageFrom(userId?, options?) and ctx.awaitModal(modal, options?) (command + component contexts).
743
+ ```
744
+
745
+ ### Discord errors — [guide](./errors.md)
746
+
747
+ ```ts
748
+ const DiscordErrorCode = { UnknownChannel, UnknownGuild, UnknownMember, UnknownMessage, UnknownUser,
749
+ UnknownInteraction, MissingAccess, CannotExecuteActionOnDMChannel, CannotSendMessagesToThisUser,
750
+ MissingPermissions, InvalidFormBodyOrContentType, InteractionHasAlreadyBeenAcknowledged,
751
+ MaximumNumberOfGuildsReached, MaximumNumberOfReactionsReached } as const; // named RESTJSONErrorCodes
752
+ type DiscordErrorCodeValue = (typeof DiscordErrorCode)[keyof typeof DiscordErrorCode];
753
+ function isDiscordError(error: unknown, code?: number | string | readonly (number | string)[]): error is DiscordAPIError;
754
+ function isHTTPError(error: unknown): error is HTTPError;
755
+ function isRateLimitError(error: unknown): boolean; // HTTP 429
756
+ function explainDiscordError(error: unknown): string | null; // end-user-friendly sentence, or null
757
+ // The default command/component error reply uses explainDiscordError(...) when it can.
758
+ ```
759
+
760
+ ### Message formatting
761
+
762
+ ```ts
763
+ const MESSAGE_CHARACTER_LIMIT = 2000;
764
+ function truncate(text: string, max: number, suffix?: string): string; // suffix default "…"
765
+ interface ChunkOptions { max?: number; } // default MESSAGE_CHARACTER_LIMIT
766
+ function chunkMessage(text: string, options?: ChunkOptions): string[]; // splits on line/word boundaries
767
+ ```
768
+
769
+ ### Dynamic prefixes
770
+
771
+ ```ts
772
+ // PrefixOptions gains a per-message resolver (e.g. a per-guild prefix from a store):
773
+ interface PrefixOptions { /* …prefix, mention, ignoreBots, caseInsensitive… */
774
+ dynamic?: (message: Message) => Awaitable<string | readonly string[] | null | undefined>;
775
+ }
776
+ // Dynamic prefixes are tried in addition to any static prefix. Keep the resolver fast (cache it).
777
+ ```