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,125 @@
1
+ # Cooldowns
2
+
3
+ Rate-limit commands per user, per guild, per channel, or globally — with per-role
4
+ and per-user exemptions and overrides.
5
+ Cooldowns are enforced automatically by command dispatch: when an actor is
6
+ still on cooldown, spearkit replies (ephemerally) with a message and the
7
+ handler does not run.
8
+
9
+ ## Per-command
10
+
11
+ Pass a number (milliseconds) or a full config to any command:
12
+
13
+ ```ts
14
+ import { command } from "spearkit";
15
+
16
+ export const daily = command({
17
+ name: "daily",
18
+ description: "Claim your daily reward",
19
+ cooldown: 86_400_000, // once per day, per user
20
+ run: (ctx) => ctx.reply("Reward claimed!"),
21
+ });
22
+ ```
23
+
24
+ ## Client-wide default
25
+
26
+ A default applies to every command; a command's own `cooldown` overrides it.
27
+
28
+ ```ts
29
+ import { SpearClient } from "spearkit";
30
+
31
+ const client = new SpearClient({ cooldown: { duration: 3000 } });
32
+ ```
33
+
34
+ ## Scope
35
+
36
+ `scope` controls what the cooldown is keyed on. Default `"user"`.
37
+
38
+ ```ts
39
+ command({
40
+ name: "announce",
41
+ description: "Post an announcement",
42
+ cooldown: { duration: 60_000, scope: "guild" }, // one per guild per minute
43
+ run: (ctx) => ctx.reply("Announced."),
44
+ });
45
+ ```
46
+
47
+ | Scope | Keyed on |
48
+ | --- | --- |
49
+ | `user` | the invoking user (default) |
50
+ | `guild` | the guild |
51
+ | `channel` | the channel |
52
+ | `global` | everyone shares one bucket |
53
+
54
+ ## Exemptions — who waits and who doesn't
55
+
56
+ `exempt` lists users and roles that bypass the cooldown entirely.
57
+
58
+ ```ts
59
+ command({
60
+ name: "purge",
61
+ description: "Bulk delete",
62
+ cooldown: {
63
+ duration: 10_000,
64
+ exempt: { roles: ["111111111111111111"], users: ["222222222222222222"] },
65
+ },
66
+ run: (ctx) => ctx.reply("Purged."),
67
+ });
68
+ ```
69
+
70
+ ## Per-role / per-user overrides
71
+
72
+ `overrides` gives specific roles or users a different duration (milliseconds).
73
+ A user override beats role overrides; among matching roles the most lenient
74
+ (shortest) duration wins. Use `0` to effectively disable the wait for them.
75
+
76
+ ```ts
77
+ command({
78
+ name: "search",
79
+ description: "Search the archive",
80
+ cooldown: {
81
+ duration: 10_000, // everyone else
82
+ overrides: {
83
+ roles: { "333333333333333333": 2_000 }, // VIP role: 2s
84
+ users: { "444444444444444444": 0 }, // this user: no wait
85
+ },
86
+ },
87
+ run: (ctx) => ctx.reply("Searching…"),
88
+ });
89
+ ```
90
+
91
+ ## The message
92
+
93
+ `message` customises what blocked users see — a string, or a function of the
94
+ remaining milliseconds.
95
+
96
+ ```ts
97
+ command({
98
+ name: "spin",
99
+ description: "Spin the wheel",
100
+ cooldown: {
101
+ duration: 5_000,
102
+ message: (ms) => `Hold on — ${Math.ceil(ms / 1000)}s to go.`,
103
+ },
104
+ run: (ctx) => ctx.reply("🎡"),
105
+ });
106
+ ```
107
+
108
+ ## The manager
109
+
110
+ `client.cooldowns` is the shared `CooldownManager` (also used by
111
+ [prefix commands](./prefix.md)). Use it directly for custom flows:
112
+
113
+ ```ts
114
+ const result = client.cooldowns.consume("vote", 5_000, {
115
+ userId: "1",
116
+ roleIds: [],
117
+ guildId: null,
118
+ channelId: null,
119
+ });
120
+ if (!result.allowed) console.log(`wait ${result.remaining}ms`);
121
+ ```
122
+
123
+ `consume` records the use and returns `{ allowed: true }` or
124
+ `{ allowed: false, remaining }`. `peek` checks without recording; `reset` and
125
+ `clear` drop tracked cooldowns.
package/docs/env.md ADDED
@@ -0,0 +1,130 @@
1
+ # Environment & dotenv
2
+
3
+ spearkit includes a tiny, dependency-free `.env` loader and a typed reader over
4
+ `process.env`, so a bot needs no extra dotenv dependency. The client auto-loads
5
+ `.env` on `start()`, and the same helpers are exported for your own use.
6
+
7
+ ## Loading a `.env` file
8
+
9
+ `loadEnv(options?)` reads a `.env` file and merges it into `process.env`. By
10
+ default it reads `.env` from the current working directory. Variables already
11
+ present in `process.env` win unless you pass `override: true`. A missing file is
12
+ ignored — it simply returns `{}` — so it is safe to call unconditionally.
13
+
14
+ ```ts
15
+ import { loadEnv } from "spearkit";
16
+
17
+ const parsed = loadEnv(); // reads ./.env
18
+ loadEnv({ path: ".env.local" }); // a different file
19
+ loadEnv({ override: true }); // let the file win over existing vars
20
+ ```
21
+
22
+ `loadEnv` returns the parsed key/value pairs it read from the file:
23
+
24
+ ```ts
25
+ import { loadEnv } from "spearkit";
26
+
27
+ const parsed = loadEnv(); // ParsedEnv = Record<string, string>
28
+ console.log(Object.keys(parsed));
29
+ ```
30
+
31
+ ## Parsing without touching `process.env`
32
+
33
+ `parseEnv(text)` parses `.env`-formatted text into a flat object and never
34
+ mutates `process.env`. It understands single/double quotes, a leading `export `,
35
+ `#` comments, and `\n`/`\r`/`\t` escapes inside double quotes.
36
+
37
+ ```ts
38
+ import { parseEnv } from "spearkit";
39
+
40
+ const vars = parseEnv(`
41
+ # a comment
42
+ export TOKEN="abc#notacomment"
43
+ GREETING="line one\nline two"
44
+ RAW='no $escapes here'
45
+ `);
46
+
47
+ vars.TOKEN; // "abc#notacomment"
48
+ vars.GREETING; // "line one\nline two" (real newline)
49
+ vars.RAW; // "no $escapes here"
50
+ ```
51
+
52
+ ## The typed `env` reader
53
+
54
+ `env` reads from `process.env` with coercion and optional fallbacks. Empty
55
+ strings count as missing.
56
+
57
+ ```ts
58
+ import { env } from "spearkit";
59
+
60
+ env.string("REGION"); // string | undefined
61
+ env.string("REGION", "eu"); // string (fallback when missing)
62
+
63
+ env.number("PORT"); // number | undefined
64
+ env.number("PORT", 3000); // number (fallback when missing or non-numeric)
65
+
66
+ env.boolean("DEBUG"); // boolean | undefined
67
+ env.boolean("DEBUG", false); // boolean
68
+
69
+ env.require("DISCORD_TOKEN"); // string, throws if missing or empty
70
+ ```
71
+
72
+ `env.boolean` treats `true`/`1`/`yes`/`on` as `true` and `false`/`0`/`no`/`off`
73
+ as `false` (case-insensitive); anything else yields the fallback. `env.require`
74
+ throws a descriptive error when the variable is missing or empty — use it for
75
+ values your bot cannot run without.
76
+
77
+ ```ts
78
+ import { loadEnv, env } from "spearkit";
79
+
80
+ loadEnv();
81
+ const token = env.require("DISCORD_TOKEN"); // guaranteed string
82
+ const port = env.number("PORT", 8080); // number
83
+ const verbose = env.boolean("VERBOSE", false);
84
+ ```
85
+
86
+ ## Auto-loading on the client
87
+
88
+ `SpearClient` calls `loadEnv()` for you inside `client.start()`, so `.env` is
89
+ picked up before login. That means `await client.start()` finds
90
+ `DISCORD_TOKEN` from `.env` without any extra wiring:
91
+
92
+ ```ts
93
+ import { SpearClient } from "spearkit";
94
+
95
+ const client = new SpearClient();
96
+
97
+ async function main(): Promise<void> {
98
+ await client.start(); // loads .env, then reads DISCORD_TOKEN
99
+ }
100
+
101
+ void main();
102
+ ```
103
+
104
+ ### The `dotenv` option
105
+
106
+ Control the auto-load with the `dotenv` construction option:
107
+
108
+ ```ts
109
+ import { SpearClient } from "spearkit";
110
+
111
+ // Default: load ./.env on start.
112
+ new SpearClient({ dotenv: true });
113
+
114
+ // Disable auto-loading entirely (e.g. env is provided by the platform).
115
+ new SpearClient({ dotenv: false });
116
+
117
+ // Customize: same shape as loadEnv's options.
118
+ new SpearClient({ dotenv: { path: ".env.production", override: true } });
119
+ ```
120
+
121
+ | `dotenv` value | Effect |
122
+ | -------------- | ------ |
123
+ | `true` / omitted | Load `.env` from the cwd on `start()`. |
124
+ | `false` | Skip auto-loading; `process.env` is used as-is. |
125
+ | `{ path?, override? }` | Load with those `loadEnv` options. |
126
+
127
+ ## See also
128
+
129
+ - [Client](./client.md) — the `dotenv` and other construction options.
130
+ - [Logging](./logging.md) — structured logging that pairs with `env`-driven config.
package/docs/errors.md ADDED
@@ -0,0 +1,73 @@
1
+ # Discord API errors
2
+
3
+ discord.js reports REST failures as `DiscordAPIError` with a numeric `code`
4
+ (`10008` "Unknown Message", `50013` "Missing Permissions", `50007` "Cannot send
5
+ DMs to this user", …). Catching *everything* turns recoverable failures — a
6
+ deleted message, a closed DM — into crashes or scary stack traces. spearkit gives
7
+ you named codes, a type-narrowing predicate, and a friendly explanation.
8
+
9
+ ## Recognise and recover
10
+
11
+ `isDiscordError(err, code?)` narrows the throw and optionally matches a code
12
+ (or a list). Perfect for "ignore this one, re-throw the rest":
13
+
14
+ ```ts
15
+ import { DiscordErrorCode, isDiscordError } from "spearkit";
16
+
17
+ try {
18
+ await message.delete();
19
+ } catch (err) {
20
+ if (isDiscordError(err, DiscordErrorCode.UnknownMessage)) return; // already gone
21
+ throw err;
22
+ }
23
+ ```
24
+
25
+ ```ts
26
+ // match any of several codes
27
+ if (isDiscordError(err, [DiscordErrorCode.UnknownChannel, DiscordErrorCode.MissingAccess])) {
28
+ return;
29
+ }
30
+ ```
31
+
32
+ ## Friendly messages
33
+
34
+ `explainDiscordError(err)` returns an end-user-appropriate sentence for a
35
+ recognised failure, or `null` otherwise (fall back to a generic message + log):
36
+
37
+ ```ts
38
+ import { explainDiscordError } from "spearkit";
39
+
40
+ catch (err) {
41
+ await ctx.error(explainDiscordError(err) ?? "Something went wrong.");
42
+ }
43
+ ```
44
+
45
+ spearkit already routes its own command/context-menu errors through
46
+ `explainDiscordError`, so a handler that throws `Missing Permissions` shows the
47
+ user *"I'm missing the permissions needed to do that."* instead of a generic
48
+ error.
49
+
50
+ ## Named codes
51
+
52
+ `DiscordErrorCode` is a curated map of the codes bots actually hit:
53
+
54
+ | Name | Code | When |
55
+ | --- | --- | --- |
56
+ | `UnknownChannel` | 10003 | Channel gone/invisible |
57
+ | `UnknownMessage` | 10008 | Message deleted |
58
+ | `UnknownMember` | 10007 | Member left |
59
+ | `UnknownInteraction` | 10062 | Token expired (the 3s window) |
60
+ | `MissingAccess` | 50001 | No access to the resource |
61
+ | `CannotSendMessagesToThisUser` | 50007 | DMs closed / blocked |
62
+ | `MissingPermissions` | 50013 | Missing a permission |
63
+ | `InteractionHasAlreadyBeenAcknowledged` | 40060 | Double-acked |
64
+
65
+ (See the type for the full set — it mirrors discord.js' `RESTJSONErrorCodes`.)
66
+
67
+ ## Transport & rate-limit errors
68
+
69
+ - `isHTTPError(err)` — a transport-level `HTTPError` (timeout, 5xx, aborted): an
70
+ HTTP status with no Discord JSON code.
71
+ - `isRateLimitError(err)` — a `DiscordAPIError` with HTTP status `429`.
72
+ `explainDiscordError` handles this case first, returning a "try again in a
73
+ moment" message.
package/docs/events.md ADDED
@@ -0,0 +1,152 @@
1
+ # Events
2
+
3
+ `event()` defines a reusable, loadable discord.js event listener with a
4
+ fully-typed handler. The handler's arguments are inferred from discord.js'
5
+ `ClientEvents`, so you never annotate them by hand. Register an event with the
6
+ client and spearkit attaches the listener for you.
7
+
8
+ ```ts
9
+ import { event } from "spearkit";
10
+
11
+ export default event("messageCreate", (message) => {
12
+ if (message.author.bot) return;
13
+ // message is fully typed as Message
14
+ });
15
+ ```
16
+
17
+ ## Defining an event
18
+
19
+ `event` has two forms. The positional form takes the event name and handler:
20
+
21
+ ```ts
22
+ import { event } from "spearkit";
23
+
24
+ const onMessage = event("messageCreate", (message) => {
25
+ // message: Message
26
+ console.log(message.content);
27
+ });
28
+
29
+ const onReady = event("clientReady", (client) => {
30
+ // client: Client<true> — the ready client
31
+ console.log(`Logged in as ${client.user.tag}`);
32
+ });
33
+ ```
34
+
35
+ The object form (`EventConfig`) additionally accepts `once`, which runs the
36
+ handler at most once and then auto-detaches:
37
+
38
+ ```ts
39
+ import { event } from "spearkit";
40
+
41
+ const onceReady = event({
42
+ name: "clientReady",
43
+ once: true,
44
+ run: (client) => {
45
+ // client: Client<true>
46
+ console.log(`Ready as ${client.user.tag}`);
47
+ },
48
+ });
49
+ ```
50
+
51
+ Both forms return an `EventDef` — a type-erased, ready-to-attach listener
52
+ (`{ name, once, attach, detach }`). Register it like anything else:
53
+
54
+ ```ts
55
+ import { SpearClient } from "spearkit";
56
+
57
+ const client = new SpearClient();
58
+ client.register(onMessage, onReady);
59
+ ```
60
+
61
+ ### Handlers are fully typed from `ClientEvents`
62
+
63
+ The event name drives the parameter types. There is nothing to import or
64
+ annotate — picking `"messageCreate"` types the argument as `Message`, picking
65
+ `"guildMemberAdd"` types it as `GuildMember`, and so on. The handler type is
66
+ exported as `EventHandler<E>` (`(...args: ClientEvents[E]) => Awaitable<void>`).
67
+
68
+ ```ts
69
+ import { event } from "spearkit";
70
+
71
+ const onJoin = event("guildMemberAdd", (member) => {
72
+ // member: GuildMember
73
+ void member.roles.add("123456789012345678");
74
+ });
75
+
76
+ const onReaction = event("messageReactionAdd", (reaction, user) => {
77
+ // reaction: MessageReaction | PartialMessageReaction
78
+ // user: User | PartialUser
79
+ console.log(`${user.id} reacted with ${reaction.emoji.name}`);
80
+ });
81
+ ```
82
+
83
+ ### Intents are required
84
+
85
+ An event only fires if the client connected with the matching gateway intents.
86
+ For example, `messageCreate` with message content needs `Intents.messages` (or
87
+ at least the `GuildMessages` / `MessageContent` bits); `guildMemberAdd` needs
88
+ `GuildMembers`. See [Client](./client.md) for the intent presets.
89
+
90
+ ## Errors are routed, not fatal
91
+
92
+ If a handler throws synchronously or rejects a returned promise, spearkit catches
93
+ it and emits it on the client's `error` event instead of crashing the process.
94
+ Listen for `error` to log or report failures centrally:
95
+
96
+ ```ts
97
+ import { SpearClient } from "spearkit";
98
+
99
+ const client = new SpearClient();
100
+
101
+ client.on("error", (err) => {
102
+ console.error("A handler failed:", err);
103
+ });
104
+ ```
105
+
106
+ ## Inline listeners still work
107
+
108
+ Because spearkit re-exports discord.js, the plain `client.on(...)` / `client.once(...)`
109
+ listeners work exactly as before — they are the same methods. Reach for them for
110
+ quick, inline, client-local listeners:
111
+
112
+ ```ts
113
+ import { SpearClient } from "spearkit";
114
+
115
+ const client = new SpearClient();
116
+ client.on("guildCreate", (guild) => console.log(`Joined ${guild.name}`));
117
+ ```
118
+
119
+ Use `event()` when you want a listener that is **reusable and loadable** — a
120
+ self-contained module you can export, register from anywhere, or pick up via
121
+ `client.load(...)`. Note that inline `client.on` listeners do **not** get the
122
+ automatic error-routing that `event()` handlers do.
123
+
124
+ ## The `EventRegistry`
125
+
126
+ `client.events` is an `EventRegistry`. The client attaches it automatically at
127
+ construction and again when you `register` an event, so you usually never call
128
+ its methods directly. They are available for advanced control:
129
+
130
+ | Member | Type | Description |
131
+ | ------ | ---- | ----------- |
132
+ | `add(...defs)` | `this` | Register one or more `EventDef`s (and attach them to already-attached clients). |
133
+ | `size` | `number` | Number of registered listeners. |
134
+ | `attachAll(client)` | `void` | Attach every registered listener to a client. |
135
+ | `detachAll(client)` | `void` | Detach every registered listener from a client. |
136
+
137
+ ```ts
138
+ import { SpearClient, event } from "spearkit";
139
+
140
+ const client = new SpearClient();
141
+ client.events.add(event("warn", (info) => console.warn(info)));
142
+
143
+ console.log(client.events.size); // 1
144
+
145
+ // Detach all spearkit-managed listeners (e.g. before a hot reload).
146
+ client.events.detachAll(client);
147
+ ```
148
+
149
+ ## See also
150
+
151
+ - [Client](./client.md) — registering events and the required intents.
152
+ - [File-based loading](./loading.md) — one event per file, auto-registered.
@@ -0,0 +1,147 @@
1
+ # Getting started
2
+
3
+ spearkit is **discord.js++**: it re-exports the entire discord.js surface and adds a
4
+ fully type-safe layer for events, slash commands and interactive components. This
5
+ page takes you from an empty folder to a running bot that responds to a slash
6
+ command.
7
+
8
+ ## Install
9
+
10
+ spearkit sits alongside discord.js, so install both:
11
+
12
+ ```bash
13
+ npm install spearkit discord.js
14
+ ```
15
+
16
+ Everything in your code imports from `"spearkit"` — including the plain discord.js
17
+ symbols, which spearkit re-exports unchanged.
18
+
19
+ ## Credentials you need
20
+
21
+ Create an application in the [Discord Developer Portal](https://discord.com/developers/applications)
22
+ and collect three values:
23
+
24
+ | Value | Where to find it | Used for |
25
+ | ----- | ---------------- | -------- |
26
+ | Bot token | Application → **Bot** → *Reset Token* | `client.start(token)` |
27
+ | Application id | Application → **General Information** → *Application ID* | command deployment (spearkit reads it from the client once ready) |
28
+ | Test guild id | Right-click your server in Discord (with Developer Mode on) → *Copy Server ID* | guild-scoped deploy |
29
+
30
+ Keep the token secret. The examples below read these from the environment
31
+ (`DISCORD_TOKEN`, `GUILD_ID`).
32
+
33
+ ## Your first bot
34
+
35
+ ```ts
36
+ import { SpearClient, Intents, command, option, event } from "spearkit";
37
+
38
+ const client = new SpearClient({ intents: Intents.default });
39
+
40
+ const greet = command({
41
+ name: "greet",
42
+ description: "Greet someone",
43
+ options: {
44
+ who: option.user({ description: "Who to greet", required: true }),
45
+ },
46
+ run: (ctx) => ctx.reply(`Hello ${ctx.options.who}!`), // ctx.options.who: User
47
+ });
48
+
49
+ const ready = event("clientReady", (c) => console.log(`Online as ${c.user.tag}`));
50
+
51
+ client.register(greet, ready);
52
+
53
+ await client.start(process.env.DISCORD_TOKEN);
54
+ await client.deployCommands({ guildId: process.env.GUILD_ID });
55
+ ```
56
+
57
+ What each step does:
58
+
59
+ 1. **`new SpearClient({ intents })`** — a discord.js `Client` with command, event
60
+ and component routing wired up. `Intents.default` is `[Guilds]`, enough for
61
+ slash commands and interactions.
62
+ 2. **`command({ ... })`** — defines a leaf slash command. Required options resolve
63
+ to their value type (`who` is a `User`); optional options would resolve to
64
+ `value | undefined`.
65
+ 3. **`client.register(...)`** — routes each item to the matching registry
66
+ (commands, events, components) by its kind.
67
+ 4. **`client.start(token)`** — logs in. With no argument it falls back to the
68
+ `DISCORD_TOKEN` environment variable.
69
+ 5. **`client.deployCommands({ guildId })`** — pushes your command definitions to
70
+ Discord over the client's own authenticated REST connection. Must run after the
71
+ client is ready (i.e. after `start`).
72
+
73
+ ### Guild vs global deploy
74
+
75
+ `deployCommands` takes an optional `guildId`:
76
+
77
+ - **Guild deploy** (`{ guildId }`) registers commands in a single server. Changes
78
+ appear **instantly** — ideal while developing.
79
+ - **Global deploy** (omit `guildId`) registers commands across every server the
80
+ bot is in. Propagation can take up to an hour.
81
+
82
+ ```ts
83
+ await client.deployCommands({ guildId: process.env.GUILD_ID }); // instant, one guild
84
+ await client.deployCommands(); // global, slow to propagate
85
+ ```
86
+
87
+ You only need to deploy when your command *definitions* change (names,
88
+ descriptions, options) — not on every restart.
89
+
90
+ ## Suggested project layout
91
+
92
+ As a bot grows, give each command, event and component its own file:
93
+
94
+ ```
95
+ my-bot/
96
+ src/
97
+ index.ts # construct the client, register/load, start, deploy
98
+ commands/
99
+ greet.ts
100
+ ping.ts
101
+ events/
102
+ ready.ts
103
+ components/
104
+ vote.ts
105
+ package.json
106
+ tsconfig.json
107
+ ```
108
+
109
+ A module exports a command, event or component as a default or named export:
110
+
111
+ ```ts
112
+ // src/commands/ping.ts
113
+ import { command } from "spearkit";
114
+
115
+ export default command({
116
+ name: "ping",
117
+ description: "Check that the bot is alive",
118
+ run: (ctx) => ctx.reply(`Pong! ${ctx.client.ws.ping}ms`),
119
+ });
120
+ ```
121
+
122
+ You can wire the pieces up explicitly with `register`, or let spearkit discover them
123
+ with `client.load` (see [File-based loading](./loading.md)).
124
+
125
+ ## Running it
126
+
127
+ **With tsx** (run TypeScript directly, great for development):
128
+
129
+ ```bash
130
+ npx tsx src/index.ts
131
+ ```
132
+
133
+ **Compiled JavaScript** (for production):
134
+
135
+ ```bash
136
+ npx tsc # emit JS into dist/ per your tsconfig
137
+ node dist/index.js
138
+ ```
139
+
140
+ Note that `client.load` imports **compiled JavaScript**, so if you use file-based
141
+ loading you must build before running the compiled output. Explicit `register`
142
+ calls work the same under `tsx` or `node`.
143
+
144
+ ## See also
145
+
146
+ - [Client](./client.md) — `SpearClient`, intents, `register`, `start`, deployment.
147
+ - [Commands](./commands.md) — slash commands, subcommands, options, deployment.