Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,100 @@
# @suaveplan/discord-client - Generic Discord API client for webhooks, bot messaging, and embeds with injectable fetch, OpenTelemetry instrumentation, and structured error codes
Generic Discord API client for webhooks, bot messaging, and embeds. Runtime-agnostic via injectable `fetch` and `clock`. Maps every documented Discord failure mode to a stable `DISCORD.*` error code and emits OpenTelemetry counters and histograms for every request.
<!-- begin:auto-docs -->
## Documentation tree
- [bot-client](./src/core/bot-client/discord-bot-client.md) — Implements the bot-token half of `@suaveplan/discord-client`. Wraps the Discord Bot REST API (`/channels/:id/messages`,…
- [discord-bot-client](./src/core/bot-client/discord-bot-client.md) — Implements the bot-token half of `@suaveplan/discord-client`. Wraps the Discord Bot REST API (`/channels/:id/messages`,…
- [webhook](./src/core/webhook/discord-webhook.md) — Implements the webhook half of `@suaveplan/discord-client`. Discord webhooks are URLs that anyone with the URL can POST…
- [discord-webhook](./src/core/webhook/discord-webhook.md) — Implements the webhook half of `@suaveplan/discord-client`. Discord webhooks are URLs that anyone with the URL can POST…
- [errors](./src/errors/discord-client-error.md) — Defines the namespaced, frozen error-code registry and the typed `DiscordClientError` class used by every runtime failu…
- [discord-client-error](./src/errors/discord-client-error.md) — Defines the namespaced, frozen error-code registry and the typed `DiscordClientError` class used by every runtime failu…
- [http](./src/utils/http.md) — Single funnel through which every Discord API and webhook request flows. It maps Discord response codes onto the `Disco…
- [rate-limit](./src/utils/rate-limit.md) — Parses Discord's three rate-limit response headers into a single millisecond back-off duration that callers can use to…
<!-- end:auto-docs -->
## Installation
```bash
bun add @suaveplan/discord-client
```
## Quick Start
```ts
import { DiscordBotClient, DiscordWebhook } from "@suaveplan/discord-client";
const bot = new DiscordBotClient({ token: process.env.DISCORD_BOT_TOKEN! });
await bot.sendMessage("123", { content: "hello from the bot" });
const hook = new DiscordWebhook({
webhookUrl: "https://discord.com/api/webhooks/123/abc",
});
await hook.sendText("hello from a webhook");
```
## Modules
| Module | Path | Description |
|--------|------|-------------|
| Bot Client | [src/core/bot-client/discord-bot-client.md](src/core/bot-client/discord-bot-client.md) | `DiscordBotClient` — send/edit/delete messages, reactions, channels, guilds |
| Webhook | [src/core/webhook/discord-webhook.md](src/core/webhook/discord-webhook.md) | `DiscordWebhook` — webhook-URL delivery with structured reports |
| HTTP Helper | [src/utils/http.md](src/utils/http.md) | Shared request funnel: error mapping, timeout, OTel metrics |
| Rate-Limit | [src/utils/rate-limit.md](src/utils/rate-limit.md) | `parseRetryAfterMs` parser for Discord's three rate-limit headers |
| Errors | [src/errors/discord-client-error.md](src/errors/discord-client-error.md) | `DISCORD_CODES` registry and `DiscordClientError` |
| Types | [src/types/discord-client.types.md](src/types/discord-client.types.md) | Public type surface + Zod boundary schemas |
## Subpath Exports
```ts
import { DiscordBotClient } from "@suaveplan/discord-client/bot";
import { DiscordWebhook } from "@suaveplan/discord-client/webhook";
import { DISCORD_CODES } from "@suaveplan/discord-client/errors";
import { DiscordMessageSchema } from "@suaveplan/discord-client/types";
```
## Error Codes
| Code | HTTP | Meaning |
|------|------|---------|
| `DISCORD.API_ERROR` | 502 | Generic non-2xx bot response |
| `DISCORD.AUTH_ERROR` | 401 | Token rejected |
| `DISCORD.RATE_LIMITED` | 429 | Honour `error.context.retryAfterMs` |
| `DISCORD.CHANNEL_NOT_FOUND` | 404 | Channel missing or bot lacks access |
| `DISCORD.GUILD_NOT_FOUND` | 404 | Guild missing or bot is not a member |
| `DISCORD.CONFIG_MISSING` | 500 | Required config value missing |
| `DISCORD.INVALID_CONFIG` | 500 | Config failed Zod validation |
| `DISCORD.INVALID_MESSAGE` | 400 | Message payload failed Zod validation |
| `DISCORD.WEBHOOK_FAILED` | 502 | Webhook delivery rejected |
| `DISCORD.TIMEOUT` | 504 | Request aborted by timeout |
| `DISCORD.FORBIDDEN` | 403 | Caller lacks permissions |
## Injectable Fetch & Clock
Both `DiscordBotClient` and `DiscordWebhook` accept `fetch` and `clock` injectables — pass deterministic stubs in tests, omit them in production to use `globalThis.fetch` and `Date.now`.
```ts
import { DiscordBotClient, type FetchLike } from "@suaveplan/discord-client";
const stubFetch: FetchLike = async () => ({
ok: true, status: 200, statusText: "OK",
headers: { get: () => null },
text: async () => "", json: async () => ({ id: "1", channelId: "1", content: "x", timestamp: "" }),
});
new DiscordBotClient({
token: "abc",
fetch: stubFetch,
clock: () => 1_700_000_000_000,
});
```
## OTel Metrics
| Metric | Type | Labels |
|--------|------|--------|
| `discord_client.request.total` | Counter | `kind`, `method`, `outcome`, `status` |
| `discord_client.request.duration_ms` | Histogram | `kind`, `method`, `outcome` |

View File

@@ -0,0 +1,86 @@
{
"name": "@suaveplan/discord-client",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "Generic Discord API client for webhooks, bot messaging, and embeds with injectable fetch, OpenTelemetry instrumentation, and structured error codes",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"./bot": {
"import": {
"types": "./dist/core/bot-client/discord-bot-client.d.ts",
"default": "./dist/core/bot-client/discord-bot-client.js"
}
},
"./webhook": {
"import": {
"types": "./dist/core/webhook/discord-webhook.d.ts",
"default": "./dist/core/webhook/discord-webhook.js"
}
},
"./errors": {
"import": {
"types": "./dist/errors/discord-client-error.d.ts",
"default": "./dist/errors/discord-client-error.js"
}
},
"./types": {
"import": {
"types": "./dist/types/discord-client.types.d.ts",
"default": "./dist/types/discord-client.types.js"
}
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
"format": "bunx biome format --write --max-diagnostics 500 src/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@suaveplan/error": "workspace:^",
"@suaveplan/types": "workspace:^",
"tslib": "catalog:build",
"zod": "catalog:forms",
"@opentelemetry/api": "catalog:observability"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
},
"files": [
"dist",
"src",
"README.md"
]
}

View File

@@ -0,0 +1,92 @@
# Discord Bot Client
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Implements the bot-token half of `@suaveplan/discord-client`. Wraps the
Discord Bot REST API (`/channels/:id/messages`, `/guilds/:id`, reactions) with
a runtime-agnostic surface: every constructor option is Zod-validated, every
network call routes through the shared `executeDiscordRequest` funnel (which
emits OTel metrics and maps responses to `DiscordClientError`), and both the
HTTP client and the wall clock are injectable so tests stay deterministic
without ever touching the real Discord API.
## Features
- `sendMessage`, `editMessage`, `deleteMessage`, `getChannel`, `getGuild`,
`createReaction`, `getStoredMessage`.
- In-memory message cache keyed by Discord message ID. `deleteMessage` evicts.
- Boundary validation of `DiscordMessage` via `DiscordMessageSchema`.
- Boundary validation of `DiscordBotConfig` via `DiscordBotConfigSchema`.
- Per-request `AbortController` timeout (default 10s) enforced through
`executeDiscordRequest`.
- `Bot <token>` authorization header set on every request.
- `/guilds/:id` requests automatically tag `isGuildRequest: true` so 404s
raise `DISCORD.GUILD_NOT_FOUND` instead of `DISCORD.CHANNEL_NOT_FOUND`.
- camelCase → snake_case mapping (`avatarUrl` → `avatar_url`) for Discord
payload compatibility.
## Usage: Basic
```ts
import { DiscordBotClient } from "@suaveplan/discord-client";
const bot = new DiscordBotClient({ token: process.env.DISCORD_BOT_TOKEN! });
await bot.sendMessage("123", { content: "hello" });
```
## Usage: Advanced
```ts
import { DiscordBotClient, type FetchLike } from "@suaveplan/discord-client";
const fakeFetch: FetchLike = async (_url, _init) => ({
ok: true,
status: 200,
statusText: "OK",
headers: { get: () => null },
text: async () => "",
json: async () => ({
id: "42",
channelId: "1",
content: "hi",
timestamp: "2026-01-01T00:00:00.000Z",
}),
});
const bot = new DiscordBotClient({
token: "abc",
fetch: fakeFetch,
clock: () => 1_700_000_000_000,
});
const sent = await bot.sendMessage("1", { content: "hi" });
expect(bot.getStoredMessage(sent.id)).toEqual(sent);
```
## API Reference
- `new DiscordBotClient(config: DiscordBotConfig)` — throws
`DiscordClientError(DISCORD.CONFIG_MISSING)` if `token` is missing,
`DiscordClientError(DISCORD.INVALID_CONFIG)` for other Zod failures.
- `sendMessage(channelId, message)` → `DiscordResponse`. Validates payload.
- `editMessage(channelId, messageId, partial)` → `DiscordResponse`.
- `deleteMessage(channelId, messageId)` → `void`.
- `getChannel(channelId)` → `DiscordChannel`.
- `getGuild(guildId)` → `{ id; name }`.
- `createReaction(channelId, messageId, emoji)` → `void`.
- `getStoredMessage(messageId)` → `DiscordResponse | undefined`.
## Implementation Notes
The in-memory `messages` cache is intentionally simple — Discord assigns
monotonic snowflake IDs so no eviction policy is required for typical usage.
Callers needing TTLs should layer their own cache around `getStoredMessage`.
`createReaction` URL-encodes the emoji segment because Discord rejects raw
non-ASCII path segments. `request<T>` deliberately funnels through
`executeDiscordRequest` so the bot client contributes zero bespoke error or
metric code — the funnel owns the entire error mapping and observability
surface.

View File

@@ -0,0 +1,365 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
DISCORD_CODES,
DiscordClientError,
} from "../../errors/discord-client-error.js";
import type {
DiscordFetchResponse,
FetchLike,
} from "../../types/discord-client.types.js";
import { DiscordBotClient } from "./discord-bot-client.js";
function ok(
body: unknown,
headers: Record<string, string> = {}
): DiscordFetchResponse {
return {
ok: true,
status: 200,
statusText: "OK",
headers: {
get(name: string): string | null {
const v = headers[name.toLowerCase()];
return v === undefined ? null : v;
},
},
text: async () => "",
json: async () => body,
};
}
function noContent(): DiscordFetchResponse {
return {
ok: true,
status: 204,
statusText: "No Content",
headers: { get: () => null },
text: async () => "",
json: async () => ({}),
};
}
function makeFetch(
handler: (
url: string,
method: string,
body?: string
) => DiscordFetchResponse
): {
fetch: FetchLike;
calls: {
url: string;
method: string;
body?: string;
headers: Record<string, string>;
}[];
} {
const calls: {
url: string;
method: string;
body?: string;
headers: Record<string, string>;
}[] = [];
const fetchFn: FetchLike = async (url, init) => {
calls.push({
url,
method: init.method,
...(init.body !== undefined ? { body: init.body } : {}),
headers: init.headers,
});
return handler(url, init.method, init.body);
};
return { fetch: fetchFn, calls };
}
describe("DiscordBotClient constructor", () => {
it("rejects missing token with DISCORD.CONFIG_MISSING", {
timeout: 5000,
}, () => {
try {
new DiscordBotClient({ token: "" });
throw new Error("expected throw");
} catch (e) {
expect(e).toBeInstanceOf(DiscordClientError);
expect((e as DiscordClientError).code).toBe(
DISCORD_CODES.CONFIG_MISSING
);
}
});
it("rejects invalid baseUrl with DISCORD.INVALID_CONFIG", {
timeout: 5000,
}, () => {
try {
new DiscordBotClient({ token: "abc", baseUrl: "not-a-url" });
throw new Error("expected throw");
} catch (e) {
expect((e as DiscordClientError).code).toBe(
DISCORD_CODES.INVALID_CONFIG
);
}
});
it("rejects negative timeoutMs with DISCORD.INVALID_CONFIG", {
timeout: 5000,
}, () => {
try {
new DiscordBotClient({ token: "abc", timeoutMs: -1 });
throw new Error("expected throw");
} catch (e) {
expect((e as DiscordClientError).code).toBe(
DISCORD_CODES.INVALID_CONFIG
);
}
});
it("defaults baseUrl and timeoutMs", { timeout: 5000 }, () => {
const c = new DiscordBotClient({ token: "abc" });
expect(c).toBeInstanceOf(DiscordBotClient);
});
});
describe("DiscordBotClient.sendMessage", () => {
it("POSTs to /channels/:id/messages and caches the response", {
timeout: 30000,
}, async () => {
const { fetch, calls } = makeFetch(() =>
ok({
id: "42",
channelId: "1",
content: "hi",
timestamp: "2026-01-01T00:00:00.000Z",
})
);
const bot = new DiscordBotClient({ token: "abc", fetch });
const res = await bot.sendMessage("1", { content: "hi" });
expect(res.id).toBe("42");
expect(calls[0]?.method).toBe("POST");
expect(calls[0]?.url).toBe(
"https://discord.com/api/v10/channels/1/messages"
);
expect(calls[0]?.headers["Authorization"]).toBe("Bot abc");
expect(calls[0]?.headers["Content-Type"]).toBe("application/json");
expect(bot.getStoredMessage("42")).toEqual(res);
});
it("forwards every optional field including avatar/tts/embed", {
timeout: 30000,
}, async () => {
const { fetch, calls } = makeFetch(() =>
ok({
id: "43",
channelId: "1",
content: "x",
timestamp: "2026-01-01T00:00:00.000Z",
})
);
const bot = new DiscordBotClient({ token: "abc", fetch });
await bot.sendMessage("1", {
content: "x",
embeds: [{ title: "t", description: "d" }],
username: "n",
avatarUrl: "https://example.com/a.png",
tts: true,
});
const body = JSON.parse(calls[0]?.body ?? "{}") as Record<
string,
unknown
>;
expect(body["content"]).toBe("x");
expect(body["embeds"]).toEqual([{ title: "t", description: "d" }]);
expect(body["username"]).toBe("n");
expect(body["avatar_url"]).toBe("https://example.com/a.png");
expect(body["tts"]).toBe(true);
});
it("rejects an invalid message with DISCORD.INVALID_MESSAGE", {
timeout: 30000,
}, async () => {
const bot = new DiscordBotClient({
token: "abc",
fetch: async () => ok({}),
});
await expect(
bot.sendMessage("1", { content: "x".repeat(2001) })
).rejects.toMatchObject({ code: DISCORD_CODES.INVALID_MESSAGE });
});
});
describe("DiscordBotClient.editMessage / deleteMessage", () => {
it("PATCHes and caches the new payload", { timeout: 30000 }, async () => {
const { fetch, calls } = makeFetch(() =>
ok({
id: "44",
channelId: "1",
content: "edited",
timestamp: "2026-01-01T00:00:00.000Z",
})
);
const bot = new DiscordBotClient({ token: "abc", fetch });
const res = await bot.editMessage("1", "44", { content: "edited" });
expect(calls[0]?.method).toBe("PATCH");
expect(calls[0]?.url).toBe(
"https://discord.com/api/v10/channels/1/messages/44"
);
expect(bot.getStoredMessage("44")?.content).toBe("edited");
expect(res.content).toBe("edited");
});
it("editMessage with only embeds (no content) skips message validation", {
timeout: 30000,
}, async () => {
const { fetch, calls } = makeFetch(() =>
ok({
id: "46",
channelId: "1",
content: "",
timestamp: "2026-01-01T00:00:00.000Z",
})
);
const bot = new DiscordBotClient({ token: "abc", fetch });
const res = await bot.editMessage("1", "46", {
embeds: [{ title: "updated" }],
});
expect(res.id).toBe("46");
const body = JSON.parse(calls[0]?.body ?? "{}") as Record<
string,
unknown
>;
expect(body["content"]).toBeUndefined();
expect(body["embeds"]).toEqual([{ title: "updated" }]);
});
it("editMessage rejects oversized content with INVALID_MESSAGE", {
timeout: 30000,
}, async () => {
const bot = new DiscordBotClient({
token: "abc",
fetch: async () => ok({}),
});
await expect(
bot.editMessage("1", "44", { content: "x".repeat(2001) })
).rejects.toMatchObject({ code: DISCORD_CODES.INVALID_MESSAGE });
});
it("DELETEs and evicts from the cache", { timeout: 30000 }, async () => {
const { fetch, calls } = makeFetch((_url, method) => {
if (method === "DELETE") return noContent();
return ok({
id: "45",
channelId: "1",
content: "x",
timestamp: "2026-01-01T00:00:00.000Z",
});
});
const bot = new DiscordBotClient({ token: "abc", fetch });
await bot.sendMessage("1", { content: "x" });
await bot.deleteMessage("1", "45");
expect(calls[1]?.method).toBe("DELETE");
expect(bot.getStoredMessage("45")).toBeUndefined();
});
});
describe("DiscordBotClient.getChannel / getGuild", () => {
it("GETs the channel", { timeout: 30000 }, async () => {
const { fetch, calls } = makeFetch(() =>
ok({ id: "1", name: "general", type: 0 })
);
const bot = new DiscordBotClient({ token: "abc", fetch });
const ch = await bot.getChannel("1");
expect(ch.name).toBe("general");
expect(calls[0]?.method).toBe("GET");
expect(calls[0]?.url).toBe("https://discord.com/api/v10/channels/1");
});
it("getGuild raises GUILD_NOT_FOUND on 404", {
timeout: 30000,
}, async () => {
const fetchFn: FetchLike = async () => ({
ok: false,
status: 404,
statusText: "Not Found",
headers: { get: () => null },
text: async () => "no",
json: async () => ({}),
});
const bot = new DiscordBotClient({ token: "abc", fetch: fetchFn });
await expect(bot.getGuild("999")).rejects.toMatchObject({
code: DISCORD_CODES.GUILD_NOT_FOUND,
});
});
});
describe("DiscordBotClient.createReaction", () => {
it("URL-encodes the emoji and PUTs", { timeout: 30000 }, async () => {
const { fetch, calls } = makeFetch(() => noContent());
const bot = new DiscordBotClient({ token: "abc", fetch });
await bot.createReaction("1", "44", "👍");
expect(calls[0]?.method).toBe("PUT");
expect(calls[0]?.url).toBe(
`https://discord.com/api/v10/channels/1/messages/44/reactions/${encodeURIComponent("👍")}/@me`
);
});
});
describe("DiscordBotClient defaults branch", () => {
it("uses Date.now and globalThis.fetch when not injected", {
timeout: 30000,
}, async () => {
const originalFetch = globalThis.fetch;
const headersGet = (n: string): string | null => null;
type MinimalResponse = {
ok: boolean;
status: number;
statusText: string;
headers: { get(n: string): string | null };
text(): Promise<string>;
json(): Promise<unknown>;
};
const stubResponse: MinimalResponse = {
ok: true,
status: 200,
statusText: "OK",
headers: { get: headersGet },
text: async () => "",
json: async () => ({ id: "1", name: "general", type: 0 }),
};
let mode: "json" | "text" | "rate" = "json";
const stub404: MinimalResponse = {
ok: false,
status: 404,
statusText: "Not Found",
headers: { get: headersGet },
text: async () => "missing",
json: async () => ({}),
};
const stub429: MinimalResponse = {
ok: false,
status: 429,
statusText: "Too Many",
headers: { get: (n) => (n === "retry-after" ? "1" : null) },
text: async () => "",
json: async () => ({}),
};
globalThis.fetch = (async () => {
if (mode === "json") return stubResponse;
if (mode === "text") return stub404;
return stub429;
}) as unknown as typeof fetch;
try {
const c = new DiscordBotClient({ token: "abc" });
const ch = await c.getChannel("1");
expect(ch.name).toBe("general");
mode = "text";
await expect(c.getChannel("2")).rejects.toMatchObject({
code: DISCORD_CODES.CHANNEL_NOT_FOUND,
});
mode = "rate";
await expect(c.getChannel("3")).rejects.toMatchObject({
code: DISCORD_CODES.RATE_LIMITED,
});
} finally {
globalThis.fetch = originalFetch;
}
});
});

