Initial Commit

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

View File

@@ -0,0 +1,221 @@
# Capability Spec Delta: discord-client (add-discord-client)
**Package**: `@suaveplan/discord-client`
**Status**: PROPOSED
> Every requirement below carries a `**Verified by:**` line at authoring time.
## ADDED Requirements
### DISCORD-01 — Error-code registry is namespaced and frozen
`DISCORD_CODES` MUST be declared via `defineCodes("DISCORD", [...])` from `@suaveplan/error`. `Object.isFrozen(DISCORD_CODES)` MUST be `true`. Every code value MUST start with `DISCORD.`. `DiscordClientError` MUST extend `SuaveplanError<DiscordClientCode>`. The registry MUST contain at minimum: `API_ERROR`, `AUTH_ERROR`, `RATE_LIMITED`, `CHANNEL_NOT_FOUND`, `GUILD_NOT_FOUND`, `CONFIG_MISSING`, `INVALID_CONFIG`, `INVALID_MESSAGE`, `WEBHOOK_FAILED`, `TIMEOUT`, `FORBIDDEN`.
#### Scenario: registry is frozen
- **WHEN** `Object.isFrozen(DISCORD_CODES)` is called
- **THEN** it returns `true`
#### Scenario: every value starts with `DISCORD.`
- **WHEN** the test iterates `Object.values(DISCORD_CODES)`
- **THEN** every entry starts with the string `DISCORD.`
#### Scenario: error class extends `SuaveplanError` and carries the code
- **WHEN** `new DiscordClientError(DISCORD_CODES.AUTH_ERROR, "auth failed")` is constructed
- **THEN** the instance is an `Error`, a `DiscordClientError`, has `.code === "DISCORD.AUTH_ERROR"`, and surfaces the resolved message
**Verified by:** `src/errors/discord-client-error.test.ts::"is frozen and namespaces every code with DISCORD."` AND `src/errors/discord-client-error.test.ts::"exposes all 11 documented codes"` AND `src/errors/discord-client-error.test.ts::"extends SuaveplanError and carries the code"`
---
### DISCORD-02 — Rate-limit header parser
`parseRetryAfterMs(response, clock?)` MUST resolve Discord's three rate-limit headers (`Retry-After` seconds-or-HTTP-date, `X-RateLimit-Reset-After` seconds, `X-RateLimit-Reset` epoch-seconds-or-ms) into a millisecond back-off duration. Header precedence MUST be `Retry-After` → `X-RateLimit-Reset-After` → `X-RateLimit-Reset`. Unparsable values MUST return `undefined`. Negative deltas against the injected clock MUST clamp to zero.
#### Scenario: parses fractional seconds with ceiling rounding
- **WHEN** `Retry-After: 0.25` is parsed
- **THEN** the result is `250`
#### Scenario: distinguishes epoch-seconds (< 1e12) from epoch-ms (≥ 1e12)
- **WHEN** `X-RateLimit-Reset: 1700000060` is parsed against a clock at `1_700_000_000_000`
- **THEN** the result is `60_000`
#### Scenario: returns `undefined` when no header is parsable
- **WHEN** no rate-limit header is present
- **THEN** the result is `undefined`
**Verified by:** `src/utils/rate-limit.test.ts::"parses retry-after seconds as ceil-rounded ms"` AND `src/utils/rate-limit.test.ts::"treats x-ratelimit-reset < 1e12 as epoch seconds"` AND `src/utils/rate-limit.test.ts::"returns undefined when no parsable header is present"`
---
### DISCORD-03 — Response→error mapping
`executeDiscordRequest` MUST map Discord HTTP responses onto `DiscordClientError` codes as follows: `401 → DISCORD.AUTH_ERROR`; `403 → DISCORD.FORBIDDEN`; `404 → DISCORD.CHANNEL_NOT_FOUND` (or `DISCORD.GUILD_NOT_FOUND` when `isGuildRequest`); `429 → DISCORD.RATE_LIMITED` with `context.retryAfterMs`; any other non-2xx → `DISCORD.API_ERROR` for `kind: "bot"` or `DISCORD.WEBHOOK_FAILED` for `kind: "webhook"`. HTTP 204 responses MUST resolve with `undefined`.
#### Scenario: 401 raises AUTH_ERROR
- **WHEN** the fetch stub returns status 401
- **THEN** `executeDiscordRequest` rejects with `DiscordClientError` whose `code === "DISCORD.AUTH_ERROR"`
#### Scenario: 404 on a `/guilds/` URL raises GUILD_NOT_FOUND
- **WHEN** the fetch stub returns status 404 and `isGuildRequest: true`
- **THEN** the rejection carries `code === "DISCORD.GUILD_NOT_FOUND"`
#### Scenario: 429 carries `retryAfterMs` on the error context
- **WHEN** the fetch stub returns status 429 with `retry-after: 2`
- **THEN** the rejection's `context.retryAfterMs === 2000`
#### Scenario: webhook-path 5xx raises WEBHOOK_FAILED
- **WHEN** `kind: "webhook"` and the fetch stub returns status 500
- **THEN** the rejection's `code === "DISCORD.WEBHOOK_FAILED"`
**Verified by:** `src/utils/http.test.ts::"maps 401 to DISCORD.AUTH_ERROR"` AND `src/utils/http.test.ts::"maps 404 guild responses to DISCORD.GUILD_NOT_FOUND when isGuildRequest"` AND `src/utils/http.test.ts::"maps 429 to DISCORD.RATE_LIMITED with retryAfterMs context"` AND `src/utils/http.test.ts::"maps generic 5xx on webhook path to DISCORD.WEBHOOK_FAILED"`
---
### DISCORD-04 — Timeout and abort propagation
Every Discord request MUST be wrapped in an `AbortController` whose abort is scheduled at `timeoutMs`. An `AbortError` raised by the underlying fetch MUST surface as `DISCORD.TIMEOUT` carrying `context.timeoutMs` and `context.url`. Other fetch rejections MUST surface as `DISCORD.API_ERROR` (bot) or `DISCORD.WEBHOOK_FAILED` (webhook), preserving the original error as `cause`. Non-`Error` thrown values MUST be coerced via `String`.
#### Scenario: timeout raises DISCORD.TIMEOUT
- **WHEN** the injected fetch never resolves and the request's `AbortController` aborts
- **THEN** `executeDiscordRequest` rejects with `code === "DISCORD.TIMEOUT"`
#### Scenario: non-abort network failure on bot path raises API_ERROR
- **WHEN** the injected fetch throws `new Error("ECONNREFUSED")` and `kind: "bot"`
- **THEN** the rejection's `code === "DISCORD.API_ERROR"`
#### Scenario: non-`Error` thrown value is coerced
- **WHEN** the injected fetch throws a string
- **THEN** the rejection's `code === "DISCORD.API_ERROR"`
**Verified by:** `src/utils/http.test.ts::"maps AbortError to DISCORD.TIMEOUT"` AND `src/utils/http.test.ts::"maps generic fetch rejections on bot path to DISCORD.API_ERROR"` AND `src/utils/http.test.ts::"wraps non-Error thrown values via String coercion"`
---
### DISCORD-05 — OpenTelemetry instrumentation
`executeDiscordRequest` MUST emit one `discord_client.request.total` counter increment AND one `discord_client.request.duration_ms` histogram record for every request, carrying the labels `kind`, `method`, and `outcome` (plus `status` on the counter). `outcome` MUST be one of `ok`, `auth_error`, `forbidden`, `not_found`, `rate_limited`, `timeout`, `network_error`, `api_error`. The metrics MUST be created against the global `metrics.getMeter("@suaveplan/discord-client")` and the meter cache MUST be invalidated by `resetDiscordHttpMetrics()` so tests can swap providers.
#### Scenario: counter and histogram are emitted with matching label sets
- **WHEN** a recording `MeterProvider` is registered, the cache is reset, and one successful + one failing request execute
- **THEN** the recorded counter names contain `discord_client.request.total`, the histogram names contain `discord_client.request.duration_ms`, the outcomes seen include both `ok` and `auth_error`, and the histogram-record count equals the counter-add count
**Verified by:** `src/utils/http.test.ts::"emits OTel counters and histograms for ok and failure outcomes [spec-conformance]"`
---
### DISCORD-06 — Bot REST surface
`DiscordBotClient` MUST expose `sendMessage`, `editMessage`, `deleteMessage`, `getChannel`, `getGuild`, `createReaction`, and `getStoredMessage`. Every request MUST attach `Authorization: Bot <token>`. JSON-bodied requests MUST set `Content-Type: application/json`. `createReaction` MUST URL-encode the emoji path segment. `getGuild` MUST be tagged so 404 responses raise `DISCORD.GUILD_NOT_FOUND`. CamelCase fields (`avatarUrl`, `threadId`) MUST be mapped to Discord's snake_case (`avatar_url`).
#### Scenario: sendMessage POSTs to the correct URL with the bot header
- **WHEN** `bot.sendMessage("1", { content: "hi" })` runs against the stub
- **THEN** the stub records a `POST` to `/api/v10/channels/1/messages` with `Authorization: Bot abc` and `Content-Type: application/json`
#### Scenario: createReaction URL-encodes the emoji
- **WHEN** `bot.createReaction("1", "44", "👍")` runs
- **THEN** the URL ends with `/reactions/${encodeURIComponent("👍")}/@me`
#### Scenario: getGuild raises GUILD_NOT_FOUND on 404
- **WHEN** the stub returns 404 for a `/guilds/999` request
- **THEN** `bot.getGuild("999")` rejects with `code === "DISCORD.GUILD_NOT_FOUND"`
**Verified by:** `src/core/bot-client/discord-bot-client.test.ts::"POSTs to /channels/:id/messages and caches the response"` AND `src/core/bot-client/discord-bot-client.test.ts::"URL-encodes the emoji and PUTs"` AND `src/core/bot-client/discord-bot-client.test.ts::"getGuild raises GUILD_NOT_FOUND on 404"`
---
### DISCORD-07 — Zod boundary validation
`DiscordBotConfig`, `DiscordWebhookConfig`, and every `DiscordMessage` accepted by a public method MUST be parsed via the corresponding Zod schema. A missing `token` / missing `webhookUrl` MUST raise `DISCORD.CONFIG_MISSING`. Any other config-shape failure MUST raise `DISCORD.INVALID_CONFIG`. A message-shape failure MUST raise `DISCORD.INVALID_MESSAGE`.
#### Scenario: missing token raises CONFIG_MISSING
- **WHEN** `new DiscordBotClient({ token: "" })` is called
- **THEN** the constructor throws `DiscordClientError` with `code === "DISCORD.CONFIG_MISSING"`
#### Scenario: invalid baseUrl raises INVALID_CONFIG
- **WHEN** `new DiscordBotClient({ token: "abc", baseUrl: "not-a-url" })` is called
- **THEN** the constructor throws with `code === "DISCORD.INVALID_CONFIG"`
#### Scenario: webhook URL outside `discord.com/api/webhooks/` raises INVALID_CONFIG
- **WHEN** `new DiscordWebhook({ webhookUrl: "https://example.com/hook" })` is called
- **THEN** the constructor throws with `code === "DISCORD.INVALID_CONFIG"`
#### Scenario: oversized message content raises INVALID_MESSAGE
- **WHEN** `bot.sendMessage("1", { content: "x".repeat(2001) })` is called
- **THEN** the call rejects with `code === "DISCORD.INVALID_MESSAGE"`
**Verified by:** `src/core/bot-client/discord-bot-client.test.ts::"rejects missing token with DISCORD.CONFIG_MISSING"` AND `src/core/bot-client/discord-bot-client.test.ts::"rejects invalid baseUrl with DISCORD.INVALID_CONFIG"` AND `src/core/webhook/discord-webhook.test.ts::"rejects non-Discord URL with DISCORD.INVALID_CONFIG"` AND `src/core/bot-client/discord-bot-client.test.ts::"rejects an invalid message with DISCORD.INVALID_MESSAGE"`
---
### DISCORD-08 — Bot client in-memory message cache
`DiscordBotClient` MUST maintain an in-memory `Map<string, DiscordResponse>` keyed by message ID. `sendMessage` and `editMessage` MUST set the cache. `deleteMessage` MUST evict. `getStoredMessage(id)` MUST return the cached entry or `undefined`.
#### Scenario: sendMessage caches, deleteMessage evicts
- **WHEN** `sendMessage` for id `45` succeeds, then `deleteMessage("1", "45")` runs
- **THEN** `getStoredMessage("45")` returns `undefined`
**Verified by:** `src/core/bot-client/discord-bot-client.test.ts::"DELETEs and evicts from the cache"`
---
### DISCORD-09 — Webhook delivery and structured report
`DiscordWebhook.send` MUST POST to `<webhookUrl>?wait=true` (or `?wait=true&thread_id=<id>`), accept the same `DiscordMessage` shape as the bot client, and return a `DiscordDeliveryReport` carrying `{ messageId, channelId, status: "delivered", timestamp }`. The timestamp MUST be derived from the injected `Clock`. Constructor-level `username` / `avatarUrl` MUST default into the payload when the message omits them; per-message values MUST override the defaults. `sendText` and `sendEmbed` MUST be thin convenience wrappers.
#### Scenario: send appends `?wait=true` and returns a delivered report
- **WHEN** `webhook.send({ content: "hi" })` succeeds with `clock` fixed at `1_700_000_000_000`
- **THEN** the stub URL ends with `?wait=true`, the report `status === "delivered"`, and `report.timestamp` equals `new Date(1_700_000_000_000)`
#### Scenario: thread_id is appended when provided
- **WHEN** `webhook.send({ content: "hi", threadId: "999" })` runs
- **THEN** the stub URL ends with `?wait=true&thread_id=999`
#### Scenario: per-message overrides take precedence over constructor defaults
- **WHEN** constructor sets `username: "default"` and `send` is called with `username: "override"`
- **THEN** the serialized body carries `username: "override"`
**Verified by:** `src/core/webhook/discord-webhook.test.ts::"POSTs with ?wait=true and returns a delivered report"` AND `src/core/webhook/discord-webhook.test.ts::"appends thread_id when provided"` AND `src/core/webhook/discord-webhook.test.ts::"per-message overrides take precedence over constructor defaults"`
---
### DISCORD-10 — Runtime-agnostic via injectable fetch and clock
Both `DiscordBotClient` and `DiscordWebhook` MUST accept an optional `FetchLike` and `Clock` on every config. When omitted, `globalThis.fetch` and `Date.now` MUST be used; when supplied, the injected functions MUST be used for every request. The runtime dependency surface MUST be exactly `@suaveplan/error`, `@suaveplan/types`, `tslib`, `zod`, and `@opentelemetry/api`. `sideEffects: false` MUST be declared.
#### Scenario: constructors succeed with no injectables
- **WHEN** `new DiscordBotClient({ token: "abc" })` and `new DiscordWebhook({ webhookUrl: VALID })` are constructed without `fetch` or `clock`
- **THEN** both succeed
#### Scenario: runtime dep graph is pinned
- **WHEN** `tests/dep-graph.test.ts` runs
- **THEN** the runtime dep keys are exactly `@opentelemetry/api`, `@suaveplan/error`, `@suaveplan/types`, `tslib`, `zod`, and `sideEffects` is `false`
**Verified by:** `src/core/bot-client/discord-bot-client.test.ts::"uses Date.now and globalThis.fetch when not injected"` AND `src/core/webhook/discord-webhook.test.ts::"constructs without injectables"` AND `tests/dep-graph.test.ts::"runtime deps are exactly the expected set (sorted)"` AND `tests/dep-graph.test.ts::"declares sideEffects: false"`