spearkit 0.3.1 → 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 +11 -0
- package/.claude/skills/spearkit/reference/cheatsheet.md +117 -6
- package/AGENTS.md +98 -1
- package/README.md +10 -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 +21 -12
- package/docs/api-reference.md +222 -34
- package/docs/auto-defer.md +74 -0
- package/docs/client.md +60 -22
- package/docs/collectors.md +65 -0
- package/docs/commands.md +5 -0
- package/docs/components.md +7 -0
- package/docs/context-menus.md +121 -0
- package/docs/context.md +94 -2
- package/docs/cooldown.md +2 -1
- package/docs/errors.md +73 -0
- package/docs/guards.md +146 -0
- package/docs/loading.md +7 -5
- package/docs/messages.md +35 -0
- package/docs/permissions.md +68 -0
- package/docs/prefix.md +54 -0
- package/docs/scheduler.md +26 -2
- package/docs/shutdown.md +42 -0
- package/docs/store.md +90 -0
- package/docs/usage.md +20 -10
- package/llms-full.txt +1337 -85
- package/llms.txt +91 -3
- package/package.json +1 -1
package/docs/guards.md
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# Guards
|
|
2
|
+
|
|
3
|
+
Guards are declarative **preconditions** that run before a handler. They work
|
|
4
|
+
uniformly across slash commands, components (buttons, selects, modals), prefix
|
|
5
|
+
commands and context-menu commands — and can also be applied client-wide. A
|
|
6
|
+
guard returns `true` to allow the handler, or a denial (with an optional reason)
|
|
7
|
+
to block it; spearkit replies with the reason and the handler never runs.
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
import { command, requireUserPermissions, PermissionFlagsBits } from "spearkit";
|
|
11
|
+
|
|
12
|
+
export const purge = command({
|
|
13
|
+
name: "purge",
|
|
14
|
+
description: "Bulk-delete messages",
|
|
15
|
+
guards: [requireUserPermissions(PermissionFlagsBits.ManageMessages)],
|
|
16
|
+
run: (ctx) => ctx.reply("Purged."),
|
|
17
|
+
});
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Where guards attach
|
|
21
|
+
|
|
22
|
+
Pass `guards: [...]` to any handler definition, or set client-wide defaults that
|
|
23
|
+
run before every handler's own guards.
|
|
24
|
+
|
|
25
|
+
```ts
|
|
26
|
+
import {
|
|
27
|
+
SpearClient,
|
|
28
|
+
command,
|
|
29
|
+
button,
|
|
30
|
+
prefixCommand,
|
|
31
|
+
userCommand,
|
|
32
|
+
guildOnly,
|
|
33
|
+
} from "spearkit";
|
|
34
|
+
|
|
35
|
+
// Per-handler — on commands, components, prefix and context-menu commands.
|
|
36
|
+
command({ name: "kick", description: "…", guards: [guildOnly()], run: () => {} });
|
|
37
|
+
button({ id: "del:{id}", guards: [guildOnly()], run: () => {} });
|
|
38
|
+
prefixCommand({ name: "ban", guards: [guildOnly()], run: () => {} });
|
|
39
|
+
userCommand({ name: "Report", guards: [guildOnly()], run: () => {} });
|
|
40
|
+
|
|
41
|
+
// Client-wide — applied before each handler's own guards.
|
|
42
|
+
const client = new SpearClient({ guards: [guildOnly()] });
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Client-wide guards run first; if they pass, the handler's own guards run next.
|
|
46
|
+
The first denial short-circuits the rest.
|
|
47
|
+
|
|
48
|
+
## Built-in guards
|
|
49
|
+
|
|
50
|
+
Each built-in returns a `Guard` and accepts an optional custom `reason`. When
|
|
51
|
+
omitted, a sensible default message is used (shown below).
|
|
52
|
+
|
|
53
|
+
| Guard | Denies unless… | Default reason |
|
|
54
|
+
| ----- | -------------- | -------------- |
|
|
55
|
+
| `guildOnly(reason?)` | used inside a guild | `"This can only be used in a server."` |
|
|
56
|
+
| `dmOnly(reason?)` | used in a DM | `"This can only be used in DMs."` |
|
|
57
|
+
| `requireAnyRole(roleIds, reason?)` | the member holds **any** of `roleIds` | `"You don't have permission to use this."` |
|
|
58
|
+
| `requireAllRoles(roleIds, reason?)` | the member holds **every** id in `roleIds` | `"You're missing one of the required roles."` |
|
|
59
|
+
| `requireOwner(ownerIds, reason?)` | the user id is in `ownerIds` | `"This is owner-only."` |
|
|
60
|
+
| `requireUserPermissions(permission, reason?)` | the member has the Discord `permission` | `"You don't have permission to use this."` |
|
|
61
|
+
| `requireBotPermissions(permission, reason?)` | the bot's member has the Discord `permission` | `"I don't have permission to do that here."` |
|
|
62
|
+
|
|
63
|
+
```ts
|
|
64
|
+
import {
|
|
65
|
+
command,
|
|
66
|
+
requireAnyRole,
|
|
67
|
+
requireBotPermissions,
|
|
68
|
+
PermissionFlagsBits,
|
|
69
|
+
} from "spearkit";
|
|
70
|
+
|
|
71
|
+
export const announce = command({
|
|
72
|
+
name: "announce",
|
|
73
|
+
description: "Post an announcement",
|
|
74
|
+
guards: [
|
|
75
|
+
requireAnyRole(["111111111111111111"], "Staff only."),
|
|
76
|
+
requireBotPermissions(PermissionFlagsBits.SendMessages),
|
|
77
|
+
],
|
|
78
|
+
run: (ctx) => ctx.reply("Announced."),
|
|
79
|
+
});
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## Custom guards
|
|
83
|
+
|
|
84
|
+
`guard(predicate)` wraps an inline predicate so a one-off check still types as a
|
|
85
|
+
`Guard`. The predicate receives a `GuardContext` and returns a `GuardResult`;
|
|
86
|
+
use `denied(reason?)` to build a denial.
|
|
87
|
+
|
|
88
|
+
```ts
|
|
89
|
+
import { command, guard, denied } from "spearkit";
|
|
90
|
+
|
|
91
|
+
const cooldownOver = guard((ctx) =>
|
|
92
|
+
isReady(ctx.user.id) ? true : denied("Still warming up — try again soon."),
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
export const cast = command({
|
|
96
|
+
name: "cast",
|
|
97
|
+
description: "Cast a spell",
|
|
98
|
+
guards: [cooldownOver],
|
|
99
|
+
run: (ctx) => ctx.reply("✨"),
|
|
100
|
+
});
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
`GuardContext` exposes the actor/location fields every handler shares, so the
|
|
104
|
+
same guard works on commands, components, prefix and context-menu handlers:
|
|
105
|
+
|
|
106
|
+
```ts
|
|
107
|
+
interface GuardContext {
|
|
108
|
+
client: Client;
|
|
109
|
+
user: User;
|
|
110
|
+
member: GuildMember | APIInteractionGuildMember | null;
|
|
111
|
+
guild: Guild | null;
|
|
112
|
+
guildId: string | null;
|
|
113
|
+
channelId: string | null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
type GuardResult = boolean | { allowed: false; reason?: string };
|
|
117
|
+
type Guard<TCtx extends GuardContext = GuardContext> = (ctx: TCtx) => Awaitable<GuardResult>;
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
## Running guards manually
|
|
121
|
+
|
|
122
|
+
`runGuards(ctx, guards)` evaluates a list in order and short-circuits on the
|
|
123
|
+
first denial — useful if you build your own dispatch on top of spearkit.
|
|
124
|
+
|
|
125
|
+
```ts
|
|
126
|
+
import { runGuards, guildOnly } from "spearkit";
|
|
127
|
+
|
|
128
|
+
const result = await runGuards(ctx, [guildOnly()]);
|
|
129
|
+
if (!result.allowed) {
|
|
130
|
+
// result.reason is the denial message (or undefined)
|
|
131
|
+
}
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
`runGuards` resolves to `RunGuardsResult`:
|
|
135
|
+
|
|
136
|
+
```ts
|
|
137
|
+
type RunGuardsResult = { allowed: true } | { allowed: false; reason: string | undefined };
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
## See also
|
|
141
|
+
|
|
142
|
+
- [Commands](./commands.md) — `guards` on slash commands.
|
|
143
|
+
- [Components](./components.md) — `guards` on buttons, selects and modals.
|
|
144
|
+
- [Prefix commands](./prefix.md) — `guards` on text commands.
|
|
145
|
+
- [Context menus](./context-menus.md) — `guards` on "Apps" actions.
|
|
146
|
+
- [Cooldowns](./cooldown.md) — the other built-in precondition.
|
package/docs/loading.md
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
# File-based loading
|
|
2
2
|
|
|
3
3
|
Instead of importing and registering every handler by hand, you can keep one
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
4
|
+
handler per file and let spearkit discover them. The loader imports a directory,
|
|
5
|
+
inspects each module's exports, and registers everything that is a command,
|
|
6
|
+
event, component, scheduled task or prefix command.
|
|
7
7
|
|
|
8
8
|
## `client.load`
|
|
9
9
|
|
|
@@ -36,8 +36,10 @@ await client.deployCommands({ guildId: process.env.GUILD_ID });
|
|
|
36
36
|
|
|
37
37
|
For every imported file, spearkit walks **all** of its exports — default *and*
|
|
38
38
|
named — and registers each value that is a command (`command`, `commandGroup`),
|
|
39
|
-
an event (`event`),
|
|
40
|
-
|
|
39
|
+
an event (`event`), a component (`button`, `stringSelect`, `modal`, …), a
|
|
40
|
+
scheduled task (`task`) or a prefix command (`prefixCommand`). Other exports
|
|
41
|
+
(helpers, constants, types) are ignored, and context-menu commands are **not**
|
|
42
|
+
auto-detected — register those explicitly. So both of these are picked up:
|
|
41
43
|
|
|
42
44
|
```ts
|
|
43
45
|
// default export
|
package/docs/messages.md
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# Messages & limits
|
|
2
|
+
|
|
3
|
+
Discord caps a message's `content` at **2000 characters**. Long output — a log
|
|
4
|
+
dump, a list, an AI response — silently fails or throws unless you split it.
|
|
5
|
+
spearkit ships two helpers (in addition to the duration/timestamp formatters; see
|
|
6
|
+
the [API reference](./api-reference.md)).
|
|
7
|
+
|
|
8
|
+
## Split long output
|
|
9
|
+
|
|
10
|
+
`chunkMessage(text, options?)` breaks text into chunks that each fit the limit,
|
|
11
|
+
preferring line boundaries (and word boundaries for an over-long single line) so
|
|
12
|
+
you never lose the tail:
|
|
13
|
+
|
|
14
|
+
```ts
|
|
15
|
+
import { chunkMessage } from "spearkit";
|
|
16
|
+
|
|
17
|
+
const parts = chunkMessage(hugeLog); // default max = 2000
|
|
18
|
+
await ctx.reply(parts[0] ?? "(empty)");
|
|
19
|
+
for (const part of parts.slice(1)) await ctx.followUp(part);
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Pass `{ max }` to target a smaller budget (e.g. inside a code block or embed
|
|
23
|
+
description). `MESSAGE_CHARACTER_LIMIT` (2000) is exported as the default.
|
|
24
|
+
|
|
25
|
+
## Truncate
|
|
26
|
+
|
|
27
|
+
`truncate(text, max, suffix?)` cuts text to `max` characters, appending the
|
|
28
|
+
suffix (default `…`) — the result, suffix included, never exceeds `max`:
|
|
29
|
+
|
|
30
|
+
```ts
|
|
31
|
+
import { truncate } from "spearkit";
|
|
32
|
+
|
|
33
|
+
embed.setFooter({ text: truncate(reason, 100) });
|
|
34
|
+
truncate("a very long reason", 10); // → "a very lo…"
|
|
35
|
+
```
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# Permissions & hierarchy
|
|
2
|
+
|
|
3
|
+
Moderation commands fail in two predictable ways: the bot lacks a permission in
|
|
4
|
+
the channel (`Missing Permissions`, 50013), or the target sits above the bot (or
|
|
5
|
+
the moderator) in the role list. Both are checkable *before* you act, so you can
|
|
6
|
+
bail out with a clear message instead of a half-finished action and an exception.
|
|
7
|
+
|
|
8
|
+
## Did the bot/user get the permissions? (zero-fetch)
|
|
9
|
+
|
|
10
|
+
Every interaction carries the bot's and the invoker's resolved permissions for
|
|
11
|
+
the current channel. `ctx.botMissing(...)` / `ctx.userMissing(...)` read them
|
|
12
|
+
with no API calls and return the **missing** flag names:
|
|
13
|
+
|
|
14
|
+
```ts
|
|
15
|
+
import { PermissionFlagsBits, command, formatPermissions } from "spearkit";
|
|
16
|
+
|
|
17
|
+
export const slowmode = command({
|
|
18
|
+
name: "slowmode",
|
|
19
|
+
description: "Set channel slowmode",
|
|
20
|
+
run: async (ctx) => {
|
|
21
|
+
const missing = ctx.botMissing(PermissionFlagsBits.ManageChannels);
|
|
22
|
+
if (missing.length > 0) return ctx.error(`I need: ${formatPermissions(missing)}`);
|
|
23
|
+
// …
|
|
24
|
+
},
|
|
25
|
+
});
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
`formatPermissions(...)` renders flag names as a friendly list
|
|
29
|
+
(`"Manage Channels, Ban Members"`).
|
|
30
|
+
|
|
31
|
+
## Permissions in another channel
|
|
32
|
+
|
|
33
|
+
For a channel other than the current one, use the standalone helpers:
|
|
34
|
+
|
|
35
|
+
```ts
|
|
36
|
+
import { botMissingPermissions, hasPermissions, missingPermissions } from "spearkit";
|
|
37
|
+
|
|
38
|
+
const missing = botMissingPermissions(targetChannel, [PermissionFlagsBits.SendMessages]);
|
|
39
|
+
if (missing.length > 0) return ctx.error("I can't post there.");
|
|
40
|
+
|
|
41
|
+
// or for a specific member/role:
|
|
42
|
+
hasPermissions(targetChannel, member, PermissionFlagsBits.ViewChannel); // boolean
|
|
43
|
+
missingPermissions(targetChannel, role, [PermissionFlagsBits.Connect]); // PermissionsString[]
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Role hierarchy
|
|
47
|
+
|
|
48
|
+
`moderationCheck(...)` validates both the moderator and the bot against a target,
|
|
49
|
+
returning a ready-to-show reason on the first failing rule (self, server owner,
|
|
50
|
+
moderator hierarchy, bot hierarchy):
|
|
51
|
+
|
|
52
|
+
```ts
|
|
53
|
+
import { moderationCheck } from "spearkit";
|
|
54
|
+
|
|
55
|
+
const moderator = await ctx.guild!.members.fetch(ctx.user.id);
|
|
56
|
+
const check = moderationCheck({ moderator, target, action: "ban" });
|
|
57
|
+
if (!check.ok) return ctx.error(check.reason);
|
|
58
|
+
await target.ban();
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
The `me` (bot) member defaults to `target.guild.members.me`; pass `me: null` to
|
|
62
|
+
skip the bot check. `action` is the verb used in messages (default `"moderate"`).
|
|
63
|
+
|
|
64
|
+
Lower-level primitives are exported too:
|
|
65
|
+
|
|
66
|
+
- `canActOn(actor, target)` — boolean: not self, target isn't the owner, actor is
|
|
67
|
+
the owner or outranks the target.
|
|
68
|
+
- `compareRoles(a, b)` — highest-role position comparison (`>0`, `<0`, `0`).
|
package/docs/prefix.md
CHANGED
|
@@ -31,6 +31,25 @@ new SpearClient({
|
|
|
31
31
|
});
|
|
32
32
|
```
|
|
33
33
|
|
|
34
|
+
### Dynamic (per-guild) prefixes
|
|
35
|
+
|
|
36
|
+
Pass `dynamic` to resolve extra prefix(es) per message — for example a custom
|
|
37
|
+
per-guild prefix from a database or [`createSettings`](./api-reference.md#persistent-storage).
|
|
38
|
+
Dynamic prefixes are tried in addition to any static `prefix`; return
|
|
39
|
+
`null`/`undefined` for none. It runs on every candidate message, so keep it fast
|
|
40
|
+
(and cached).
|
|
41
|
+
|
|
42
|
+
```ts
|
|
43
|
+
new SpearClient({
|
|
44
|
+
intents: Intents.messages,
|
|
45
|
+
prefix: {
|
|
46
|
+
prefix: "!", // static fallback
|
|
47
|
+
dynamic: async (message) =>
|
|
48
|
+
message.guildId ? await settings.get(message.guildId).then((s) => s.prefix) : null,
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
```
|
|
52
|
+
|
|
34
53
|
## You need the MessageContent intent
|
|
35
54
|
|
|
36
55
|
Reading the text of other users' messages is a **privileged** gateway intent.
|
|
@@ -79,6 +98,8 @@ client.register(ping);
|
|
|
79
98
|
| `aliases` | `string[]` | Extra names that also trigger it. |
|
|
80
99
|
| `description` | `string` | Human description, for your own help command. |
|
|
81
100
|
| `cooldown` | `number \| CooldownConfig` | Per-user rate limit (a number is milliseconds). |
|
|
101
|
+
| `guards` | `readonly Guard[]` | Preconditions run before the handler. See [Guards](./guards.md). |
|
|
102
|
+
| `args` | `(a) => PrefixArgsBuilder` | Typed argument schema; shapes `ctx.options`. See [Typed arguments](#typed-arguments). |
|
|
82
103
|
| `run` | `(ctx: PrefixContext) => void \| Promise<void>` | The handler. |
|
|
83
104
|
|
|
84
105
|
## The prefix context
|
|
@@ -92,6 +113,7 @@ adds the parsed arguments plus reply helpers.
|
|
|
92
113
|
| `ctx.commandName` | The matched name as the user typed it (an alias if they used one). |
|
|
93
114
|
| `ctx.args` | Whitespace-split arguments after the command name (`string[]`). |
|
|
94
115
|
| `ctx.rest` | The raw text after the command name (unsplit). |
|
|
116
|
+
| `ctx.options` | Typed parsed arguments from the `args` schema (`{}` when none). |
|
|
95
117
|
| `ctx.author` / `ctx.member` / `ctx.guild` / `ctx.guildId` / `ctx.channel` / `ctx.channelId` | Actor and location accessors. |
|
|
96
118
|
| `ctx.reply(content)` | Reply to the triggering message. |
|
|
97
119
|
| `ctx.send(content)` | Send a message to the same channel without a reply reference. |
|
|
@@ -113,6 +135,38 @@ export const echo = prefixCommand({
|
|
|
113
135
|
`ctx.args` and `ctx.rest` are two views of the same input: `!say hello world`
|
|
114
136
|
gives `args === ["hello", "world"]` and `rest === "hello world"`.
|
|
115
137
|
|
|
138
|
+
## Typed arguments
|
|
139
|
+
|
|
140
|
+
Pass an `args` schema to parse positional arguments into typed values. Chain
|
|
141
|
+
builder methods — first token → first arg, second → second, and so on — and read
|
|
142
|
+
the result from `ctx.options`. Each method requires a name and takes optional
|
|
143
|
+
settings (`required`, `default`, and per-type bounds).
|
|
144
|
+
|
|
145
|
+
```ts
|
|
146
|
+
import { prefixCommand } from "spearkit";
|
|
147
|
+
|
|
148
|
+
export const mute = prefixCommand({
|
|
149
|
+
name: "mute",
|
|
150
|
+
description: "Mute a member",
|
|
151
|
+
args: (a) =>
|
|
152
|
+
a
|
|
153
|
+
.snowflake("target", { required: true }) // raw id or <@mention> → string
|
|
154
|
+
.duration("duration", { required: true }) // "1h30m" → number (ms)
|
|
155
|
+
.rest("reason", { default: "No reason given" }), // remaining text → string
|
|
156
|
+
run: (ctx) => {
|
|
157
|
+
ctx.options.target; // string
|
|
158
|
+
ctx.options.duration; // number
|
|
159
|
+
ctx.options.reason; // string
|
|
160
|
+
return ctx.reply(`Muted <@${ctx.options.target}> for ${ctx.options.duration}ms.`);
|
|
161
|
+
},
|
|
162
|
+
});
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
Builder methods: `.string`, `.integer`, `.number`, `.boolean`, `.snowflake`,
|
|
166
|
+
`.duration`, `.rest`. A missing required argument — or a value that fails to
|
|
167
|
+
parse — makes spearkit reply with an error and skip the handler. Without an `args`
|
|
168
|
+
schema, `ctx.options` is `{}`; use `ctx.args` / `ctx.rest` for raw access.
|
|
169
|
+
|
|
116
170
|
## Aliases
|
|
117
171
|
|
|
118
172
|
List alternative names in `aliases`; any of them triggers the command, and
|
package/docs/scheduler.md
CHANGED
|
@@ -6,7 +6,7 @@ outlive your bot.
|
|
|
6
6
|
|
|
7
7
|
## Define a task
|
|
8
8
|
|
|
9
|
-
Provide exactly one of `cron` or `interval
|
|
9
|
+
Provide exactly one of `cron` or `interval` (if both are set, the interval is used):
|
|
10
10
|
|
|
11
11
|
```ts
|
|
12
12
|
import { task } from "spearkit";
|
|
@@ -55,7 +55,7 @@ Each field supports `*`, ranges (`1-5`), lists (`1,3,5`) and steps (`*/15`).
|
|
|
55
55
|
When both day-of-month and day-of-week are restricted, a date matches if
|
|
56
56
|
**either** does (standard cron behaviour).
|
|
57
57
|
|
|
58
|
-
Aliases: `@yearly`, `@monthly`, `@weekly`, `@daily`, `@hourly`.
|
|
58
|
+
Aliases: `@yearly`/`@annually`, `@monthly`, `@weekly`, `@daily`/`@midnight`, `@hourly`.
|
|
59
59
|
|
|
60
60
|
```ts
|
|
61
61
|
task({ name: "report", cron: "@daily", run: () => {} });
|
|
@@ -71,6 +71,30 @@ import { cron } from "spearkit";
|
|
|
71
71
|
const next = cron("*/15 * * * *").next(new Date());
|
|
72
72
|
```
|
|
73
73
|
|
|
74
|
+
## One-shot jobs, follow-ups and on-ready recovery
|
|
75
|
+
|
|
76
|
+
Beyond recurring tasks, the scheduler runs one-shot timers (they `unref()`
|
|
77
|
+
themselves, so they never keep the process alive) and a once-on-ready reconciler.
|
|
78
|
+
|
|
79
|
+
```ts
|
|
80
|
+
// Run once after a delay; returns a cancel handle.
|
|
81
|
+
const handle = client.scheduler.delay("remind", 10 * 60_000, async () => {
|
|
82
|
+
// …remind the moderator if nothing happened…
|
|
83
|
+
});
|
|
84
|
+
handle.cancel(); // true if it was still pending
|
|
85
|
+
|
|
86
|
+
// A series of fires measured from "now"; the callback gets the fire index.
|
|
87
|
+
client.scheduler.followUp("escalate", [10_000, 30_000, 60_000], (i) => {
|
|
88
|
+
// i = 0, then 1, then 2
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// Run once the first time the scheduler starts (typically on clientReady) and
|
|
92
|
+
// never again — ideal for restart recovery.
|
|
93
|
+
client.scheduler.reconcile("voice-sessions", async (client) => {
|
|
94
|
+
// …close orphaned voice sessions, reapply cached state…
|
|
95
|
+
});
|
|
96
|
+
```
|
|
97
|
+
|
|
74
98
|
## The scheduler
|
|
75
99
|
|
|
76
100
|
`client.scheduler` is the `TaskScheduler`:
|
package/docs/shutdown.md
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# Graceful shutdown
|
|
2
|
+
|
|
3
|
+
A `Ctrl-C` or a container stop sends your process a signal. If you don't handle
|
|
4
|
+
it, the process dies mid-flight — the gateway connection, scheduler timers, and
|
|
5
|
+
any open database handles are reaped abruptly. Graceful shutdown runs an optional
|
|
6
|
+
cleanup hook, calls `client.destroy()` (which also stops spearkit's scheduler),
|
|
7
|
+
then exits — with a hard timeout so a wedged shutdown can't hang forever.
|
|
8
|
+
|
|
9
|
+
## On a SpearClient
|
|
10
|
+
|
|
11
|
+
```ts
|
|
12
|
+
client.enableGracefulShutdown({
|
|
13
|
+
onShutdown: () => db.close(), // flush state before we exit
|
|
14
|
+
});
|
|
15
|
+
await client.start();
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Progress is logged through `client.logger`. The method returns a disposer that
|
|
19
|
+
removes the signal handlers (useful for tests / hot-reload).
|
|
20
|
+
|
|
21
|
+
## Standalone
|
|
22
|
+
|
|
23
|
+
`gracefulShutdown(client, options)` works with any object that has a `destroy()`
|
|
24
|
+
method:
|
|
25
|
+
|
|
26
|
+
```ts
|
|
27
|
+
import { gracefulShutdown } from "spearkit";
|
|
28
|
+
|
|
29
|
+
gracefulShutdown(client, { onShutdown: () => db.close() });
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Options
|
|
33
|
+
|
|
34
|
+
| Field | Default | Meaning |
|
|
35
|
+
| --- | --- | --- |
|
|
36
|
+
| `signals` | `["SIGINT", "SIGTERM"]` | Signals to listen for. |
|
|
37
|
+
| `timeoutMs` | `10000` | Force-exit if shutdown exceeds this. |
|
|
38
|
+
| `onShutdown` | — | Runs before `destroy()`; receives the signal. |
|
|
39
|
+
| `exit` | `true` | Call `process.exit()` when done (set `false` in tests). |
|
|
40
|
+
| `logger` | — | `{ info?, error? }` progress logger. |
|
|
41
|
+
|
|
42
|
+
Shutdown runs **once** — repeated signals during teardown are ignored.
|
package/docs/store.md
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# Key-value store & settings
|
|
2
|
+
|
|
3
|
+
Almost every community bot needs to remember *something* per guild — a custom
|
|
4
|
+
prefix, a mod-log channel, a welcome message — and reaches for a database on day
|
|
5
|
+
one. spearkit ships a dependency-free `KeyValueStore` interface with two
|
|
6
|
+
backends, plus a typed per-guild settings helper. Swap in Redis/SQL later by
|
|
7
|
+
implementing the same interface.
|
|
8
|
+
|
|
9
|
+
## Stores
|
|
10
|
+
|
|
11
|
+
```ts
|
|
12
|
+
import { JsonStore, MemoryStore } from "spearkit";
|
|
13
|
+
|
|
14
|
+
const dev = new MemoryStore(); // in-memory, great for tests
|
|
15
|
+
const prod = new JsonStore("data/db.json"); // durable JSON file
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Both implement `KeyValueStore`:
|
|
19
|
+
|
|
20
|
+
```ts
|
|
21
|
+
await store.set("key", { any: "json" });
|
|
22
|
+
await store.get<{ any: string }>("key"); // typed read, or undefined
|
|
23
|
+
await store.has("key");
|
|
24
|
+
await store.delete("key"); // → boolean (existed?)
|
|
25
|
+
await store.keys(); // → string[]
|
|
26
|
+
await store.clear();
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
`MemoryStore` deep-clones on read and write, so callers can't mutate stored
|
|
30
|
+
state. `JsonStore` serves reads from an in-memory cache and commits writes
|
|
31
|
+
atomically (temp file + rename) through a queue — a crash mid-write can't corrupt
|
|
32
|
+
the file, and concurrent writes don't interleave.
|
|
33
|
+
|
|
34
|
+
## Typed per-guild settings
|
|
35
|
+
|
|
36
|
+
`createSettings` wraps a store with defaults. `get` always returns a complete
|
|
37
|
+
object; `set` persists *only* the overrides, so widening `defaults` later is
|
|
38
|
+
safe.
|
|
39
|
+
|
|
40
|
+
```ts
|
|
41
|
+
import { JsonStore, createSettings } from "spearkit";
|
|
42
|
+
|
|
43
|
+
const settings = createSettings({
|
|
44
|
+
store: new JsonStore("data/guilds.json"),
|
|
45
|
+
defaults: { prefix: "!", modLogChannelId: null as string | null },
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const cfg = await settings.get(guildId); // { prefix, modLogChannelId }
|
|
49
|
+
await settings.set(guildId, { prefix: "?" }); // shallow-merged + persisted
|
|
50
|
+
await settings.reset(guildId); // back to defaults
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Pass `namespace` to keep several settings groups in one store:
|
|
54
|
+
|
|
55
|
+
```ts
|
|
56
|
+
const guilds = createSettings({ store, defaults: { prefix: "!" }, namespace: "guild" });
|
|
57
|
+
const users = createSettings({ store, defaults: { xp: 0 }, namespace: "user" });
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Dynamic per-guild prefix
|
|
61
|
+
|
|
62
|
+
A stored prefix is only useful if prefix commands respect it. `prefix.dynamic`
|
|
63
|
+
resolves extra prefix(es) per message — combine it with `createSettings` for true
|
|
64
|
+
per-guild prefixes:
|
|
65
|
+
|
|
66
|
+
```ts
|
|
67
|
+
const client = new SpearClient({
|
|
68
|
+
prefix: {
|
|
69
|
+
dynamic: async (message) =>
|
|
70
|
+
message.guildId ? (await settings.get(message.guildId)).prefix : null,
|
|
71
|
+
},
|
|
72
|
+
});
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
The resolver runs on every candidate message, so keep it fast (cache or use the
|
|
76
|
+
in-memory `JsonStore` cache). Returned prefixes are tried *in addition* to any
|
|
77
|
+
static `prefix`. See [Prefix commands](./prefix.md) for the rest of the prefix
|
|
78
|
+
system.
|
|
79
|
+
|
|
80
|
+
## Namespacing a raw store
|
|
81
|
+
|
|
82
|
+
`namespaced(store, prefix)` returns a `KeyValueStore` whose keys are
|
|
83
|
+
transparently prefixed — handy for sharing one file across features:
|
|
84
|
+
|
|
85
|
+
```ts
|
|
86
|
+
import { namespaced } from "spearkit";
|
|
87
|
+
|
|
88
|
+
const tags = namespaced(store, "tags");
|
|
89
|
+
await tags.set("hello", "world"); // stored under "tags:hello"
|
|
90
|
+
```
|
package/docs/usage.md
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
# Usage tracking
|
|
2
2
|
|
|
3
|
-
Usage tracking records **who used what**: every
|
|
4
|
-
and prefix-command invocation becomes a `UsageEvent`
|
|
3
|
+
Usage tracking records **who used what**: every command, component, context-menu
|
|
4
|
+
and prefix-command invocation — successful or errored — becomes a `UsageEvent`
|
|
5
|
+
that spearkit can persist to a
|
|
5
6
|
store and/or mirror into a Discord channel. Turn it on with the client's `usage`
|
|
6
7
|
option.
|
|
7
8
|
|
|
@@ -13,13 +14,14 @@ independent sinks:
|
|
|
13
14
|
| | Logger | Usage tracking |
|
|
14
15
|
| --- | --- | --- |
|
|
15
16
|
| Question | *What is the bot doing?* (diagnostics) | *Who used which feature?* (audit) |
|
|
16
|
-
| Content | Free-form messages, levels, errors, internals | Structured `UsageEvent`s for
|
|
17
|
+
| Content | Free-form messages, levels, errors, internals | Structured `UsageEvent`s for every completed use (with its `outcome`) |
|
|
17
18
|
| Sinks | Console / your log pipeline | A database store and/or a Discord channel |
|
|
18
19
|
| Configured by | the `logger` option | the `usage` option |
|
|
19
20
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
21
|
+
Both successes and handler errors are recorded as usage events — an error carries
|
|
22
|
+
`outcome: "error"` and an `errorMessage` — so usage is a complete audit trail.
|
|
23
|
+
The [logger](./logging.md) is for debugging; usage tracking is for analytics,
|
|
24
|
+
audit trails, and "top commands" dashboards.
|
|
23
25
|
|
|
24
26
|
## Enabling it
|
|
25
27
|
|
|
@@ -37,8 +39,9 @@ const client = new SpearClient({
|
|
|
37
39
|
});
|
|
38
40
|
```
|
|
39
41
|
|
|
40
|
-
Once enabled, spearkit auto-tracks every
|
|
41
|
-
command —
|
|
42
|
+
Once enabled, spearkit auto-tracks every command, component, context-menu and
|
|
43
|
+
prefix-command invocation — successes and errors alike — with no tracking code in
|
|
44
|
+
your handlers.
|
|
42
45
|
|
|
43
46
|
## The usage event
|
|
44
47
|
|
|
@@ -46,15 +49,22 @@ Each tracked use is a `UsageEvent`:
|
|
|
46
49
|
|
|
47
50
|
```ts
|
|
48
51
|
interface UsageEvent {
|
|
49
|
-
type: "command" | "prefix" | "component" | "event"
|
|
50
|
-
name: string; // command/component/event name
|
|
52
|
+
type: UsageType; // "command" | "prefix" | "component" | "event"
|
|
53
|
+
name: string; // command / component / event name
|
|
51
54
|
userId?: string;
|
|
52
55
|
userTag?: string;
|
|
53
56
|
guildId?: string | null;
|
|
54
57
|
channelId?: string | null;
|
|
55
58
|
detail?: string; // free-form extra detail
|
|
59
|
+
outcome?: UsageOutcome; // "success" | "error"
|
|
60
|
+
durationMs?: number; // handler wall-clock time
|
|
61
|
+
options?: Readonly<Record<string, UsageMetaValue>>; // snapshot of typed options
|
|
62
|
+
errorMessage?: string; // set when outcome === "error"
|
|
56
63
|
timestamp: Date;
|
|
57
64
|
}
|
|
65
|
+
type UsageType = "command" | "prefix" | "component" | "event";
|
|
66
|
+
type UsageOutcome = "success" | "error";
|
|
67
|
+
type UsageMetaValue = string | number | boolean | null;
|
|
58
68
|
```
|
|
59
69
|
|
|
60
70
|
## Stores (the database)
|