View File

@@ -0,0 +1,184 @@
import type { Clock } from "@suaveplan/types";
import {
DISCORD_CODES,
DiscordClientError,
} from "../../errors/discord-client-error.js";
import {
type DiscordBotConfig,
DiscordBotConfigSchema,
type DiscordChannel,
type DiscordMessage,
DiscordMessageSchema,
type DiscordResponse,
type FetchLike,
} from "../../types/discord-client.types.js";
import { executeDiscordRequest } from "../../utils/http.js";
const DEFAULT_BASE_URL = "https://discord.com/api/v10";
const DEFAULT_TIMEOUT_MS = 10_000;
/**
* Client for the Discord Bot API. Sends, edits, deletes messages; fetches
* channels; and creates reactions using a bot token. Runtime-agnostic via
* injectable `fetch` and `clock`.
*/
export class DiscordBotClient {
protected readonly token: string;
protected readonly baseUrl: string;
protected readonly timeoutMs: number;
protected readonly fetchFn: FetchLike;
protected readonly clock: Clock;
protected readonly messages = new Map<string, DiscordResponse>();
public constructor(config: DiscordBotConfig) {
const parsed = DiscordBotConfigSchema.safeParse(config);
if (!parsed.success) {
// Zod guarantees `issues` is non-empty when `success` is false.
const first = parsed.error.issues[0] as {
path: PropertyKey[];
message: string;
};
const missingToken = first.path[0] === "token";
throw new DiscordClientError(
missingToken
? DISCORD_CODES.CONFIG_MISSING
: DISCORD_CODES.INVALID_CONFIG,
first.message
);
}
this.token = config.token;
this.baseUrl = config.baseUrl ?? DEFAULT_BASE_URL;
this.timeoutMs = config.timeoutMs ?? DEFAULT_TIMEOUT_MS;
this.fetchFn =
config.fetch ??
((url, init) =>
fetch(url, init as RequestInit).then((res) => ({
ok: res.ok,
status: res.status,
statusText: res.statusText,
headers: { get: (n) => res.headers.get(n) },
text: () => res.text(),
json: () => res.json(),
})));
this.clock = config.clock ?? Date.now;
}
/** Send a message to a Discord channel. */
public async sendMessage(
channelId: string,
message: DiscordMessage
): Promise<DiscordResponse> {
this.validateMessage(message);
const body = this.buildMessageBody(message);
const url = `${this.baseUrl}/channels/${channelId}/messages`;
const response = await this.request<DiscordResponse>("POST", url, body);
this.messages.set(response.id, response);
return response;
}
/** Edit an existing message. */
public async editMessage(
channelId: string,
messageId: string,
message: Partial<DiscordMessage>
): Promise<DiscordResponse> {
if (message.content !== undefined) {
this.validateMessage({ ...message, content: message.content });
}
const body = this.buildMessageBody(message);
const url = `${this.baseUrl}/channels/${channelId}/messages/${messageId}`;
const response = await this.request<DiscordResponse>(
"PATCH",
url,
body
);
this.messages.set(response.id, response);
return response;
}
/** Delete a message from a channel. */
public async deleteMessage(
channelId: string,
messageId: string
): Promise<void> {
const url = `${this.baseUrl}/channels/${channelId}/messages/${messageId}`;
await this.request<void>("DELETE", url);
this.messages.delete(messageId);
}
/** Get channel information. */
public async getChannel(channelId: string): Promise<DiscordChannel> {
const url = `${this.baseUrl}/channels/${channelId}`;
return this.request<DiscordChannel>("GET", url);
}
/** Get guild metadata (drives the GUILD_NOT_FOUND error mapping on 404). */
public async getGuild(
guildId: string
): Promise<{ id: string; name: string }> {
const url = `${this.baseUrl}/guilds/${guildId}`;
return this.request<{ id: string; name: string }>("GET", url);
}
/** Add a reaction to a message. */
public async createReaction(
channelId: string,
messageId: string,
emoji: string
): Promise<void> {
const encoded = encodeURIComponent(emoji);
const url = `${this.baseUrl}/channels/${channelId}/messages/${messageId}/reactions/${encoded}/@me`;
await this.request<void>("PUT", url);
}
/** Get a previously sent message by ID from the in-memory cache. */
public getStoredMessage(messageId: string): DiscordResponse | undefined {
return this.messages.get(messageId);
}
protected validateMessage(message: DiscordMessage): void {
const result = DiscordMessageSchema.safeParse(message);
if (!result.success) {
const first = result.error.issues[0] as { message: string };
throw new DiscordClientError(
DISCORD_CODES.INVALID_MESSAGE,
first.message
);
}
}
protected buildMessageBody(
message: Partial<DiscordMessage>
): Record<string, unknown> {
const body: Record<string, unknown> = {};
if (message.content !== undefined) body["content"] = message.content;
if (message.embeds) body["embeds"] = message.embeds;
if (message.username) body["username"] = message.username;
if (message.avatarUrl) body["avatar_url"] = message.avatarUrl;
if (message.tts !== undefined) body["tts"] = message.tts;
return body;
}
protected async request<T>(
method: string,
url: string,
body?: Record<string, unknown>
): Promise<T> {
const headers: Record<string, string> = {
Authorization: `Bot ${this.token}`,
};
if (body) headers["Content-Type"] = "application/json";
return executeDiscordRequest<T>({
fetch: this.fetchFn,
clock: this.clock,
method,
url,
headers,
...(body ? { body } : {}),
timeoutMs: this.timeoutMs,
kind: "bot",
isGuildRequest: url.includes("/guilds/"),
});
}
}

