Files
2026-05-27 23:29:53 +01:00

14 KiB

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"