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.
- package/.claude/skills/spearkit/SKILL.md +247 -0
- package/.claude/skills/spearkit/reference/cheatsheet.md +329 -0
- package/AGENTS.md +261 -0
- package/README.md +23 -3
- package/dist/index.cjs +599 -16
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +524 -2
- package/dist/index.d.ts +524 -2
- package/dist/index.js +576 -19
- package/dist/index.js.map +1 -1
- package/docs/README.md +72 -0
- package/docs/api-reference.md +777 -0
- package/docs/auto-defer.md +74 -0
- package/docs/client.md +245 -0
- package/docs/collectors.md +65 -0
- package/docs/commands.md +203 -0
- package/docs/components.md +281 -0
- package/docs/context-menus.md +121 -0
- package/docs/context.md +293 -0
- package/docs/cooldown.md +125 -0
- package/docs/env.md +130 -0
- package/docs/errors.md +73 -0
- package/docs/events.md +152 -0
- package/docs/getting-started.md +147 -0
- package/docs/guards.md +146 -0
- package/docs/loading.md +144 -0
- package/docs/logging.md +195 -0
- package/docs/messages.md +35 -0
- package/docs/migration.md +160 -0
- package/docs/options.md +163 -0
- package/docs/permissions.md +68 -0
- package/docs/plugins.md +116 -0
- package/docs/prefix.md +234 -0
- package/docs/scheduler.md +111 -0
- package/docs/shutdown.md +42 -0
- package/docs/store.md +90 -0
- package/docs/usage.md +188 -0
- package/llms-full.txt +4619 -0
- package/llms.txt +127 -0
- package/package.json +9 -3
|
@@ -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
|
+
```
|