View File

@@ -0,0 +1 @@
export { DiscordBotClient } from "./discord-bot-client.js";

View File

@@ -0,0 +1,2 @@
export { DiscordBotClient } from "./bot-client/discord-bot-client.js";
export { DiscordWebhook } from "./webhook/discord-webhook.js";

View File

@@ -0,0 +1,94 @@
# Discord Webhook
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Implements the webhook half of `@suaveplan/discord-client`. Discord webhooks
are URLs that anyone with the URL can POST to — no bot token required — and
they accept the same message shape as the bot API. This module wraps that
endpoint with Zod boundary validation, injectable fetch/clock, OpenTelemetry
instrumentation (inherited from the shared HTTP funnel), and a structured
`DiscordDeliveryReport` return value so callers get persistable delivery
telemetry rather than a raw Discord response.
## Features
- `send(message)`, `sendText(content)`, `sendEmbed(embed)` convenience methods.
- Defaults `username` and `avatarUrl` from the constructor when the message
payload omits them.
- Threaded webhook routing via `?thread_id=<id>` query string.
- Always sets `?wait=true` so Discord returns the persisted message ID,
required for the `DiscordDeliveryReport.messageId` field.
- Boundary validation:
- `DiscordWebhookConfigSchema` at construction; missing/non-string URL →
`DISCORD.CONFIG_MISSING`, other failures (bad URL shape, wrong host) →
`DISCORD.INVALID_CONFIG`.
- `DiscordMessageSchema` on every `send` call → `DISCORD.INVALID_MESSAGE`.
- Uses the shared `executeDiscordRequest` funnel so error mapping
(401/403/404/429/5xx) is identical to the bot client; on the webhook path,
generic non-ok responses raise `DISCORD.WEBHOOK_FAILED`.
## Usage: Basic
```ts
import { DiscordWebhook } from "@suaveplan/discord-client";
const hook = new DiscordWebhook({
webhookUrl: "https://discord.com/api/webhooks/123/abc",
});
await hook.sendText("deploy succeeded");
```
## Usage: Advanced
```ts
import { DiscordWebhook, type FetchLike } from "@suaveplan/discord-client";
const stub: FetchLike = async () => ({
ok: true,
status: 200,
statusText: "OK",
headers: { get: () => null },
text: async () => "",
json: async () => ({ id: "42", channel_id: "1" }),
});
const hook = new DiscordWebhook({
webhookUrl: "https://discord.com/api/webhooks/1/tok",
username: "deploybot",
fetch: stub,
clock: () => 1_700_000_000_000,
});
const report = await hook.sendEmbed({
title: "Deploy",
description: "succeeded",
color: 0x00ff00,
});
expect(report.status).toBe("delivered");
expect(report.timestamp).toEqual(new Date(1_700_000_000_000));
```
## API Reference
- `new DiscordWebhook(config: DiscordWebhookConfig)` — Zod-validated;
missing or non-string `webhookUrl` raises `DISCORD.CONFIG_MISSING`; any
other validation failure (non-Discord host, bad URL) raises
`DISCORD.INVALID_CONFIG`.
- `send(message: DiscordMessage): Promise<DiscordDeliveryReport>`.
- `sendText(content: string): Promise<DiscordDeliveryReport>`.
- `sendEmbed(embed: DiscordEmbed): Promise<DiscordDeliveryReport>`.
## Implementation Notes
`buildReport` derives the `timestamp` from the injected `Clock` rather than
calling `new Date()` so tests can pin the value deterministically. The
`errorCode` and `errorMessage` fields on the report are only populated when
the caller explicitly constructs a failure report — in the happy path the
report carries `status: "delivered"` and no error fields. The webhook URL is
appended with `?wait=true` unconditionally because Discord returns 204 (no
body) when `wait` is absent, which would leave the delivery report without an
ID.

View File

