Initial Commit
This commit is contained in:
100
packages/communication/messaging/discord-client/README.md
Normal file
100
packages/communication/messaging/discord-client/README.md
Normal 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` |
|
||||
|
||||
86
packages/communication/messaging/discord-client/package.json
Normal file
86
packages/communication/messaging/discord-client/package.json
Normal 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"
|
||||
]
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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/"),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { DiscordBotClient } from "./discord-bot-client.js";
|
||||
@@ -0,0 +1,2 @@
|
||||
export { DiscordBotClient } from "./bot-client/discord-bot-client.js";
|
||||
export { DiscordWebhook } from "./webhook/discord-webhook.js";
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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] });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { DiscordWebhook } from "./discord-webhook.js";
|
||||
@@ -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.
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
DISCORD_CODES,
|
||||
type DiscordClientCode,
|
||||
DiscordClientError,
|
||||
} from "./discord-client-error.js";
|
||||
38
packages/communication/messaging/discord-client/src/index.ts
Normal file
38
packages/communication/messaging/discord-client/src/index.ts
Normal 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";
|
||||
@@ -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.
|
||||
@@ -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(),
|
||||
});
|
||||
@@ -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";
|
||||
@@ -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.
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export {
|
||||
type DiscordRequestKind,
|
||||
type DiscordRequestOptions,
|
||||
executeDiscordRequest,
|
||||
resetDiscordHttpMetrics,
|
||||
} from "./http.js";
|
||||
export { parseRetryAfterMs } from "./rate-limit.js";
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "./tsconfig.lib.json",
|
||||
"include": ["src/**/*.ts", "tests/**/*.ts"]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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:/,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user