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,247 @@
1
+ ---
2
+ name: spearkit
3
+ description: >-
4
+ Write and edit Discord bots built with the spearkit package ("discord.js++"):
5
+ type-safe slash commands, options, subcommands, autocomplete, buttons / select
6
+ menus / modals with custom-id routing, events, cooldowns, scheduled tasks,
7
+ prefix commands, guards, context menus, pagination / confirm, preset embeds,
8
+ structured logging, usage tracking and dotenv. Use whenever a file imports from
9
+ "spearkit", or when asked to build or modify a Discord bot, slash command,
10
+ button, select menu, modal, autocomplete, or interaction handler in a spearkit
11
+ project.
12
+ ---
13
+
14
+ # spearkit
15
+
16
+ spearkit is a developer-experience-first layer over [discord.js](https://discord.js.org).
17
+ It **re-exports the entire discord.js surface** (so it is a drop-in replacement)
18
+ and adds a fully type-safe API: handlers never see `any`/`unknown`. Install with
19
+ `npm install spearkit discord.js`.
20
+
21
+ When a task needs more than this file, load
22
+ [`reference/cheatsheet.md`](reference/cheatsheet.md) (every exported symbol + ready
23
+ recipes). The package also ships `llms-full.txt` (all docs in one file) and a
24
+ `docs/` folder; in a consumer project they sit under `node_modules/spearkit/`.
25
+
26
+ ## Non-negotiable rules
27
+
28
+ 1. **Import only from `"spearkit"`** — spearkit's helpers *and* every discord.js
29
+ symbol (`Client`, `EmbedBuilder`, `GatewayIntentBits`, `REST`, `Routes`,
30
+ `PermissionFlagsBits`, …). Never add a separate `import … from "discord.js"`.
31
+ 2. **Use `SpearClient`** (extends discord.js `Client`; routes interactions).
32
+ `intents` is optional → defaults to `Intents.default` (`[Guilds]`).
33
+ 3. **Co-locate** definition + handler in one object; `client.register(...)` it.
34
+ **Never** write an `interactionCreate` switch or parse custom ids by hand —
35
+ spearkit routes commands by name and components by custom-id namespace.
36
+ 4. **Lifecycle order:** `register(...)` → `await client.start(token)` →
37
+ `await client.deployCommands({ guildId })`. Deploy **after** `start()`; deploy
38
+ only when command *definitions* change (not every restart).
39
+ 5. **The ready event is `clientReady`**, not `ready` (discord.js v14.16 rename).
40
+ 6. **Trust inference.** Required options are non-nullable, optional ones are
41
+ `T | undefined`, `choices` narrow to a literal union, custom-id `{param}`s and
42
+ modal field keys are typed. Do not cast; do not annotate handler args.
43
+ 7. **Hidden replies:** `ctx.replyEphemeral(...)` or `ctx.reply({ content, ephemeral: true })`
44
+ (spearkit normalizes `ephemeral`). Do not hand-set message flags.
45
+
46
+ ## Minimal bot
47
+
48
+ ```ts
49
+ import { SpearClient, Intents, command, option, event } from "spearkit";
50
+
51
+ const client = new SpearClient({ intents: Intents.default });
52
+
53
+ const greet = command({
54
+ name: "greet",
55
+ description: "Greet someone",
56
+ options: { who: option.user({ description: "Who", required: true }) },
57
+ run: (ctx) => ctx.reply(`Hello ${ctx.options.who}!`), // ctx.options.who: User
58
+ });
59
+
60
+ client.register(greet, event("clientReady", (c) => console.log(c.user.tag)));
61
+ await client.start(process.env.DISCORD_TOKEN); // falls back to DISCORD_TOKEN
62
+ await client.deployCommands({ guildId: process.env.GUILD_ID }); // omit guildId → global (slow)
63
+ ```
64
+
65
+ ## Pick the right tool
66
+
67
+ - **Slash command** → `command()`; **typed inputs** → `option.*`; **grouped** → `commandGroup` + `subcommand`; **type-ahead** → `option.string({ autocomplete })`.
68
+ - **Right-click on a user/message** → `userCommand` / `messageCommand`; **`!text` command** → `prefixCommand` (+ typed `args`).
69
+ - **Button** → `button`; **URL button** → `linkButton`; **dropdown** → `stringSelect`; **pick user/role/channel/mentionable** → `userSelect` / `roleSelect` / `channelSelect` / `mentionableSelect`; **form** → `modal` + `textInput`; **carry data** → custom-id `{param}`.
70
+ - **Paged list** → `paginate`; **yes/no gate** → `confirm`.
71
+ - **Reply** → `ctx.reply` / `replyEphemeral`; **>3s work** → `ctx.defer()` then `editReply`; **styled embed** → `ctx.success/error/info/warn`.
72
+ - **Gateway events** → `event(...)`; **rate-limit** → `cooldown`; **role/permission/owner gate** → guards; **cron/interval** → `task` / `client.schedule`; **logs** → `client.logger` + sinks; **usage tracking** → `usage`; **typed env / `.env`** → `env.*`.
73
+ - **Reusable bundle** → `definePlugin` + `client.use`; **file-per-handler** → `client.load`; **deploy** → `client.deployCommands` / `deployAllCommands`.
74
+ - **Primitives** — per-key lock → `KeyedLock`; null-safe fetch → `safeFetch.*`; durations → `formatDuration` / `parseDuration`; timestamps → `discordTimestamp`; cache / rate-limit → `MemoryCache`; config files → `loadConfig`.
75
+
76
+ ## Recipes
77
+
78
+ ### Slash command with typed options
79
+
80
+ ```ts
81
+ const echo = command({
82
+ name: "echo",
83
+ description: "Repeat a message",
84
+ options: {
85
+ text: option.string({ description: "What to say", required: true }), // string
86
+ times: option.integer({ description: "Count", minValue: 1, maxValue: 5 }),// number | undefined
87
+ mode: option.string({
88
+ description: "Visibility",
89
+ choices: [{ name: "Everyone", value: "public" }, { name: "Just me", value: "private" }],
90
+ }), // "public" | "private" | undefined
91
+ },
92
+ run: (ctx) =>
93
+ ctx.reply({
94
+ content: ctx.options.text.repeat(ctx.options.times ?? 1),
95
+ ephemeral: ctx.options.mode === "private",
96
+ }),
97
+ });
98
+ ```
99
+
100
+ Option builders: `option.string|integer|number|boolean|user|channel|role|mentionable|attachment`.
101
+ Co-located autocomplete: `option.string({ autocomplete: (ctx) => choices.filter(c => c.startsWith(ctx.value)).map(c => ({ name: c, value: c })) })`.
102
+
103
+ ### Subcommands
104
+
105
+ ```ts
106
+ import { commandGroup, subcommand, subcommandGroup, option } from "spearkit";
107
+
108
+ commandGroup({
109
+ name: "admin", description: "Admin tools", guildOnly: true,
110
+ subcommands: {
111
+ say: subcommand({
112
+ description: "Speak",
113
+ options: { message: option.string({ description: "Text", required: true }) },
114
+ run: (ctx) => ctx.reply(ctx.options.message),
115
+ }),
116
+ },
117
+ groups: {
118
+ users: subcommandGroup({
119
+ description: "Manage users",
120
+ subcommands: {
121
+ ban: subcommand({
122
+ description: "Ban", options: { target: option.user({ description: "Who", required: true }) },
123
+ run: (ctx) => ctx.reply(`Banned ${ctx.options.target.tag}`),
124
+ }),
125
+ },
126
+ }),
127
+ },
128
+ });
129
+ ```
130
+
131
+ ### Components (button / select / modal)
132
+
133
+ ```ts
134
+ import { button, stringSelect, modal, textInput, row } from "spearkit";
135
+
136
+ const vote = button({
137
+ id: "vote:{choice}", // {choice} → typed param
138
+ label: "Yes", style: "Success", // "Primary" | "Secondary" | "Success" | "Danger"
139
+ run: (ctx) => ctx.update(`You chose ${ctx.params.choice}`), // ctx.params.choice: string
140
+ });
141
+
142
+ const colour = stringSelect({
143
+ id: "colour",
144
+ options: [{ label: "Red", value: "red" }, { label: "Blue", value: "blue" }],
145
+ run: (ctx) => ctx.replyEphemeral(ctx.values.join(", ")), // ctx.values: string[]
146
+ });
147
+
148
+ const feedback = modal({
149
+ id: "feedback:{ticket}",
150
+ title: "Feedback",
151
+ fields: {
152
+ summary: textInput({ label: "Summary", required: true }),
153
+ detail: textInput({ label: "Details", style: "Paragraph" }),
154
+ },
155
+ run: (ctx) => ctx.reply(`#${ctx.params.ticket}: ${ctx.fields.summary}`), // params + fields typed
156
+ });
157
+
158
+ client.register(vote, colour, feedback);
159
+
160
+ // build() requires exactly the {param}s the id declares (ids cap at 100 chars):
161
+ await channel.send({ content: "Choose:", components: [row(vote.build({ choice: "yes" })), row(colour.build())] });
162
+ ```
163
+
164
+ Component builders: `button`, `linkButton`, `stringSelect`, `userSelect`,
165
+ `roleSelect`, `channelSelect`, `mentionableSelect`, `modal` (+ `textInput`), `row`.
166
+ Component context: `ctx.params`, `ctx.update`, `ctx.deferUpdate`, `ctx.showModal`,
167
+ `ctx.message`; selects add `ctx.values` (+ `ctx.users/roles/channels/members`);
168
+ modals add `ctx.fields`.
169
+
170
+ ### Guards, cooldown, context menus, pagination/confirm
171
+
172
+ ```ts
173
+ import { command, requireAnyRole, guildOnly, userCommand, paginate, confirm } from "spearkit";
174
+
175
+ const purge = command({
176
+ name: "purge", description: "Delete messages",
177
+ guards: [guildOnly(), requireAnyRole(["MOD_ROLE_ID"])],
178
+ cooldown: { duration: 10_000, scope: "user" },
179
+ run: (ctx) => ctx.replySuccess("Done"),
180
+ });
181
+
182
+ const report = userCommand({ name: "Report", run: (ctx) => ctx.replyEphemeral(`Reported ${ctx.targetUser.tag}`) });
183
+
184
+ await paginate(ctx.interaction, items, {
185
+ pageSize: 10,
186
+ render: (slice, { page, pages }) =>
187
+ new EmbedBuilder().setTitle(`Page ${page + 1}/${pages}`).setDescription(slice.join("\n")),
188
+ });
189
+
190
+ const { confirmed } = await confirm(ctx.interaction, {
191
+ body: "Reset everything?", confirm: { label: "Reset", style: "Danger" },
192
+ });
193
+ if (!confirmed) return ctx.error("Cancelled.");
194
+ ```
195
+
196
+ Guards usable on `command`/`prefixCommand`/`button`/`userCommand`/`messageCommand`
197
+ configs, or client-wide via `new SpearClient({ guards: [...] })`.
198
+
199
+ ## Context (every handler)
200
+
201
+ `reply` · `replyEphemeral` · `defer({ ephemeral? })` · `editReply` · `followUp` ·
202
+ `send` (state-aware) · `error(msg)` · preset embeds `success/info/warn/error`
203
+ (+ `replySuccess/Info/Warn/Error`) · accessors `client/user/member/guild/guildId/
204
+ channel/channelId/locale` · state `deferred/replied`. Commands add `ctx.options`,
205
+ `ctx.commandName`, `ctx.subcommand`, `ctx.showModal`.
206
+
207
+ ## Subsystems (configured on `SpearClient` options)
208
+
209
+ - **Cooldowns** — `command({ cooldown: number | CooldownConfig })` or
210
+ `new SpearClient({ cooldown })`; scopes `user|guild|channel|global`, plus
211
+ `exempt`/`overrides`.
212
+ - **Scheduled tasks** — `task({ name, cron?, interval?, runOnStart?, run })`,
213
+ `client.schedule(...)`, `client.scheduler.delay/followUp/reconcile`, `cron(expr)`.
214
+ - **Prefix commands** — `prefixCommand({ name, aliases?, cooldown?, guards?, args?, run })`
215
+ + `new SpearClient({ prefix: "!" })`. Typed args:
216
+ `args: (a) => a.snowflake("target").duration("d").rest("reason")` → `ctx.options`.
217
+ Reading **other users'** content needs the privileged `MessageContent` intent —
218
+ use `Intents.messages` and enable it in the Developer Portal.
219
+ - **Context menus** — `userCommand`/`messageCommand`; deploy with slash commands
220
+ via `client.deployAllCommands({ guildId, strategy: "diff", dryRun? })`.
221
+ - **Logging** — `client.logger`; `new SpearClient({ logger: { level, transports: [consoleSink, jsonlSink(path), webhookSink({ url })] } })`.
222
+ - **Usage tracking** — `new SpearClient({ usage: { store?, channel?, format? } })`;
223
+ `MemoryUsageStore`, `JsonFileUsageStore`.
224
+ - **Env** — `.env` auto-loads on `start()`; read with `env.string/number/boolean/require`.
225
+ - **Plugins** — `definePlugin({ name, setup(client) })`, then `await client.use(plugin)`.
226
+ - **File-based loading** — `await client.load(dir)`. Imports **compiled JS**
227
+ (default extensions `.js/.mjs/.cjs`); build before running compiled output.
228
+ - **Primitives** — `KeyedLock`, `safeFetch.{member,channel,message,user,guild,role,try}`,
229
+ `formatDuration`/`parseDuration`/`discordTimestamp`/`relativeTimestamp`,
230
+ `MemoryCache`, `loadConfig`.
231
+
232
+ ## Mistakes to avoid
233
+
234
+ - Importing from `"discord.js"` (use `"spearkit"`).
235
+ - Listening on `"ready"` instead of `"clientReady"`.
236
+ - `deployCommands()` before `start()`, or deploying on every restart.
237
+ - Hand-rolling an `interactionCreate` switch or splitting custom ids manually.
238
+ - Passing wrong/missing params to `component.build(...)` (typed; pass exactly the
239
+ declared `{param}`s).
240
+ - Forgetting the `MessageContent` intent when prefix commands must read other
241
+ users' messages.
242
+
243
+ ## Verify your work
244
+
245
+ In a spearkit repo, run `npm run typecheck` (and `npm test`) — option values,
246
+ custom-id params and modal fields are statically checked, so a type error usually
247
+ means the definition and handler disagree.
@@ -0,0 +1,329 @@
1
+ # spearkit cheatsheet
2
+
3
+ Condensed map of every spearkit export. All importable from `"spearkit"` alongside
4
+ the full discord.js surface. For prose and edge cases, read the package's
5
+ `llms-full.txt` or `docs/`.
6
+
7
+ ## Use cases — reach for
8
+
9
+ **Bot setup & lifecycle**
10
+
11
+ | Want to… | Reach for |
12
+ | --- | --- |
13
+ | Start a bot and connect | `new SpearClient({ intents })` + `await client.start(token)` |
14
+ | Choose gateway intents | `Intents.none / default / guilds / messages / all` |
15
+ | Wire up handlers | `client.register(...)`; one file per handler → `client.load(dir)` |
16
+ | Push commands to Discord | `client.deployCommands({ guildId })`; slash + menus, safe CI → `client.deployAllCommands({ strategy: "diff", dryRun })` |
17
+ | Package a reusable feature | `definePlugin(...)` + `client.use(...)` |
18
+ | Migrate an existing discord.js bot | import from `"spearkit"`, swap `Client` → `SpearClient` |
19
+
20
+ **Commands & input**
21
+
22
+ | Want to… | Reach for |
23
+ | --- | --- |
24
+ | A slash command | `command({ name, description, run })` |
25
+ | Typed inputs to a command | `options: { x: option.string/integer/number/boolean/user/channel/role/mentionable/attachment(...) }` |
26
+ | Group many commands under one name | `commandGroup` + `subcommand` / `subcommandGroup` |
27
+ | Suggest values while the user types | `option.string({ autocomplete })` |
28
+ | A right-click "Apps" action on a user/message | `userCommand` / `messageCommand` |
29
+ | A classic `!text` command | `prefixCommand(...)` + `new SpearClient({ prefix })` |
30
+ | Parse `!cmd` arguments into typed values | `args: (a) => a.snowflake().duration().rest()` → `ctx.options` |
31
+
32
+ **Interactivity (components)**
33
+
34
+ | Want to… | Reach for |
35
+ | --- | --- |
36
+ | A clickable button | `button({ id, run })` → `row(btn.build(...))` |
37
+ | A URL button (no handler) | `linkButton` |
38
+ | A dropdown of fixed options | `stringSelect` |
39
+ | Pick users / roles / channels / mentionables | `userSelect` / `roleSelect` / `channelSelect` / `mentionableSelect` |
40
+ | A form with text fields | `modal` + `textInput` |
41
+ | Carry data through a component | custom-id params `id: "x:{id}"` → `ctx.params.id` |
42
+ | A paged list with next/prev | `paginate(...)` |
43
+ | An "Are you sure?" yes/no gate | `confirm(...)` |
44
+
45
+ **Replies & UX**
46
+
47
+ | Want to… | Reach for |
48
+ | --- | --- |
49
+ | Reply, public or hidden | `ctx.reply(...)` / `ctx.replyEphemeral(...)` |
50
+ | Work that takes >3s | `ctx.defer()` then `ctx.editReply(...)` |
51
+ | A styled success/error/info/warn embed | `ctx.success/error/info/warn(...)` |
52
+ | "Reply, edit, or follow-up — whichever fits" | `ctx.send(...)` |
53
+
54
+ **Cross-cutting concerns**
55
+
56
+ | Want to… | Reach for |
57
+ | --- | --- |
58
+ | React to gateway events | `event(name, run)`; once on startup → `event("clientReady", ...)` |
59
+ | Rate-limit a command/handler | `cooldown` (per-command or client-wide) |
60
+ | Restrict by role / permission / owner / guild | guards: `requireAnyRole` / `requireUserPermissions` / `requireOwner` / `guildOnly` |
61
+ | Run jobs on cron or interval | `task({ cron \| interval })` / `client.schedule(...)` |
62
+ | Delay once / staged follow-ups / recover on restart | `client.scheduler.delay` / `followUp` / `reconcile` |
63
+ | Structured logs to file/webhook | `client.logger` + `consoleSink` / `jsonlSink` / `webhookSink` |
64
+ | Track who used what | `new SpearClient({ usage })` + `MemoryUsageStore` / `JsonFileUsageStore` |
65
+ | Read typed env / load `.env` | `env.string/number/boolean/require` (auto-loaded on `start()`) |
66
+
67
+ **Utilities (primitives)**
68
+
69
+ | Want to… | Reach for |
70
+ | --- | --- |
71
+ | Stop concurrent runs per key (e.g. per user) | `KeyedLock` |
72
+ | Fetch that returns `null` instead of throwing | `safeFetch.{member,channel,message,user,guild,role}` |
73
+ | Format/parse `"1h30m"` durations | `formatDuration` / `parseDuration` |
74
+ | Render `<t:…>` Discord timestamps | `discordTimestamp` / `relativeTimestamp` |
75
+ | In-memory cache / counters / rate-limit window | `MemoryCache` |
76
+ | Load JSON/JSON5/YAML config | `loadConfig` |
77
+
78
+ ## Client
79
+
80
+ ```ts
81
+ new SpearClient(options?: Partial<ClientOptions> & SpearOptions) // intents optional → Intents.default; SpearOptions: logger/dotenv/cooldown/prefix/usage/embeds/guards
82
+ client.commands // CommandRegistry
83
+ client.events // EventRegistry
84
+ client.components // ComponentRegistry
85
+ client.register(...items) // route SlashCommand | EventDef | ComponentDef | PrefixCommand | ContextMenu | ScheduledTask
86
+ client.use(...plugins): Promise<this>
87
+ client.load(dir, options?): Promise<number> // imports compiled JS (.js/.mjs/.cjs)
88
+ client.start(token?): Promise<this> // login; falls back to DISCORD_TOKEN
89
+ client.deployCommands({ guildId? }): Promise<DeployResult> // after start()
90
+ client.deployAllCommands({ guildId?, applicationId?, strategy?: "diff", dryRun? }) // slash + context menus
91
+ client.schedule(taskConfig); client.scheduler // TaskScheduler
92
+ client.enableGracefulShutdown({ onShutdown?, timeoutMs? }): () => void // clean SIGINT/SIGTERM teardown
93
+ client.cooldowns; client.prefix; client.usage; client.logger; client.embeds; client.contextMenus
94
+ new SpearClient({ autoDefer: true }) // default auto-defer for slash + context-menu handlers
95
+
96
+ const Intents = { none: [], default: [Guilds], guilds: [Guilds, GuildMembers],
97
+ messages: [Guilds, GuildMessages, MessageContent], all: /* every intent */ }
98
+ ```
99
+
100
+ ## Commands & options
101
+
102
+ ```ts
103
+ command({ name, description, options?, defaultMemberPermissions?, nsfw?, guildOnly?,
104
+ nameLocalizations?, descriptionLocalizations?, guards?, cooldown?, autoDefer?, run })
105
+ commandGroup({ name, description, subcommands?, groups?, defaultMemberPermissions?, nsfw?, guildOnly?, ... })
106
+ subcommand({ description, options?, run, ... })
107
+ subcommandGroup({ description, subcommands, ... })
108
+
109
+ option.string({ description, required?, choices?, minLength?, maxLength?, autocomplete? }) // string
110
+ option.integer({ description, required?, choices?, minValue?, maxValue?, autocomplete? }) // number
111
+ option.number(...) // number
112
+ option.boolean(...) // boolean
113
+ option.user(...) // User
114
+ option.channel({ ..., channelTypes? }) // channel union
115
+ option.role(...) // Role | APIRole
116
+ option.mentionable(...) // user/role/member
117
+ option.attachment(...) // Attachment
118
+
119
+ // choices: { name, value, nameLocalizations? }[] (value narrows the resolved type to a literal union)
120
+ // autocomplete: (ctx: AutocompleteContext) => Awaitable<OptionChoice[]> ctx.value / ctx.respond(...)
121
+ ```
122
+
123
+ `CommandContext`: `options`, `commandName`, `subcommand`, `showModal(modal)` + BaseContext.
124
+
125
+ ## Components
126
+
127
+ ```ts
128
+ button({ id, label?, style?, emoji?, disabled?, guards?, run }) // style: "Primary"|"Secondary"|"Success"|"Danger"|ButtonStyle.*
129
+ linkButton({ url, label?, emoji?, disabled? }) // returns ButtonBuilder (no handler)
130
+ stringSelect({ id, options, placeholder?, minValues?, maxValues?, disabled?, guards?, run })
131
+ userSelect / roleSelect / mentionableSelect ({ id, placeholder?, minValues?, maxValues?, disabled?, guards?, run })
132
+ channelSelect({ id, channelTypes?, placeholder?, minValues?, maxValues?, disabled?, guards?, run })
133
+ modal({ id, title, fields, run }) // fields: Record<string, TextInputDef>
134
+ textInput({ label, style?, placeholder?, required?, minLength?, maxLength?, value? }) // style: "Short"|"Paragraph"
135
+ row(...components): ActionRowBuilder
136
+
137
+ component.build(params?) // requires exactly the {param}s in id; no args when none
138
+
139
+ // id pattern: "name" or "name:{a}:{b}" → ctx.params.a, ctx.params.b (string)
140
+ // MAX_CUSTOM_ID_LENGTH = 100
141
+ ```
142
+
143
+ Contexts: `ButtonContext` (params); `StringSelectContext` (values, value);
144
+ `UserSelectContext`/`MentionableSelectContext` (values, users, members[, roles]);
145
+ `RoleSelectContext` (values, roles); `ChannelSelectContext` (values, channels);
146
+ `ModalContext` (params, fields). All component contexts: `update`, `deferUpdate`,
147
+ `showModal`, `message`, `customId`.
148
+
149
+ ## Events
150
+
151
+ ```ts
152
+ event("clientReady", (c) => ...) // name from discord.js ClientEvents (args inferred)
153
+ event({ name, once?, run })
154
+ // errors + rejections route to the client's "error" event
155
+ ```
156
+
157
+ ## Contexts (BaseContext — every handler)
158
+
159
+ ```ts
160
+ reply(input) · replyEphemeral(input) · defer({ ephemeral? }) · editReply(input) ·
161
+ followUp(input) · send(input)
162
+ success(input) · info(input) · warn(input) · error(input) // preset embeds, state-aware
163
+ replySuccess/replyInfo/replyWarn/replyError(input)
164
+ client · user · member · guild · guildId · channel · channelId · locale · deferred · replied
165
+ botPermissions · botMissing(perm) · userMissing(perm) // permission preflight (zero-fetch)
166
+ awaitMessageFrom(userId?, { time?, filter? }) · awaitModal(modal, { time?, filter? }) // → T | null
167
+ // ReplyInput = string | (InteractionReplyOptions & { ephemeral?: boolean })
168
+ ```
169
+
170
+ ## Guards
171
+
172
+ ```ts
173
+ guildOnly(reason?) · dmOnly(reason?) · requireAnyRole(ids, reason?) · requireAllRoles(ids, reason?)
174
+ requireOwner(ownerIds, reason?) · requireUserPermissions(perm, reason?) · requireBotPermissions(perm, reason?)
175
+ guard(predicate) · denied(reason?)
176
+ // per-handler: { guards: [...] } on command/prefixCommand/button/userCommand/messageCommand
177
+ // client-wide: new SpearClient({ guards: [...] })
178
+ ```
179
+
180
+ ## Cooldowns
181
+
182
+ ```ts
183
+ command({ cooldown: number | CooldownConfig })
184
+ new SpearClient({ cooldown: CooldownConfig })
185
+ CooldownConfig = { duration, scope?: "user"|"guild"|"channel"|"global",
186
+ exempt?: { users?, roles? }, overrides?: { users?, roles? },
187
+ message?: string | ((remainingMs) => string) }
188
+ ```
189
+
190
+ ## Scheduler
191
+
192
+ ```ts
193
+ task({ name, cron?, interval?, runOnStart?, run: (client) => ... }) // register() it
194
+ client.schedule(config)
195
+ cron(expr).next(from?: Date): Date
196
+ client.scheduler.delay(name, ms, fn) -> { cancel() }
197
+ client.scheduler.followUp(name, [10_000, 30_000], (i) => ...) -> { cancel() }
198
+ client.scheduler.reconcile(name, async (client) => ...) // once on ready
199
+ ```
200
+
201
+ ## Prefix commands
202
+
203
+ ```ts
204
+ new SpearClient({ prefix: "!" | string[] | { prefix, mention?, ignoreBots?, caseInsensitive? } })
205
+ prefixCommand({ name, aliases?, description?, cooldown?, guards?, args?, run })
206
+ // PrefixContext: message, commandName, args: string[], rest: string, reply, send, options (when args used)
207
+ prefixArgs() builder: .string(n) .integer(n) .number(n) .boolean(n) .snowflake(n) .duration(n) .rest(n)
208
+ prefixCommand({ args: (a) => a.snowflake("target").duration("d").rest("reason"), run: (ctx) => ctx.options })
209
+ // reading other users' content requires the privileged MessageContent intent (Intents.messages)
210
+ ```
211
+
212
+ ## Context menus
213
+
214
+ ```ts
215
+ userCommand({ name, guards?, cooldown?, run: (ctx) => ctx.targetUser / ctx.targetMember })
216
+ messageCommand({ name, guards?, cooldown?, run: (ctx) => ctx.targetMessage })
217
+ // deploy with slash commands via client.deployAllCommands(...)
218
+ ```
219
+
220
+ ## Pagination & confirm
221
+
222
+ ```ts
223
+ paginate(interaction, items, { render, pageSize?, user?, timeoutMs?, controls?: "prev-next"|"first-prev-next-last", ephemeral?, labels?, namespace? })
224
+ buildPaginatorPage(items, page, options) -> { payload, pages }
225
+ confirm(interaction, { body, title?, confirm?, cancel?, user?, timeoutMs?, ephemeral?, namespace? })
226
+ -> { confirmed: boolean, reason: "confirm"|"cancel"|"timeout", interaction? }
227
+ ```
228
+
229
+ ## Embeds
230
+
231
+ ```ts
232
+ client.embeds.{ error|success|info|warn|build(level, input) }
233
+ new Embeds(opts?); defaultEmbeds // shared default used when client.embeds is unset
234
+ new SpearClient({ embeds: { /* colors/icons per level */ } })
235
+ // BaseContext exposes ctx.success/info/warn/error + replySuccess/Info/Warn/Error
236
+ ```
237
+
238
+ ## Logging
239
+
240
+ ```ts
241
+ new Logger({ level, transports: [consoleSink, jsonlSink("./logs/bot.jsonl"), webhookSink({ url, minLevel: "error" })] })
242
+ logger.debug/info/warn/error(message, { error?, data? }); logger.child(scope); logger.setLevel(level); logger.enabled(level)
243
+ logger.addTransport(sink); logger.setTransports([sinks])
244
+ new SpearClient({ logger: { level: "debug" } }) // client.logger
245
+ ```
246
+
247
+ ## Usage tracking
248
+
249
+ ```ts
250
+ new SpearClient({ usage: { store?, channel?, format? } }) // client.usage
251
+ MemoryUsageStore // record, all, size, byUser(id), clear
252
+ JsonFileUsageStore(path)
253
+ // UsageEvent { type: "command"|"prefix"|"component"|"event", name, userId?, userTag?, guildId?, channelId?,
254
+ // detail?, outcome?: "success"|"error", durationMs?, errorMessage?, options?, timestamp }
255
+ ```
256
+
257
+ ## Env
258
+
259
+ ```ts
260
+ loadEnv({ path?, override? }); parseEnv(content)
261
+ env.string(k, fallback?) · env.number(k, fallback?) · env.boolean(k, fallback?) · env.require(k)
262
+ // SpearClient auto-loads .env on start(); configure/disable via the `dotenv` option
263
+ ```
264
+
265
+ ## Plugins & loading
266
+
267
+ ```ts
268
+ definePlugin({ name, setup(client) }); await client.use(plugin)
269
+ collectModules(dir, options?); loadInto(client, dir, options?)
270
+ client.load(dir, { extensions?: readonly string[], recursive? }) // defaults [.js,.mjs,.cjs], true
271
+ ```
272
+
273
+ ## Primitives
274
+
275
+ ```ts
276
+ new KeyedLock(); lock.tryAcquire(key, ttl?); lock.run(key, fn, { onBusy?, ttl? }); lock.isHeld(key); lock.forget(key); lock.dispose()
277
+ safeFetch.{ member, channel, message, user, guild, role, try } // each returns T | null
278
+ withSafeTimeout(promise, ms) // T | null
279
+ formatDuration(ms, { locale?: string | custom-labels, largest?, units? }) // "en"|"en-US"|"en-GB"|"tr"|"tr-TR" or a custom label set
280
+ parseDuration(input): number | null
281
+ discordTimestamp(date, style?: "t"|"T"|"d"|"D"|"f"|"F"|"R"); relativeTimestamp(date)
282
+ new MemoryCache() // CacheStore: get/set/delete/has/increment/rateLimit/clear (TTL + counters + fixed-window rate limit)
283
+ loadConfig({ file, parser?, schema?, encoding? }); loadConfigAsync(opts) // JSON/JSON5/YAML
284
+ lookup(table, resourceName?) -> (key) => value
285
+ ```
286
+
287
+ ## Reliability, permissions, storage & collectors
288
+
289
+ ```ts
290
+ // Auto-defer — dodge "Unknown interaction" (10062) on slow handlers
291
+ command({ autoDefer: true | { ephemeral?, delayMs? } }); new SpearClient({ autoDefer: true })
292
+ armAutoDefer(interaction, config) -> cancel(); normalizeAutoDefer(input); DEFAULT_AUTO_DEFER_DELAY_MS = 2000
293
+
294
+ // Graceful shutdown
295
+ client.enableGracefulShutdown({ signals?, timeoutMs?, exit?, onShutdown?, logger? }) -> dispose()
296
+ gracefulShutdown(client, options) // standalone variant
297
+
298
+ // Permissions & moderation
299
+ missingPermissions(channel, who, required) -> PermissionsString[]; botMissingPermissions(channel, required)
300
+ hasPermissions(channel, who, required); compareRoles(a, b); canActOn(actor, target); formatPermissions(perm)
301
+ moderationCheck({ moderator, target, me?, action? }) -> { ok: true } | { ok: false, reason }
302
+
303
+ // Persistent storage + per-guild settings
304
+ new MemoryStore(); new JsonStore(path) // KeyValueStore: get/set/has/delete/keys/clear
305
+ namespaced(store, prefix)
306
+ createSettings({ store, defaults, namespace? }) -> { defaults, store, get(id), set(id, patch), reset(id) }
307
+
308
+ // Collectors (all resolve null on timeout)
309
+ awaitMessage(channel, { filter?, time? }); awaitComponent(message, { filter?, time?, componentType? })
310
+ showAndAwaitModal(interaction, modal, { time?, filter? })
311
+
312
+ // Discord errors
313
+ isDiscordError(err, DiscordErrorCode.UnknownMessage?); isHTTPError(err); isRateLimitError(err)
314
+ explainDiscordError(err) -> string | null; DiscordErrorCode.{ UnknownMessage, UnknownInteraction, MissingPermissions, ... }
315
+
316
+ // Message formatting
317
+ truncate(text, max, suffix?); chunkMessage(text, { max? }) -> string[]; MESSAGE_CHARACTER_LIMIT = 2000
318
+
319
+ // Dynamic prefixes
320
+ new SpearClient({ prefix: { prefix?, dynamic: (message) => string | string[] | null } })
321
+ ```
322
+
323
+ ## Error handling
324
+
325
+ ```ts
326
+ client.commands.onError((error, interaction) => interaction.reply({ content: "Oops.", flags: 64 }))
327
+ client.components.onError((error, interaction) => console.error(error))
328
+ // handler errors never crash the process; uncaught ones flow through client.logger
329
+ ```