@@ -0,0 +1,269 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
DISCORD_CODES,
DiscordClientError,
} from "../../errors/discord-client-error.js";
import type {
DiscordFetchResponse,
FetchLike,
} from "../../types/discord-client.types.js";
import { DiscordWebhook } from "./discord-webhook.js";
const VALID_URL = "https://discord.com/api/webhooks/123/abctoken";
function ok(): DiscordFetchResponse {
return {
ok: true,
status: 200,
statusText: "OK",
headers: { get: () => null },
text: async () => "",
json: async () => ({ id: "msg", channel_id: "1" }),
};
}
function makeFetch(): {
fetch: FetchLike;
calls: { url: string; method: string; body?: string }[];
} {
const calls: { url: string; method: string; body?: string }[] = [];
const fetchFn: FetchLike = async (url, init) => {
calls.push({
url,
method: init.method,
...(init.body !== undefined ? { body: init.body } : {}),
});
return ok();
};
return { fetch: fetchFn, calls };
}
describe("DiscordWebhook constructor", () => {
it("rejects missing webhookUrl with DISCORD.CONFIG_MISSING", {
timeout: 5000,
}, () => {
try {
new DiscordWebhook({ webhookUrl: undefined as unknown as string });
throw new Error("expected throw");
} catch (e) {
expect(e).toBeInstanceOf(DiscordClientError);
expect((e as DiscordClientError).code).toBe(
DISCORD_CODES.CONFIG_MISSING
);
}
});
it("rejects non-Discord URL with DISCORD.INVALID_CONFIG", {
timeout: 5000,
}, () => {
try {
new DiscordWebhook({ webhookUrl: "https://example.com/hook" });
throw new Error("expected throw");
} catch (e) {
expect((e as DiscordClientError).code).toBe(
DISCORD_CODES.INVALID_CONFIG
);
}
});
it("accepts a valid Discord webhook URL", { timeout: 5000 }, () => {
const w = new DiscordWebhook({ webhookUrl: VALID_URL });
expect(w).toBeInstanceOf(DiscordWebhook);
});
});
describe("DiscordWebhook.send", () => {
it("POSTs with ?wait=true and returns a delivered report", {
timeout: 30000,
}, async () => {
const { fetch, calls } = makeFetch();
const w = new DiscordWebhook({
webhookUrl: VALID_URL,
fetch,
clock: () => 1_700_000_000_000,
});
const report = await w.send({ content: "hi" });
expect(report.status).toBe("delivered");
expect(report.messageId).toBe("msg");
expect(report.channelId).toBe("1");
expect(report.timestamp).toEqual(new Date(1_700_000_000_000));
expect(calls[0]?.url).toBe(`${VALID_URL}?wait=true`);
const body = JSON.parse(calls[0]?.body ?? "{}") as Record<
string,
unknown
>;
expect(body["content"]).toBe("hi");
});
it("appends thread_id when provided", { timeout: 30000 }, async () => {
const { fetch, calls } = makeFetch();
const w = new DiscordWebhook({ webhookUrl: VALID_URL, fetch });
await w.send({ content: "hi", threadId: "999" });
expect(calls[0]?.url).toBe(`${VALID_URL}?wait=true&thread_id=999`);
});
it("uses constructor username/avatarUrl as defaults", {
timeout: 30000,
}, async () => {
const { fetch, calls } = makeFetch();
const w = new DiscordWebhook({
webhookUrl: VALID_URL,
username: "default-user",
avatarUrl: "https://example.com/default.png",
fetch,
});
await w.send({ content: "hi" });
const body = JSON.parse(calls[0]?.body ?? "{}") as Record<
string,
unknown
>;
expect(body["username"]).toBe("default-user");
expect(body["avatar_url"]).toBe("https://example.com/default.png");
});
it("per-message overrides take precedence over constructor defaults", {
timeout: 30000,
}, async () => {
const { fetch, calls } = makeFetch();
const w = new DiscordWebhook({
webhookUrl: VALID_URL,
username: "default",
fetch,
});
await w.send({
content: "hi",
username: "override",
avatarUrl: "https://example.com/o.png",
tts: true,
embeds: [{ title: "t" }],
});
const body = JSON.parse(calls[0]?.body ?? "{}") as Record<
string,
unknown
>;
expect(body["username"]).toBe("override");
expect(body["avatar_url"]).toBe("https://example.com/o.png");
expect(body["tts"]).toBe(true);
expect(body["embeds"]).toEqual([{ title: "t" }]);
});
it("rejects an invalid message with DISCORD.INVALID_MESSAGE", {
timeout: 30000,
}, async () => {
const w = new DiscordWebhook({
webhookUrl: VALID_URL,
fetch: async () => ok(),
});
await expect(
w.send({ content: "x".repeat(2001) })
).rejects.toMatchObject({ code: DISCORD_CODES.INVALID_MESSAGE });
});
});
describe("DiscordWebhook.sendText / sendEmbed", () => {
it("sendText wraps content in DiscordMessage", {
timeout: 30000,
}, async () => {
const { fetch, calls } = makeFetch();
const w = new DiscordWebhook({ webhookUrl: VALID_URL, fetch });
await w.sendText("hi");
const body = JSON.parse(calls[0]?.body ?? "{}") as Record<
string,
unknown
>;
expect(body["content"]).toBe("hi");
});
it("sendEmbed sends an embed array with empty content", {
timeout: 30000,
}, async () => {
const { fetch, calls } = makeFetch();
const w = new DiscordWebhook({ webhookUrl: VALID_URL, fetch });
await w.sendEmbed({ title: "t" });
const body = JSON.parse(calls[0]?.body ?? "{}") as Record<
string,
unknown
>;
expect(body["content"]).toBe("");
expect(body["embeds"]).toEqual([{ title: "t" }]);
});
});
describe("DiscordWebhook upstream errors", () => {
it("maps 5xx to DISCORD.WEBHOOK_FAILED", { timeout: 30000 }, async () => {
const fetchFn: FetchLike = async () => ({
ok: false,
status: 502,
statusText: "Bad Gateway",
headers: { get: () => null },
text: async () => "oops",
json: async () => ({}),
});
const w = new DiscordWebhook({ webhookUrl: VALID_URL, fetch: fetchFn });
await expect(w.sendText("hi")).rejects.toMatchObject({
code: DISCORD_CODES.WEBHOOK_FAILED,
});
});
});
describe("DiscordWebhook defaults branch", () => {
it("uses globalThis.fetch and Date.now when not injected", {
timeout: 30000,
}, async () => {
const originalFetch = globalThis.fetch;
type MinimalResponse = {
ok: boolean;
status: number;
statusText: string;
headers: { get(n: string): string | null };
text(): Promise<string>;
json(): Promise<unknown>;
};
const stubResponse: MinimalResponse = {
ok: true,
status: 200,
statusText: "OK",
headers: { get: () => null },
text: async () => "",
json: async () => ({ id: "msg", channel_id: "1" }),
};
let mode: "json" | "err" | "rate" = "json";
const stubErr: MinimalResponse = {
ok: false,
status: 500,
statusText: "Server Error",
headers: { get: () => null },
text: async () => "boom",
json: async () => ({}),
};
const stub429: MinimalResponse = {
ok: false,
status: 429,
statusText: "Too Many",
headers: { get: (n) => (n === "retry-after" ? "1" : null) },
text: async () => "",
json: async () => ({}),
};
globalThis.fetch = (async () => {
if (mode === "json") return stubResponse;
if (mode === "err") return stubErr;
return stub429;
}) as unknown as typeof fetch;
try {
const w = new DiscordWebhook({ webhookUrl: VALID_URL });
const report = await w.sendText("hi");
expect(report.status).toBe("delivered");
expect(report.messageId).toBe("msg");
mode = "err";
await expect(w.sendText("hi")).rejects.toMatchObject({
code: DISCORD_CODES.WEBHOOK_FAILED,
});
mode = "rate";
await expect(w.sendText("hi")).rejects.toMatchObject({
code: DISCORD_CODES.RATE_LIMITED,
});
} finally {
globalThis.fetch = originalFetch;
}
});
});

View File

@@ -0,0 +1,123 @@
import type { Clock } from "@suaveplan/types";
import {
DISCORD_CODES,
DiscordClientError,
} from "../../errors/discord-client-error.js";
import {
type DiscordDeliveryReport,
type DiscordEmbed,
type DiscordMessage,
DiscordMessageSchema,
type DiscordWebhookConfig,
DiscordWebhookConfigSchema,
type FetchLike,
} from "../../types/discord-client.types.js";
import { executeDiscordRequest } from "../../utils/http.js";
const DEFAULT_TIMEOUT_MS = 10_000;
/**
* Sends messages to Discord channels via webhook URLs. Supports plain text,
* embeds, and full message payloads. Returns a structured
* `DiscordDeliveryReport` so callers can persist delivery telemetry.
*/
export class DiscordWebhook {
protected readonly webhookUrl: string;
protected readonly username?: string;
protected readonly avatarUrl?: string;
protected readonly timeoutMs: number;
protected readonly fetchFn: FetchLike;
protected readonly clock: Clock;
public constructor(config: DiscordWebhookConfig) {
const parsed = DiscordWebhookConfigSchema.safeParse(config);
if (!parsed.success) {
const first = parsed.error.issues[0] as {
path: PropertyKey[];
code: string;
message: string;
};
const isMissingUrl =
first.path[0] === "webhookUrl" && first.code === "invalid_type";
throw new DiscordClientError(
isMissingUrl
? DISCORD_CODES.CONFIG_MISSING
: DISCORD_CODES.INVALID_CONFIG,
first.message
);
}
this.webhookUrl = config.webhookUrl;
if (config.username !== undefined) this.username = config.username;
if (config.avatarUrl !== undefined) this.avatarUrl = config.avatarUrl;
this.timeoutMs = config.timeoutMs ?? DEFAULT_TIMEOUT_MS;
this.fetchFn =
config.fetch ??
((url, init) =>
fetch(url, init as RequestInit).then((res) => ({
ok: res.ok,
status: res.status,
statusText: res.statusText,
headers: { get: (n) => res.headers.get(n) },
text: () => res.text(),
json: () => res.json(),
})));
this.clock = config.clock ?? Date.now;
}
/** Send a full message payload via the webhook. */
public async send(message: DiscordMessage): Promise<DiscordDeliveryReport> {
const result = DiscordMessageSchema.safeParse(message);
if (!result.success) {
const first = result.error.issues[0] as { message: string };
throw new DiscordClientError(
DISCORD_CODES.INVALID_MESSAGE,
first.message
);
}
const body: Record<string, unknown> = {
content: message.content,
};
if (message.embeds) body["embeds"] = message.embeds;
if (message.tts !== undefined) body["tts"] = message.tts;
const username = message.username ?? this.username;
if (username !== undefined) body["username"] = username;
const avatarUrl = message.avatarUrl ?? this.avatarUrl;
if (avatarUrl !== undefined) body["avatar_url"] = avatarUrl;
const url = message.threadId
? `${this.webhookUrl}?wait=true&thread_id=${message.threadId}`
: `${this.webhookUrl}?wait=true`;
const data = await executeDiscordRequest<{
id: string;
channel_id: string;
}>({
fetch: this.fetchFn,
clock: this.clock,
method: "POST",
url,
headers: { "Content-Type": "application/json" },
body,
timeoutMs: this.timeoutMs,
kind: "webhook",
});
return {
messageId: data.id,
channelId: data.channel_id,
status: "delivered",
timestamp: new Date(this.clock()),
};
}
/** Send a plain-text message via the webhook. */
public sendText(content: string): Promise<DiscordDeliveryReport> {
return this.send({ content });
}
/** Send a single embed via the webhook. */
public sendEmbed(embed: DiscordEmbed): Promise<DiscordDeliveryReport> {
return this.send({ content: "", embeds: [embed] });
}
}

View File

@@ -0,0 +1 @@
export { DiscordWebhook } from "./discord-webhook.js";

View File

@@ -0,0 +1,84 @@
# Discord Client Error
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines the namespaced, frozen error-code registry and the typed
`DiscordClientError` class used by every runtime failure path in
`@suaveplan/discord-client`. Co-locating the codes with the error class
guarantees that adding a new code is a single-file change and that the
TypeScript union stays in sync with the runtime registry the conformance
audit greps for.
## Features
- `DISCORD_CODES` — a `defineCodes("DISCORD", [...])` frozen record. Each entry
has the form `DISCORD.<REASON>` so it is globally unique across the
shared code space.
- `DiscordClientCode` — type alias for the union of every value in
`DISCORD_CODES`.
- `DiscordClientError` — extends `SuaveplanError<DiscordClientCode>` so
callers can do `instanceof DiscordClientError` to narrow on the code union
AND/OR pattern-match on `.code` for stable programmatic handling.
## Usage: Basic
```ts
import {
DISCORD_CODES,
DiscordClientError,
} from "@suaveplan/discord-client/errors";
throw new DiscordClientError(
DISCORD_CODES.AUTH_ERROR,
"discord.errors.authError"
);
```
## Usage: Advanced
```ts
import { DiscordClientError, DISCORD_CODES } from "@suaveplan/discord-client";
try {
await client.sendMessage("1", { content: "hi" });
} catch (err) {
if (err instanceof DiscordClientError) {
switch (err.code) {
case DISCORD_CODES.RATE_LIMITED:
// back off using `err.context?.retryAfterMs`
break;
case DISCORD_CODES.FORBIDDEN:
case DISCORD_CODES.AUTH_ERROR:
// surface to ops, do not retry
break;
default:
// generic retry / report
break;
}
}
}
```
## API Reference
- `DISCORD_CODES`: frozen record. Keys: `API_ERROR`, `AUTH_ERROR`,
`RATE_LIMITED`, `CHANNEL_NOT_FOUND`, `GUILD_NOT_FOUND`, `CONFIG_MISSING`,
`INVALID_CONFIG`, `INVALID_MESSAGE`, `WEBHOOK_FAILED`, `TIMEOUT`,
`FORBIDDEN`. Values are the `DISCORD.<REASON>` strings.
- `DiscordClientCode`: `typeof DISCORD_CODES[keyof typeof DISCORD_CODES]`.
- `DiscordClientError(code, messageKey, options?)`: typed error subclass.
`options` is the `SuaveplanErrorOptions` bag — pass `cause`, `context`,
`traceId`, or `clock` through.
## Implementation Notes
Every code in this registry is thrown by at least one production code path
inside `src/utils/http.ts`, `src/core/bot-client/discord-bot-client.ts`, or
`src/core/webhook/discord-webhook.ts`. The conformance audit
(`bun scripts/conformance.ts @suaveplan/discord-client`) greps for
exactly that mapping; do not add a code here unless you wire a real throw
site for it in the same change.

