Initial Commit
This commit is contained in:
87
packages/communication/messaging/slack-client/README.md
Normal file
87
packages/communication/messaging/slack-client/README.md
Normal file
@@ -0,0 +1,87 @@
|
||||
# @suaveplan/slack-client - Typed deterministic Slack client — Slack Web API + incoming-webhook surface, Zod-validated boundaries, OpenTelemetry-instrumented hot paths, injectable Clock/Rng/Logger/FetchLike, stable defineCodes('SLACK', [...]) error registry. Universal: runs unchanged in Bun, Node, and the browser.
|
||||
|
||||
Typed, deterministic Slack client — Web API + incoming-webhook surface — with Zod-validated boundaries, OpenTelemetry-instrumented hot paths, injectable `Clock` / `Rng` / `LoggerLike` / `FetchLike`, and a stable `defineCodes("SLACK", […])` error registry.
|
||||
|
||||
Universal: runs unchanged in Bun, Node, and the browser.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [classify](./src/client/classify.md) — `classify.ts` exports two pure mapping functions that fold transport-level failure signals into the stable `SLACK_CODES…
|
||||
- [retry-after](./src/client/retry-after.md) — `retry-after.ts` exports a single pure function `parseRetryAfterMs(response, clock?)` that converts an HTTP `Retry-Afte…
|
||||
- [slack-api-client](./src/client/slack-api-client.md) — `SlackApiClient` is the typed transport for the Slack Web API. It exposes the six methods downstream packages rely on —…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — `error-codes.ts` is the single source of truth for every error the `@suaveplan/slack-client` package can throw. It regi…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — `error-codes.ts` is the single source of truth for every error the `@suaveplan/slack-client` package can throw. It regi…
|
||||
- [fakes](./src/fakes/fake-fetch.md) — `fake-fetch.ts` exports `FakeFetch`, the deterministic test double the package's own unit tests use to exercise every t…
|
||||
- [fake-fetch](./src/fakes/fake-fetch.md) — `fake-fetch.ts` exports `FakeFetch`, the deterministic test double the package's own unit tests use to exercise every t…
|
||||
- [otel](./src/otel/metrics.md) — `metrics.ts` registers four OpenTelemetry instruments under the meter name `@suaveplan/slack-client` and exports the he…
|
||||
- [metrics](./src/otel/metrics.md) — `metrics.ts` registers four OpenTelemetry instruments under the meter name `@suaveplan/slack-client` and exports the he…
|
||||
- [schemas](./src/schemas/schemas.md) — `schemas.ts` exports the Zod schemas that validate every public option bag and every Slack Web API request payload that…
|
||||
- [schemas](./src/schemas/schemas.md) — `schemas.ts` exports the Zod schemas that validate every public option bag and every Slack Web API request payload that…
|
||||
- [webhook](./src/webhook/slack-webhook.md) — `SlackWebhook` is the simpler half of the package — a typed wrapper around Slack's incoming-webhook URLs. Where `SlackA…
|
||||
- [slack-webhook](./src/webhook/slack-webhook.md) — `SlackWebhook` is the simpler half of the package — a typed wrapper around Slack's incoming-webhook URLs. Where `SlackA…
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
bun add @suaveplan/slack-client
|
||||
```
|
||||
|
||||
## Two clients
|
||||
|
||||
- `SlackApiClient` — Slack Web API. Token-authenticated. Supports `sendMessage`, `updateMessage`, `deleteMessage`, `listChannels`, `getChannelInfo`, `listUsers`, `getStatus`.
|
||||
- `SlackWebhook` — Slack incoming webhooks. URL-authenticated. Supports `send` and `sendText`.
|
||||
|
||||
## Quickstart
|
||||
|
||||
```ts
|
||||
import { SlackApiClient, SlackWebhook } from "@suaveplan/slack-client";
|
||||
|
||||
const api = new SlackApiClient({ token: process.env["SLACK_TOKEN"]! });
|
||||
await api.sendMessage({ channel: "C0123", text: "Deploy succeeded." });
|
||||
|
||||
const hook = new SlackWebhook({
|
||||
webhookUrl: "https://hooks.slack.com/services/T/B/X",
|
||||
});
|
||||
await hook.sendText("Build broken on main.");
|
||||
```
|
||||
|
||||
## Modules
|
||||
|
||||
- [`error-codes`](./src/error-codes.md) — `SLACK_CODES` registry + 8 typed error subclasses.
|
||||
- [`types/slack-client`](./src/types/slack-client.types.ts) — public shapes.
|
||||
- [`schemas/schemas`](./src/schemas/schemas.md) — Zod boundary schemas.
|
||||
- [`client/slack-api-client`](./src/client/slack-api-client.md) — Slack Web API client.
|
||||
- [`client/retry-after`](./src/client/retry-after.md) — `Retry-After` / `x-ratelimit-reset` parser.
|
||||
- [`client/classify`](./src/client/classify.md) — HTTP-status + Slack-error string classifier.
|
||||
- [`webhook/slack-webhook`](./src/webhook/slack-webhook.md) — incoming-webhook client.
|
||||
- [`otel/metrics`](./src/otel/metrics.md) — four OpenTelemetry instruments.
|
||||
- [`fakes/fake-fetch`](./src/fakes/fake-fetch.md) — deterministic `FetchLike` test double.
|
||||
|
||||
## Error codes
|
||||
|
||||
All eight reasons land under the `SLACK.*` namespace:
|
||||
|
||||
- `SLACK.API_ERROR` — generic non-ok response from the Slack API.
|
||||
- `SLACK.AUTH_ERROR` — token rejected (HTTP 401/403, `invalid_auth`, `token_revoked`, `account_inactive`).
|
||||
- `SLACK.RATE_LIMITED` — HTTP 429; `context.retryAfterMs` carries parsed delay.
|
||||
- `SLACK.CHANNEL_NOT_FOUND` — HTTP 404 or `channel_not_found` / `is_archived` / `not_in_channel`.
|
||||
- `SLACK.WORKSPACE_NOT_FOUND` — `team_not_found` / `no_workspace`.
|
||||
- `SLACK.CONFIG_MISSING` — empty/invalid token or webhook URL at construct time.
|
||||
- `SLACK.WEBHOOK_FAILED` — webhook POST returned non-2xx (non-auth).
|
||||
- `SLACK.TIMEOUT` — internal `timeoutMs` fired OR caller `AbortSignal` aborted OR network failure.
|
||||
|
||||
## OpenTelemetry
|
||||
|
||||
Four instruments under meter `@suaveplan/slack-client@0.1.0`:
|
||||
|
||||
- `slack.requests` (counter) — `{ method, outcome }`
|
||||
- `slack.errors` (counter) — `{ method, code }`
|
||||
- `slack.latency_ms` (histogram, unit `ms`) — `{ method, outcome }`
|
||||
- `slack.webhook_sends` (counter) — `{ outcome }`
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
|
||||
109
packages/communication/messaging/slack-client/package.json
Normal file
109
packages/communication/messaging/slack-client/package.json
Normal file
@@ -0,0 +1,109 @@
|
||||
{
|
||||
"name": "@suaveplan/slack-client",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"description": "Typed deterministic Slack client — Slack Web API + incoming-webhook surface, Zod-validated boundaries, OpenTelemetry-instrumented hot paths, injectable Clock/Rng/Logger/FetchLike, stable defineCodes('SLACK', [...]) error registry. Universal: runs unchanged in Bun, Node, and the browser.",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"./error-codes": {
|
||||
"import": {
|
||||
"types": "./dist/error-codes/index.d.ts",
|
||||
"default": "./dist/error-codes/index.js"
|
||||
}
|
||||
},
|
||||
"./types/slack-client": {
|
||||
"import": {
|
||||
"types": "./dist/types/slack-client.types.d.ts",
|
||||
"default": "./dist/types/slack-client.types.js"
|
||||
}
|
||||
},
|
||||
"./schemas/schemas": {
|
||||
"import": {
|
||||
"types": "./dist/schemas/schemas.d.ts",
|
||||
"default": "./dist/schemas/schemas.js"
|
||||
}
|
||||
},
|
||||
"./client/slack-api-client": {
|
||||
"import": {
|
||||
"types": "./dist/client/slack-api-client.d.ts",
|
||||
"default": "./dist/client/slack-api-client.js"
|
||||
}
|
||||
},
|
||||
"./client/retry-after": {
|
||||
"import": {
|
||||
"types": "./dist/client/retry-after.d.ts",
|
||||
"default": "./dist/client/retry-after.js"
|
||||
}
|
||||
},
|
||||
"./client/classify": {
|
||||
"import": {
|
||||
"types": "./dist/client/classify.d.ts",
|
||||
"default": "./dist/client/classify.js"
|
||||
}
|
||||
},
|
||||
"./webhook/slack-webhook": {
|
||||
"import": {
|
||||
"types": "./dist/webhook/slack-webhook.d.ts",
|
||||
"default": "./dist/webhook/slack-webhook.js"
|
||||
}
|
||||
},
|
||||
"./otel/metrics": {
|
||||
"import": {
|
||||
"types": "./dist/otel/metrics.d.ts",
|
||||
"default": "./dist/otel/metrics.js"
|
||||
}
|
||||
},
|
||||
"./fakes/fake-fetch": {
|
||||
"import": {
|
||||
"types": "./dist/fakes/fake-fetch.d.ts",
|
||||
"default": "./dist/fakes/fake-fetch.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": {
|
||||
"@opentelemetry/api": "catalog:observability",
|
||||
"@suaveplan/error": "workspace:^",
|
||||
"@suaveplan/types": "workspace:^",
|
||||
"tslib": "catalog:build",
|
||||
"zod": "catalog:forms"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@opentelemetry/sdk-metrics": "1.30.0",
|
||||
"@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,57 @@
|
||||
# `client/classify` — HTTP status + Slack-error classifier
|
||||
|
||||
## Purpose
|
||||
|
||||
`classify.ts` exports two pure mapping functions that fold transport-level failure signals into the stable `SLACK_CODES` registry. `classifyHttpStatus` partitions every observed HTTP status code into one of `AUTH_ERROR`, `CHANNEL_NOT_FOUND`, `TIMEOUT`, `RATE_LIMITED`, or the catch-all `API_ERROR`. `classifySlackError` does the same for Slack's JSON-level error strings (`invalid_auth`, `channel_not_found`, `team_not_found`, etc.). Centralising the classification keeps the API client and the webhook client in lock-step and gives downstream retry wrappers a single source of truth for what the package considers a retryable failure.
|
||||
|
||||
## Features
|
||||
|
||||
- Total HTTP-status partition — every numeric status maps to exactly one code.
|
||||
- Total Slack-error partition — every JSON `error` string maps to exactly one code, with regex matchers grouping family members (`is_archived`/`not_in_channel` count as `CHANNEL_NOT_FOUND`).
|
||||
- Pure functions — no side effects, no module-scope state, safe to tree-shake.
|
||||
- Literal-typed return — `SlackErrorCode` discriminated union is preserved.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
classifyHttpStatus,
|
||||
classifySlackError,
|
||||
} from "@suaveplan/slack-client/client/classify";
|
||||
|
||||
const fromStatus = classifyHttpStatus(429);
|
||||
// "SLACK.RATE_LIMITED"
|
||||
|
||||
const fromBody = classifySlackError("invalid_auth");
|
||||
// "SLACK.AUTH_ERROR"
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
classifyHttpStatus,
|
||||
classifySlackError,
|
||||
} from "@suaveplan/slack-client/client/classify";
|
||||
import { SLACK_CODES } from "@suaveplan/slack-client/error-codes";
|
||||
|
||||
function isRetryable(code: ReturnType<typeof classifyHttpStatus>): boolean {
|
||||
return (
|
||||
code === SLACK_CODES.RATE_LIMITED ||
|
||||
code === SLACK_CODES.TIMEOUT ||
|
||||
code === SLACK_CODES.API_ERROR
|
||||
);
|
||||
}
|
||||
|
||||
const retry = isRetryable(classifyHttpStatus(503));
|
||||
// true
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `classifyHttpStatus(status: number): SlackErrorCode`
|
||||
- `classifySlackError(slackError: string): SlackErrorCode`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The Slack-error matchers use case-insensitive regular expressions because Slack's documentation is inconsistent on casing and the API has historically returned both `invalid_auth` and `Invalid_Auth` in different surfaces. The order of the checks is important — `invalid_auth` must match before any `not_*` matcher because `not_authed` is an auth error, not a channel error. The HTTP-status partition deliberately uses `===` comparisons rather than range checks so that adding a new status (e.g. 451) requires an explicit decision rather than silently joining the catch-all bucket.
|
||||
@@ -0,0 +1,119 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { SLACK_CODES } from "../error-codes/error-codes.js";
|
||||
import { classifyHttpStatus, classifySlackError } from "./classify.js";
|
||||
|
||||
describe("classifyHttpStatus", () => {
|
||||
it("HTTP 401 classifies to SLACK.AUTH_ERROR", { timeout: 5000 }, () => {
|
||||
expect(classifyHttpStatus(401)).toBe(SLACK_CODES.AUTH_ERROR);
|
||||
});
|
||||
|
||||
it("HTTP 403 classifies to SLACK.AUTH_ERROR", { timeout: 5000 }, () => {
|
||||
expect(classifyHttpStatus(403)).toBe(SLACK_CODES.AUTH_ERROR);
|
||||
});
|
||||
|
||||
it("HTTP 404 classifies to SLACK.CHANNEL_NOT_FOUND", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(classifyHttpStatus(404)).toBe(SLACK_CODES.CHANNEL_NOT_FOUND);
|
||||
});
|
||||
|
||||
it("HTTP 408 classifies to SLACK.TIMEOUT", { timeout: 5000 }, () => {
|
||||
expect(classifyHttpStatus(408)).toBe(SLACK_CODES.TIMEOUT);
|
||||
});
|
||||
|
||||
it("HTTP 504 classifies to SLACK.TIMEOUT", { timeout: 5000 }, () => {
|
||||
expect(classifyHttpStatus(504)).toBe(SLACK_CODES.TIMEOUT);
|
||||
});
|
||||
|
||||
it("HTTP 429 classifies to SLACK.RATE_LIMITED", { timeout: 5000 }, () => {
|
||||
expect(classifyHttpStatus(429)).toBe(SLACK_CODES.RATE_LIMITED);
|
||||
});
|
||||
|
||||
it("HTTP 500 classifies to SLACK.API_ERROR", { timeout: 5000 }, () => {
|
||||
expect(classifyHttpStatus(500)).toBe(SLACK_CODES.API_ERROR);
|
||||
});
|
||||
|
||||
it("HTTP 200 classifies to SLACK.API_ERROR (fallback)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(classifyHttpStatus(200)).toBe(SLACK_CODES.API_ERROR);
|
||||
});
|
||||
});
|
||||
|
||||
describe("classifySlackError", () => {
|
||||
it("slack error string invalid_auth maps to SLACK.AUTH_ERROR", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(classifySlackError("invalid_auth")).toBe(SLACK_CODES.AUTH_ERROR);
|
||||
});
|
||||
|
||||
it("slack error string token_revoked maps to SLACK.AUTH_ERROR", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(classifySlackError("token_revoked")).toBe(
|
||||
SLACK_CODES.AUTH_ERROR
|
||||
);
|
||||
});
|
||||
|
||||
it("slack error string account_inactive maps to SLACK.AUTH_ERROR", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(classifySlackError("account_inactive")).toBe(
|
||||
SLACK_CODES.AUTH_ERROR
|
||||
);
|
||||
});
|
||||
|
||||
it("slack error string channel_not_found maps to SLACK.CHANNEL_NOT_FOUND", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(classifySlackError("channel_not_found")).toBe(
|
||||
SLACK_CODES.CHANNEL_NOT_FOUND
|
||||
);
|
||||
});
|
||||
|
||||
it("slack error string is_archived maps to SLACK.CHANNEL_NOT_FOUND", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(classifySlackError("is_archived")).toBe(
|
||||
SLACK_CODES.CHANNEL_NOT_FOUND
|
||||
);
|
||||
});
|
||||
|
||||
it("slack error string not_in_channel maps to SLACK.CHANNEL_NOT_FOUND", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(classifySlackError("not_in_channel")).toBe(
|
||||
SLACK_CODES.CHANNEL_NOT_FOUND
|
||||
);
|
||||
});
|
||||
|
||||
it("slack error string team_not_found maps to SLACK.WORKSPACE_NOT_FOUND", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(classifySlackError("team_not_found")).toBe(
|
||||
SLACK_CODES.WORKSPACE_NOT_FOUND
|
||||
);
|
||||
});
|
||||
|
||||
it("slack error string no_workspace maps to SLACK.WORKSPACE_NOT_FOUND", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(classifySlackError("no_workspace")).toBe(
|
||||
SLACK_CODES.WORKSPACE_NOT_FOUND
|
||||
);
|
||||
});
|
||||
|
||||
it("unknown slack error string falls back to SLACK.API_ERROR", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(classifySlackError("ratelimited_internal")).toBe(
|
||||
SLACK_CODES.API_ERROR
|
||||
);
|
||||
});
|
||||
|
||||
it("slack error string not_authed maps to SLACK.AUTH_ERROR", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(classifySlackError("not_authed")).toBe(SLACK_CODES.AUTH_ERROR);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import {
|
||||
SLACK_CODES,
|
||||
type SlackErrorCode,
|
||||
} from "../error-codes/error-codes.js";
|
||||
|
||||
/** Map an HTTP status code to a stable Slack error code. */
|
||||
export function classifyHttpStatus(status: number): SlackErrorCode {
|
||||
if (status === 401 || status === 403) return SLACK_CODES.AUTH_ERROR;
|
||||
if (status === 404) return SLACK_CODES.CHANNEL_NOT_FOUND;
|
||||
if (status === 408 || status === 504) return SLACK_CODES.TIMEOUT;
|
||||
if (status === 429) return SLACK_CODES.RATE_LIMITED;
|
||||
return SLACK_CODES.API_ERROR;
|
||||
}
|
||||
|
||||
/** Map a Slack JSON `error` string to a stable Slack error code. */
|
||||
export function classifySlackError(slackError: string): SlackErrorCode {
|
||||
if (
|
||||
/invalid_auth|not_authed|token_revoked|account_inactive/i.test(
|
||||
slackError
|
||||
)
|
||||
) {
|
||||
return SLACK_CODES.AUTH_ERROR;
|
||||
}
|
||||
if (/channel_not_found|is_archived|not_in_channel/i.test(slackError)) {
|
||||
return SLACK_CODES.CHANNEL_NOT_FOUND;
|
||||
}
|
||||
if (/team_not_found|no_workspace/i.test(slackError)) {
|
||||
return SLACK_CODES.WORKSPACE_NOT_FOUND;
|
||||
}
|
||||
return SLACK_CODES.API_ERROR;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
# `client/retry-after` — Retry-After header parser
|
||||
|
||||
## Purpose
|
||||
|
||||
`retry-after.ts` exports a single pure function `parseRetryAfterMs(response, clock?)` that converts an HTTP `Retry-After` header into a millisecond delay suitable for backoff loops. Slack's rate limiter emits both the standard `Retry-After` header (RFC 7231) and a Slack-specific `x-ratelimit-reset` header in epoch-seconds form, sometimes ISO-8601, sometimes both — the parser handles every observed shape and degrades gracefully to `undefined` when nothing parseable is present. The function injects a `Clock` so tests can pin the wall-clock at a deterministic instant and assert against an exact millisecond delta.
|
||||
|
||||
## Features
|
||||
|
||||
- Delay-seconds form (`"30"`) — returns `seconds * 1000`.
|
||||
- HTTP-date form (`"Wed, 21 Oct 2026 07:28:00 GMT"`) — returns `parsed - clock()`.
|
||||
- `x-ratelimit-reset` fallback in epoch-seconds, epoch-milliseconds (auto-detected by magnitude), or ISO-8601.
|
||||
- `undefined` when no header is present OR all parse attempts fail.
|
||||
- Negative deltas are clamped to `0` (a past reset means retry immediately).
|
||||
- Pure function — no module-scope clock, no `Date.now()` baked in.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { parseRetryAfterMs } from "@suaveplan/slack-client/client/retry-after";
|
||||
|
||||
const delay = parseRetryAfterMs(response);
|
||||
if (delay !== undefined) {
|
||||
await new Promise((r) => setTimeout(r, delay));
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { parseRetryAfterMs } from "@suaveplan/slack-client/client/retry-after";
|
||||
|
||||
// Pin the clock for a deterministic test
|
||||
const t0 = 1_000_000_000_000;
|
||||
const fakeResponse = new Response(null, {
|
||||
status: 429,
|
||||
headers: { "Retry-After": new Date(t0 + 60_000).toUTCString() },
|
||||
});
|
||||
const delay = parseRetryAfterMs(fakeResponse, () => t0);
|
||||
// delay ≈ 60000
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
`parseRetryAfterMs(response: Response, clock?: Clock): number | undefined`
|
||||
|
||||
- `response` — any `Response` whose `headers` are inspected.
|
||||
- `clock` — wall-clock source defaulted to `() => Date.now()`.
|
||||
- Returns the milliseconds-to-wait, or `undefined` when no header is parseable.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The epoch detection uses the magnitude heuristic `resetNum < 1e12` to distinguish seconds from milliseconds — `1e12` corresponds to year 33658 in epoch-seconds and is therefore safely "in the future" for both interpretations. The function intentionally tests `Number.isFinite` first, before `Date.parse`, because a numeric `Retry-After` like `"0"` produces `Date.parse("0") = NaN` in some engines and `0` in others, and the seconds branch handles the universal case unambiguously.
|
||||
@@ -0,0 +1,107 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { parseRetryAfterMs } from "./retry-after.js";
|
||||
|
||||
function makeResponse(headers: Record<string, string>): Response {
|
||||
return new Response(null, { status: 429, headers });
|
||||
}
|
||||
|
||||
describe("parseRetryAfterMs", () => {
|
||||
it("parses delay-seconds form", { timeout: 5000 }, () => {
|
||||
const r = makeResponse({ "Retry-After": "45" });
|
||||
expect(parseRetryAfterMs(r)).toBe(45_000);
|
||||
});
|
||||
|
||||
it("parses HTTP-date form", { timeout: 5000 }, () => {
|
||||
const t0 = 1_700_000_000_000;
|
||||
const future = new Date(t0 + 60_000).toUTCString();
|
||||
const r = makeResponse({ "Retry-After": future });
|
||||
const delta = parseRetryAfterMs(r, () => t0);
|
||||
expect(delta).toBeGreaterThanOrEqual(0);
|
||||
expect(delta).toBeLessThanOrEqual(60_500);
|
||||
});
|
||||
|
||||
it("falls back to x-ratelimit-reset epoch seconds", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const t0 = 1_700_000_000_000;
|
||||
const epochSec = Math.floor((t0 + 30_000) / 1000);
|
||||
const r = makeResponse({ "x-ratelimit-reset": String(epochSec) });
|
||||
const delta = parseRetryAfterMs(r, () => t0);
|
||||
expect(delta).toBeGreaterThanOrEqual(29_000);
|
||||
expect(delta).toBeLessThanOrEqual(31_000);
|
||||
});
|
||||
|
||||
it("falls back to x-ratelimit-reset epoch milliseconds", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const t0 = 1_700_000_000_000;
|
||||
const r = makeResponse({
|
||||
"x-ratelimit-reset": String(t0 + 15_000),
|
||||
});
|
||||
const delta = parseRetryAfterMs(r, () => t0);
|
||||
expect(delta).toBe(15_000);
|
||||
});
|
||||
|
||||
it("falls back to x-ratelimit-reset ISO string", { timeout: 5000 }, () => {
|
||||
const t0 = 1_700_000_000_000;
|
||||
const iso = new Date(t0 + 20_000).toISOString();
|
||||
const r = makeResponse({ "x-ratelimit-reset": iso });
|
||||
const delta = parseRetryAfterMs(r, () => t0);
|
||||
expect(delta).toBe(20_000);
|
||||
});
|
||||
|
||||
it("returns undefined when no headers are present", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const r = makeResponse({});
|
||||
expect(parseRetryAfterMs(r)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("clamps negative deltas to 0", { timeout: 5000 }, () => {
|
||||
const t0 = 1_700_000_000_000;
|
||||
const past = new Date(t0 - 60_000).toUTCString();
|
||||
const r = makeResponse({ "Retry-After": past });
|
||||
const delta = parseRetryAfterMs(r, () => t0);
|
||||
expect(delta).toBe(0);
|
||||
});
|
||||
|
||||
it("clamps negative x-ratelimit-reset to 0", { timeout: 5000 }, () => {
|
||||
const t0 = 1_700_000_000_000;
|
||||
const r = makeResponse({
|
||||
"x-ratelimit-reset": String(Math.floor((t0 - 60_000) / 1000)),
|
||||
});
|
||||
expect(parseRetryAfterMs(r, () => t0)).toBe(0);
|
||||
});
|
||||
|
||||
it("clamps negative x-ratelimit-reset ISO to 0", { timeout: 5000 }, () => {
|
||||
const t0 = 1_700_000_000_000;
|
||||
const r = makeResponse({
|
||||
"x-ratelimit-reset": new Date(t0 - 30_000).toISOString(),
|
||||
});
|
||||
expect(parseRetryAfterMs(r, () => t0)).toBe(0);
|
||||
});
|
||||
|
||||
it("returns undefined when retry-after is unparseable", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const r = makeResponse({ "Retry-After": "tomorrow-ish" });
|
||||
expect(parseRetryAfterMs(r)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("default clock falls back to Date.now() when none provided", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const future = new Date(Date.now() + 5000).toUTCString();
|
||||
const r = makeResponse({ "Retry-After": future });
|
||||
const delta = parseRetryAfterMs(r);
|
||||
expect(delta).toBeGreaterThanOrEqual(0);
|
||||
expect(delta).toBeLessThanOrEqual(6000);
|
||||
});
|
||||
|
||||
it("returns undefined when x-ratelimit-reset is unparseable", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const r = makeResponse({ "x-ratelimit-reset": "never" });
|
||||
expect(parseRetryAfterMs(r)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { Clock } from "@suaveplan/types";
|
||||
|
||||
/**
|
||||
* Parse the `Retry-After` HTTP header. Supports two RFC forms:
|
||||
*
|
||||
* - delay-seconds — `"30"`
|
||||
* - HTTP-date — `"Wed, 21 Oct 2026 07:28:00 GMT"`
|
||||
*
|
||||
* Falls back to `x-ratelimit-reset` which Slack returns in epoch-seconds
|
||||
* (numeric) or ISO-8601 form. Returns `undefined` when neither header is
|
||||
* present or the value is unparseable. Negative deltas are clamped to `0`.
|
||||
*
|
||||
* `clock` is injected for deterministic tests — production callers pass
|
||||
* `() => Date.now()` (the default).
|
||||
*/
|
||||
export function parseRetryAfterMs(
|
||||
response: Response,
|
||||
clock: Clock = (): number => Date.now()
|
||||
): number | undefined {
|
||||
const retryAfter = response.headers.get("retry-after");
|
||||
if (retryAfter !== null) {
|
||||
const seconds = Number(retryAfter);
|
||||
if (Number.isFinite(seconds) && seconds >= 0) {
|
||||
return Math.round(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) {
|
||||
const resetNum = Number(reset);
|
||||
if (Number.isFinite(resetNum)) {
|
||||
const resetMs = resetNum < 1e12 ? resetNum * 1000 : resetNum;
|
||||
return Math.max(0, resetMs - clock());
|
||||
}
|
||||
const isoMs = Date.parse(reset);
|
||||
if (Number.isFinite(isoMs)) {
|
||||
return Math.max(0, isoMs - clock());
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
# `client/slack-api-client` — Slack Web API client
|
||||
|
||||
## Purpose
|
||||
|
||||
`SlackApiClient` is the typed transport for the Slack Web API. It exposes the six methods downstream packages rely on — `sendMessage`, `updateMessage`, `deleteMessage`, `listChannels`, `getChannelInfo`, `listUsers` — plus `getStatus` for retrieving the delivery report stored after a successful `sendMessage`. The client accepts an injectable transport bag (`fetch`, `clock`, `logger`) so production code can pass `globalThis.fetch` while tests inject a `FakeFetch`. Every public method is bounded by an internal timeout enforced via `AbortController`, supports a caller-supplied `AbortSignal`, classifies non-2xx responses through the shared HTTP / Slack-error matchers, and emits the four OTel instruments defined in `otel/metrics`.
|
||||
|
||||
## Features
|
||||
|
||||
- Zod boundary validation in the constructor — empty `token` rejects with `SlackConfigMissingError` whose `cause` is the originating `ZodError`.
|
||||
- Eight typed error subclasses surfaced through `classify` + `#errorForCode`.
|
||||
- `Retry-After` and `x-ratelimit-reset` parsing on HTTP 429 — millisecond delta exposed via `error.context.retryAfterMs`.
|
||||
- snake_case → camelCase mapping for channel and user payloads with `undefined`-elision so the public shape stays compatible with `exactOptionalPropertyTypes`.
|
||||
- Internal `timeoutMs` (default 10 000 ms) AND caller `AbortSignal` both routed through one `AbortController`.
|
||||
- `slack.requests` / `slack.errors` / `slack.latency_ms` emitted from every code path.
|
||||
- Delivery-report cache (`Map<ts, SlackDeliveryReport>`) for `getStatus(ts)` lookups.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { SlackApiClient } from "@suaveplan/slack-client";
|
||||
|
||||
const client = new SlackApiClient({ token: process.env["SLACK_TOKEN"]! });
|
||||
const res = await client.sendMessage({
|
||||
channel: "C0123456789",
|
||||
text: "Build is green :white_check_mark:",
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { SlackApiClient } from "@suaveplan/slack-client";
|
||||
import { FakeFetch } from "@suaveplan/slack-client/fakes/fake-fetch";
|
||||
|
||||
const fake = new FakeFetch();
|
||||
fake.push({
|
||||
json: { ok: true, channel: "C1", ts: "1.1" },
|
||||
});
|
||||
|
||||
let now = 1_700_000_000_000;
|
||||
const client = new SlackApiClient({
|
||||
token: "xoxb-test",
|
||||
fetch: fake.fetch,
|
||||
clock: () => now++,
|
||||
logger: { debug() {}, info() {}, warn() {}, error() {} },
|
||||
});
|
||||
|
||||
const res = await client.sendMessage({ channel: "C1", text: "hi" });
|
||||
const report = client.getStatus(res.ts!);
|
||||
// report.status === "delivered"
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `new SlackApiClient(options: SlackApiClientOptions)`
|
||||
- `sendMessage(message: SlackMessage, signal?: AbortSignal): Promise<SlackResponse>`
|
||||
- `updateMessage(channel, ts, text, blocks?): Promise<SlackResponse>`
|
||||
- `deleteMessage(channel, ts): Promise<SlackResponse>`
|
||||
- `listChannels(limit?): Promise<SlackChannel[]>`
|
||||
- `getChannelInfo(channelId): Promise<SlackChannel>`
|
||||
- `listUsers(limit?): Promise<SlackUser[]>`
|
||||
- `getStatus(ts: string): SlackDeliveryReport | undefined`
|
||||
|
||||
Errors thrown: `SlackConfigMissingError`, `SlackChannelNotFoundError`, `SlackAuthError`, `SlackRateLimitedError`, `SlackTimeoutError`, `SlackWorkspaceNotFoundError`, `SlackApiError`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The client uses private `#`-prefixed class fields (not TS `private`) so consumer tooling cannot accidentally reach into the instance through bracket access — the contract is enforced at the runtime level by the engine. The request method assembles one `AbortController` that listens to both the caller signal AND the internal timer, then aborts on whichever fires first; the listener is removed in `finish()` to avoid leaking attached handlers across requests. The `#errorForCode` switch is exhaustive over `SlackErrorCode` and defaults to `SlackApiError` for forward-compatibility when the registry expands.
|
||||
@@ -0,0 +1,571 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
SlackApiError,
|
||||
SlackAuthError,
|
||||
SlackChannelNotFoundError,
|
||||
SlackConfigMissingError,
|
||||
SlackRateLimitedError,
|
||||
SlackTimeoutError,
|
||||
SlackWorkspaceNotFoundError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import { FakeFetch } from "../fakes/fake-fetch.js";
|
||||
import type { SlackApiClientOptions } from "../types/slack-client.types.js";
|
||||
import {
|
||||
defaultClock,
|
||||
defaultFetch,
|
||||
SlackApiClient,
|
||||
} from "./slack-api-client.js";
|
||||
|
||||
function make(
|
||||
fake: FakeFetch,
|
||||
extra: Partial<SlackApiClientOptions> = {}
|
||||
): SlackApiClient {
|
||||
return new SlackApiClient({
|
||||
token: "xoxb-test",
|
||||
fetch: fake.fetch,
|
||||
clock: () => 1_700_000_000_000,
|
||||
...extra,
|
||||
});
|
||||
}
|
||||
|
||||
describe("SlackApiClient constructor", () => {
|
||||
it("empty token rejects with SlackConfigMissingError", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() => new SlackApiClient({ token: "" })).toThrow(
|
||||
SlackConfigMissingError
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts optional config fields", { timeout: 5000 }, () => {
|
||||
const fake = new FakeFetch();
|
||||
const client = make(fake, {
|
||||
baseUrl: "https://custom.slack.com/api",
|
||||
defaultChannel: "C0",
|
||||
timeoutMs: 5000,
|
||||
});
|
||||
expect(client).toBeInstanceOf(SlackApiClient);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SlackApiClient.sendMessage", () => {
|
||||
it("sendMessage posts to chat.postMessage and records a delivered report", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fake = new FakeFetch();
|
||||
fake.push({ json: { ok: true, channel: "C1", ts: "99.99" } });
|
||||
const client = make(fake);
|
||||
const res = await client.sendMessage({
|
||||
channel: "C1",
|
||||
text: "hi",
|
||||
});
|
||||
expect(res.ok).toBe(true);
|
||||
expect(res.ts).toBe("99.99");
|
||||
expect(fake.calls[0]?.url).toBe(
|
||||
"https://slack.com/api/chat.postMessage"
|
||||
);
|
||||
const status = client.getStatus("99.99");
|
||||
expect(status?.status).toBe("delivered");
|
||||
expect(status?.channel).toBe("C1");
|
||||
});
|
||||
|
||||
it("sendMessage with ok:true but no ts does not record a delivery", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fake = new FakeFetch();
|
||||
fake.push({ json: { ok: true, channel: "C1" } });
|
||||
const client = make(fake);
|
||||
const res = await client.sendMessage({ channel: "C1", text: "hi" });
|
||||
expect(res.ok).toBe(true);
|
||||
expect(client.getStatus("anything")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("sendMessage forwards blocks thread metadata and unfurl flags", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fake = new FakeFetch();
|
||||
fake.push({ json: { ok: true, channel: "C1", ts: "1.1" } });
|
||||
const client = make(fake);
|
||||
await client.sendMessage({
|
||||
channel: "C1",
|
||||
text: "Fallback",
|
||||
blocks: [
|
||||
{
|
||||
type: "section",
|
||||
text: { type: "mrkdwn", text: "*x*" },
|
||||
},
|
||||
],
|
||||
threadTs: "111.000",
|
||||
unfurlLinks: false,
|
||||
unfurlMedia: true,
|
||||
metadata: { event_type: "test" },
|
||||
});
|
||||
const body = JSON.parse(String(fake.calls[0]?.init?.body)) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect(body["thread_ts"]).toBe("111.000");
|
||||
expect(body["unfurl_links"]).toBe(false);
|
||||
expect(body["unfurl_media"]).toBe(true);
|
||||
expect(Array.isArray(body["blocks"])).toBe(true);
|
||||
expect(body["metadata"]).toEqual({ event_type: "test" });
|
||||
});
|
||||
|
||||
it("sendMessage uses defaultChannel when message.channel is empty", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fake = new FakeFetch();
|
||||
fake.push({ json: { ok: true, channel: "C999", ts: "1.2" } });
|
||||
const client = make(fake, { defaultChannel: "C999" });
|
||||
const res = await client.sendMessage({ channel: "", text: "hi" });
|
||||
expect(res.ok).toBe(true);
|
||||
const body = JSON.parse(String(fake.calls[0]?.init?.body)) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect(body["channel"]).toBe("C999");
|
||||
});
|
||||
|
||||
it("sendMessage throws SLACK.CHANNEL_NOT_FOUND when no channel is available", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fake = new FakeFetch();
|
||||
const client = make(fake);
|
||||
await expect(
|
||||
client.sendMessage({ channel: "", text: "hi" })
|
||||
).rejects.toThrow(SlackChannelNotFoundError);
|
||||
});
|
||||
|
||||
it("sendMessage sets correct authorization header", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fake = new FakeFetch();
|
||||
fake.push({ json: { ok: true, channel: "C1", ts: "1.1" } });
|
||||
const client = make(fake, { token: "xoxb-my-token" });
|
||||
await client.sendMessage({ channel: "C1", text: "hi" });
|
||||
const headers = (fake.calls[0]?.init?.headers ?? {}) as Record<
|
||||
string,
|
||||
string
|
||||
>;
|
||||
expect(headers["Authorization"]).toBe("Bearer xoxb-my-token");
|
||||
expect(headers["Content-Type"]).toBe("application/json; charset=utf-8");
|
||||
});
|
||||
|
||||
it("sendMessage uses custom base URL", { timeout: 5000 }, async () => {
|
||||
const fake = new FakeFetch();
|
||||
fake.push({ json: { ok: true, channel: "C1", ts: "1.1" } });
|
||||
const client = make(fake, { baseUrl: "https://custom.api" });
|
||||
await client.sendMessage({ channel: "C1", text: "hi" });
|
||||
expect(fake.calls[0]?.url).toBe("https://custom.api/chat.postMessage");
|
||||
});
|
||||
|
||||
it("sendMessage on slack-api error rejects with SlackChannelNotFoundError", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fake = new FakeFetch();
|
||||
fake.push({ json: { ok: false, error: "channel_not_found" } });
|
||||
const client = make(fake);
|
||||
await expect(
|
||||
client.sendMessage({ channel: "C1", text: "hi" })
|
||||
).rejects.toThrow(SlackChannelNotFoundError);
|
||||
});
|
||||
|
||||
it("sendMessage on slack invalid_auth rejects with SlackAuthError", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fake = new FakeFetch();
|
||||
fake.push({ json: { ok: false, error: "invalid_auth" } });
|
||||
const client = make(fake);
|
||||
await expect(
|
||||
client.sendMessage({ channel: "C1", text: "hi" })
|
||||
).rejects.toThrow(SlackAuthError);
|
||||
});
|
||||
|
||||
it("sendMessage on team_not_found rejects with SlackWorkspaceNotFoundError", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fake = new FakeFetch();
|
||||
fake.push({ json: { ok: false, error: "team_not_found" } });
|
||||
const client = make(fake);
|
||||
await expect(
|
||||
client.sendMessage({ channel: "C1", text: "hi" })
|
||||
).rejects.toThrow(SlackWorkspaceNotFoundError);
|
||||
});
|
||||
|
||||
it("sendMessage on unknown slack error rejects with SlackApiError", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fake = new FakeFetch();
|
||||
fake.push({ json: { ok: false, error: "ratelimited_internal" } });
|
||||
const client = make(fake);
|
||||
await expect(
|
||||
client.sendMessage({ channel: "C1", text: "hi" })
|
||||
).rejects.toThrow(SlackApiError);
|
||||
});
|
||||
|
||||
it("sendMessage on slack error without error field falls back to API_ERROR", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fake = new FakeFetch();
|
||||
fake.push({ json: { ok: false } });
|
||||
const client = make(fake);
|
||||
await expect(
|
||||
client.sendMessage({ channel: "C1", text: "hi" })
|
||||
).rejects.toThrow(SlackApiError);
|
||||
});
|
||||
|
||||
it("HTTP 401 rejects with SlackAuthError", { timeout: 5000 }, async () => {
|
||||
const fake = new FakeFetch();
|
||||
fake.push({ status: 401, text: "unauthorized" });
|
||||
const client = make(fake);
|
||||
await expect(
|
||||
client.sendMessage({ channel: "C1", text: "hi" })
|
||||
).rejects.toThrow(SlackAuthError);
|
||||
});
|
||||
|
||||
it("HTTP 404 rejects with SlackChannelNotFoundError", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fake = new FakeFetch();
|
||||
fake.push({ status: 404, text: "not found" });
|
||||
const client = make(fake);
|
||||
await expect(
|
||||
client.sendMessage({ channel: "C1", text: "hi" })
|
||||
).rejects.toThrow(SlackChannelNotFoundError);
|
||||
});
|
||||
|
||||
it("HTTP 504 rejects with SlackTimeoutError", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fake = new FakeFetch();
|
||||
fake.push({ status: 504, text: "gateway timeout" });
|
||||
const client = make(fake);
|
||||
await expect(
|
||||
client.sendMessage({ channel: "C1", text: "hi" })
|
||||
).rejects.toThrow(SlackTimeoutError);
|
||||
});
|
||||
|
||||
it("HTTP 500 rejects with SlackApiError", { timeout: 5000 }, async () => {
|
||||
const fake = new FakeFetch();
|
||||
fake.push({ status: 500, text: "internal" });
|
||||
const client = make(fake);
|
||||
await expect(
|
||||
client.sendMessage({ channel: "C1", text: "hi" })
|
||||
).rejects.toThrow(SlackApiError);
|
||||
});
|
||||
|
||||
it("HTTP 429 produces SlackRateLimitedError with parsed retryAfterMs", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fake = new FakeFetch();
|
||||
fake.push({
|
||||
status: 429,
|
||||
headers: { "Retry-After": "30" },
|
||||
text: "rate limited",
|
||||
});
|
||||
const client = make(fake);
|
||||
try {
|
||||
await client.sendMessage({ channel: "C1", text: "hi" });
|
||||
expect.fail("should have thrown");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(SlackRateLimitedError);
|
||||
expect(
|
||||
(err as { context?: { retryAfterMs?: number } }).context
|
||||
?.retryAfterMs
|
||||
).toBe(30_000);
|
||||
}
|
||||
});
|
||||
|
||||
it("HTTP 429 without retry-after still throws SlackRateLimitedError", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fake = new FakeFetch();
|
||||
fake.push({ status: 429, text: "rate limited" });
|
||||
const client = make(fake);
|
||||
await expect(
|
||||
client.sendMessage({ channel: "C1", text: "hi" })
|
||||
).rejects.toThrow(SlackRateLimitedError);
|
||||
});
|
||||
|
||||
it("network failure rejects with SlackTimeoutError", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fake = new FakeFetch();
|
||||
fake.push({ reject: new Error("Network failure") });
|
||||
const client = make(fake);
|
||||
await expect(
|
||||
client.sendMessage({ channel: "C1", text: "hi" })
|
||||
).rejects.toThrow(SlackTimeoutError);
|
||||
});
|
||||
|
||||
it("network failure with non-Error reject still rejects", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fake = new FakeFetch();
|
||||
// Coerce non-Error rejection through reject path
|
||||
fake.push({ reject: { name: "weird" } as unknown as Error });
|
||||
const client = make(fake);
|
||||
await expect(
|
||||
client.sendMessage({ channel: "C1", text: "hi" })
|
||||
).rejects.toThrow(SlackTimeoutError);
|
||||
});
|
||||
|
||||
it("timeoutMs aborts an in-flight request with SlackTimeoutError", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fake = new FakeFetch();
|
||||
fake.push({ pending: true });
|
||||
const client = make(fake, { timeoutMs: 1 });
|
||||
await expect(
|
||||
client.sendMessage({ channel: "C1", text: "hi" })
|
||||
).rejects.toThrow(SlackTimeoutError);
|
||||
});
|
||||
|
||||
it("caller-supplied AbortSignal cancels the request with SlackTimeoutError", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fake = new FakeFetch();
|
||||
fake.push({ pending: true });
|
||||
const client = make(fake);
|
||||
const controller = new AbortController();
|
||||
const promise = client.sendMessage(
|
||||
{ channel: "C1", text: "hi" },
|
||||
controller.signal
|
||||
);
|
||||
controller.abort();
|
||||
await expect(promise).rejects.toThrow(SlackTimeoutError);
|
||||
});
|
||||
|
||||
it("pre-aborted caller AbortSignal rejects with SlackTimeoutError", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fake = new FakeFetch();
|
||||
fake.push({ pending: true });
|
||||
const client = make(fake);
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
await expect(
|
||||
client.sendMessage({ channel: "C1", text: "hi" }, controller.signal)
|
||||
).rejects.toThrow(SlackTimeoutError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SlackApiClient.updateMessage / deleteMessage", () => {
|
||||
it("updateMessage posts to chat.update with new text and blocks", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fake = new FakeFetch();
|
||||
fake.push({ json: { ok: true, channel: "C1", ts: "1.1" } });
|
||||
const client = make(fake);
|
||||
const blocks = [
|
||||
{ type: "section", text: { type: "mrkdwn" as const, text: "x" } },
|
||||
];
|
||||
const res = await client.updateMessage("C1", "1.1", "Updated", blocks);
|
||||
expect(res.ok).toBe(true);
|
||||
expect(fake.calls[0]?.url).toBe("https://slack.com/api/chat.update");
|
||||
const body = JSON.parse(String(fake.calls[0]?.init?.body)) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect(body["ts"]).toBe("1.1");
|
||||
expect(body["text"]).toBe("Updated");
|
||||
expect(Array.isArray(body["blocks"])).toBe(true);
|
||||
});
|
||||
|
||||
it("updateMessage without blocks omits the blocks field", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fake = new FakeFetch();
|
||||
fake.push({ json: { ok: true, channel: "C1", ts: "1.1" } });
|
||||
const client = make(fake);
|
||||
await client.updateMessage("C1", "1.1", "Updated");
|
||||
const body = JSON.parse(String(fake.calls[0]?.init?.body)) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect(body["blocks"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("deleteMessage posts to chat.delete with channel and ts", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fake = new FakeFetch();
|
||||
fake.push({ json: { ok: true, channel: "C1", ts: "1.1" } });
|
||||
const client = make(fake);
|
||||
await client.deleteMessage("C1", "1.1");
|
||||
const body = JSON.parse(String(fake.calls[0]?.init?.body)) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect(body).toEqual({ channel: "C1", ts: "1.1" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("SlackApiClient.listChannels / getChannelInfo", () => {
|
||||
it("listChannels maps snake_case payload to SlackChannel", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fake = new FakeFetch();
|
||||
fake.push({
|
||||
json: {
|
||||
ok: true,
|
||||
channels: [
|
||||
{
|
||||
id: "C1",
|
||||
name: "general",
|
||||
is_private: false,
|
||||
topic: { value: "General chat" },
|
||||
purpose: { value: "Company-wide" },
|
||||
num_members: 50,
|
||||
},
|
||||
{ id: "C2", name: "secret", is_private: true },
|
||||
],
|
||||
},
|
||||
});
|
||||
const client = make(fake);
|
||||
const channels = await client.listChannels();
|
||||
expect(channels).toHaveLength(2);
|
||||
expect(channels[0]?.id).toBe("C1");
|
||||
expect(channels[0]?.isPrivate).toBe(false);
|
||||
expect(channels[0]?.topic).toBe("General chat");
|
||||
expect(channels[0]?.purpose).toBe("Company-wide");
|
||||
expect(channels[0]?.memberCount).toBe(50);
|
||||
expect(channels[1]?.isPrivate).toBe(true);
|
||||
expect(channels[1]?.topic).toBeUndefined();
|
||||
});
|
||||
|
||||
it("listChannels returns empty array when channels key is absent", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fake = new FakeFetch();
|
||||
fake.push({ json: { ok: true } });
|
||||
const client = make(fake);
|
||||
const channels = await client.listChannels(10);
|
||||
expect(channels).toEqual([]);
|
||||
});
|
||||
|
||||
it("getChannelInfo maps a single channel", { timeout: 5000 }, async () => {
|
||||
const fake = new FakeFetch();
|
||||
fake.push({
|
||||
json: {
|
||||
ok: true,
|
||||
channel: {
|
||||
id: "C1",
|
||||
name: "general",
|
||||
is_private: false,
|
||||
topic: { value: "Topic" },
|
||||
purpose: { value: "Purpose" },
|
||||
num_members: 10,
|
||||
},
|
||||
},
|
||||
});
|
||||
const client = make(fake);
|
||||
const channel = await client.getChannelInfo("C1");
|
||||
expect(channel.id).toBe("C1");
|
||||
expect(channel.name).toBe("general");
|
||||
expect(channel.memberCount).toBe(10);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SlackApiClient.listUsers", () => {
|
||||
it("listUsers maps snake_case payload to SlackUser", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fake = new FakeFetch();
|
||||
fake.push({
|
||||
json: {
|
||||
ok: true,
|
||||
members: [
|
||||
{
|
||||
id: "U1",
|
||||
name: "alice",
|
||||
real_name: "Alice Smith",
|
||||
profile: { email: "alice@example.com" },
|
||||
is_bot: false,
|
||||
},
|
||||
{ id: "U2", name: "bot", is_bot: true },
|
||||
],
|
||||
},
|
||||
});
|
||||
const client = make(fake);
|
||||
const users = await client.listUsers();
|
||||
expect(users).toHaveLength(2);
|
||||
expect(users[0]?.realName).toBe("Alice Smith");
|
||||
expect(users[0]?.email).toBe("alice@example.com");
|
||||
expect(users[0]?.isBot).toBe(false);
|
||||
expect(users[1]?.isBot).toBe(true);
|
||||
expect(users[1]?.realName).toBeUndefined();
|
||||
});
|
||||
|
||||
it("listUsers returns empty array when members key is absent", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fake = new FakeFetch();
|
||||
fake.push({ json: { ok: true } });
|
||||
const client = make(fake);
|
||||
const users = await client.listUsers(50);
|
||||
expect(users).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SlackApiClient defaults", () => {
|
||||
it("defaultClock returns a Date.now()-ish number", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const before = Date.now();
|
||||
const t = defaultClock();
|
||||
const after = Date.now();
|
||||
expect(t).toBeGreaterThanOrEqual(before);
|
||||
expect(t).toBeLessThanOrEqual(after);
|
||||
});
|
||||
|
||||
it("defaultFetch invokes globalThis.fetch", { timeout: 5000 }, async () => {
|
||||
const original = globalThis.fetch;
|
||||
let captured: string | URL | Request | undefined;
|
||||
const stub = ((input: string | URL | Request) => {
|
||||
captured = input;
|
||||
return Promise.resolve(new Response("{}", { status: 200 }));
|
||||
}) as typeof globalThis.fetch;
|
||||
globalThis.fetch = stub;
|
||||
try {
|
||||
await defaultFetch("https://example.invalid/x");
|
||||
expect(captured).toBe("https://example.invalid/x");
|
||||
} finally {
|
||||
globalThis.fetch = original;
|
||||
}
|
||||
});
|
||||
|
||||
it("constructor without fetch/clock options uses internal defaults", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const client = new SlackApiClient({ token: "xoxb-x" });
|
||||
expect(client.getStatus("nope")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("SlackApiClient.getStatus", () => {
|
||||
it("returns undefined for unknown message", { timeout: 5000 }, () => {
|
||||
const fake = new FakeFetch();
|
||||
const client = make(fake);
|
||||
expect(client.getStatus("unknown")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("logger.warn is called on network failure", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fake = new FakeFetch();
|
||||
fake.push({ reject: new Error("boom") });
|
||||
const records: string[] = [];
|
||||
const logger = {
|
||||
debug: () => undefined,
|
||||
info: () => undefined,
|
||||
warn: (msg: string) => records.push(msg),
|
||||
error: () => undefined,
|
||||
};
|
||||
const client = make(fake, { logger });
|
||||
await expect(
|
||||
client.sendMessage({ channel: "C1", text: "hi" })
|
||||
).rejects.toThrow();
|
||||
expect(records).toContain("slack request failed");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,304 @@
|
||||
import type { Clock, FetchLike, LoggerLike } from "@suaveplan/types";
|
||||
import {
|
||||
SLACK_CODES,
|
||||
SlackApiError,
|
||||
SlackAuthError,
|
||||
SlackChannelNotFoundError,
|
||||
SlackConfigMissingError,
|
||||
type SlackError,
|
||||
type SlackErrorCode,
|
||||
SlackRateLimitedError,
|
||||
SlackTimeoutError,
|
||||
SlackWorkspaceNotFoundError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import { recordError, recordRequest } from "../otel/metrics.js";
|
||||
import { SlackApiClientOptionsSchema } from "../schemas/schemas.js";
|
||||
import type {
|
||||
SlackApiClientOptions,
|
||||
SlackBlock,
|
||||
SlackChannel,
|
||||
SlackDeliveryReport,
|
||||
SlackMessage,
|
||||
SlackResponse,
|
||||
SlackUser,
|
||||
} from "../types/slack-client.types.js";
|
||||
import { classifyHttpStatus, classifySlackError } from "./classify.js";
|
||||
import { parseRetryAfterMs } from "./retry-after.js";
|
||||
|
||||
const DEFAULT_BASE_URL = "https://slack.com/api";
|
||||
const DEFAULT_TIMEOUT_MS = 10_000;
|
||||
|
||||
/** Internal default clock — exported so unit tests pin the fallback branch. */
|
||||
export const defaultClock: Clock = (): number => Date.now();
|
||||
|
||||
/** Internal default fetch — exported so unit tests pin the fallback branch. */
|
||||
export const defaultFetch: FetchLike = (
|
||||
input: string | URL | Request,
|
||||
init?: RequestInit
|
||||
): Promise<Response> => globalThis.fetch(input, init);
|
||||
|
||||
interface RawChannel {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly is_private: boolean;
|
||||
readonly topic?: { value: string };
|
||||
readonly purpose?: { value: string };
|
||||
readonly num_members?: number;
|
||||
}
|
||||
|
||||
interface RawUser {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly real_name?: string;
|
||||
readonly profile?: { email?: string };
|
||||
readonly is_bot: boolean;
|
||||
}
|
||||
|
||||
function mapChannel(raw: RawChannel): SlackChannel {
|
||||
const out: {
|
||||
-readonly [K in keyof SlackChannel]: SlackChannel[K];
|
||||
} = { id: raw.id, name: raw.name, isPrivate: raw.is_private };
|
||||
if (raw.topic?.value !== undefined) out.topic = raw.topic.value;
|
||||
if (raw.purpose?.value !== undefined) out.purpose = raw.purpose.value;
|
||||
if (raw.num_members !== undefined) out.memberCount = raw.num_members;
|
||||
return out;
|
||||
}
|
||||
|
||||
function mapUser(raw: RawUser): SlackUser {
|
||||
const out: { -readonly [K in keyof SlackUser]: SlackUser[K] } = {
|
||||
id: raw.id,
|
||||
name: raw.name,
|
||||
isBot: raw.is_bot,
|
||||
};
|
||||
if (raw.real_name !== undefined) out.realName = raw.real_name;
|
||||
if (raw.profile?.email !== undefined) out.email = raw.profile.email;
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Slack Web API client. Methods cover `chat.postMessage`, `chat.update`,
|
||||
* `chat.delete`, `conversations.list`, `conversations.info`, `users.list`.
|
||||
* Every public method records a `slack.requests` increment and a
|
||||
* `slack.latency_ms` observation; failure paths also record `slack.errors`.
|
||||
*/
|
||||
export class SlackApiClient {
|
||||
readonly #token: string;
|
||||
readonly #baseUrl: string;
|
||||
readonly #defaultChannel: string | undefined;
|
||||
readonly #timeoutMs: number;
|
||||
readonly #fetch: FetchLike;
|
||||
readonly #clock: Clock;
|
||||
readonly #logger: LoggerLike | undefined;
|
||||
readonly #deliveries = new Map<string, SlackDeliveryReport>();
|
||||
|
||||
public constructor(options: SlackApiClientOptions) {
|
||||
const parsed = SlackApiClientOptionsSchema.safeParse(options);
|
||||
if (!parsed.success) {
|
||||
throw new SlackConfigMissingError({
|
||||
message: "slack.errors.configMissing",
|
||||
cause: parsed.error,
|
||||
context: {
|
||||
code: SLACK_CODES.CONFIG_MISSING,
|
||||
field: parsed.error.issues[0]?.path.join("."),
|
||||
},
|
||||
});
|
||||
}
|
||||
this.#token = options.token;
|
||||
this.#baseUrl = options.baseUrl ?? DEFAULT_BASE_URL;
|
||||
this.#defaultChannel = options.defaultChannel;
|
||||
this.#timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
||||
this.#fetch = options.fetch ?? defaultFetch;
|
||||
this.#clock = options.clock ?? defaultClock;
|
||||
this.#logger = options.logger;
|
||||
}
|
||||
|
||||
public async sendMessage(
|
||||
message: SlackMessage,
|
||||
signal?: AbortSignal
|
||||
): Promise<SlackResponse> {
|
||||
const channel = message.channel || this.#defaultChannel;
|
||||
if (channel === undefined || channel === "") {
|
||||
throw new SlackChannelNotFoundError({
|
||||
message: "slack.errors.channelNotFound",
|
||||
context: { reason: "no_channel_supplied" },
|
||||
});
|
||||
}
|
||||
const body: Record<string, unknown> = {
|
||||
channel,
|
||||
text: message.text,
|
||||
};
|
||||
if (message.blocks !== undefined) body["blocks"] = message.blocks;
|
||||
if (message.threadTs !== undefined)
|
||||
body["thread_ts"] = message.threadTs;
|
||||
if (message.unfurlLinks !== undefined)
|
||||
body["unfurl_links"] = message.unfurlLinks;
|
||||
if (message.unfurlMedia !== undefined)
|
||||
body["unfurl_media"] = message.unfurlMedia;
|
||||
if (message.metadata !== undefined) body["metadata"] = message.metadata;
|
||||
|
||||
const response = await this.#request("chat.postMessage", body, signal);
|
||||
if (response.ok && response.ts !== undefined) {
|
||||
this.#deliveries.set(response.ts, {
|
||||
messageId: response.ts,
|
||||
channel,
|
||||
status: "delivered",
|
||||
timestamp: this.#clock(),
|
||||
});
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
public async updateMessage(
|
||||
channel: string,
|
||||
ts: string,
|
||||
text: string,
|
||||
blocks?: readonly SlackBlock[]
|
||||
): Promise<SlackResponse> {
|
||||
const body: Record<string, unknown> = { channel, ts, text };
|
||||
if (blocks !== undefined) body["blocks"] = blocks;
|
||||
return this.#request("chat.update", body);
|
||||
}
|
||||
|
||||
public async deleteMessage(
|
||||
channel: string,
|
||||
ts: string
|
||||
): Promise<SlackResponse> {
|
||||
return this.#request("chat.delete", { channel, ts });
|
||||
}
|
||||
|
||||
public async listChannels(limit = 100): Promise<SlackChannel[]> {
|
||||
const res = await this.#request("conversations.list", {
|
||||
limit,
|
||||
types: "public_channel,private_channel",
|
||||
});
|
||||
const raw = (res as unknown as { channels?: readonly RawChannel[] })
|
||||
.channels;
|
||||
return raw === undefined ? [] : raw.map(mapChannel);
|
||||
}
|
||||
|
||||
public async getChannelInfo(channelId: string): Promise<SlackChannel> {
|
||||
const res = await this.#request("conversations.info", {
|
||||
channel: channelId,
|
||||
});
|
||||
const raw = (res as unknown as { channel: RawChannel }).channel;
|
||||
return mapChannel(raw);
|
||||
}
|
||||
|
||||
public async listUsers(limit = 100): Promise<SlackUser[]> {
|
||||
const res = await this.#request("users.list", { limit });
|
||||
const raw = (res as unknown as { members?: readonly RawUser[] })
|
||||
.members;
|
||||
return raw === undefined ? [] : raw.map(mapUser);
|
||||
}
|
||||
|
||||
public getStatus(messageTs: string): SlackDeliveryReport | undefined {
|
||||
return this.#deliveries.get(messageTs);
|
||||
}
|
||||
|
||||
async #request(
|
||||
method: string,
|
||||
body: Record<string, unknown>,
|
||||
callerSignal?: AbortSignal
|
||||
): Promise<SlackResponse> {
|
||||
const url = `${this.#baseUrl}/${method}`;
|
||||
const started = this.#clock();
|
||||
const controller = new AbortController();
|
||||
const onCallerAbort = (): void => controller.abort();
|
||||
if (callerSignal !== undefined) {
|
||||
if (callerSignal.aborted) controller.abort();
|
||||
else
|
||||
callerSignal.addEventListener("abort", onCallerAbort, {
|
||||
once: true,
|
||||
});
|
||||
}
|
||||
const timer = setTimeout(() => controller.abort(), this.#timeoutMs);
|
||||
|
||||
const finish = (outcome: "ok" | "error"): void => {
|
||||
clearTimeout(timer);
|
||||
if (callerSignal !== undefined) {
|
||||
callerSignal.removeEventListener("abort", onCallerAbort);
|
||||
}
|
||||
recordRequest(method, outcome, this.#clock() - started);
|
||||
};
|
||||
|
||||
let res: Response;
|
||||
try {
|
||||
res = await this.#fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.#token}`,
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
signal: controller.signal,
|
||||
});
|
||||
} catch (err) {
|
||||
finish("error");
|
||||
recordError(method, SLACK_CODES.TIMEOUT);
|
||||
const cause = err instanceof Error ? err : new Error(String(err));
|
||||
this.#logger?.warn("slack request failed", {
|
||||
method,
|
||||
cause: cause.message,
|
||||
});
|
||||
throw new SlackTimeoutError({
|
||||
message: "slack.errors.timeout",
|
||||
cause,
|
||||
context: { url, method },
|
||||
});
|
||||
}
|
||||
|
||||
if (res.status === 429) {
|
||||
const retryAfterMs = parseRetryAfterMs(res, this.#clock);
|
||||
finish("error");
|
||||
recordError(method, SLACK_CODES.RATE_LIMITED);
|
||||
throw new SlackRateLimitedError({
|
||||
message: "slack.errors.rateLimited",
|
||||
...(retryAfterMs !== undefined ? { retryAfterMs } : {}),
|
||||
context: { url, method },
|
||||
});
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
const code = classifyHttpStatus(res.status);
|
||||
finish("error");
|
||||
recordError(method, code);
|
||||
throw this.#errorForCode(code, {
|
||||
message: `slack.errors.${code}`,
|
||||
context: { url, method, status: res.status, body: text },
|
||||
});
|
||||
}
|
||||
|
||||
const data = (await res.json()) as SlackResponse;
|
||||
if (!data.ok) {
|
||||
const code = classifySlackError(data.error ?? "unknown");
|
||||
finish("error");
|
||||
recordError(method, code);
|
||||
throw this.#errorForCode(code, {
|
||||
message: `slack.errors.${code}`,
|
||||
context: { url, method, slackError: data.error ?? "unknown" },
|
||||
});
|
||||
}
|
||||
|
||||
finish("ok");
|
||||
return data;
|
||||
}
|
||||
|
||||
#errorForCode(
|
||||
code: SlackErrorCode,
|
||||
opts: { message: string; context: Readonly<Record<string, unknown>> }
|
||||
): SlackError {
|
||||
switch (code) {
|
||||
case SLACK_CODES.AUTH_ERROR:
|
||||
return new SlackAuthError(opts);
|
||||
case SLACK_CODES.CHANNEL_NOT_FOUND:
|
||||
return new SlackChannelNotFoundError(opts);
|
||||
case SLACK_CODES.WORKSPACE_NOT_FOUND:
|
||||
return new SlackWorkspaceNotFoundError(opts);
|
||||
case SLACK_CODES.TIMEOUT:
|
||||
return new SlackTimeoutError(opts);
|
||||
default:
|
||||
return new SlackApiError(opts);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
# `error-codes` — Slack error-code registry
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`error-codes.ts` is the single source of truth for every error the `@suaveplan/slack-client` package can throw. It registers a stable namespace `SLACK` with eight reason codes via `defineCodes("SLACK", [...])` from `@suaveplan/error`, and exposes a typed `SuaveplanError` subclass for each reason. The registry preserves the literal-typed union through the public surface so downstream consumers can `instanceof`-narrow on a specific subclass or match on the `.code` discriminant without unsafe casts. Side-effect import from `src/index.ts` ensures the codes are part of the package's public ABI even for consumers that pull a subpath export.
|
||||
|
||||
## Features
|
||||
|
||||
- Eight stable reason codes: `API_ERROR`, `AUTH_ERROR`, `RATE_LIMITED`, `CHANNEL_NOT_FOUND`, `WORKSPACE_NOT_FOUND`, `CONFIG_MISSING`, `WEBHOOK_FAILED`, `TIMEOUT`.
|
||||
- One typed subclass per code, extending an abstract `SlackError` base.
|
||||
- Compile-time-frozen `SLACK_CODES` record with literal `"SLACK.REASON"` values.
|
||||
- Every constructor accepts the standard `SuaveplanErrorOptions` bag (`cause`, `context`, `traceId`, `clock`).
|
||||
- `SlackRateLimitedError` carries an additional `retryAfterMs` field merged into `context`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
SLACK_CODES,
|
||||
SlackAuthError,
|
||||
type SlackErrorCode,
|
||||
} from "@suaveplan/slack-client/error-codes";
|
||||
|
||||
try {
|
||||
// ... call Slack ...
|
||||
} catch (err) {
|
||||
if (err instanceof SlackAuthError) {
|
||||
// err.code is literal-typed as "SLACK.AUTH_ERROR"
|
||||
const code: SlackErrorCode = err.code;
|
||||
void code;
|
||||
}
|
||||
}
|
||||
|
||||
void SLACK_CODES.AUTH_ERROR; // "SLACK.AUTH_ERROR"
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { SlackRateLimitedError } from "@suaveplan/slack-client/error-codes";
|
||||
|
||||
throw new SlackRateLimitedError({
|
||||
message: "slack.errors.rateLimited",
|
||||
retryAfterMs: 30_000,
|
||||
context: { url: "https://slack.com/api/chat.postMessage" },
|
||||
traceId: "trace-abc",
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `SLACK_CODES` — frozen record: `{ readonly [K in Reason]: "SLACK.${K}" }`.
|
||||
- `SlackErrorCode` — discriminated union of all eight literal codes.
|
||||
- `SlackError` — abstract base extending `SuaveplanError<SlackErrorCode>`.
|
||||
- `SlackApiError`, `SlackAuthError`, `SlackRateLimitedError`, `SlackChannelNotFoundError`, `SlackWorkspaceNotFoundError`, `SlackConfigMissingError`, `SlackWebhookFailedError`, `SlackTimeoutError` — concrete subclasses.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The registry uses `defineCodes` rather than the legacy `ErrorCodes.registerMany` runtime registry from the upstream library. `defineCodes` is purely type-level + a frozen object, so it has no module-load side effect beyond the freeze, and the literal-typed union survives tree-shaking. `pickOpts` discards undefined fields explicitly to remain compatible with `exactOptionalPropertyTypes: true` in the `tsconfig.base.json` — passing `{ traceId: undefined }` directly would violate that flag.
|
||||
@@ -0,0 +1,98 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
SLACK_CODES,
|
||||
SlackApiError,
|
||||
SlackAuthError,
|
||||
SlackChannelNotFoundError,
|
||||
SlackConfigMissingError,
|
||||
type SlackErrorCode,
|
||||
SlackRateLimitedError,
|
||||
SlackTimeoutError,
|
||||
SlackWebhookFailedError,
|
||||
SlackWorkspaceNotFoundError,
|
||||
} from "./error-codes.js";
|
||||
|
||||
describe("error-codes", () => {
|
||||
it("SLACK_CODES exposes exactly the eight stable reasons", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(Object.keys(SLACK_CODES).sort()).toEqual(
|
||||
[
|
||||
"API_ERROR",
|
||||
"AUTH_ERROR",
|
||||
"CHANNEL_NOT_FOUND",
|
||||
"CONFIG_MISSING",
|
||||
"RATE_LIMITED",
|
||||
"TIMEOUT",
|
||||
"WEBHOOK_FAILED",
|
||||
"WORKSPACE_NOT_FOUND",
|
||||
].sort()
|
||||
);
|
||||
expect(SLACK_CODES.AUTH_ERROR).toBe("SLACK.AUTH_ERROR");
|
||||
});
|
||||
|
||||
it("every error subclass carries its SLACK.<REASON> code", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const pairs: ReadonlyArray<readonly [SlackErrorCode, Error]> = [
|
||||
[SLACK_CODES.API_ERROR, new SlackApiError()],
|
||||
[SLACK_CODES.AUTH_ERROR, new SlackAuthError()],
|
||||
[SLACK_CODES.RATE_LIMITED, new SlackRateLimitedError()],
|
||||
[SLACK_CODES.CHANNEL_NOT_FOUND, new SlackChannelNotFoundError()],
|
||||
[
|
||||
SLACK_CODES.WORKSPACE_NOT_FOUND,
|
||||
new SlackWorkspaceNotFoundError(),
|
||||
],
|
||||
[SLACK_CODES.CONFIG_MISSING, new SlackConfigMissingError()],
|
||||
[SLACK_CODES.WEBHOOK_FAILED, new SlackWebhookFailedError()],
|
||||
[SLACK_CODES.TIMEOUT, new SlackTimeoutError()],
|
||||
];
|
||||
for (const [expected, err] of pairs) {
|
||||
expect((err as { code: string }).code).toBe(expected);
|
||||
}
|
||||
});
|
||||
|
||||
it("SlackRateLimitedError merges retryAfterMs into context", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const err = new SlackRateLimitedError({ retryAfterMs: 30_000 });
|
||||
expect(err.context?.["retryAfterMs"]).toBe(30_000);
|
||||
});
|
||||
|
||||
it("constructors preserve cause and traceId", { timeout: 5000 }, () => {
|
||||
const cause = new Error("nope");
|
||||
const err = new SlackApiError({
|
||||
cause,
|
||||
traceId: "trace-123",
|
||||
context: { extra: 1 },
|
||||
});
|
||||
expect(err.cause).toBe(cause);
|
||||
expect(err.traceId).toBe("trace-123");
|
||||
expect(err.context?.["extra"]).toBe(1);
|
||||
});
|
||||
|
||||
it("constructors with empty options bag still work", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const err = new SlackApiError({});
|
||||
expect(err.code).toBe(SLACK_CODES.API_ERROR);
|
||||
expect(err.context).toBeUndefined();
|
||||
});
|
||||
|
||||
it("SlackRateLimitedError without retryAfterMs uses provided context", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const err = new SlackRateLimitedError({
|
||||
context: { url: "https://slack.com/api" },
|
||||
});
|
||||
expect(err.context?.["url"]).toBe("https://slack.com/api");
|
||||
expect(err.context?.["retryAfterMs"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("SlackError is abstract — subclasses inherit from it", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// The five subclasses should all be instances of Error
|
||||
expect(new SlackAuthError()).toBeInstanceOf(Error);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,139 @@
|
||||
import type { SuaveplanErrorOptions } from "@suaveplan/error";
|
||||
import { defineCodes, SuaveplanError } from "@suaveplan/error";
|
||||
|
||||
/**
|
||||
* Stable error-code registry for `@suaveplan/slack-client`. Every error
|
||||
* thrown by the package surfaces one of these eight codes so downstream
|
||||
* consumers — loggers, problem+json responders, retry wrappers — can map
|
||||
* to retry policy, HTTP status, and i18n keys without re-implementing the
|
||||
* classifier.
|
||||
*
|
||||
* The registry uses the defineCodes builder from `@suaveplan/error`,
|
||||
* which produces a frozen `{ REASON: "SLACK.REASON" }` record at compile
|
||||
* time so the literal-typed union is preserved through the public surface.
|
||||
*/
|
||||
export const SLACK_CODES = defineCodes("SLACK", [
|
||||
"API_ERROR",
|
||||
"AUTH_ERROR",
|
||||
"RATE_LIMITED",
|
||||
"CHANNEL_NOT_FOUND",
|
||||
"WORKSPACE_NOT_FOUND",
|
||||
"CONFIG_MISSING",
|
||||
"WEBHOOK_FAILED",
|
||||
"TIMEOUT",
|
||||
] as const);
|
||||
|
||||
/** Union of every fully-qualified code emitted by this package. */
|
||||
export type SlackErrorCode = (typeof SLACK_CODES)[keyof typeof SLACK_CODES];
|
||||
|
||||
/** Base class for every typed Slack error. */
|
||||
export abstract class SlackError extends SuaveplanError<SlackErrorCode> {}
|
||||
|
||||
interface BaseOpts extends SuaveplanErrorOptions {
|
||||
readonly message?: string;
|
||||
}
|
||||
|
||||
function pickOpts(opts: BaseOpts): SuaveplanErrorOptions {
|
||||
const out: {
|
||||
cause?: unknown;
|
||||
context?: Readonly<Record<string, unknown>>;
|
||||
traceId?: string;
|
||||
} = {};
|
||||
if (opts.cause !== undefined) out.cause = opts.cause;
|
||||
if (opts.context !== undefined) out.context = opts.context;
|
||||
if (opts.traceId !== undefined) out.traceId = opts.traceId;
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Slack API returned a non-ok response (default mapping for other 4xx/5xx). */
|
||||
export class SlackApiError extends SlackError {
|
||||
public constructor(opts: BaseOpts = {}) {
|
||||
super(
|
||||
SLACK_CODES.API_ERROR,
|
||||
opts.message ?? "slack.errors.apiError",
|
||||
pickOpts(opts)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Token rejected or missing (401/403, invalid_auth, token_revoked, ...). */
|
||||
export class SlackAuthError extends SlackError {
|
||||
public constructor(opts: BaseOpts = {}) {
|
||||
super(
|
||||
SLACK_CODES.AUTH_ERROR,
|
||||
opts.message ?? "slack.errors.authError",
|
||||
pickOpts(opts)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Slack rate-limited the caller; honour `context.retryAfterMs`. */
|
||||
export class SlackRateLimitedError extends SlackError {
|
||||
public constructor(
|
||||
opts: BaseOpts & { readonly retryAfterMs?: number } = {}
|
||||
) {
|
||||
const ctx: Record<string, unknown> = { ...(opts.context ?? {}) };
|
||||
if (opts.retryAfterMs !== undefined)
|
||||
ctx["retryAfterMs"] = opts.retryAfterMs;
|
||||
super(
|
||||
SLACK_CODES.RATE_LIMITED,
|
||||
opts.message ?? "slack.errors.rateLimited",
|
||||
pickOpts({ ...opts, context: ctx })
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Channel does not exist or is inaccessible (404, channel_not_found, ...). */
|
||||
export class SlackChannelNotFoundError extends SlackError {
|
||||
public constructor(opts: BaseOpts = {}) {
|
||||
super(
|
||||
SLACK_CODES.CHANNEL_NOT_FOUND,
|
||||
opts.message ?? "slack.errors.channelNotFound",
|
||||
pickOpts(opts)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Workspace not found (team_not_found, no_workspace). */
|
||||
export class SlackWorkspaceNotFoundError extends SlackError {
|
||||
public constructor(opts: BaseOpts = {}) {
|
||||
super(
|
||||
SLACK_CODES.WORKSPACE_NOT_FOUND,
|
||||
opts.message ?? "slack.errors.workspaceNotFound",
|
||||
pickOpts(opts)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Required configuration value (token, webhookUrl, ...) is missing. */
|
||||
export class SlackConfigMissingError extends SlackError {
|
||||
public constructor(opts: BaseOpts = {}) {
|
||||
super(
|
||||
SLACK_CODES.CONFIG_MISSING,
|
||||
opts.message ?? "slack.errors.configMissing",
|
||||
pickOpts(opts)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Incoming-webhook POST failed with a non-2xx response. */
|
||||
export class SlackWebhookFailedError extends SlackError {
|
||||
public constructor(opts: BaseOpts = {}) {
|
||||
super(
|
||||
SLACK_CODES.WEBHOOK_FAILED,
|
||||
opts.message ?? "slack.errors.webhookFailed",
|
||||
pickOpts(opts)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Slack request timed out (internal `timeoutMs` or caller `AbortSignal`). */
|
||||
export class SlackTimeoutError extends SlackError {
|
||||
public constructor(opts: BaseOpts = {}) {
|
||||
super(
|
||||
SLACK_CODES.TIMEOUT,
|
||||
opts.message ?? "slack.errors.timeout",
|
||||
pickOpts(opts)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
export type { SlackErrorCode } from "./error-codes.js";
|
||||
export {
|
||||
SLACK_CODES,
|
||||
SlackApiError,
|
||||
SlackAuthError,
|
||||
SlackChannelNotFoundError,
|
||||
SlackConfigMissingError,
|
||||
SlackError,
|
||||
SlackRateLimitedError,
|
||||
SlackTimeoutError,
|
||||
SlackWebhookFailedError,
|
||||
SlackWorkspaceNotFoundError,
|
||||
} from "./error-codes.js";
|
||||
@@ -0,0 +1,61 @@
|
||||
# `fakes/fake-fetch` — Deterministic FetchLike test double
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`fake-fetch.ts` exports `FakeFetch`, the deterministic test double the package's own unit tests use to exercise every transport-level scenario without monkey-patching `globalThis.fetch` and without `vi.mock`. Each `FakeFetch` instance carries a FIFO queue of scripted `FakeFetchResponse` objects and a `calls` array that records every invocation in order. The `fetch` property satisfies the `FetchLike` shape from `@suaveplan/types`, so any client that accepts a `FetchLike` (notably `SlackApiClient` and `SlackWebhook`) can be driven directly from a test without touching the global. Downstream packages can re-export the class for their own tests too.
|
||||
|
||||
## Features
|
||||
|
||||
- FIFO response queue — the first `push` is consumed by the first `fetch` call.
|
||||
- Captured-call recording — `calls[]` preserves every URL and `RequestInit` verbatim.
|
||||
- Three response modes — scripted (status + body), `pending: true` (never resolves), `reject: Error` (immediate rejection).
|
||||
- Abort propagation — a `pending` response respects the caller's `AbortSignal` and rejects with an `AbortError` whose `name === "AbortError"`.
|
||||
- Headers via the standard WHATWG `Headers` API — case-insensitive lookup, multi-value support.
|
||||
- Zero external deps.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { FakeFetch } from "@suaveplan/slack-client/fakes/fake-fetch";
|
||||
|
||||
const fake = new FakeFetch();
|
||||
fake.push({ json: { ok: true, channel: "C1", ts: "1.1" } });
|
||||
|
||||
const res = await fake.fetch("https://slack.com/api/chat.postMessage", {
|
||||
method: "POST",
|
||||
});
|
||||
const body = (await res.json()) as { ok: boolean };
|
||||
// body.ok === true
|
||||
// fake.calls[0].url === "https://slack.com/api/chat.postMessage"
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { FakeFetch } from "@suaveplan/slack-client/fakes/fake-fetch";
|
||||
|
||||
const fake = new FakeFetch();
|
||||
fake.push({ pending: true });
|
||||
|
||||
const controller = new AbortController();
|
||||
const promise = fake.fetch("https://example", { signal: controller.signal });
|
||||
controller.abort();
|
||||
await expect(promise).rejects.toMatchObject({ name: "AbortError" });
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class FakeFetch`
|
||||
- `push(response: FakeFetchResponse): void` — queue a scripted response.
|
||||
- `readonly fetch: FetchLike` — the callable to inject into the client under test.
|
||||
- `readonly calls: FakeFetchCall[]` — captured calls in FIFO order.
|
||||
- `interface FakeFetchResponse { status?: number; headers?: Record<string, string>; json?: unknown; text?: string; pending?: boolean; reject?: Error }`
|
||||
- `interface FakeFetchCall { url: string; init: RequestInit | undefined }`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The `fetch` member is declared as an arrow function so the `this` binding is preserved when callers destructure (`const { fetch } = new FakeFetch()`). The `pending` mode allocates a `Promise` whose only resolution path is via the caller's `AbortSignal`; this matches the abort semantics of real `globalThis.fetch` and lets us pin the in-flight-then-cancelled scenario without a real timer. The shim builds the response body through the standard `Response` constructor so callers see exactly the WHATWG shape (`.json()`, `.text()`, `.headers`) — no bespoke method set means the double is byte-compatible with `globalThis.fetch`.
|
||||
@@ -0,0 +1,113 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { FakeFetch } from "./fake-fetch.js";
|
||||
|
||||
describe("FakeFetch", () => {
|
||||
it("queued responses are consumed in FIFO order", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fake = new FakeFetch();
|
||||
fake.push({ json: { ok: true, n: 1 } });
|
||||
fake.push({ json: { ok: true, n: 2 } });
|
||||
|
||||
const r1 = await fake.fetch("https://x/1", { method: "POST" });
|
||||
const r2 = await fake.fetch("https://x/2", { method: "POST" });
|
||||
const j1 = (await r1.json()) as { n: number };
|
||||
const j2 = (await r2.json()) as { n: number };
|
||||
expect(j1.n).toBe(1);
|
||||
expect(j2.n).toBe(2);
|
||||
expect(fake.calls.map((c) => c.url)).toEqual([
|
||||
"https://x/1",
|
||||
"https://x/2",
|
||||
]);
|
||||
});
|
||||
|
||||
it("rejects when queue is empty", { timeout: 5000 }, async () => {
|
||||
const fake = new FakeFetch();
|
||||
await expect(fake.fetch("https://x")).rejects.toThrow(
|
||||
"no response queued"
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects with scripted error", { timeout: 5000 }, async () => {
|
||||
const fake = new FakeFetch();
|
||||
fake.push({ reject: new Error("network down") });
|
||||
await expect(fake.fetch("https://x")).rejects.toThrow("network down");
|
||||
});
|
||||
|
||||
it("pending request rejects with AbortError when signal aborts", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fake = new FakeFetch();
|
||||
fake.push({ pending: true });
|
||||
const controller = new AbortController();
|
||||
const promise = fake.fetch("https://x", { signal: controller.signal });
|
||||
controller.abort();
|
||||
await expect(promise).rejects.toMatchObject({ name: "AbortError" });
|
||||
});
|
||||
|
||||
it("pending request with pre-aborted signal rejects immediately", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fake = new FakeFetch();
|
||||
fake.push({ pending: true });
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
await expect(
|
||||
fake.fetch("https://x", { signal: controller.signal })
|
||||
).rejects.toMatchObject({ name: "AbortError" });
|
||||
});
|
||||
|
||||
it("returns text body when text is set", { timeout: 5000 }, async () => {
|
||||
const fake = new FakeFetch();
|
||||
fake.push({ status: 400, text: "invalid_payload" });
|
||||
const r = await fake.fetch("https://x");
|
||||
expect(r.status).toBe(400);
|
||||
expect(await r.text()).toBe("invalid_payload");
|
||||
});
|
||||
|
||||
it("default status is 200 and body null when neither set", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fake = new FakeFetch();
|
||||
fake.push({});
|
||||
const r = await fake.fetch("https://x");
|
||||
expect(r.status).toBe(200);
|
||||
expect(await r.text()).toBe("");
|
||||
});
|
||||
|
||||
it("captures URL input as string", { timeout: 5000 }, async () => {
|
||||
const fake = new FakeFetch();
|
||||
fake.push({ json: {} });
|
||||
await fake.fetch(new URL("https://x/path"));
|
||||
expect(fake.calls[0]?.url).toBe("https://x/path");
|
||||
});
|
||||
|
||||
it("captures Request input", { timeout: 5000 }, async () => {
|
||||
const fake = new FakeFetch();
|
||||
fake.push({ json: {} });
|
||||
await fake.fetch(new Request("https://x/r"));
|
||||
expect(fake.calls[0]?.url).toBe("https://x/r");
|
||||
});
|
||||
|
||||
it("forwards headers to Response", { timeout: 5000 }, async () => {
|
||||
const fake = new FakeFetch();
|
||||
fake.push({ status: 429, headers: { "Retry-After": "30" } });
|
||||
const r = await fake.fetch("https://x");
|
||||
expect(r.headers.get("retry-after")).toBe("30");
|
||||
});
|
||||
|
||||
it("pending request with no signal never resolves (smoke)", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fake = new FakeFetch();
|
||||
fake.push({ pending: true });
|
||||
const promise = fake.fetch("https://x");
|
||||
const winner = await Promise.race([
|
||||
promise
|
||||
.then(() => "resolved" as const)
|
||||
.catch(() => "rejected" as const),
|
||||
new Promise<"pending">((r) => setTimeout(() => r("pending"), 50)),
|
||||
]);
|
||||
expect(winner).toBe("pending");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
import type { FetchLike } from "@suaveplan/types";
|
||||
|
||||
/** A scripted response queued on a `FakeFetch`. */
|
||||
export interface FakeFetchResponse {
|
||||
readonly status?: number;
|
||||
readonly headers?: Readonly<Record<string, string>>;
|
||||
/** JSON body — serialised via `JSON.stringify` on consumption. */
|
||||
readonly json?: unknown;
|
||||
/** Raw text body — wins over `json` if both are set. */
|
||||
readonly text?: string;
|
||||
/** If true, the response never resolves — useful for abort tests. */
|
||||
readonly pending?: boolean;
|
||||
/** If set, the fetch promise rejects with this error. */
|
||||
readonly reject?: Error;
|
||||
}
|
||||
|
||||
/** A captured request, recorded in FIFO order. */
|
||||
export interface FakeFetchCall {
|
||||
readonly url: string;
|
||||
readonly init: RequestInit | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deterministic test double for `FetchLike`. Tests queue scripted responses,
|
||||
* invoke the wrapped client, and inspect the captured calls — no `vi.mock`,
|
||||
* no `globalThis.fetch` monkey-patching, no real network.
|
||||
*
|
||||
* Multiple responses are consumed in FIFO order. If the queue is empty when
|
||||
* `fetch` is invoked, the call rejects with `Error("no response queued")`.
|
||||
*/
|
||||
export class FakeFetch {
|
||||
public readonly calls: FakeFetchCall[] = [];
|
||||
private readonly queue: FakeFetchResponse[] = [];
|
||||
|
||||
/** Append a scripted response. */
|
||||
public push(response: FakeFetchResponse): void {
|
||||
this.queue.push(response);
|
||||
}
|
||||
|
||||
/** The `FetchLike` callable to inject into clients. */
|
||||
public readonly fetch: FetchLike = (input, init) => {
|
||||
const url =
|
||||
typeof input === "string" || input instanceof URL
|
||||
? String(input)
|
||||
: input.url;
|
||||
this.calls.push({ url, init });
|
||||
|
||||
const scripted = this.queue.shift();
|
||||
if (scripted === undefined) {
|
||||
return Promise.reject(new Error("no response queued"));
|
||||
}
|
||||
if (scripted.reject !== undefined) {
|
||||
return Promise.reject(scripted.reject);
|
||||
}
|
||||
|
||||
if (scripted.pending === true) {
|
||||
return new Promise<Response>((_resolve, reject) => {
|
||||
const signal = init?.signal;
|
||||
if (signal !== undefined && signal !== null) {
|
||||
if (signal.aborted) {
|
||||
reject(makeAbortError());
|
||||
return;
|
||||
}
|
||||
signal.addEventListener(
|
||||
"abort",
|
||||
() => reject(makeAbortError()),
|
||||
{ once: true }
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const status = scripted.status ?? 200;
|
||||
const headers = new Headers(scripted.headers);
|
||||
const body =
|
||||
scripted.text !== undefined
|
||||
? scripted.text
|
||||
: scripted.json !== undefined
|
||||
? JSON.stringify(scripted.json)
|
||||
: null;
|
||||
return Promise.resolve(new Response(body, { status, headers }));
|
||||
};
|
||||
}
|
||||
|
||||
function makeAbortError(): Error {
|
||||
const err = new Error("aborted");
|
||||
err.name = "AbortError";
|
||||
return err;
|
||||
}
|
||||
58
packages/communication/messaging/slack-client/src/index.ts
Normal file
58
packages/communication/messaging/slack-client/src/index.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
// Side-effect: registers the SLACK_* error-code namespace so subpath
|
||||
// consumers also see the registered codes (rule: src/index.ts must
|
||||
// side-effect-import "./error-codes/index.js").
|
||||
import "./error-codes/index.js";
|
||||
|
||||
export { classifyHttpStatus, classifySlackError } from "./client/classify.js";
|
||||
export { parseRetryAfterMs } from "./client/retry-after.js";
|
||||
export { SlackApiClient } from "./client/slack-api-client.js";
|
||||
export {
|
||||
SLACK_CODES,
|
||||
SlackApiError,
|
||||
SlackAuthError,
|
||||
SlackChannelNotFoundError,
|
||||
SlackConfigMissingError,
|
||||
SlackError,
|
||||
type SlackErrorCode,
|
||||
SlackRateLimitedError,
|
||||
SlackTimeoutError,
|
||||
SlackWebhookFailedError,
|
||||
SlackWorkspaceNotFoundError,
|
||||
} from "./error-codes/index.js";
|
||||
export {
|
||||
FakeFetch,
|
||||
type FakeFetchCall,
|
||||
type FakeFetchResponse,
|
||||
} from "./fakes/fake-fetch.js";
|
||||
|
||||
export {
|
||||
recordError,
|
||||
recordRequest,
|
||||
recordWebhook,
|
||||
refreshSlackMeters,
|
||||
} from "./otel/metrics.js";
|
||||
|
||||
export {
|
||||
SlackApiClientOptionsSchema,
|
||||
SlackBlockElementSchema,
|
||||
SlackBlockSchema,
|
||||
SlackMessageSchema,
|
||||
SlackTextObjectSchema,
|
||||
SlackWebhookOptionsSchema,
|
||||
} from "./schemas/schemas.js";
|
||||
|
||||
export type {
|
||||
SlackApiClientOptions,
|
||||
SlackBlock,
|
||||
SlackBlockElement,
|
||||
SlackChannel,
|
||||
SlackDeliveryReport,
|
||||
SlackDeliveryStatus,
|
||||
SlackMessage,
|
||||
SlackResponse,
|
||||
SlackTextObject,
|
||||
SlackTransport,
|
||||
SlackUser,
|
||||
SlackWebhookOptions,
|
||||
} from "./types/slack-client.types.js";
|
||||
export { SlackWebhook } from "./webhook/slack-webhook.js";
|
||||
@@ -0,0 +1,65 @@
|
||||
# `otel/metrics` — OpenTelemetry instrumentation
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`metrics.ts` registers four OpenTelemetry instruments under the meter name `@suaveplan/slack-client` and exports the helper functions every production path uses to record observations: `recordRequest`, `recordError`, `recordWebhook`. The module intentionally retrieves the meter once at module load AND exposes `refreshSlackMeters()` so tests can rewire the meter against a real in-memory `MeterProvider` after configuring the global provider. This keeps the production hot path free of `MeterProvider` lookups while still permitting `getMetrics()` assertions in the test suite.
|
||||
|
||||
## Features
|
||||
|
||||
- Four instruments: `slack.requests` (Counter), `slack.errors` (Counter), `slack.latency_ms` (Histogram, unit `ms`), `slack.webhook_sends` (Counter).
|
||||
- Attributes — `{ method, outcome }` on requests, `{ method, code }` on errors, `{ outcome }` on webhook sends.
|
||||
- Module-load resolution against `metrics.getMeter("@suaveplan/slack-client", "0.1.0")`.
|
||||
- `refreshSlackMeters()` for tests to rebind after `metrics.setGlobalMeterProvider(...)`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { recordRequest } from "@suaveplan/slack-client/otel/metrics";
|
||||
|
||||
recordRequest("chat.postMessage", "ok", 142);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
import {
|
||||
AggregationTemporality,
|
||||
InMemoryMetricExporter,
|
||||
MeterProvider,
|
||||
PeriodicExportingMetricReader,
|
||||
} from "@opentelemetry/sdk-metrics";
|
||||
import { refreshSlackMeters } from "@suaveplan/slack-client/otel/metrics";
|
||||
|
||||
const exporter = new InMemoryMetricExporter(AggregationTemporality.CUMULATIVE);
|
||||
const provider = new MeterProvider({
|
||||
readers: [
|
||||
new PeriodicExportingMetricReader({
|
||||
exporter,
|
||||
exportIntervalMillis: 60_000,
|
||||
}),
|
||||
],
|
||||
});
|
||||
metrics.setGlobalMeterProvider(provider);
|
||||
refreshSlackMeters();
|
||||
|
||||
// ... run code that records ...
|
||||
|
||||
await provider.forceFlush();
|
||||
const observed = exporter.getMetrics();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `recordRequest(method: string, outcome: "ok" | "error", latencyMs: number): void` — increments `slack.requests` and records `slack.latency_ms`.
|
||||
- `recordError(method: string, code: string): void` — increments `slack.errors`.
|
||||
- `recordWebhook(outcome: "ok" | "error"): void` — increments `slack.webhook_sends`.
|
||||
- `refreshSlackMeters(): void` — re-resolves every instrument against the current global meter provider.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The instruments are held in module-scope `let` bindings (not `const`) because `refreshSlackMeters()` rebinds them after a test wires a fresh `MeterProvider`. This is the standard pattern for packages that exercise OTel in tests via a real in-memory exporter — the alternative (proxying every call through a getter) adds a per-call indirection on the production hot path. Instrument names follow the `<domain>.<metric>` convention seen in the wider ecosystem; the histogram unit is set to `"ms"` so dashboards render correctly without a unit-conversion step.
|
||||
@@ -0,0 +1,98 @@
|
||||
import { beforeEach, describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { flushTestMetrics } from "../../tests/setup/polyfills.js";
|
||||
import { SlackApiClient } from "../client/slack-api-client.js";
|
||||
import { FakeFetch } from "../fakes/fake-fetch.js";
|
||||
import { SlackWebhook } from "../webhook/slack-webhook.js";
|
||||
import { refreshSlackMeters } from "./metrics.js";
|
||||
|
||||
describe("OTel metric instruments", () => {
|
||||
beforeEach(async () => {
|
||||
refreshSlackMeters();
|
||||
await flushTestMetrics(); // drain anything from prior tests
|
||||
});
|
||||
|
||||
it("successful sendMessage emits slack.requests with outcome ok", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fake = new FakeFetch();
|
||||
fake.push({ json: { ok: true, channel: "C1", ts: "1.1" } });
|
||||
const client = new SlackApiClient({
|
||||
token: "x",
|
||||
fetch: fake.fetch,
|
||||
clock: () => 1_700_000_000_000,
|
||||
});
|
||||
await client.sendMessage({ channel: "C1", text: "hi" });
|
||||
const collected = await flushTestMetrics();
|
||||
const names = collected.flatMap((rm) =>
|
||||
rm.scopeMetrics.flatMap((sm) =>
|
||||
sm.metrics.map((m) => m.descriptor.name)
|
||||
)
|
||||
);
|
||||
expect(names).toContain("slack.requests");
|
||||
expect(names).toContain("slack.latency_ms");
|
||||
});
|
||||
|
||||
it("failed sendMessage emits slack.requests outcome error and slack.errors", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fake = new FakeFetch();
|
||||
fake.push({ status: 500, text: "boom" });
|
||||
const client = new SlackApiClient({
|
||||
token: "x",
|
||||
fetch: fake.fetch,
|
||||
clock: () => 1_700_000_000_000,
|
||||
});
|
||||
await expect(
|
||||
client.sendMessage({ channel: "C1", text: "hi" })
|
||||
).rejects.toThrow();
|
||||
const collected = await flushTestMetrics();
|
||||
const names = collected.flatMap((rm) =>
|
||||
rm.scopeMetrics.flatMap((sm) =>
|
||||
sm.metrics.map((m) => m.descriptor.name)
|
||||
)
|
||||
);
|
||||
expect(names).toContain("slack.requests");
|
||||
expect(names).toContain("slack.errors");
|
||||
});
|
||||
|
||||
it("webhook send emits slack.webhook_sends", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fake = new FakeFetch();
|
||||
fake.push({ status: 200, text: "ok" });
|
||||
const webhook = new SlackWebhook({
|
||||
webhookUrl: "https://hooks.slack.com/services/T/B/X",
|
||||
fetch: fake.fetch,
|
||||
clock: () => 1_700_000_000_000,
|
||||
});
|
||||
await webhook.send({ text: "hi" });
|
||||
const collected = await flushTestMetrics();
|
||||
const names = collected.flatMap((rm) =>
|
||||
rm.scopeMetrics.flatMap((sm) =>
|
||||
sm.metrics.map((m) => m.descriptor.name)
|
||||
)
|
||||
);
|
||||
expect(names).toContain("slack.webhook_sends");
|
||||
});
|
||||
|
||||
it("webhook failure emits slack.errors and slack.webhook_sends error", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fake = new FakeFetch();
|
||||
fake.push({ status: 400, text: "invalid_payload" });
|
||||
const webhook = new SlackWebhook({
|
||||
webhookUrl: "https://hooks.slack.com/services/T/B/X",
|
||||
fetch: fake.fetch,
|
||||
clock: () => 1_700_000_000_000,
|
||||
});
|
||||
await expect(webhook.send({ text: "hi" })).rejects.toThrow();
|
||||
const collected = await flushTestMetrics();
|
||||
const names = collected.flatMap((rm) =>
|
||||
rm.scopeMetrics.flatMap((sm) =>
|
||||
sm.metrics.map((m) => m.descriptor.name)
|
||||
)
|
||||
);
|
||||
expect(names).toContain("slack.errors");
|
||||
expect(names).toContain("slack.webhook_sends");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { Counter, Histogram, Meter } from "@opentelemetry/api";
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
|
||||
const METER_NAME = "@suaveplan/slack-client";
|
||||
const METER_VERSION = "0.1.0";
|
||||
|
||||
/** Mutable holder so tests can rebind the meter against the test provider. */
|
||||
let meter: Meter = metrics.getMeter(METER_NAME, METER_VERSION);
|
||||
|
||||
let requestCounter: Counter = meter.createCounter("slack.requests", {
|
||||
description: "Count of Slack API requests by method and outcome",
|
||||
});
|
||||
|
||||
let errorCounter: Counter = meter.createCounter("slack.errors", {
|
||||
description: "Count of Slack API request failures by method and code",
|
||||
});
|
||||
|
||||
let latencyHistogram: Histogram = meter.createHistogram("slack.latency_ms", {
|
||||
description: "Slack API request latency in milliseconds",
|
||||
unit: "ms",
|
||||
});
|
||||
|
||||
let webhookCounter: Counter = meter.createCounter("slack.webhook_sends", {
|
||||
description: "Count of incoming-webhook posts by outcome",
|
||||
});
|
||||
|
||||
/**
|
||||
* Re-resolve every instrument against the current global `MeterProvider`.
|
||||
* Tests call this after wiring an in-memory exporter so subsequent record
|
||||
* calls land on the test provider rather than the module-load meter.
|
||||
*/
|
||||
export function refreshSlackMeters(): void {
|
||||
meter = metrics.getMeter(METER_NAME, METER_VERSION);
|
||||
requestCounter = meter.createCounter("slack.requests", {
|
||||
description: "Count of Slack API requests by method and outcome",
|
||||
});
|
||||
errorCounter = meter.createCounter("slack.errors", {
|
||||
description: "Count of Slack API request failures by method and code",
|
||||
});
|
||||
latencyHistogram = meter.createHistogram("slack.latency_ms", {
|
||||
description: "Slack API request latency in milliseconds",
|
||||
unit: "ms",
|
||||
});
|
||||
webhookCounter = meter.createCounter("slack.webhook_sends", {
|
||||
description: "Count of incoming-webhook posts by outcome",
|
||||
});
|
||||
}
|
||||
|
||||
export function recordRequest(
|
||||
method: string,
|
||||
outcome: "ok" | "error",
|
||||
latencyMs: number
|
||||
): void {
|
||||
requestCounter.add(1, { method, outcome });
|
||||
latencyHistogram.record(latencyMs, { method, outcome });
|
||||
}
|
||||
|
||||
export function recordError(method: string, code: string): void {
|
||||
errorCounter.add(1, { method, code });
|
||||
}
|
||||
|
||||
export function recordWebhook(outcome: "ok" | "error"): void {
|
||||
webhookCounter.add(1, { outcome });
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
# `schemas` — Zod boundary validation
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`schemas.ts` exports the Zod schemas that validate every public option bag and every Slack Web API request payload that crosses the package boundary. The schemas are the single source of truth for the runtime shape contract — they pair with the structural types in `types/slack-client.types.ts` so callers can rely on identical compile-time AND runtime guarantees. Schema-driven validation lets the constructors reject malformed input with a typed `SlackConfigMissingError` whose `cause` is the original `ZodError`, surfacing the offending field path through the standard Zod issue list.
|
||||
|
||||
## Features
|
||||
|
||||
- `SlackApiClientOptionsSchema` — validates `{ token, baseUrl?, defaultChannel?, timeoutMs? }`.
|
||||
- `SlackWebhookOptionsSchema` — validates `{ webhookUrl, timeoutMs? }`.
|
||||
- `SlackMessageSchema` — validates the `chat.postMessage` payload.
|
||||
- `SlackBlockSchema`, `SlackBlockElementSchema`, `SlackTextObjectSchema` — Block Kit boundary shapes.
|
||||
- All schemas are exported as values (not just types) so callers can re-use them at their own boundaries.
|
||||
- Strict-mode (`.strict()`) on Block Kit shapes; `.passthrough()` on top-level option bags so callers may forward arbitrary transport overrides (`fetch`, `clock`, `rng`, `logger`) which Zod cannot describe.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { SlackMessageSchema } from "@suaveplan/slack-client/schemas/schemas";
|
||||
|
||||
const parsed = SlackMessageSchema.parse({
|
||||
channel: "C123",
|
||||
text: "hi",
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { SlackApiClientOptionsSchema } from "@suaveplan/slack-client/schemas/schemas";
|
||||
|
||||
const result = SlackApiClientOptionsSchema.safeParse(input);
|
||||
if (!result.success) {
|
||||
for (const issue of result.error.issues) {
|
||||
console.warn(issue.path.join("."), issue.message);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `SlackApiClientOptionsSchema: ZodObject` — primary client options.
|
||||
- `SlackWebhookOptionsSchema: ZodObject` — webhook options.
|
||||
- `SlackMessageSchema: ZodObject` — full message payload.
|
||||
- `SlackBlockSchema: ZodObject` — single block.
|
||||
- `SlackBlockElementSchema: ZodType` — nested block element.
|
||||
- `SlackTextObjectSchema: ZodObject` — Block Kit text composition object.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`SlackBlockElementSchema` is annotated with an explicit `ZodType<...>` shape because TypeScript cannot infer the recursive cross-reference between blocks and elements through Zod's chained builders. The top-level option schemas use `.passthrough()` rather than `.strict()` because the transport bag (`fetch`, `clock`, `rng`, `logger`) is structural and falls outside Zod's serialisable surface — strict mode would reject these legitimate option keys.
|
||||
@@ -0,0 +1,125 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
SlackApiClientOptionsSchema,
|
||||
SlackBlockElementSchema,
|
||||
SlackBlockSchema,
|
||||
SlackMessageSchema,
|
||||
SlackTextObjectSchema,
|
||||
SlackWebhookOptionsSchema,
|
||||
} from "./schemas.js";
|
||||
|
||||
describe("schemas", () => {
|
||||
it("SlackTextObjectSchema accepts plain_text and mrkdwn", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(
|
||||
SlackTextObjectSchema.safeParse({ type: "plain_text", text: "hi" })
|
||||
.success
|
||||
).toBe(true);
|
||||
expect(
|
||||
SlackTextObjectSchema.safeParse({ type: "mrkdwn", text: "*hi*" })
|
||||
.success
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("SlackTextObjectSchema rejects unknown type", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
SlackTextObjectSchema.safeParse({ type: "html", text: "<b>x</b>" })
|
||||
.success
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("SlackBlockElementSchema validates element shape", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(
|
||||
SlackBlockElementSchema.safeParse({
|
||||
type: "button",
|
||||
text: { type: "plain_text", text: "Click" },
|
||||
actionId: "a1",
|
||||
value: "v",
|
||||
}).success
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("SlackBlockSchema validates a section block", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
SlackBlockSchema.safeParse({
|
||||
type: "section",
|
||||
text: { type: "mrkdwn", text: "*x*" },
|
||||
}).success
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("SlackMessageSchema rejects non-string channel", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const res = SlackMessageSchema.safeParse({ channel: 1, text: "x" });
|
||||
expect(res.success).toBe(false);
|
||||
});
|
||||
|
||||
it("SlackMessageSchema accepts a full payload", { timeout: 5000 }, () => {
|
||||
const res = SlackMessageSchema.safeParse({
|
||||
channel: "C1",
|
||||
text: "hi",
|
||||
blocks: [{ type: "section", text: { type: "mrkdwn", text: "x" } }],
|
||||
threadTs: "1.1",
|
||||
unfurlLinks: false,
|
||||
unfurlMedia: true,
|
||||
metadata: { event_type: "test" },
|
||||
});
|
||||
expect(res.success).toBe(true);
|
||||
});
|
||||
|
||||
it("SlackApiClientOptionsSchema rejects empty token", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(
|
||||
SlackApiClientOptionsSchema.safeParse({ token: "" }).success
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("SlackApiClientOptionsSchema accepts transport extras via passthrough", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const res = SlackApiClientOptionsSchema.safeParse({
|
||||
token: "xoxb-x",
|
||||
fetch: globalThis.fetch,
|
||||
clock: () => 0,
|
||||
});
|
||||
expect(res.success).toBe(true);
|
||||
});
|
||||
|
||||
it("SlackWebhookOptionsSchema rejects non-URL webhookUrl", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(
|
||||
SlackWebhookOptionsSchema.safeParse({ webhookUrl: "" }).success
|
||||
).toBe(false);
|
||||
expect(
|
||||
SlackWebhookOptionsSchema.safeParse({ webhookUrl: "not-url" })
|
||||
.success
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("SlackWebhookOptionsSchema accepts a valid URL", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(
|
||||
SlackWebhookOptionsSchema.safeParse({
|
||||
webhookUrl: "https://hooks.slack.com/services/T/B/X",
|
||||
}).success
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("SlackApiClientOptionsSchema rejects negative timeoutMs", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(
|
||||
SlackApiClientOptionsSchema.safeParse({
|
||||
token: "x",
|
||||
timeoutMs: -1,
|
||||
}).success
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
// schema: boundary-only — these schemas are part of the public surface;
|
||||
// consumers (CLI tools, API handlers, downstream apps) invoke them at their
|
||||
// own boundaries. The package's own constructors use `SlackApiClientOptionsSchema`
|
||||
// and `SlackWebhookOptionsSchema` internally.
|
||||
import z from "zod";
|
||||
|
||||
/**
|
||||
* Zod boundary schemas for `@suaveplan/slack-client`. Each public option bag
|
||||
* and every wire payload that crosses the package boundary is validated
|
||||
* against one of these schemas. The schemas are intentionally permissive
|
||||
* (`.passthrough()` on `metadata`, optional fields marked optional) so
|
||||
* callers can supply Slack-future fields without a code change.
|
||||
*/
|
||||
|
||||
export const SlackTextObjectSchema = z
|
||||
.object({
|
||||
type: z.enum(["plain_text", "mrkdwn"]),
|
||||
text: z.string(),
|
||||
emoji: z.boolean().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const SlackBlockElementSchema = z
|
||||
.object({
|
||||
type: z.string().min(1),
|
||||
text: SlackTextObjectSchema.optional(),
|
||||
actionId: z.string().optional(),
|
||||
url: z.string().optional(),
|
||||
value: z.string().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const SlackBlockSchema = z
|
||||
.object({
|
||||
type: z.string().min(1),
|
||||
text: SlackTextObjectSchema.optional(),
|
||||
elements: z.array(SlackBlockElementSchema).optional(),
|
||||
accessory: SlackBlockElementSchema.optional(),
|
||||
blockId: z.string().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const SlackMessageSchema = z
|
||||
.object({
|
||||
channel: z.string(),
|
||||
text: z.string(),
|
||||
blocks: z.array(SlackBlockSchema).optional(),
|
||||
threadTs: z.string().optional(),
|
||||
unfurlLinks: z.boolean().optional(),
|
||||
unfurlMedia: z.boolean().optional(),
|
||||
metadata: z.record(z.string(), z.unknown()).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const SlackApiClientOptionsSchema = z
|
||||
.object({
|
||||
token: z.string().min(1),
|
||||
baseUrl: z.string().url().optional(),
|
||||
defaultChannel: z.string().optional(),
|
||||
timeoutMs: z.number().int().positive().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export const SlackWebhookOptionsSchema = z
|
||||
.object({
|
||||
webhookUrl: z.string().url(),
|
||||
timeoutMs: z.number().int().positive().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
@@ -0,0 +1,110 @@
|
||||
import type { Clock, FetchLike, LoggerLike, Rng } from "@suaveplan/types";
|
||||
|
||||
/** Shared transport bag injected into every public client constructor. */
|
||||
export interface SlackTransport {
|
||||
/** Fetch implementation. Defaults to `globalThis.fetch`. */
|
||||
readonly fetch?: FetchLike;
|
||||
/** Wall-clock source. Defaults to `() => Date.now()`. */
|
||||
readonly clock?: Clock;
|
||||
/** Optional random source used for the webhook message-id suffix. */
|
||||
readonly rng?: Rng;
|
||||
/** Structured logger. Defaults to a no-op. */
|
||||
readonly logger?: LoggerLike;
|
||||
}
|
||||
|
||||
/** Configuration for the Slack API client. */
|
||||
export interface SlackApiClientOptions extends SlackTransport {
|
||||
/** Bot or user OAuth token (xoxb-... or xoxp-...). */
|
||||
readonly token: string;
|
||||
/** Base URL for the Slack API. Defaults to `https://slack.com/api`. */
|
||||
readonly baseUrl?: string;
|
||||
/** Default channel used when `SlackMessage.channel` is empty. */
|
||||
readonly defaultChannel?: string;
|
||||
/** Per-request timeout in ms. Defaults to `10_000`. */
|
||||
readonly timeoutMs?: number;
|
||||
}
|
||||
|
||||
/** Configuration for the Slack incoming-webhook client. */
|
||||
export interface SlackWebhookOptions extends SlackTransport {
|
||||
/** Full incoming-webhook URL (`https://hooks.slack.com/services/...`). */
|
||||
readonly webhookUrl: string;
|
||||
/** Per-request timeout in ms. Defaults to `10_000`. */
|
||||
readonly timeoutMs?: number;
|
||||
}
|
||||
|
||||
/** A Block Kit text composition object. */
|
||||
export interface SlackTextObject {
|
||||
readonly type: "plain_text" | "mrkdwn";
|
||||
readonly text: string;
|
||||
readonly emoji?: boolean;
|
||||
}
|
||||
|
||||
/** A Block Kit element nested inside a block. */
|
||||
export interface SlackBlockElement {
|
||||
readonly type: string;
|
||||
readonly text?: SlackTextObject;
|
||||
readonly actionId?: string;
|
||||
readonly url?: string;
|
||||
readonly value?: string;
|
||||
}
|
||||
|
||||
/** A single Block Kit block. */
|
||||
export interface SlackBlock {
|
||||
readonly type: string;
|
||||
readonly text?: SlackTextObject;
|
||||
readonly elements?: readonly SlackBlockElement[];
|
||||
readonly accessory?: SlackBlockElement;
|
||||
readonly blockId?: string;
|
||||
}
|
||||
|
||||
/** Message payload for `chat.postMessage`. */
|
||||
export interface SlackMessage {
|
||||
readonly channel: string;
|
||||
readonly text: string;
|
||||
readonly blocks?: readonly SlackBlock[];
|
||||
readonly threadTs?: string;
|
||||
readonly unfurlLinks?: boolean;
|
||||
readonly unfurlMedia?: boolean;
|
||||
readonly metadata?: Readonly<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
/** Response from a Slack Web-API call. */
|
||||
export interface SlackResponse {
|
||||
readonly ok: boolean;
|
||||
readonly channel?: string;
|
||||
readonly ts?: string;
|
||||
readonly error?: string;
|
||||
readonly responseMetadata?: Readonly<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
/** A Slack channel surfaced through `conversations.*`. */
|
||||
export interface SlackChannel {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly isPrivate: boolean;
|
||||
readonly topic?: string;
|
||||
readonly purpose?: string;
|
||||
readonly memberCount?: number;
|
||||
}
|
||||
|
||||
/** A Slack workspace member surfaced through `users.list`. */
|
||||
export interface SlackUser {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly realName?: string;
|
||||
readonly email?: string;
|
||||
readonly isBot: boolean;
|
||||
}
|
||||
|
||||
/** Lifecycle status of a Slack delivery attempt. */
|
||||
export type SlackDeliveryStatus = "queued" | "sent" | "delivered" | "failed";
|
||||
|
||||
/** Delivery report stored after a successful send. */
|
||||
export interface SlackDeliveryReport {
|
||||
readonly messageId: string;
|
||||
readonly channel: string;
|
||||
readonly status: SlackDeliveryStatus;
|
||||
readonly timestamp: number;
|
||||
readonly errorCode?: string;
|
||||
readonly errorMessage?: string;
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
# `webhook/slack-webhook` — Slack incoming-webhook client
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`SlackWebhook` is the simpler half of the package — a typed wrapper around Slack's incoming-webhook URLs. Where `SlackApiClient` requires an OAuth token and supports the full Web API, the webhook client takes only the URL and POSTs `{ text, blocks? }` payloads. It exists because many ops surfaces (CI bots, alerting, build notifications) only need fire-and-forget delivery and the webhook URL is the lower-friction credential — no token rotation, no scoped permissions. The client returns a `SlackDeliveryReport` on success and surfaces typed errors on every failure path so retry middleware can pattern-match on `.code`.
|
||||
|
||||
## Features
|
||||
|
||||
- Zod boundary validation in the constructor — invalid `webhookUrl` rejects with `SlackConfigMissingError`.
|
||||
- Three failure paths — network/timeout (`SlackTimeoutError`), HTTP 401/403 (`SlackAuthError`), other 4xx/5xx (`SlackWebhookFailedError`).
|
||||
- Injectable `Clock` for deterministic message-id timestamps.
|
||||
- Optional `Rng` injection — when supplied, the message-id suffix uses `rng.nextUint32().toString(36)` for uniqueness across rapidly-issued sends; otherwise falls back to the monotonic counter.
|
||||
- `slack.webhook_sends` and `slack.errors` OTel instruments emitted on every path.
|
||||
- Caller-supplied `AbortSignal` AND internal `timeoutMs` (default 10 000 ms) both honoured.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { SlackWebhook } from "@suaveplan/slack-client";
|
||||
|
||||
const webhook = new SlackWebhook({
|
||||
webhookUrl: "https://hooks.slack.com/services/T0/B0/X",
|
||||
});
|
||||
|
||||
const report = await webhook.sendText("Deploy complete.");
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { SlackWebhook } from "@suaveplan/slack-client";
|
||||
import { FakeFetch } from "@suaveplan/slack-client/fakes/fake-fetch";
|
||||
|
||||
const fake = new FakeFetch();
|
||||
fake.push({ status: 200 });
|
||||
|
||||
let counter = 0;
|
||||
const webhook = new SlackWebhook({
|
||||
webhookUrl: "https://hooks.slack.com/services/T/B/X",
|
||||
fetch: fake.fetch,
|
||||
clock: () => 1_700_000_000_000 + counter++,
|
||||
rng: { nextUint32: () => 42, nextFloat: () => 0.42 },
|
||||
logger: { debug() {}, info() {}, warn() {}, error() {} },
|
||||
});
|
||||
|
||||
const report = await webhook.send({
|
||||
text: "Block kit",
|
||||
blocks: [{ type: "section", text: { type: "mrkdwn", text: "*Hi*" } }],
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `new SlackWebhook(options: SlackWebhookOptions)`
|
||||
- `send(message: { text: string; blocks?: SlackBlock[] }, signal?: AbortSignal): Promise<SlackDeliveryReport>`
|
||||
- `sendText(text: string, signal?: AbortSignal): Promise<SlackDeliveryReport>`
|
||||
|
||||
Errors thrown: `SlackConfigMissingError`, `SlackTimeoutError`, `SlackAuthError`, `SlackWebhookFailedError`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The message-id format is `webhook-<ms>-<counter>-<rngOrCounter>` so even rapid bursts on the same clock tick produce distinct ids. The counter is monotone within a single instance; injecting an `Rng` adds cross-instance uniqueness without coupling to `crypto.randomUUID` (which is universally available but adds an unwanted dependency on the runtime's CSPRNG availability in test environments). The constructor explicitly binds `globalThis.fetch` to `globalThis` when used as default — without the bind, `fetch.call(undefined, ...)` triggers an "Illegal invocation" `TypeError` in some browser builds.
|
||||
@@ -0,0 +1,272 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
SlackAuthError,
|
||||
SlackConfigMissingError,
|
||||
SlackTimeoutError,
|
||||
SlackWebhookFailedError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import { FakeFetch } from "../fakes/fake-fetch.js";
|
||||
import type { SlackWebhookOptions } from "../types/slack-client.types.js";
|
||||
import { defaultClock, defaultFetch, SlackWebhook } from "./slack-webhook.js";
|
||||
|
||||
function make(
|
||||
fake: FakeFetch,
|
||||
extra: Partial<SlackWebhookOptions> = {}
|
||||
): SlackWebhook {
|
||||
return new SlackWebhook({
|
||||
webhookUrl: "https://hooks.slack.com/services/T/B/X",
|
||||
fetch: fake.fetch,
|
||||
clock: () => 1_700_000_000_000,
|
||||
...extra,
|
||||
});
|
||||
}
|
||||
|
||||
describe("SlackWebhook constructor", () => {
|
||||
it("empty webhookUrl rejects with SlackConfigMissingError", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() => new SlackWebhook({ webhookUrl: "" })).toThrow(
|
||||
SlackConfigMissingError
|
||||
);
|
||||
});
|
||||
|
||||
it("non-URL webhookUrl rejects with SlackConfigMissingError", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() => new SlackWebhook({ webhookUrl: "not-a-url" })).toThrow(
|
||||
SlackConfigMissingError
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SlackWebhook defaults", () => {
|
||||
it("defaultClock returns a Date.now()-ish number", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const before = Date.now();
|
||||
const t = defaultClock();
|
||||
const after = Date.now();
|
||||
expect(t).toBeGreaterThanOrEqual(before);
|
||||
expect(t).toBeLessThanOrEqual(after);
|
||||
});
|
||||
|
||||
it("defaultFetch invokes globalThis.fetch", { timeout: 5000 }, async () => {
|
||||
const original = globalThis.fetch;
|
||||
let captured: string | URL | Request | undefined;
|
||||
globalThis.fetch = ((input: string | URL | Request) => {
|
||||
captured = input;
|
||||
return Promise.resolve(new Response("{}", { status: 200 }));
|
||||
}) as typeof globalThis.fetch;
|
||||
try {
|
||||
await defaultFetch("https://example.invalid/x");
|
||||
expect(captured).toBe("https://example.invalid/x");
|
||||
} finally {
|
||||
globalThis.fetch = original;
|
||||
}
|
||||
});
|
||||
|
||||
it("constructor without fetch/clock uses internal defaults", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const webhook = new SlackWebhook({
|
||||
webhookUrl: "https://hooks.slack.com/services/T/B/X",
|
||||
});
|
||||
expect(webhook).toBeInstanceOf(SlackWebhook);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SlackWebhook.send", () => {
|
||||
it("send returns a delivered report on HTTP 200", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fake = new FakeFetch();
|
||||
fake.push({ status: 200, text: "ok" });
|
||||
const webhook = make(fake);
|
||||
const report = await webhook.send({ text: "Hello!" });
|
||||
expect(report.status).toBe("delivered");
|
||||
expect(report.channel).toBe("webhook");
|
||||
expect(report.messageId.startsWith("webhook-")).toBe(true);
|
||||
expect(report.timestamp).toBe(1_700_000_000_000);
|
||||
});
|
||||
|
||||
it("send forwards blocks", { timeout: 5000 }, async () => {
|
||||
const fake = new FakeFetch();
|
||||
fake.push({ status: 200, text: "ok" });
|
||||
const webhook = make(fake);
|
||||
await webhook.send({
|
||||
text: "Fallback",
|
||||
blocks: [
|
||||
{
|
||||
type: "section",
|
||||
text: { type: "mrkdwn", text: "*x*" },
|
||||
},
|
||||
],
|
||||
});
|
||||
const body = JSON.parse(String(fake.calls[0]?.init?.body)) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect(Array.isArray(body["blocks"])).toBe(true);
|
||||
});
|
||||
|
||||
it("send sets correct Content-Type header", { timeout: 5000 }, async () => {
|
||||
const fake = new FakeFetch();
|
||||
fake.push({ status: 200, text: "ok" });
|
||||
const webhook = make(fake);
|
||||
await webhook.send({ text: "hi" });
|
||||
const headers = (fake.calls[0]?.init?.headers ?? {}) as Record<
|
||||
string,
|
||||
string
|
||||
>;
|
||||
expect(headers["Content-Type"]).toBe("application/json; charset=utf-8");
|
||||
});
|
||||
|
||||
it("sendText posts only the text field", { timeout: 5000 }, async () => {
|
||||
const fake = new FakeFetch();
|
||||
fake.push({ status: 200, text: "ok" });
|
||||
const webhook = make(fake);
|
||||
const report = await webhook.sendText("Simple");
|
||||
expect(report.status).toBe("delivered");
|
||||
const body = JSON.parse(String(fake.calls[0]?.init?.body)) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect(body["text"]).toBe("Simple");
|
||||
expect(body["blocks"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("HTTP 401 rejects with SlackAuthError", { timeout: 5000 }, async () => {
|
||||
const fake = new FakeFetch();
|
||||
fake.push({ status: 401, text: "unauthorized" });
|
||||
const webhook = make(fake);
|
||||
await expect(webhook.send({ text: "hi" })).rejects.toThrow(
|
||||
SlackAuthError
|
||||
);
|
||||
});
|
||||
|
||||
it("HTTP 403 rejects with SlackAuthError", { timeout: 5000 }, async () => {
|
||||
const fake = new FakeFetch();
|
||||
fake.push({ status: 403, text: "forbidden" });
|
||||
const webhook = make(fake);
|
||||
await expect(webhook.send({ text: "hi" })).rejects.toThrow(
|
||||
SlackAuthError
|
||||
);
|
||||
});
|
||||
|
||||
it("HTTP 400 rejects with SlackWebhookFailedError", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fake = new FakeFetch();
|
||||
fake.push({ status: 400, text: "invalid_payload" });
|
||||
const webhook = make(fake);
|
||||
try {
|
||||
await webhook.send({ text: "hi" });
|
||||
expect.fail("should have thrown");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(SlackWebhookFailedError);
|
||||
expect(
|
||||
(err as { context?: { status?: number } }).context?.status
|
||||
).toBe(400);
|
||||
}
|
||||
});
|
||||
|
||||
it("network error rejects with SlackTimeoutError", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fake = new FakeFetch();
|
||||
fake.push({ reject: new Error("Connection refused") });
|
||||
const webhook = make(fake);
|
||||
await expect(webhook.send({ text: "hi" })).rejects.toThrow(
|
||||
SlackTimeoutError
|
||||
);
|
||||
});
|
||||
|
||||
it("network error with non-Error reject still rejects", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fake = new FakeFetch();
|
||||
fake.push({ reject: { foo: 1 } as unknown as Error });
|
||||
const webhook = make(fake);
|
||||
await expect(webhook.send({ text: "hi" })).rejects.toThrow(
|
||||
SlackTimeoutError
|
||||
);
|
||||
});
|
||||
|
||||
it("timeoutMs aborts an in-flight request with SlackTimeoutError", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fake = new FakeFetch();
|
||||
fake.push({ pending: true });
|
||||
const webhook = make(fake, { timeoutMs: 1 });
|
||||
await expect(webhook.send({ text: "hi" })).rejects.toThrow(
|
||||
SlackTimeoutError
|
||||
);
|
||||
});
|
||||
|
||||
it("caller AbortSignal cancels the request with SlackTimeoutError", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fake = new FakeFetch();
|
||||
fake.push({ pending: true });
|
||||
const webhook = make(fake);
|
||||
const controller = new AbortController();
|
||||
const promise = webhook.send({ text: "hi" }, controller.signal);
|
||||
controller.abort();
|
||||
await expect(promise).rejects.toThrow(SlackTimeoutError);
|
||||
});
|
||||
|
||||
it("pre-aborted caller signal rejects with SlackTimeoutError", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fake = new FakeFetch();
|
||||
fake.push({ pending: true });
|
||||
const webhook = make(fake);
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
await expect(
|
||||
webhook.send({ text: "hi" }, controller.signal)
|
||||
).rejects.toThrow(SlackTimeoutError);
|
||||
});
|
||||
|
||||
it("sequential sends yield distinct messageIds", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fake = new FakeFetch();
|
||||
fake.push({ status: 200, text: "ok" });
|
||||
fake.push({ status: 200, text: "ok" });
|
||||
const webhook = make(fake);
|
||||
const r1 = await webhook.send({ text: "one" });
|
||||
const r2 = await webhook.send({ text: "two" });
|
||||
expect(r1.messageId).not.toBe(r2.messageId);
|
||||
});
|
||||
|
||||
it("rng injection drives the messageId suffix", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fake = new FakeFetch();
|
||||
fake.push({ status: 200, text: "ok" });
|
||||
const webhook = make(fake, {
|
||||
rng: { nextUint32: () => 42, nextFloat: () => 0.42 },
|
||||
});
|
||||
const r = await webhook.send({ text: "hi" });
|
||||
expect(r.messageId.endsWith("-16")).toBe(true); // 42 in base36 is "16"
|
||||
});
|
||||
|
||||
it("logger.warn is called on network failure", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fake = new FakeFetch();
|
||||
fake.push({ reject: new Error("boom") });
|
||||
const records: string[] = [];
|
||||
const webhook = make(fake, {
|
||||
logger: {
|
||||
debug: () => undefined,
|
||||
info: () => undefined,
|
||||
warn: (msg: string) => records.push(msg),
|
||||
error: () => undefined,
|
||||
},
|
||||
});
|
||||
await expect(webhook.send({ text: "hi" })).rejects.toThrow();
|
||||
expect(records).toContain("slack webhook failed");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,164 @@
|
||||
import type { Clock, FetchLike, LoggerLike, Rng } from "@suaveplan/types";
|
||||
import {
|
||||
SLACK_CODES,
|
||||
SlackAuthError,
|
||||
SlackConfigMissingError,
|
||||
SlackTimeoutError,
|
||||
SlackWebhookFailedError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import { recordError, recordWebhook } from "../otel/metrics.js";
|
||||
import { SlackWebhookOptionsSchema } from "../schemas/schemas.js";
|
||||
import type {
|
||||
SlackBlock,
|
||||
SlackDeliveryReport,
|
||||
SlackWebhookOptions,
|
||||
} from "../types/slack-client.types.js";
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 10_000;
|
||||
|
||||
/** Internal default clock — exported so unit tests pin the fallback branch. */
|
||||
export const defaultClock: Clock = (): number => Date.now();
|
||||
|
||||
/** Internal default fetch — exported so unit tests pin the fallback branch. */
|
||||
export const defaultFetch: FetchLike = (
|
||||
input: string | URL | Request,
|
||||
init?: RequestInit
|
||||
): Promise<Response> => globalThis.fetch(input, init);
|
||||
|
||||
/**
|
||||
* Slack incoming-webhook client. Simpler than the API client — one URL,
|
||||
* one POST, no authentication header. Returns a `SlackDeliveryReport` on
|
||||
* 2xx and throws a typed error on 4xx/5xx/network failure.
|
||||
*/
|
||||
export class SlackWebhook {
|
||||
readonly #webhookUrl: string;
|
||||
readonly #timeoutMs: number;
|
||||
readonly #fetch: FetchLike;
|
||||
readonly #clock: Clock;
|
||||
readonly #rng: Rng | undefined;
|
||||
readonly #logger: LoggerLike | undefined;
|
||||
#counter = 0;
|
||||
|
||||
public constructor(options: SlackWebhookOptions) {
|
||||
const parsed = SlackWebhookOptionsSchema.safeParse(options);
|
||||
if (!parsed.success) {
|
||||
throw new SlackConfigMissingError({
|
||||
message: "slack.errors.configMissing",
|
||||
cause: parsed.error,
|
||||
context: {
|
||||
code: SLACK_CODES.CONFIG_MISSING,
|
||||
field: parsed.error.issues[0]?.path.join("."),
|
||||
},
|
||||
});
|
||||
}
|
||||
this.#webhookUrl = options.webhookUrl;
|
||||
this.#timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
||||
this.#fetch = options.fetch ?? defaultFetch;
|
||||
this.#clock = options.clock ?? defaultClock;
|
||||
this.#rng = options.rng;
|
||||
this.#logger = options.logger;
|
||||
}
|
||||
|
||||
public async send(
|
||||
message: {
|
||||
readonly text: string;
|
||||
readonly blocks?: readonly SlackBlock[];
|
||||
},
|
||||
signal?: AbortSignal
|
||||
): Promise<SlackDeliveryReport> {
|
||||
const messageId = this.#nextMessageId();
|
||||
const body: Record<string, unknown> = { text: message.text };
|
||||
if (message.blocks !== undefined) body["blocks"] = message.blocks;
|
||||
|
||||
const controller = new AbortController();
|
||||
const onCallerAbort = (): void => controller.abort();
|
||||
if (signal !== undefined) {
|
||||
if (signal.aborted) controller.abort();
|
||||
else
|
||||
signal.addEventListener("abort", onCallerAbort, { once: true });
|
||||
}
|
||||
const timer = setTimeout(() => controller.abort(), this.#timeoutMs);
|
||||
|
||||
const cleanup = (): void => {
|
||||
clearTimeout(timer);
|
||||
if (signal !== undefined)
|
||||
signal.removeEventListener("abort", onCallerAbort);
|
||||
};
|
||||
|
||||
let res: Response;
|
||||
try {
|
||||
res = await this.#fetch(this.#webhookUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
signal: controller.signal,
|
||||
});
|
||||
} catch (err) {
|
||||
cleanup();
|
||||
recordWebhook("error");
|
||||
recordError("webhook", SLACK_CODES.TIMEOUT);
|
||||
const cause = err instanceof Error ? err : new Error(String(err));
|
||||
this.#logger?.warn("slack webhook failed", {
|
||||
cause: cause.message,
|
||||
});
|
||||
throw new SlackTimeoutError({
|
||||
message: "slack.errors.timeout",
|
||||
cause,
|
||||
context: { url: this.#webhookUrl },
|
||||
});
|
||||
}
|
||||
cleanup();
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
recordWebhook("error");
|
||||
if (res.status === 401 || res.status === 403) {
|
||||
recordError("webhook", SLACK_CODES.AUTH_ERROR);
|
||||
throw new SlackAuthError({
|
||||
message: "slack.errors.authError",
|
||||
context: {
|
||||
url: this.#webhookUrl,
|
||||
status: res.status,
|
||||
body: text,
|
||||
},
|
||||
});
|
||||
}
|
||||
recordError("webhook", SLACK_CODES.WEBHOOK_FAILED);
|
||||
throw new SlackWebhookFailedError({
|
||||
message: "slack.errors.webhookFailed",
|
||||
context: {
|
||||
url: this.#webhookUrl,
|
||||
status: res.status,
|
||||
body: text,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
recordWebhook("ok");
|
||||
return {
|
||||
messageId,
|
||||
channel: "webhook",
|
||||
status: "delivered",
|
||||
timestamp: this.#clock(),
|
||||
};
|
||||
}
|
||||
|
||||
public async sendText(
|
||||
text: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<SlackDeliveryReport> {
|
||||
return this.send({ text }, signal);
|
||||
}
|
||||
|
||||
#nextMessageId(): string {
|
||||
this.#counter += 1;
|
||||
const ts = this.#clock();
|
||||
const rngSuffix =
|
||||
this.#rng !== undefined
|
||||
? this.#rng.nextUint32().toString(36)
|
||||
: this.#counter.toString(36);
|
||||
return `webhook-${ts}-${this.#counter}-${rngSuffix}`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Test-environment setup for @suaveplan/slack-client.
|
||||
*
|
||||
* Wires a real in-memory OpenTelemetry `MeterProvider` BEFORE any source
|
||||
* module loads, so the meter resolved at module load is the real provider's
|
||||
* meter (and not the noop fallback). Tests that inspect emitted instruments
|
||||
* read them out of `testInMemoryMetricExporter` via `flushTestMetrics()`.
|
||||
*
|
||||
* Universal: no `node:fs`, no `process.cwd()`, no DOM globals.
|
||||
*/
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
import {
|
||||
AggregationTemporality,
|
||||
InMemoryMetricExporter,
|
||||
MeterProvider,
|
||||
PeriodicExportingMetricReader,
|
||||
} from "@opentelemetry/sdk-metrics";
|
||||
|
||||
export const testInMemoryMetricExporter = new InMemoryMetricExporter(
|
||||
AggregationTemporality.CUMULATIVE
|
||||
);
|
||||
|
||||
const reader = new PeriodicExportingMetricReader({
|
||||
exporter: testInMemoryMetricExporter,
|
||||
exportIntervalMillis: 60_000,
|
||||
});
|
||||
|
||||
export const testMeterProvider = new MeterProvider({ readers: [reader] });
|
||||
metrics.setGlobalMeterProvider(testMeterProvider);
|
||||
|
||||
export async function flushTestMetrics(): Promise<
|
||||
ReturnType<typeof testInMemoryMetricExporter.getMetrics>
|
||||
> {
|
||||
await testMeterProvider.forceFlush();
|
||||
const collected = testInMemoryMetricExporter.getMetrics();
|
||||
testInMemoryMetricExporter.reset();
|
||||
return collected;
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
37
packages/communication/messaging/slack-client/vite.config.ts
Normal file
37
packages/communication/messaging/slack-client/vite.config.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { defineConfig } from "vite";
|
||||
import dts from "vite-plugin-dts";
|
||||
|
||||
export default defineConfig({
|
||||
root: __dirname,
|
||||
cacheDir: "../../../node_modules/.vite/packages/slack-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: [
|
||||
"@opentelemetry/api",
|
||||
"@suaveplan/error",
|
||||
"@suaveplan/types",
|
||||
"tslib",
|
||||
"zod",
|
||||
/^node:/,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
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",
|
||||
"src/index.ts",
|
||||
"tests/**",
|
||||
],
|
||||
thresholds: {
|
||||
statements: 100,
|
||||
branches: 100,
|
||||
functions: 100,
|
||||
lines: 100,
|
||||
},
|
||||
},
|
||||
testTimeout: 30000,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user