Initial Commit
This commit is contained in:
75
openspec/archive/2026-05-18-add-discord-client/proposal.md
Normal file
75
openspec/archive/2026-05-18-add-discord-client/proposal.md
Normal file
@@ -0,0 +1,75 @@
|
||||
# Add @suaveplan/discord-client
|
||||
|
||||
**Package**: `@suaveplan/discord-client`
|
||||
**Status**: PROPOSED
|
||||
**Type**: new-package
|
||||
|
||||
## Why
|
||||
|
||||
Genesis needs a Discord transport for operations notifications, bot-driven workflows, and webhook-based delivery from server-side jobs. The upstream package at `../suaveplan/packages/universal/discord-client/` covers bot-token messaging plus webhook delivery, but it bakes in `globalThis.fetch` and `Date.now`, throws `BaseError` from a parallel error hierarchy, and ships no OpenTelemetry instruments. Migrating into genesis re-grounds it on `SuaveplanError` + `defineCodes`, makes `fetch`/`clock` injectable for deterministic testing, and wires OTel counters and histograms so every Discord request is observable from day one.
|
||||
|
||||
## What changes
|
||||
|
||||
- ADDED: `@suaveplan/discord-client` package at `packages/messaging/discord-client/`.
|
||||
- ADDED: `DiscordBotClient` — bot-token client with `sendMessage`, `editMessage`, `deleteMessage`, `getChannel`, `getGuild`, `createReaction`, `getStoredMessage`.
|
||||
- ADDED: `DiscordWebhook` — webhook-URL delivery with `send`, `sendText`, `sendEmbed` returning `DiscordDeliveryReport`.
|
||||
- ADDED: `DISCORD_CODES` frozen registry with 11 codes namespaced `DISCORD.*`.
|
||||
- ADDED: `DiscordClientError extends SuaveplanError<DiscordClientCode>`.
|
||||
- ADDED: `executeDiscordRequest` — shared HTTP funnel emitting `discord_client.request.total` (Counter) and `discord_client.request.duration_ms` (Histogram).
|
||||
- ADDED: `parseRetryAfterMs` — header parser for Discord's three rate-limit headers.
|
||||
- ADDED: Zod boundary schemas (`DiscordBotConfigSchema`, `DiscordWebhookConfigSchema`, `DiscordMessageSchema`).
|
||||
- ADDED: `FetchLike` injectable contract on every public client.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Gateway / WebSocket support — bot REST + webhooks only, matching upstream parity.
|
||||
- File / attachment uploads — upstream does not implement them.
|
||||
- Slash-command registration — separate concern, future change.
|
||||
|
||||
## Source mapping
|
||||
|
||||
This change derives from `../suaveplan/enterprise/discord-client.md` and the upstream implementation at `../suaveplan/packages/universal/discord-client/`.
|
||||
|
||||
| Requirement | Source lines | Notes |
|
||||
|---|---|---|
|
||||
| Generic Discord API client for webhooks, bot messaging, embeds | `enterprise/discord-client.md` L10-L13 | one-line purpose verbatim |
|
||||
| Zero browser/Node/React deps — runs in Bun, Node, browser | `enterprise/discord-client.md` L37 | enforced via `FetchLike` injectable |
|
||||
| Zod schemas for all public types | `enterprise/discord-client.md` L38 | wired at every constructor + every `send`/`sendMessage` |
|
||||
| Deterministic behaviour — injectable clock, no module-scope `Date.now()` | `enterprise/discord-client.md` L40 | `Clock` injectable |
|
||||
| OpenTelemetry instrumentation for all public entrypoints | `enterprise/discord-client.md` L41 | counter + histogram in `utils/http.ts` |
|
||||
| Structured errors with stable codes | `enterprise/discord-client.md` L42 | `DISCORD_CODES` + `DiscordClientError` |
|
||||
| `AbortSignal` everywhere async | `enterprise/discord-client.md` L43 | `AbortController` per request |
|
||||
| No mocks; real services via deterministic injectables | `enterprise/discord-client.md` L53 | `FetchLike`, `Clock` stubs in tests |
|
||||
| `sideEffects: false` | `enterprise/discord-client.md` L55 | declared in package.json |
|
||||
| `workspace:*` internal, Bun catalog external | `enterprise/discord-client.md` L56 | enforced by `tests/dep-graph.test.ts` |
|
||||
| TypeScript strict — no `any`, no `ts-ignore` | `enterprise/discord-client.md` L59 | inherited from `tsconfig.base.json` |
|
||||
| Public API stability — Zod boundary schemas | `enterprise/discord-client.md` L60 | three schemas at every public boundary |
|
||||
| Error hierarchy — extends `@suaveplan/error` | `enterprise/discord-client.md` L61 | `DiscordClientError extends SuaveplanError` |
|
||||
| Bot REST surface: send/edit/delete/getChannel/createReaction | upstream `src/core/discord-bot-client/discord-bot-client.ts` L75-L130 | reimplemented fresh |
|
||||
| Webhook surface: `send`, `sendText`, `sendEmbed`, threaded routing | upstream `src/core/discord-webhook/discord-webhook.ts` L81-L105 | reimplemented fresh |
|
||||
| Discord rate-limit header parsing (3 header variants) | upstream `src/core/discord-bot-client/discord-bot-client.ts` L26-L48 | extracted to `utils/rate-limit.ts` |
|
||||
| 401/403/404/429/non-ok response → DISCORD code mapping | upstream `src/core/discord-bot-client/discord-bot-client.ts` L167-L215 | concentrated in `utils/http.ts` |
|
||||
| `DiscordDeliveryReport` for webhook responses | upstream `src/types/discord-client.types.ts` L86-L97 | shape preserved |
|
||||
|
||||
### Requirements with no source line
|
||||
|
||||
| Requirement | Justification |
|
||||
|---|---|
|
||||
| 100% Istanbul coverage threshold | Genesis-wide policy, not package-specific |
|
||||
| `### DISCORD-NN —` requirement heading format | OpenSpec conformance contract (`openspec-conformance.ts` regex) |
|
||||
| `**Verified by:**` lines at draft time | Genesis OpenSpec migration skill mandate |
|
||||
| Zod parse failure → `DISCORD.INVALID_CONFIG` vs `DISCORD.CONFIG_MISSING` split | Required so the registry's `severity: fatal` vs `severity: error` mapping is callable |
|
||||
| OTel histogram alongside the counter | Genesis observability convention (matches `@suaveplan/webhook-client`) |
|
||||
|
||||
## Verification approach
|
||||
|
||||
- `src/errors/discord-client-error.test.ts` — covers DISCORD-01 (error-code registry).
|
||||
- `src/utils/rate-limit.test.ts` — covers DISCORD-02 (rate-limit header parser).
|
||||
- `src/utils/http.test.ts` — covers DISCORD-03 (response→error mapping), DISCORD-04 (timeout + abort), DISCORD-05 (OTel instrumentation with recording `MeterProvider`).
|
||||
- `src/core/bot-client/discord-bot-client.test.ts` — covers DISCORD-06 (bot REST surface), DISCORD-07 (Zod config validation), DISCORD-08 (in-memory message cache).
|
||||
- `src/core/webhook/discord-webhook.test.ts` — covers DISCORD-09 (webhook delivery + report), DISCORD-10 (webhook Zod boundary + URL host check).
|
||||
- `tests/dep-graph.test.ts` — pins runtime dep set and `sideEffects: false`.
|
||||
|
||||
## Migration impact
|
||||
|
||||
None — additive only. No consumer of `@suaveplan/discord-client` exists yet in genesis.
|
||||
@@ -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"`
|
||||
62
openspec/archive/2026-05-18-add-discord-client/tasks.md
Normal file
62
openspec/archive/2026-05-18-add-discord-client/tasks.md
Normal file
@@ -0,0 +1,62 @@
|
||||
# Tasks — add-discord-client
|
||||
|
||||
> Every checkbox reflects work actually done in this change.
|
||||
|
||||
## 1. Spec authoring
|
||||
|
||||
- [x] 1.1 Read `../suaveplan/enterprise/discord-client.md` end-to-end
|
||||
- [x] 1.2 Read upstream impl at `../suaveplan/packages/universal/discord-client/src/`
|
||||
- [x] 1.3 Read OpenSpec templates + enterprise-package-migration SKILL
|
||||
- [x] 1.4 Draft `proposal.md` from `openspec/templates/proposal.md`
|
||||
- [x] 1.5 Fill in `## Source mapping` table — every requirement cites a line range or is in the "no source line" table with justification
|
||||
- [x] 1.6 Draft `specs/discord-client/spec.md` from `openspec/templates/spec-delta.md`
|
||||
- [x] 1.7 Every requirement in the spec delta carries a `**Verified by:**` line naming concrete `it(...)` titles
|
||||
|
||||
## 2. Implementation
|
||||
|
||||
- [x] 2.1 Scaffold `packages/messaging/discord-client/` (package.json @0.1.0, tsconfig, vite.config, vitest.config, polyfills)
|
||||
- [x] 2.2 `src/errors/discord-client-error.ts` — `defineCodes("DISCORD", [...])` + `DiscordClientError extends SuaveplanError<DiscordClientCode>`
|
||||
- [x] 2.3 `src/types/discord-client.types.ts` — `DiscordBotConfig`/`DiscordWebhookConfig`/`DiscordMessage`/`DiscordEmbed`/`DiscordResponse`/`DiscordChannel`/`DiscordDeliveryReport`/`FetchLike` + Zod schemas
|
||||
- [x] 2.4 `src/utils/rate-limit.ts` — `parseRetryAfterMs(response, clock?)` parser for 3 header variants
|
||||
- [x] 2.5 `src/utils/http.ts` — `executeDiscordRequest` funnel mapping 401/403/404/429/5xx/non-ok to DISCORD codes, AbortController timeout → TIMEOUT, OTel counter + histogram lazy-init with `resetDiscordHttpMetrics()`
|
||||
- [x] 2.6 `src/core/bot-client/discord-bot-client.ts` — full bot REST surface
|
||||
- [x] 2.7 `src/core/webhook/discord-webhook.ts` — webhook surface + DeliveryReport
|
||||
- [x] 2.8 `src/index.ts` — explicit named re-exports (no `export *`)
|
||||
|
||||
## 3. Tests
|
||||
|
||||
- [x] 3.1 `src/errors/discord-client-error.test.ts` — frozen registry, code prefix, code union, error class
|
||||
- [x] 3.2 `src/utils/rate-limit.test.ts` — all 3 header variants, edge cases, default clock
|
||||
- [x] 3.3 `src/utils/http.test.ts` — every status code mapping, abort/timeout, OTel via recording `MeterProvider`
|
||||
- [x] 3.4 `src/core/bot-client/discord-bot-client.test.ts` — constructor validation, every public method, cache eviction, URL encoding
|
||||
- [x] 3.5 `src/core/webhook/discord-webhook.test.ts` — config validation, send/sendText/sendEmbed, threading, defaults
|
||||
- [x] 3.6 `tests/dep-graph.test.ts` — pins runtime deps + `sideEffects: false`
|
||||
- [x] 3.7 Every `it(...)` carries `{ timeout: 5000 }` or `{ timeout: 30000 }`
|
||||
|
||||
## 4. Documentation
|
||||
|
||||
- [x] 4.1 Sibling `.md` for every implementation `.ts` (≥200 words, basic + advanced examples, full API reference) — 6 modules
|
||||
- [x] 4.2 `README.md` at package root links every co-located `.md` under `## Modules`
|
||||
|
||||
## 5. Pre-archive verification (5-gate triple)
|
||||
|
||||
- [x] 5.1 `bun run check` — exit 0, "No fixes applied"
|
||||
- [x] 5.2 `bunx turbo lint typecheck test --filter=@suaveplan/discord-client` — green (62 tests pass)
|
||||
- [x] 5.3 `bunx turbo test:coverage --filter=@suaveplan/discord-client` — 100/100/100/100 (istanbul)
|
||||
- [x] 5.4 `bun scripts/openspec-conformance.ts @suaveplan/discord-client` — PASS (0 error, 0 warn, 0 info)
|
||||
- [x] 5.5 `bunx turbo build --filter=@suaveplan/discord-client` — green
|
||||
|
||||
## 6. Pre-archive 7-row checklist
|
||||
|
||||
- [x] 6.1 Generics threaded — `executeDiscordRequest<T>` and `DiscordClientCode` literal union
|
||||
- [x] 6.2 Zod schemas wired at every boundary — `DiscordBotConfigSchema`, `DiscordWebhookConfigSchema`, `DiscordMessageSchema`
|
||||
- [x] 6.3 Every `DISCORD_CODES` code thrown by ≥1 production path
|
||||
- [x] 6.4 Strict-mode behaviours tested (Zod boundary on/off — invalid config + valid config both pinned)
|
||||
- [x] 6.5 OTel instruments emitted from production paths, asserted with a recording `MeterProvider`
|
||||
- [x] 6.6 Lifecycle dedup tested where claimed — in-memory message cache set/get/evict
|
||||
- [x] 6.7 No retroactive tick
|
||||
|
||||
## 7. Archive
|
||||
|
||||
- [x] 7.1 Fold spec delta into `openspec/specs/discord-client/spec.md`
|
||||
- [x] 7.2 Move `openspec/changes/add-discord-client/` → `openspec/archive/2026-05-18-add-discord-client/`
|
||||
Reference in New Issue
Block a user