View File

@@ -0,0 +1,66 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
DISCORD_CODES,
type DiscordClientCode,
DiscordClientError,
} from "./discord-client-error.js";
describe("DISCORD_CODES registry", () => {
it("is frozen and namespaces every code with DISCORD.", {
timeout: 5000,
}, () => {
expect(Object.isFrozen(DISCORD_CODES)).toBe(true);
for (const value of Object.values(DISCORD_CODES)) {
expect(value.startsWith("DISCORD.")).toBe(true);
}
});
it("exposes all 11 documented codes", { timeout: 5000 }, () => {
expect(Object.keys(DISCORD_CODES).sort()).toEqual(
[
"API_ERROR",
"AUTH_ERROR",
"RATE_LIMITED",
"CHANNEL_NOT_FOUND",
"GUILD_NOT_FOUND",
"CONFIG_MISSING",
"INVALID_CONFIG",
"INVALID_MESSAGE",
"WEBHOOK_FAILED",
"TIMEOUT",
"FORBIDDEN",
].sort()
);
});
});
describe("DiscordClientError", () => {
it("extends SuaveplanError and carries the code", { timeout: 5000 }, () => {
const err = new DiscordClientError(
DISCORD_CODES.AUTH_ERROR,
"auth failed"
);
expect(err).toBeInstanceOf(DiscordClientError);
expect(err).toBeInstanceOf(Error);
expect(err.code).toBe(DISCORD_CODES.AUTH_ERROR);
expect(err.message).toBe("auth failed");
expect(err.name).toBe("DiscordClientError");
});
it("forwards options to SuaveplanError", { timeout: 5000 }, () => {
const cause = new Error("boom");
const err = new DiscordClientError(
DISCORD_CODES.TIMEOUT,
"Discord timed out",
{ cause, context: { url: "https://x" } }
);
expect(err.cause).toBe(cause);
expect(err.context).toEqual({ url: "https://x" });
expect(err.timestamp).toBeGreaterThan(0);
});
it("narrows on the code literal union", { timeout: 5000 }, () => {
const code: DiscordClientCode = DISCORD_CODES.FORBIDDEN;
expect(code).toBe("DISCORD.FORBIDDEN");
});
});

View File

@@ -0,0 +1,41 @@
import type { SuaveplanErrorOptions } from "@suaveplan/error";
import { defineCodes, SuaveplanError } from "@suaveplan/error";
/**
* Stable, frozen error-code registry for `@suaveplan/discord-client`. Every
* runtime failure thrown by this package carries one of these codes.
*/
export const DISCORD_CODES = defineCodes("DISCORD", [
"API_ERROR",
"AUTH_ERROR",
"RATE_LIMITED",
"CHANNEL_NOT_FOUND",
"GUILD_NOT_FOUND",
"CONFIG_MISSING",
"INVALID_CONFIG",
"INVALID_MESSAGE",
"WEBHOOK_FAILED",
"TIMEOUT",
"FORBIDDEN",
] as const);
export type DiscordClientCode =
(typeof DISCORD_CODES)[keyof typeof DISCORD_CODES];
/**
* Typed error class for every `@suaveplan/discord-client` failure.
*
* Extends `SuaveplanError<DiscordClientCode>` so callers can either
* `instanceof DiscordClientError` to narrow on the code union OR pattern-match
* on the stable `.code` string.
*/
export class DiscordClientError extends SuaveplanError<DiscordClientCode> {
// biome-ignore lint/complexity/noUselessConstructor: widens the protected base constructor to public
public constructor(
code: DiscordClientCode,
messageKey: string,
options?: SuaveplanErrorOptions
) {
super(code, messageKey, options);
}
}

View File

@@ -0,0 +1,5 @@
export {
DISCORD_CODES,
type DiscordClientCode,
DiscordClientError,
} from "./discord-client-error.js";

View File

@@ -0,0 +1,38 @@
// Core clients
export { DiscordBotClient } from "./core/bot-client/discord-bot-client.js";
export { DiscordWebhook } from "./core/webhook/discord-webhook.js";
// Errors
export {
DISCORD_CODES,
type DiscordClientCode,
DiscordClientError,
} from "./errors/discord-client-error.js";
export type {
DiscordBotConfig,
DiscordChannel,
DiscordClientCommonInjectables,
DiscordDeliveryReport,
DiscordDeliveryStatus,
DiscordEmbed,
DiscordEmbedField,
DiscordFetchResponse,
DiscordMessage,
DiscordResponse,
DiscordWebhookConfig,
FetchLike,
} from "./types/discord-client.types.js";
// Types
export {
DiscordBotConfigSchema,
DiscordMessageSchema,
DiscordWebhookConfigSchema,
} from "./types/discord-client.types.js";
// Utils (advanced — exposed for custom routes)
export {
type DiscordRequestKind,
type DiscordRequestOptions,
executeDiscordRequest,
resetDiscordHttpMetrics,
} from "./utils/http.js";
export { parseRetryAfterMs } from "./utils/rate-limit.js";

View File

@@ -0,0 +1,83 @@
# Discord Client Types
## Purpose
Authoritative public type surface for `@suaveplan/discord-client`. Defines every
data shape the bot client and webhook client emit or accept, plus the runtime
Zod schemas used to validate configuration and message payloads at the public
boundary. Centralising the types here guarantees consumers can import them
without pulling in any runtime code, and ensures Zod schemas stay in lockstep
with the TypeScript surface they validate.
## Features
- `DiscordBotConfig` / `DiscordWebhookConfig` with injectable `clock` and
`fetch` for deterministic testing.
- `DiscordMessage` / `DiscordEmbed` / `DiscordEmbedField` mirroring the Discord
v10 API payload contracts (with field-length caps enforced by Zod).
- `DiscordResponse`, `DiscordChannel`, `DiscordDeliveryReport`,
`DiscordDeliveryStatus` covering every value returned to callers.
- `FetchLike` and `DiscordFetchResponse` describe the minimal subset of the
fetch contract the client depends on — tests inject a deterministic stub.
- Zod boundary schemas (`DiscordBotConfigSchema`, `DiscordWebhookConfigSchema`,
`DiscordMessageSchema`) validate config and message payloads.
## Usage: Basic
```ts
import { DiscordMessageSchema, type DiscordMessage } from "@suaveplan/discord-client/types";
const msg: DiscordMessage = { content: "hello" };
const parsed = DiscordMessageSchema.parse(msg);
```
## Usage: Advanced
```ts
import {
DiscordBotConfigSchema,
type DiscordBotConfig,
type FetchLike,
} from "@suaveplan/discord-client/types";
const stubFetch: FetchLike = async () => ({
ok: true,
status: 200,
statusText: "OK",
headers: { get: () => null },
text: async () => "",
json: async () => ({}),
});
const config: DiscordBotConfig = {
token: "abc",
fetch: stubFetch,
clock: () => 1_700_000_000_000,
};
DiscordBotConfigSchema.parse(config);
```
## API Reference
- `DiscordBotConfig`: token, baseUrl, timeoutMs, plus injectable `clock`/`fetch`.
- `DiscordWebhookConfig`: webhookUrl (must contain `discord.com/api/webhooks/`),
username, avatarUrl, timeoutMs, plus injectables.
- `DiscordMessage`: content (≤2000), embeds (≤10), username, avatarUrl, tts,
threadId.
- `DiscordEmbed`: full Discord embed object with caps on each field.
- `DiscordResponse`: id, channelId, content, timestamp, embeds.
- `DiscordChannel`: id, name, type, guildId, topic.
- `DiscordDeliveryReport`: messageId, channelId, status, timestamp, errorCode,
errorMessage.
- `DiscordMessageSchema` / `DiscordBotConfigSchema` /
`DiscordWebhookConfigSchema`: Zod parsers used at every public entrypoint.
## Implementation Notes
`FetchLike` purposefully omits headers initialisation, body streaming, and
other parts of the WHATWG Response API the client never reads. This keeps the
test stub small and avoids tying the package to a specific runtime's fetch
quirks. `DiscordBotConfigSchema` accepts the injectables as `z.unknown()` —
their structural shape is enforced by the static `FetchLike` / `Clock` types,
not at runtime, because schemas cannot reliably introspect function signatures
across Bun and Node without false negatives.

View File

