17 KiB
Capability Spec: slack-client
Package: @suaveplan/slack-client
Status: ACCEPTED
Every requirement below carries a
**Verified by:**line at authoring time.
Requirements
Requirement: SlackApiClient sends messages via chat.postMessage
The package SHALL expose SlackApiClient.sendMessage(message) which POSTs to ${baseUrl}/chat.postMessage with an Authorization: Bearer <token> header and a JSON body containing channel, text, and the optional blocks, thread_ts, unfurl_links, unfurl_media, metadata fields. On a successful Slack response carrying ok: true and ts, the client SHALL record a SlackDeliveryReport { status: "delivered" } retrievable via getStatus(ts).
Scenario: successful send populates delivery report
- WHEN the caller invokes
sendMessage({ channel: "C1", text: "hi" })and the fake fetch resolves{ ok: true, channel: "C1", ts: "99.99" } - THEN the returned
SlackResponse.okistrue,getStatus("99.99")returns adeliveredreport with channelC1, and the OTelslack.requestscounter increments once with{ method: "chat.postMessage", outcome: "ok" }
Scenario: blocks + thread + metadata forwarded verbatim
- WHEN
sendMessageis called withblocks,threadTs,unfurlLinks,unfurlMedia,metadata - THEN the captured request body contains
thread_ts,unfurl_links,unfurl_media,blocks,metadatamatching the input
Scenario: default channel used when message channel is empty
- WHEN the client is constructed with
defaultChannel: "C999"andsendMessage({ channel: "", text: "hi" })is invoked - THEN the request body posts to channel
C999
Scenario: no channel throws SLACK.CHANNEL_NOT_FOUND
- WHEN
sendMessage({ channel: "", text: "hi" })is invoked with nodefaultChannel - THEN the call rejects with
SlackChannelNotFoundErrorcarrying codeSLACK.CHANNEL_NOT_FOUND
Verified by: src/client/slack-api-client.test.ts::"sendMessage posts to chat.postMessage and records a delivered report" AND src/client/slack-api-client.test.ts::"sendMessage forwards blocks thread metadata and unfurl flags" AND src/client/slack-api-client.test.ts::"sendMessage uses defaultChannel when message.channel is empty" AND src/client/slack-api-client.test.ts::"sendMessage throws SLACK.CHANNEL_NOT_FOUND when no channel is available"
Requirement: SlackApiClient updates and deletes messages
The package SHALL expose updateMessage(channel, ts, text, blocks?) and deleteMessage(channel, ts) which POST to chat.update and chat.delete respectively with the supplied identifiers.
Scenario: updateMessage forwards new text and optional blocks
- WHEN
updateMessage("C1", "1.1", "Updated", blocks)is invoked - THEN the request body contains
channel: "C1",ts: "1.1",text: "Updated", and the suppliedblocks
Scenario: deleteMessage forwards channel and ts
- WHEN
deleteMessage("C1", "1.1")is invoked - THEN the request body contains exactly
{ channel: "C1", ts: "1.1" }
Verified by: src/client/slack-api-client.test.ts::"updateMessage posts to chat.update with new text and blocks" AND src/client/slack-api-client.test.ts::"deleteMessage posts to chat.delete with channel and ts"
Requirement: SlackApiClient lists channels and users with mapping
The package SHALL expose listChannels(limit?), getChannelInfo(channelId), and listUsers(limit?). The client SHALL map snake_case Slack payload shapes (is_private, topic.value, purpose.value, num_members, real_name, profile.email, is_bot) onto camelCase domain types (SlackChannel, SlackUser). Missing arrays SHALL yield [], not undefined.
Scenario: listChannels maps snake_case to domain
- WHEN the fake fetch returns
{ ok: true, channels: [{ id, name, is_private, topic, purpose, num_members }, ...] } - THEN the result is
SlackChannel[]with camelCase fields populated
Scenario: listChannels returns empty array when key absent
- WHEN the fake fetch returns
{ ok: true }with nochannelskey - THEN the result is
[]
Scenario: getChannelInfo maps single channel
- WHEN
getChannelInfo("C1")is invoked and the fake fetch returns a channel object - THEN the returned
SlackChannelmatches the mapped shape
Scenario: listUsers maps snake_case to domain
- WHEN the fake fetch returns
{ ok: true, members: [...] } - THEN the result is
SlackUser[]withrealName,email,isBotpopulated
Scenario: listUsers returns empty array when key absent
- WHEN the fake fetch returns
{ ok: true }with nomemberskey - THEN the result is
[]
Verified by: src/client/slack-api-client.test.ts::"listChannels maps snake_case payload to SlackChannel" AND src/client/slack-api-client.test.ts::"listChannels returns empty array when channels key is absent" AND src/client/slack-api-client.test.ts::"getChannelInfo maps a single channel" AND src/client/slack-api-client.test.ts::"listUsers maps snake_case payload to SlackUser" AND src/client/slack-api-client.test.ts::"listUsers returns empty array when members key is absent"
Requirement: SlackApiClient classifies HTTP errors into typed Slack codes
The package SHALL convert HTTP 401/403 to SLACK.AUTH_ERROR, HTTP 404 to SLACK.CHANNEL_NOT_FOUND, HTTP 408/504 to SLACK.TIMEOUT, HTTP 429 to SLACK.RATE_LIMITED (with retryAfterMs populated from Retry-After or x-ratelimit-reset), and other non-2xx to SLACK.API_ERROR. A non-ok: true JSON body containing error: "channel_not_found" | "is_archived" | "not_in_channel" SHALL yield SLACK.CHANNEL_NOT_FOUND; invalid_auth | not_authed | token_revoked | account_inactive SHALL yield SLACK.AUTH_ERROR; team_not_found | no_workspace SHALL yield SLACK.WORKSPACE_NOT_FOUND; everything else SHALL yield SLACK.API_ERROR.
Scenario: HTTP 401 yields SLACK.AUTH_ERROR
- WHEN the fake fetch returns HTTP 401
- THEN the call rejects with
SlackAuthErrorcarrying codeSLACK.AUTH_ERROR
Scenario: HTTP 404 yields SLACK.CHANNEL_NOT_FOUND
- WHEN the fake fetch returns HTTP 404
- THEN the call rejects with
SlackChannelNotFoundError
Scenario: HTTP 429 yields SLACK.RATE_LIMITED with parsed retry-after
- WHEN the fake fetch returns HTTP 429 with
Retry-After: "30" - THEN the rejection is
SlackRateLimitedErrorwhosecontext.retryAfterMsis30000
Scenario: HTTP 500 yields SLACK.API_ERROR
- WHEN the fake fetch returns HTTP 500
- THEN the call rejects with
SlackApiError
Scenario: Slack error string classifies to typed code
- WHEN the fake fetch returns
{ ok: false, error: "channel_not_found" } - THEN the call rejects with
SlackChannelNotFoundError
Scenario: invalid_auth string yields SLACK.AUTH_ERROR
- WHEN the fake fetch returns
{ ok: false, error: "invalid_auth" } - THEN the call rejects with
SlackAuthError
Scenario: team_not_found string yields SLACK.WORKSPACE_NOT_FOUND
- WHEN the fake fetch returns
{ ok: false, error: "team_not_found" } - THEN the call rejects with
SlackWorkspaceNotFoundError
Scenario: unknown error string yields SLACK.API_ERROR
- WHEN the fake fetch returns
{ ok: false, error: "ratelimited_internal" } - THEN the call rejects with
SlackApiError
Verified by: src/client/classify.test.ts::"HTTP 401 classifies to SLACK.AUTH_ERROR" AND src/client/classify.test.ts::"HTTP 404 classifies to SLACK.CHANNEL_NOT_FOUND" AND src/client/classify.test.ts::"HTTP 500 classifies to SLACK.API_ERROR" AND src/client/classify.test.ts::"slack error string channel_not_found maps to SLACK.CHANNEL_NOT_FOUND" AND src/client/classify.test.ts::"slack error string invalid_auth maps to SLACK.AUTH_ERROR" AND src/client/classify.test.ts::"slack error string team_not_found maps to SLACK.WORKSPACE_NOT_FOUND" AND src/client/classify.test.ts::"unknown slack error string falls back to SLACK.API_ERROR" AND src/client/slack-api-client.test.ts::"HTTP 429 produces SlackRateLimitedError with parsed retryAfterMs"
Requirement: Retry-After header parsing supports three forms
The package SHALL parse the Retry-After HTTP header in delay-seconds ("30") and HTTP-date ("Wed, 21 Oct 2026 07:28:00 GMT") forms, and fall back to x-ratelimit-reset in epoch-seconds, epoch-milliseconds, or ISO-8601 form. The parser SHALL return undefined when no header is present or the value is unparseable, and SHALL clamp negative deltas to 0.
Scenario: delay-seconds form
- WHEN the response carries
Retry-After: "45" - THEN
parseRetryAfterMsreturns45000
Scenario: HTTP-date form
- WHEN the response carries
Retry-After: <ISO date 60s in the future>and the injected clock is at the present - THEN the parser returns a value
>= 0(≈60000)
Scenario: x-ratelimit-reset epoch seconds fallback
- WHEN no
Retry-Afteris present butx-ratelimit-resetis epoch seconds in the future - THEN the parser returns the difference in milliseconds
Scenario: x-ratelimit-reset ISO fallback
- WHEN no
Retry-Afteris present butx-ratelimit-resetis an ISO timestamp in the future - THEN the parser returns the difference in milliseconds
Scenario: missing headers yields undefined
- WHEN neither header is present
- THEN the parser returns
undefined
Scenario: negative delta clamps to 0
- WHEN the header points at a past timestamp
- THEN the parser returns
0
Verified by: src/client/retry-after.test.ts::"parses delay-seconds form" AND src/client/retry-after.test.ts::"parses HTTP-date form" AND src/client/retry-after.test.ts::"falls back to x-ratelimit-reset epoch seconds" AND src/client/retry-after.test.ts::"falls back to x-ratelimit-reset ISO string" AND src/client/retry-after.test.ts::"returns undefined when no headers are present" AND src/client/retry-after.test.ts::"clamps negative deltas to 0"
Requirement: SlackApiClient enforces AbortSignal-driven timeout
The package SHALL abort an in-flight Slack request after timeoutMs (default 10000) and surface the abort as SlackTimeoutError (code SLACK.TIMEOUT). Caller-supplied AbortSignal SHALL also terminate the request and propagate.
Scenario: internal timeout surfaces SlackTimeoutError
- WHEN the fake fetch never resolves and
timeoutMs: 1is configured - THEN the call rejects with
SlackTimeoutError
Scenario: caller signal aborts the request
- WHEN the caller passes a pre-aborted
AbortSignal - THEN the call rejects with
SlackTimeoutErrorwhosecontext.urlmatches
Verified by: src/client/slack-api-client.test.ts::"timeoutMs aborts an in-flight request with SlackTimeoutError" AND src/client/slack-api-client.test.ts::"caller-supplied AbortSignal cancels the request with SlackTimeoutError"
Requirement: SlackWebhook posts to an incoming webhook URL
The package SHALL expose SlackWebhook.send(message) and SlackWebhook.sendText(text). Both SHALL POST a JSON body with text and optional blocks to the configured webhookUrl with Content-Type: application/json; charset=utf-8. On 2xx, the returned SlackDeliveryReport.status SHALL be "delivered". On non-2xx, the call SHALL reject with SlackAuthError (401/403) or SlackWebhookFailedError (otherwise). On network failure or timeout, the call SHALL reject with SlackTimeoutError.
Scenario: send returns delivered report on 2xx
- WHEN the fake fetch returns HTTP 200
- THEN the report's status is
"delivered", channel"webhook", andmessageIdis non-empty
Scenario: sendText forwards plain text
- WHEN
sendText("hi")is invoked - THEN the request body parses to
{ text: "hi" }with noblocks
Scenario: 401 yields SlackAuthError
- WHEN the fake fetch returns HTTP 401
- THEN the call rejects with
SlackAuthError
Scenario: 400 yields SlackWebhookFailedError
- WHEN the fake fetch returns HTTP 400 with body
"invalid_payload" - THEN the call rejects with
SlackWebhookFailedErrorcarrying the body incontext
Scenario: message IDs are monotonic
- WHEN two successive sends complete
- THEN their
messageIds differ
Verified by: src/webhook/slack-webhook.test.ts::"send returns a delivered report on HTTP 200" AND src/webhook/slack-webhook.test.ts::"sendText posts only the text field" AND src/webhook/slack-webhook.test.ts::"HTTP 401 rejects with SlackAuthError" AND src/webhook/slack-webhook.test.ts::"HTTP 400 rejects with SlackWebhookFailedError" AND src/webhook/slack-webhook.test.ts::"sequential sends yield distinct messageIds"
Requirement: Constructors validate configuration via Zod
The package SHALL reject construction of SlackApiClient with an empty token and SlackWebhook with an empty webhookUrl, throwing SlackConfigMissingError (code SLACK.CONFIG_MISSING) at construct time. All public option bundles SHALL be validated through a Zod schema; safeParse SHALL surface a ZodError cause on the thrown SlackConfigMissingError.
Scenario: empty token rejected
- WHEN
new SlackApiClient({ token: "" })is invoked - THEN the constructor throws
SlackConfigMissingError
Scenario: empty webhookUrl rejected
- WHEN
new SlackWebhook({ webhookUrl: "" })is invoked - THEN the constructor throws
SlackConfigMissingError
Scenario: schemas are exposed for boundary parse
- WHEN
SlackMessageSchema.safeParse({ channel: 1, text: "x" })is invoked - THEN
successisfalse
Verified by: src/client/slack-api-client.test.ts::"empty token rejects with SlackConfigMissingError" AND src/webhook/slack-webhook.test.ts::"empty webhookUrl rejects with SlackConfigMissingError" AND src/schemas/schemas.test.ts::"SlackMessageSchema rejects non-string channel"
Requirement: OTel instruments emit on every Slack request
The package SHALL emit four OpenTelemetry instruments on every production path: a slack.requests counter, a slack.errors counter, a slack.latency_ms histogram, and a slack.webhook_sends counter. Counters SHALL carry attributes { method, outcome } where outcome ∈ { "ok", "error" }.
Scenario: successful sendMessage emits slack.requests + slack.latency_ms
- WHEN
sendMessagesucceeds - THEN the in-memory
MeterProviderrecords oneslack.requestsincrement withoutcome: "ok"and oneslack.latency_msobservation
Scenario: failed sendMessage emits slack.requests + slack.errors
- WHEN
sendMessagerejects withSlackApiError - THEN the in-memory
MeterProviderrecords oneslack.requestswithoutcome: "error"and oneslack.errorsincrement
Scenario: webhook send emits slack.webhook_sends
- WHEN
SlackWebhook.sendsucceeds - THEN the in-memory
MeterProviderrecords oneslack.webhook_sendsincrement withoutcome: "ok"
Verified by: src/otel/metrics.test.ts::"successful sendMessage emits slack.requests with outcome ok" AND src/otel/metrics.test.ts::"failed sendMessage emits slack.requests outcome error and slack.errors" AND src/otel/metrics.test.ts::"webhook send emits slack.webhook_sends"
Requirement: Stable error code registry
The package SHALL export SLACK_CODES (built via defineCodes("SLACK", [...])) containing eight stable codes: API_ERROR, AUTH_ERROR, RATE_LIMITED, CHANNEL_NOT_FOUND, WORKSPACE_NOT_FOUND, CONFIG_MISSING, WEBHOOK_FAILED, TIMEOUT. Each code SHALL have a typed SuaveplanError subclass and SHALL be thrown by at least one production path. src/index.ts SHALL side-effect import ./error-codes.js so subpath consumers register codes too.
Scenario: SLACK_CODES contains all eight reasons
- WHEN consumer imports
SLACK_CODES - THEN the record has exactly eight keys mapping reason →
SLACK.<REASON>
Scenario: each error subclass carries its code
- WHEN an instance of
SlackApiError,SlackAuthError,SlackRateLimitedError,SlackChannelNotFoundError,SlackWorkspaceNotFoundError,SlackConfigMissingError,SlackWebhookFailedError, orSlackTimeoutErroris constructed - THEN its
.codeequals the correspondingSLACK.<REASON>literal
Verified by: src/errors/errors.test.ts::"SLACK_CODES exposes exactly the eight stable reasons" AND src/errors/errors.test.ts::"every error subclass carries its SLACK.<REASON> code"
Requirement: Deterministic FakeFetch test double
The package SHALL ship a FakeFetch under src/fakes/fake-fetch.ts which the test suite uses to drive every transport-level scenario without monkey-patching globalThis.fetch and without vi.mock. FakeFetch SHALL queue scripted responses, record every request, and surface a FetchLike instance for direct injection into client constructors.
Scenario: queued response is consumed in FIFO order
- WHEN two responses are queued and two requests are made
- THEN the first request receives the first queued response
Scenario: pending request abort propagates to AbortError
- WHEN a request is queued as
pendingand the caller aborts the signal - THEN the returned promise rejects with
AbortError
Verified by: src/fakes/fake-fetch.test.ts::"queued responses are consumed in FIFO order" AND src/fakes/fake-fetch.test.ts::"pending request rejects with AbortError when signal aborts"