@@ -0,0 +1,196 @@
import type { Clock } from "@suaveplan/types";
import z from "zod";
/**
* Minimal injectable fetch contract. Matches the relevant subset of the
* global `fetch` shape so callers can pass `globalThis.fetch` directly OR
* a deterministic stub in tests. We avoid `typeof fetch` to keep the
* contract tight and runtime-agnostic.
*/
export type FetchLike = (
url: string,
init: {
method: string;
headers: Record<string, string>;
body?: string;
signal?: AbortSignal;
}
) => Promise<DiscordFetchResponse>;
/** Subset of the `Response` shape consumed by the Discord client. */
export interface DiscordFetchResponse {
readonly ok: boolean;
readonly status: number;
readonly statusText: string;
readonly headers: {
get(name: string): string | null;
};
text(): Promise<string>;
json(): Promise<unknown>;
}
/** Supported HMAC-of-time-source for any timestamp the client emits. */
export interface DiscordClientCommonInjectables {
/** Wall-clock used for delivery report `timestamp`s. */
clock?: Clock;
/** Injectable fetch implementation. Defaults to `globalThis.fetch`. */
fetch?: FetchLike;
}
/** Configuration for the Discord bot client. */
export interface DiscordBotConfig extends DiscordClientCommonInjectables {
/** Bot authentication token (without the `Bot ` prefix). */
token: string;
/** Base URL for the Discord API. Defaults to `https://discord.com/api/v10`. */
baseUrl?: string;
/** Request timeout in milliseconds. Defaults to `10000`. */
timeoutMs?: number;
}
/** Configuration for a Discord webhook. */
export interface DiscordWebhookConfig extends DiscordClientCommonInjectables {
/** Full webhook URL including token (must include `discord.com/api/webhooks/`). */
webhookUrl: string;
/** Default username override for messages. */
username?: string;
/** Default avatar URL override for messages. */
avatarUrl?: string;
/** Request timeout in milliseconds. Defaults to `10000`. */
timeoutMs?: number;
}
/** A Discord embed field. */
export interface DiscordEmbedField {
name: string;
value: string;
inline?: boolean;
}
/** A Discord embed object. */
export interface DiscordEmbed {
title?: string;
description?: string;
url?: string;
/** Decimal color value. */
color?: number;
/** ISO 8601 timestamp. */
timestamp?: string;
footer?: { text: string; iconUrl?: string };
thumbnail?: { url: string };
image?: { url: string };
author?: { name: string; url?: string; iconUrl?: string };
fields?: DiscordEmbedField[];
}
/** A Discord message payload. */
export interface DiscordMessage {
/** Text content of the message. */
content: string;
/** Rich embed objects to include. */
embeds?: DiscordEmbed[];
/** Username override for this message. */
username?: string;
/** Avatar URL override for this message. */
avatarUrl?: string;
/** Whether to send as text-to-speech. */
tts?: boolean;
/** Thread ID to send the message to. */
threadId?: string;
}
/** Response from the Discord API after sending a message. */
export interface DiscordResponse {
id: string;
channelId: string;
content: string;
timestamp: string;
embeds?: DiscordEmbed[];
}
/** A Discord channel object. */
export interface DiscordChannel {
id: string;
name: string;
type: number;
guildId?: string;
topic?: string;
}
/** Status of a Discord message delivery. */
export type DiscordDeliveryStatus = "queued" | "sent" | "delivered" | "failed";
/** Report on the delivery status of a Discord message. */
export interface DiscordDeliveryReport {
messageId: string;
channelId: string;
status: DiscordDeliveryStatus;
timestamp: Date;
errorCode?: string;
errorMessage?: string;
}
// ── Zod boundary schemas ─────────────────────────────────────────────────────
const embedFieldSchema = z.object({
name: z.string().min(1).max(256),
value: z.string().min(1).max(1024),
inline: z.boolean().optional(),
});
const embedSchema = z.object({
title: z.string().max(256).optional(),
description: z.string().max(4096).optional(),
url: z.string().url().optional(),
color: z.number().int().min(0).max(0xffffff).optional(),
timestamp: z.string().optional(),
footer: z
.object({
text: z.string().max(2048),
iconUrl: z.string().url().optional(),
})
.optional(),
thumbnail: z.object({ url: z.string().url() }).optional(),
image: z.object({ url: z.string().url() }).optional(),
author: z
.object({
name: z.string().min(1).max(256),
url: z.string().url().optional(),
iconUrl: z.string().url().optional(),
})
.optional(),
fields: z.array(embedFieldSchema).max(25).optional(),
});
/** Zod schema validating a `DiscordMessage` payload at the public boundary. */
export const DiscordMessageSchema = z.object({
content: z.string().max(2000),
embeds: z.array(embedSchema).max(10).optional(),
username: z.string().min(1).max(80).optional(),
avatarUrl: z.string().url().optional(),
tts: z.boolean().optional(),
threadId: z.string().regex(/^\d+$/).optional(),
});
/** Zod schema validating a `DiscordBotConfig` at construction time. */
export const DiscordBotConfigSchema = z.object({
token: z.string().min(1),
baseUrl: z.string().url().optional(),
timeoutMs: z.number().int().positive().optional(),
clock: z.unknown().optional(),
fetch: z.unknown().optional(),
});
/** Zod schema validating a `DiscordWebhookConfig` at construction time. */
export const DiscordWebhookConfigSchema = z.object({
webhookUrl: z
.string()
.url()
.refine((u) => u.includes("discord.com/api/webhooks/"), {
message: "webhookUrl must be a discord.com/api/webhooks/ URL",
}),
username: z.string().min(1).max(80).optional(),
avatarUrl: z.string().url().optional(),
timeoutMs: z.number().int().positive().optional(),
clock: z.unknown().optional(),
fetch: z.unknown().optional(),
});

View File

@@ -0,0 +1,19 @@
export type {
DiscordBotConfig,
DiscordChannel,
DiscordClientCommonInjectables,
DiscordDeliveryReport,
DiscordDeliveryStatus,
DiscordEmbed,
DiscordEmbedField,
DiscordFetchResponse,
DiscordMessage,
DiscordResponse,
DiscordWebhookConfig,
FetchLike,
} from "./discord-client.types.js";
export {
DiscordBotConfigSchema,
DiscordMessageSchema,
DiscordWebhookConfigSchema,
} from "./discord-client.types.js";

View File

@@ -0,0 +1,90 @@
# Discord HTTP Helper
## Purpose
Single funnel through which every Discord API and webhook request flows. It
maps Discord response codes onto the `DiscordClientError` taxonomy, attaches
OpenTelemetry counters and histograms, enforces an `AbortController`-driven
timeout, and treats abort errors as `DISCORD.TIMEOUT`. Centralising this logic
guarantees identical retry hints, error codes, and observability across the
bot client and webhook client — the two entrypoints differ only in their URL
construction and choice of `kind`.
## Features
- Maps `401 / 403 / 404 / 429 / 5xx / other-non-ok` Discord responses onto
stable `DISCORD.*` codes, with per-kind dispatch for the webhook path.
- Differentiates guild vs channel 404s via `isGuildRequest`.
- Pulls `Retry-After` data via `parseRetryAfterMs` and surfaces it on the
thrown error's `context.retryAfterMs` so callers can honour Discord's
back-off recommendation without re-parsing headers.
- Lazily creates `discord_client.request.total` counter and
`discord_client.request.duration_ms` histogram against the global OTel
meter provider. `resetDiscordHttpMetrics()` invalidates the cache so tests
can swap in an in-memory `MeterProvider`.
- Treats `AbortError` (raised by the timeout) as `DISCORD.TIMEOUT`; every
other fetch rejection becomes `DISCORD.API_ERROR` or `DISCORD.WEBHOOK_FAILED`
per the request kind.
- Returns `undefined` for HTTP 204 (Discord's success-with-no-body status,
used by `DELETE` and `PUT` reactions).
## Usage: Basic
```ts
import { executeDiscordRequest } from "@suaveplan/discord-client";
const channel = await executeDiscordRequest<DiscordChannel>({
fetch,
clock: Date.now,
method: "GET",
url: "https://discord.com/api/v10/channels/1",
headers: { Authorization: "Bot abc" },
timeoutMs: 10_000,
kind: "bot",
});
```
## Usage: Advanced
```ts
import {
executeDiscordRequest,
resetDiscordHttpMetrics,
} from "@suaveplan/discord-client";
import { MeterProvider } from "@opentelemetry/sdk-metrics";
const mp = new MeterProvider();
metrics.setGlobalMeterProvider(mp);
resetDiscordHttpMetrics();
await executeDiscordRequest({
fetch: stub,
clock: () => 1700000000000,
method: "POST",
url: "https://discord.com/api/v10/channels/1/messages",
headers: { Authorization: "Bot abc", "Content-Type": "application/json" },
body: { content: "hi" },
timeoutMs: 10_000,
kind: "bot",
});
```
## API Reference
- `executeDiscordRequest<T>(opts: DiscordRequestOptions): Promise<T>` — main
entrypoint. Throws `DiscordClientError`.
- `resetDiscordHttpMetrics()` — clears the cached meter / counter / histogram
so the next call rebinds to the current global meter provider. Used by tests.
- `DiscordRequestKind = "bot" | "webhook"` — drives the error code on
`WEBHOOK_FAILED` vs `API_ERROR`.
## Implementation Notes
The function intentionally records a metric on every outcome (`ok`,
`auth_error`, `forbidden`, `not_found`, `rate_limited`, `timeout`,
`network_error`, `api_error`). Histograms are emitted with the same label
set as the counter so dashboards can divide one by the other directly.
The 401/403/429/404/non-ok ordering matches Discord's documented
precedence; non-ok responses fall through to the final clause, which
distinguishes `DISCORD.API_ERROR` (bot) from `DISCORD.WEBHOOK_FAILED`
(webhook) so the error registry's retry/severity hints stay accurate.

View File

@@ -0,0 +1,350 @@
import type {
Counter,
Histogram,
Meter,
MeterProvider,
MetricOptions,
} from "@opentelemetry/api";
import { metrics } from "@opentelemetry/api";
import { beforeEach, describe, expect, it } from "@suaveplan/testing/runner";
import {
DISCORD_CODES,
DiscordClientError,
} from "../errors/discord-client-error.js";
import type {
DiscordFetchResponse,
FetchLike,
} from "../types/discord-client.types.js";
import { executeDiscordRequest, resetDiscordHttpMetrics } from "./http.js";
// ── In-memory MeterProvider recording every counter add / histogram record ──
interface CounterRecord {
name: string;
value: number;
attrs?: Record<string, string>;
}
interface HistogramRecord {
name: string;
value: number;
attrs?: Record<string, string>;
}
function makeRecordingProvider(): {
provider: MeterProvider;
counters: CounterRecord[];
histograms: HistogramRecord[];
counterNames: string[];
histogramNames: string[];
} {
const counters: CounterRecord[] = [];
const histograms: HistogramRecord[] = [];
const counterNames: string[] = [];
const histogramNames: string[] = [];
const meter: Partial<Meter> = {
createCounter(name: string, _opts?: MetricOptions): Counter {
counterNames.push(name);
return {
add: (value: number, attrs?: Record<string, string>) => {
counters.push({ name, value, attrs });
},
} as unknown as Counter;
},
createHistogram(name: string, _opts?: MetricOptions): Histogram {
histogramNames.push(name);
return {
record: (value: number, attrs?: Record<string, string>) => {
histograms.push({ name, value, attrs });
},
} as unknown as Histogram;
},
};
const provider: Partial<MeterProvider> = {
getMeter: (() => meter as Meter) as MeterProvider["getMeter"],
};
return {
provider: provider as MeterProvider,
counters,
histograms,
counterNames,
histogramNames,
};
}
function makeResponse(init: {
ok?: boolean;
status: number;
statusText?: string;
headers?: Record<string, string>;
body?: unknown;
text?: string;
}): DiscordFetchResponse {
const headers = init.headers ?? {};
return {
ok: init.ok ?? (init.status >= 200 && init.status < 300),
status: init.status,
statusText: init.statusText ?? "OK",
headers: {
get(name: string): string | null {
const v = headers[name.toLowerCase()];
return v === undefined ? null : v;
},
},
text: async () => init.text ?? "",
json: async () => init.body ?? {},
};
}
const baseOpts = (
fetchFn: FetchLike,
overrides: Partial<Parameters<typeof executeDiscordRequest>[0]> = {}
): Parameters<typeof executeDiscordRequest>[0] => ({
fetch: fetchFn,
clock: () => 1_700_000_000_000,
method: "GET",
url: "https://discord.com/api/v10/channels/1",
headers: { Authorization: "Bot abc" },
timeoutMs: 10_000,
kind: "bot",
...overrides,
});
describe("executeDiscordRequest", () => {
beforeEach(() => {
resetDiscordHttpMetrics();
});
it("returns parsed JSON for 2xx responses", {
timeout: 30000,
}, async () => {
const stub: FetchLike = async () =>
makeResponse({ status: 200, body: { id: "1" } });
const result = await executeDiscordRequest<{ id: string }>(
baseOpts(stub)
);
expect(result).toEqual({ id: "1" });
});
it("returns undefined for 204 responses", { timeout: 30000 }, async () => {
const stub: FetchLike = async () =>
makeResponse({ status: 204, ok: true });
const result = await executeDiscordRequest<unknown>(baseOpts(stub));
expect(result).toBeUndefined();
});
it("maps 401 to DISCORD.AUTH_ERROR", { timeout: 30000 }, async () => {
const stub: FetchLike = async () => makeResponse({ status: 401 });
await expect(
executeDiscordRequest(baseOpts(stub))
).rejects.toMatchObject({ code: DISCORD_CODES.AUTH_ERROR });
});
it("maps 403 to DISCORD.FORBIDDEN", { timeout: 30000 }, async () => {
const stub: FetchLike = async () => makeResponse({ status: 403 });
await expect(
executeDiscordRequest(baseOpts(stub))
).rejects.toMatchObject({ code: DISCORD_CODES.FORBIDDEN });
});
it("maps 404 channel responses to DISCORD.CHANNEL_NOT_FOUND", {
timeout: 30000,
}, async () => {
const stub: FetchLike = async () =>
makeResponse({ status: 404, text: "channel" });
await expect(
executeDiscordRequest(baseOpts(stub))
).rejects.toMatchObject({
code: DISCORD_CODES.CHANNEL_NOT_FOUND,
message: "channel",
});
});
it("maps 404 guild responses to DISCORD.GUILD_NOT_FOUND when isGuildRequest", {
timeout: 30000,
}, async () => {
const stub: FetchLike = async () =>
makeResponse({ status: 404, text: "" });
await expect(
executeDiscordRequest(
baseOpts(stub, {
url: "https://discord.com/api/v10/guilds/1",
isGuildRequest: true,
})
)
).rejects.toMatchObject({
code: DISCORD_CODES.GUILD_NOT_FOUND,
});
});
it("maps 429 to DISCORD.RATE_LIMITED with retryAfterMs context", {
timeout: 30000,
}, async () => {
const stub: FetchLike = async () =>
makeResponse({
status: 429,
headers: { "retry-after": "2" },
});
const err = await executeDiscordRequest(baseOpts(stub)).catch(
(e) => e as DiscordClientError
);
expect(err).toBeInstanceOf(DiscordClientError);
expect((err as DiscordClientError).code).toBe(
DISCORD_CODES.RATE_LIMITED
);
expect((err as DiscordClientError).context?.["retryAfterMs"]).toBe(
2000
);
});
it("maps generic 5xx on bot path to DISCORD.API_ERROR", {
timeout: 30000,
}, async () => {
const stub: FetchLike = async () =>
makeResponse({ status: 502, text: "bad gateway" });
await expect(
executeDiscordRequest(baseOpts(stub))
).rejects.toMatchObject({ code: DISCORD_CODES.API_ERROR });
});
it("maps generic 5xx on webhook path to DISCORD.WEBHOOK_FAILED", {
timeout: 30000,
}, async () => {
const stub: FetchLike = async () =>
makeResponse({ status: 500, text: "server error" });
await expect(
executeDiscordRequest(baseOpts(stub, { kind: "webhook" }))
).rejects.toMatchObject({ code: DISCORD_CODES.WEBHOOK_FAILED });
});
it("maps AbortError to DISCORD.TIMEOUT", { timeout: 30000 }, async () => {
const stub: FetchLike = async (_url, init) =>
new Promise((_resolve, reject) => {
init.signal?.addEventListener("abort", () => {
const abortErr = new Error("aborted");
abortErr.name = "AbortError";
reject(abortErr);
});
});
await expect(
executeDiscordRequest(baseOpts(stub, { timeoutMs: 10 }))
).rejects.toMatchObject({ code: DISCORD_CODES.TIMEOUT });
});
it("maps generic fetch rejections on bot path to DISCORD.API_ERROR", {
timeout: 30000,
}, async () => {
const stub: FetchLike = async () => {
throw new Error("ECONNREFUSED");
};
await expect(
executeDiscordRequest(baseOpts(stub))
).rejects.toMatchObject({ code: DISCORD_CODES.API_ERROR });
});
it("maps generic fetch rejections on webhook path to DISCORD.WEBHOOK_FAILED", {
timeout: 30000,
}, async () => {
const stub: FetchLike = async () => {
throw new Error("connection reset");
};
await expect(
executeDiscordRequest(baseOpts(stub, { kind: "webhook" }))
).rejects.toMatchObject({ code: DISCORD_CODES.WEBHOOK_FAILED });
});
it("wraps non-Error thrown values via String coercion", {
timeout: 30000,
}, async () => {
const stub: FetchLike = async () => {
throw "string failure";
};
await expect(
executeDiscordRequest(baseOpts(stub))
).rejects.toMatchObject({ code: DISCORD_CODES.API_ERROR });
});
it("tolerates response.text() throwing on 404 path", {
timeout: 30000,
}, async () => {
const stub: FetchLike = async () => ({
...makeResponse({ status: 404 }),
text: async () => {
throw new Error("body read failed");
},
});
await expect(
executeDiscordRequest(baseOpts(stub))
).rejects.toMatchObject({
code: DISCORD_CODES.CHANNEL_NOT_FOUND,
});
});
it("tolerates response.text() throwing on generic error path", {
timeout: 30000,
}, async () => {
const stub: FetchLike = async () => ({
...makeResponse({ status: 500 }),
text: async () => {
throw new Error("body read failed");
},
});
await expect(
executeDiscordRequest(baseOpts(stub))
).rejects.toMatchObject({
code: DISCORD_CODES.API_ERROR,
});
});
it("serialises body and passes signal", { timeout: 30000 }, async () => {
let captured:
| { method: string; body?: string; hasSignal: boolean }
| undefined;
const stub: FetchLike = async (_url, init) => {
captured = {
method: init.method,
...(init.body !== undefined ? { body: init.body } : {}),
hasSignal: init.signal !== undefined,
};
return makeResponse({ status: 200, body: { ok: true } });
};
await executeDiscordRequest(
baseOpts(stub, {
method: "POST",
body: { content: "hi" },
})
);
expect(captured?.method).toBe("POST");
expect(captured?.body).toBe('{"content":"hi"}');
expect(captured?.hasSignal).toBe(true);
});
it("emits OTel counters and histograms for ok and failure outcomes [spec-conformance]", {
timeout: 30000,
}, async () => {
const rec = makeRecordingProvider();
metrics.setGlobalMeterProvider(rec.provider);
resetDiscordHttpMetrics();
const okStub: FetchLike = async () =>
makeResponse({ status: 200, body: { id: "1" } });
await executeDiscordRequest(baseOpts(okStub));
const errStub: FetchLike = async () => makeResponse({ status: 401 });
await executeDiscordRequest(baseOpts(errStub)).catch(() => {});
expect(rec.counterNames).toContain("discord_client.request.total");
expect(rec.histogramNames).toContain(
"discord_client.request.duration_ms"
);
const outcomes = rec.counters.map((c) => c.attrs?.["outcome"]);
expect(outcomes).toContain("ok");
expect(outcomes).toContain("auth_error");
// every counter add carries a matching histogram record
expect(rec.histograms.length).toBe(rec.counters.length);
metrics.disable();
resetDiscordHttpMetrics();
});
});

View File

@@ -0,0 +1,176 @@
import type { Counter, Histogram, Meter } from "@opentelemetry/api";
import { metrics } from "@opentelemetry/api";
import type { Clock } from "@suaveplan/types";
import {
DISCORD_CODES,
DiscordClientError,
} from "../errors/discord-client-error.js";
import type {
DiscordFetchResponse,
FetchLike,
} from "../types/discord-client.types.js";
import { parseRetryAfterMs } from "./rate-limit.js";
/** OTel metric handles, lazily created on first request. */
let requestCounter: Counter | undefined;
let requestDuration: Histogram | undefined;
let cachedMeter: Meter | undefined;
function getMetrics(): { counter: Counter; histogram: Histogram } {
if (!cachedMeter) {
cachedMeter = metrics.getMeter("@suaveplan/discord-client");
}
if (!requestCounter) {
requestCounter = cachedMeter.createCounter(
"discord_client.request.total",
{ description: "Total Discord API/webhook requests by outcome" }
);
}
if (!requestDuration) {
requestDuration = cachedMeter.createHistogram(
"discord_client.request.duration_ms",
{
description:
"Round-trip duration of every Discord request in ms",
unit: "ms",
}
);
}
return { counter: requestCounter, histogram: requestDuration };
}
/** Reset the cached OTel meter — tests use this to point at an in-memory MP. */
export function resetDiscordHttpMetrics(): void {
requestCounter = undefined;
requestDuration = undefined;
cachedMeter = undefined;
}
/** Kind of Discord request — drives the error-code mapping on 4xx. */
export type DiscordRequestKind = "bot" | "webhook";
export interface DiscordRequestOptions {
fetch: FetchLike;
clock: Clock;
method: string;
url: string;
headers: Record<string, string>;
body?: Record<string, unknown>;
timeoutMs: number;
kind: DiscordRequestKind;
/** Set when the request URL targets a guild (drives 404 → GUILD_NOT_FOUND). */
isGuildRequest?: boolean;
}
/**
* Executes a single Discord HTTP request, mapping the response onto the
* `DiscordClientError` taxonomy and recording OTel counters/histograms.
*
* Returns the parsed JSON body on success (or `undefined` on 204). Throws
* `DiscordClientError` for every documented Discord failure mode.
*/
export async function executeDiscordRequest<T>(
opts: DiscordRequestOptions
): Promise<T> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), opts.timeoutMs);
const started = opts.clock();
const { counter, histogram } = getMetrics();
const recordOutcome = (outcome: string, status: number | "none"): void => {
const duration = opts.clock() - started;
const labels = { kind: opts.kind, method: opts.method, outcome };
counter.add(1, { ...labels, status: String(status) });
histogram.record(duration, labels);
};
let response: DiscordFetchResponse;
try {
const init: {
method: string;
headers: Record<string, string>;
body?: string;
signal?: AbortSignal;
} = {
method: opts.method,
headers: opts.headers,
signal: controller.signal,
};
if (opts.body !== undefined) init.body = JSON.stringify(opts.body);
response = await opts.fetch(opts.url, init);
} catch (err) {
clearTimeout(timer);
const isAbort = err instanceof Error && err.name === "AbortError";
if (isAbort) {
recordOutcome("timeout", "none");
throw new DiscordClientError(
DISCORD_CODES.TIMEOUT,
`Discord request timed out after ${opts.timeoutMs}ms`,
{ context: { timeoutMs: opts.timeoutMs, url: opts.url } }
);
}
recordOutcome("network_error", "none");
const cause = err instanceof Error ? err : new Error(String(err));
throw new DiscordClientError(
opts.kind === "webhook"
? DISCORD_CODES.WEBHOOK_FAILED
: DISCORD_CODES.API_ERROR,
cause.message,
{ cause, context: { url: opts.url } }
);
}
clearTimeout(timer);
if (response.status === 401) {
recordOutcome("auth_error", 401);
throw new DiscordClientError(
DISCORD_CODES.AUTH_ERROR,
"Discord credentials are invalid or expired"
);
}
if (response.status === 403) {
recordOutcome("forbidden", 403);
throw new DiscordClientError(
DISCORD_CODES.FORBIDDEN,
"Discord rejected the request: caller lacks required permissions"
);
}
if (response.status === 404) {
recordOutcome("not_found", 404);
const code =
opts.kind === "bot" && opts.isGuildRequest
? DISCORD_CODES.GUILD_NOT_FOUND
: DISCORD_CODES.CHANNEL_NOT_FOUND;
const text = await response.text().catch(() => "");
throw new DiscordClientError(
code,
text.length > 0 ? text : `Discord resource not found: ${opts.url}`
);
}
if (response.status === 429) {
recordOutcome("rate_limited", 429);
const retryAfterMs = parseRetryAfterMs(response, opts.clock);
throw new DiscordClientError(
DISCORD_CODES.RATE_LIMITED,
"Discord rate limit exceeded",
{ context: { retryAfterMs, url: opts.url } }
);
}
if (!response.ok) {
recordOutcome("api_error", response.status);
const text = await response.text().catch(() => "Unknown error");
const code =
opts.kind === "webhook"
? DISCORD_CODES.WEBHOOK_FAILED
: DISCORD_CODES.API_ERROR;
throw new DiscordClientError(
code,
`Discord ${opts.kind} error ${response.status}: ${text}`,
{ context: { status: response.status, url: opts.url } }
);
}
recordOutcome("ok", response.status);
if (response.status === 204) return undefined as T;
return (await response.json()) as T;
}

View File

@@ -0,0 +1,7 @@
export {
type DiscordRequestKind,
type DiscordRequestOptions,
executeDiscordRequest,
resetDiscordHttpMetrics,
} from "./http.js";
export { parseRetryAfterMs } from "./rate-limit.js";

View File

@@ -0,0 +1,67 @@
# Rate-Limit Header Parser
## Purpose
Parses Discord's three rate-limit response headers into a single millisecond
back-off duration that callers can use to schedule a retry. Discord is
inconsistent about which header it emits in which response (gateway-level
429s use `Retry-After`, bucket-level 429s use `X-RateLimit-Reset-After`, and
shard-level overflows can fall back to `X-RateLimit-Reset` as a wall-clock
target), so a single canonical parser ensures every code path reacts the same
way and that tests can pin every flavour with deterministic fixtures.
## Features
- Reads `Retry-After`, `X-RateLimit-Reset-After`, and `X-RateLimit-Reset`.
- Honours both seconds-as-float and HTTP-date encodings of `Retry-After`.
- Treats `X-RateLimit-Reset` values < 10^12 as Unix epoch seconds and ≥ 10^12
as Unix epoch milliseconds (Discord switched encodings mid-2023).
- Accepts an injectable `Clock` so tests stay deterministic.
- Returns `undefined` when no header is present or every header is unparsable.
## Usage: Basic
```ts
import { parseRetryAfterMs } from "@suaveplan/discord-client";
const retryAfterMs = parseRetryAfterMs(response);
```
## Usage: Advanced
```ts
import { parseRetryAfterMs } from "@suaveplan/discord-client";
const fixedClock = () => 1_700_000_000_000;
const fakeResponse = {
ok: false,
status: 429,
statusText: "Too Many Requests",
headers: { get: (name: string) => (name === "x-ratelimit-reset" ? "1700000060" : null) },
text: async () => "",
json: async () => ({}),
};
// Returns 60_000 — the difference between epoch-second 1700000060 and the
// injected clock at 1_700_000_000_000 ms.
parseRetryAfterMs(fakeResponse, fixedClock);
```
## API Reference
`parseRetryAfterMs(response: DiscordFetchResponse, clock?: Clock): number | undefined`
- `response`: any object exposing `.headers.get(name)`.
- `clock`: optional `Clock` injectable; defaults to `Date.now`.
- Returns the parsed millisecond back-off, or `undefined` when no header was
parsable.
## Implementation Notes
`Math.ceil` is applied to the floating-point seconds to avoid under-waiting
when Discord emits fractional values like `0.250`. Negative deltas from
`X-RateLimit-Reset` clamp to zero rather than returning a stale duration —
the caller should retry immediately rather than reading a negative value. The
parser does NOT throw on malformed header values; it returns `undefined`,
which is the canonical "no advice from Discord, fall back to your local
retry policy" signal.

View File

@@ -0,0 +1,100 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { DiscordFetchResponse } from "../types/discord-client.types.js";
import { parseRetryAfterMs } from "./rate-limit.js";
function makeResponse(
headers: Record<string, string | undefined>
): DiscordFetchResponse {
return {
ok: false,
status: 429,
statusText: "Too Many Requests",
headers: {
get(name: string): string | null {
const v = headers[name.toLowerCase()];
return v === undefined ? null : v;
},
},
text: async () => "",
json: async () => ({}),
};
}
describe("parseRetryAfterMs", () => {
it("parses retry-after seconds as ceil-rounded ms", {
timeout: 5000,
}, () => {
const r = makeResponse({ "retry-after": "0.25" });
expect(parseRetryAfterMs(r, () => 0)).toBe(250);
});
it("parses x-ratelimit-reset-after seconds when retry-after is missing", {
timeout: 5000,
}, () => {
const r = makeResponse({ "x-ratelimit-reset-after": "1" });
expect(parseRetryAfterMs(r, () => 0)).toBe(1000);
});
it("parses retry-after as HTTP-date when not numeric", {
timeout: 5000,
}, () => {
const r = makeResponse({
"retry-after": "Wed, 01 Jan 2025 00:00:01 GMT",
});
const clock = () => Date.parse("Wed, 01 Jan 2025 00:00:00 GMT");
expect(parseRetryAfterMs(r, clock)).toBe(1000);
});
it("treats x-ratelimit-reset < 1e12 as epoch seconds", {
timeout: 5000,
}, () => {
const r = makeResponse({ "x-ratelimit-reset": "1700000060" });
expect(parseRetryAfterMs(r, () => 1_700_000_000_000)).toBe(60_000);
});
it("treats x-ratelimit-reset >= 1e12 as epoch milliseconds", {
timeout: 5000,
}, () => {
const r = makeResponse({
"x-ratelimit-reset": "1700000060000",
});
expect(parseRetryAfterMs(r, () => 1_700_000_000_000)).toBe(60_000);
});
it("clamps negative x-ratelimit-reset deltas to zero", {
timeout: 5000,
}, () => {
const r = makeResponse({ "x-ratelimit-reset": "1" });
expect(parseRetryAfterMs(r, () => 1_700_000_000_000)).toBe(0);
});
it("returns undefined when no parsable header is present", {
timeout: 5000,
}, () => {
const r = makeResponse({});
expect(parseRetryAfterMs(r, () => 0)).toBeUndefined();
});
it("returns undefined when retry-after is non-numeric and not a date", {
timeout: 5000,
}, () => {
const r = makeResponse({ "retry-after": "soon" });
expect(parseRetryAfterMs(r, () => 0)).toBeUndefined();
});
it("returns undefined when x-ratelimit-reset is non-numeric", {
timeout: 5000,
}, () => {
const r = makeResponse({ "x-ratelimit-reset": "soon" });
expect(parseRetryAfterMs(r, () => 0)).toBeUndefined();
});
it("defaults the clock to Date.now when omitted", { timeout: 5000 }, () => {
const future = Date.now() + 60_000;
const r = makeResponse({ "x-ratelimit-reset": String(future) });
const got = parseRetryAfterMs(r);
expect(got).toBeDefined();
expect(got).toBeGreaterThanOrEqual(59_000);
expect(got).toBeLessThanOrEqual(61_000);
});
});

View File

@@ -0,0 +1,40 @@
import type { Clock } from "@suaveplan/types";
import type { DiscordFetchResponse } from "../types/discord-client.types.js";
/**
* Parse Discord's rate-limit response headers into a millisecond duration.
*
* Discord emits `Retry-After` (seconds, float), `X-RateLimit-Reset-After`
* (seconds, float), and `X-RateLimit-Reset` (unix epoch seconds OR ms).
* We accept all three and a fallback HTTP-date string in `Retry-After`.
*
* @param response - Fetch response carrying the headers.
* @param clock - Injectable wall-clock; defaults to `Date.now`.
*/
export function parseRetryAfterMs(
response: DiscordFetchResponse,
clock: Clock = Date.now
): number | undefined {
const retryAfter =
response.headers.get("retry-after") ??
response.headers.get("x-ratelimit-reset-after");
if (retryAfter !== null && retryAfter !== undefined) {
const seconds = Number(retryAfter);
if (Number.isFinite(seconds) && seconds >= 0) {
return Math.ceil(seconds * 1000);
}
const dateMs = Date.parse(retryAfter);
if (Number.isFinite(dateMs)) {
return Math.max(0, dateMs - clock());
}
}
const reset = response.headers.get("x-ratelimit-reset");
if (reset !== null && reset !== undefined) {
const resetNum = Number(reset);
if (Number.isFinite(resetNum)) {
const resetMs = resetNum < 1e12 ? resetNum * 1000 : resetNum;
return Math.max(0, resetMs - clock());
}
}
return undefined;
}

View File

@@ -0,0 +1,51 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "@suaveplan/testing/runner";
describe("@suaveplan/discord-client dep graph", () => {
it("@suaveplan/* runtime deps are exactly error + types", {
timeout: 5000,
}, () => {
const pkg = JSON.parse(
readFileSync(join(__dirname, "..", "package.json"), "utf8")
) as {
dependencies?: Record<string, string>;
peerDependencies?: Record<string, string>;
};
const runtimeScoped = Object.keys(pkg.dependencies ?? {})
.filter((name) => name.startsWith("@suaveplan/"))
.sort();
expect(runtimeScoped).toEqual(
["@suaveplan/error", "@suaveplan/types"].sort()
);
const peerScoped = Object.keys(pkg.peerDependencies ?? {}).filter(
(name) => name.startsWith("@suaveplan/")
);
expect(peerScoped).toEqual([]);
});
it("runtime deps are exactly the expected set (sorted)", {
timeout: 5000,
}, () => {
const pkg = JSON.parse(
readFileSync(join(__dirname, "..", "package.json"), "utf8")
) as { dependencies?: Record<string, string> };
const runtime = Object.keys(pkg.dependencies ?? {}).sort();
expect(runtime).toEqual(
[
"@opentelemetry/api",
"@suaveplan/error",
"@suaveplan/types",
"tslib",
"zod",
].sort()
);
});
it("declares sideEffects: false", { timeout: 5000 }, () => {
const pkg = JSON.parse(
readFileSync(join(__dirname, "..", "package.json"), "utf8")
) as { sideEffects?: boolean };
expect(pkg.sideEffects).toBe(false);
});
});

View File

@@ -0,0 +1,7 @@
import { webcrypto } from "node:crypto";
type WritableGlobal = { crypto?: Crypto };
const g = globalThis as WritableGlobal;
if (g.crypto === undefined) {
g.crypto = webcrypto as unknown as Crypto;
}

View File

@@ -0,0 +1,5 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "./tsconfig.lib.json",
"include": ["src/**/*.ts", "tests/**/*.ts"]
}

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022", "DOM"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "dist", "node_modules"]
}

View File

@@ -0,0 +1,37 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../node_modules/.vite/packages/discord-client",
plugins: [
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
insertTypesEntry: true,
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: { entry: "src/index.ts", formats: ["es"] },
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: [
"@suaveplan/error",
"@suaveplan/types",
"tslib",
"zod",
"@opentelemetry/api",
/^node:/,
],
},
},
});

View File

@@ -0,0 +1,30 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: false,
environment: "node",
pool: "forks",
setupFiles: ["./tests/setup/polyfills.ts"],
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
exclude: ["dist/**", "node_modules/**"],
coverage: {
provider: "istanbul",
reporter: ["text", "html", "lcov"],
include: ["src/**/*.ts"],
exclude: [
"src/**/*.test.ts",
"src/**/*.types.ts",
"src/**/index.ts",
"tests/**",
],
thresholds: {
statements: 100,
branches: 100,
functions: 100,
lines: 100,
},
},
testTimeout: 30000,
},
});