first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 additions and 0 deletions

View File

@@ -0,0 +1,19 @@
# `messaging`
Messaging adapters — Discord, Slack, Telegram, SMS, iMessage, and notifications.
<!-- begin:auto-docs -->
## Packages in `messaging`
7 packages.
| Package | Description |
|---|---|
| [`@suaveplan/discord-client`](./discord-client/README.md) | Generic Discord API client for webhooks, bot messaging, and embeds. Runtime-agnostic via injectable `fetch` and `clock`. Maps every documented Discord failure… |
| [`@suaveplan/imessage-adapter`](./imessage-adapter/README.md) | macOS iMessage SQLite chat.db watcher and `MessageSourceAdapter` implementation. Polls Apple's `~/Library/Messages/chat.db`, decodes Apple-epoch / nanosecond t… |
| [`@suaveplan/notification-channel`](./notification-channel/README.md) | Multi-channel notification router with templates, sliding-window rate limiting, retry + dead-letter, scheduler, preferences, hooks, OpenTelemetry instrumentati… |
| [`@suaveplan/slack-client`](./slack-client/README.md) | Typed, deterministic Slack client — Web API + incoming-webhook surface — with Zod-validated boundaries, OpenTelemetry-instrumented hot paths, injectable `Clock… |
| [`@suaveplan/sms`](./sms/README.md) | Universal SMS core: provider-agnostic send pipeline with strict E.164 validation, Zod boundaries, template rendering, opt-out registry, token-bucket rate limit… |
| [`@suaveplan/sms-providers`](./sms-providers/README.md) | Universal SMS provider implementations for @suaveplan/sms. Real HTTP clients for Twilio (Basic auth + form-encoded + X-Twilio-Signature HMAC-SHA1), Vonage (HMAC-SHA256 webhook), MessageBird (AccessKey + JWT HS256 webhook), Plivo (Basic auth + X-Plivo-Signature-V3 HMAC-SHA256), and AWS SNS (inline SigV4 + RSA-SHA1 SNS notification verifier). Ships ProviderRegistry, httpRequest over injectable FetchLike, composeAbortSignal, SMS_PROVIDERS_CODES + SmsProvidersError, per-provider mapStatus, parseInbound, parseOptOutKeyword, and five OTel instruments. No provider SDK deps; every HTTP call flows through an injectable FetchLike seam. |
| [`@suaveplan/telegram-client`](./telegram-client/README.md) | Telegram Bot API client: send/edit/delete messages, six media variants, inline + custom keyboards, typed errors, injectable `fetch` + `clock`, and OpenTelemetr… |
<!-- end:auto-docs -->

View File

@@ -0,0 +1 @@
Messaging adapters — Discord, Slack, Telegram, SMS, iMessage, and notifications.

View File

@@ -0,0 +1,100 @@
# @suaveplan/discord-client - Generic Discord API client for webhooks, bot messaging, and embeds with injectable fetch, OpenTelemetry instrumentation, and structured error codes
Generic Discord API client for webhooks, bot messaging, and embeds. Runtime-agnostic via injectable `fetch` and `clock`. Maps every documented Discord failure mode to a stable `DISCORD.*` error code and emits OpenTelemetry counters and histograms for every request.
<!-- begin:auto-docs -->
## Documentation tree
- [bot-client](./src/core/bot-client/discord-bot-client.md) — Implements the bot-token half of `@suaveplan/discord-client`. Wraps the Discord Bot REST API (`/channels/:id/messages`,…
- [discord-bot-client](./src/core/bot-client/discord-bot-client.md) — Implements the bot-token half of `@suaveplan/discord-client`. Wraps the Discord Bot REST API (`/channels/:id/messages`,…
- [webhook](./src/core/webhook/discord-webhook.md) — Implements the webhook half of `@suaveplan/discord-client`. Discord webhooks are URLs that anyone with the URL can POST…
- [discord-webhook](./src/core/webhook/discord-webhook.md) — Implements the webhook half of `@suaveplan/discord-client`. Discord webhooks are URLs that anyone with the URL can POST…
- [errors](./src/errors/discord-client-error.md) — Defines the namespaced, frozen error-code registry and the typed `DiscordClientError` class used by every runtime failu…
- [discord-client-error](./src/errors/discord-client-error.md) — Defines the namespaced, frozen error-code registry and the typed `DiscordClientError` class used by every runtime failu…
- [http](./src/utils/http.md) — Single funnel through which every Discord API and webhook request flows. It maps Discord response codes onto the `Disco…
- [rate-limit](./src/utils/rate-limit.md) — Parses Discord's three rate-limit response headers into a single millisecond back-off duration that callers can use to…
<!-- end:auto-docs -->
## Installation
```bash
bun add @suaveplan/discord-client
```
## Quick Start
```ts
import { DiscordBotClient, DiscordWebhook } from "@suaveplan/discord-client";
const bot = new DiscordBotClient({ token: process.env.DISCORD_BOT_TOKEN! });
await bot.sendMessage("123", { content: "hello from the bot" });
const hook = new DiscordWebhook({
webhookUrl: "https://discord.com/api/webhooks/123/abc",
});
await hook.sendText("hello from a webhook");
```
## Modules
| Module | Path | Description |
|--------|------|-------------|
| Bot Client | [src/core/bot-client/discord-bot-client.md](src/core/bot-client/discord-bot-client.md) | `DiscordBotClient` — send/edit/delete messages, reactions, channels, guilds |
| Webhook | [src/core/webhook/discord-webhook.md](src/core/webhook/discord-webhook.md) | `DiscordWebhook` — webhook-URL delivery with structured reports |
| HTTP Helper | [src/utils/http.md](src/utils/http.md) | Shared request funnel: error mapping, timeout, OTel metrics |
| Rate-Limit | [src/utils/rate-limit.md](src/utils/rate-limit.md) | `parseRetryAfterMs` parser for Discord's three rate-limit headers |
| Errors | [src/errors/discord-client-error.md](src/errors/discord-client-error.md) | `DISCORD_CODES` registry and `DiscordClientError` |
| Types | [src/types/discord-client.types.md](src/types/discord-client.types.md) | Public type surface + Zod boundary schemas |
## Subpath Exports
```ts
import { DiscordBotClient } from "@suaveplan/discord-client/bot";
import { DiscordWebhook } from "@suaveplan/discord-client/webhook";
import { DISCORD_CODES } from "@suaveplan/discord-client/errors";
import { DiscordMessageSchema } from "@suaveplan/discord-client/types";
```
## Error Codes
| Code | HTTP | Meaning |
|------|------|---------|
| `DISCORD.API_ERROR` | 502 | Generic non-2xx bot response |
| `DISCORD.AUTH_ERROR` | 401 | Token rejected |
| `DISCORD.RATE_LIMITED` | 429 | Honour `error.context.retryAfterMs` |
| `DISCORD.CHANNEL_NOT_FOUND` | 404 | Channel missing or bot lacks access |
| `DISCORD.GUILD_NOT_FOUND` | 404 | Guild missing or bot is not a member |
| `DISCORD.CONFIG_MISSING` | 500 | Required config value missing |
| `DISCORD.INVALID_CONFIG` | 500 | Config failed Zod validation |
| `DISCORD.INVALID_MESSAGE` | 400 | Message payload failed Zod validation |
| `DISCORD.WEBHOOK_FAILED` | 502 | Webhook delivery rejected |
| `DISCORD.TIMEOUT` | 504 | Request aborted by timeout |
| `DISCORD.FORBIDDEN` | 403 | Caller lacks permissions |
## Injectable Fetch & Clock
Both `DiscordBotClient` and `DiscordWebhook` accept `fetch` and `clock` injectables — pass deterministic stubs in tests, omit them in production to use `globalThis.fetch` and `Date.now`.
```ts
import { DiscordBotClient, type FetchLike } from "@suaveplan/discord-client";
const stubFetch: FetchLike = async () => ({
ok: true, status: 200, statusText: "OK",
headers: { get: () => null },
text: async () => "", json: async () => ({ id: "1", channelId: "1", content: "x", timestamp: "" }),
});
new DiscordBotClient({
token: "abc",
fetch: stubFetch,
clock: () => 1_700_000_000_000,
});
```
## OTel Metrics
| Metric | Type | Labels |
|--------|------|--------|
| `discord_client.request.total` | Counter | `kind`, `method`, `outcome`, `status` |
| `discord_client.request.duration_ms` | Histogram | `kind`, `method`, `outcome` |

View File

@@ -0,0 +1,92 @@
# Discord Bot Client
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Implements the bot-token half of `@suaveplan/discord-client`. Wraps the
Discord Bot REST API (`/channels/:id/messages`, `/guilds/:id`, reactions) with
a runtime-agnostic surface: every constructor option is Zod-validated, every
network call routes through the shared `executeDiscordRequest` funnel (which
emits OTel metrics and maps responses to `DiscordClientError`), and both the
HTTP client and the wall clock are injectable so tests stay deterministic
without ever touching the real Discord API.
## Features
- `sendMessage`, `editMessage`, `deleteMessage`, `getChannel`, `getGuild`,
`createReaction`, `getStoredMessage`.
- In-memory message cache keyed by Discord message ID. `deleteMessage` evicts.
- Boundary validation of `DiscordMessage` via `DiscordMessageSchema`.
- Boundary validation of `DiscordBotConfig` via `DiscordBotConfigSchema`.
- Per-request `AbortController` timeout (default 10s) enforced through
`executeDiscordRequest`.
- `Bot <token>` authorization header set on every request.
- `/guilds/:id` requests automatically tag `isGuildRequest: true` so 404s
raise `DISCORD.GUILD_NOT_FOUND` instead of `DISCORD.CHANNEL_NOT_FOUND`.
- camelCase → snake_case mapping (`avatarUrl` → `avatar_url`) for Discord
payload compatibility.
## Usage: Basic
```ts
import { DiscordBotClient } from "@suaveplan/discord-client";
const bot = new DiscordBotClient({ token: process.env.DISCORD_BOT_TOKEN! });
await bot.sendMessage("123", { content: "hello" });
```
## Usage: Advanced
```ts
import { DiscordBotClient, type FetchLike } from "@suaveplan/discord-client";
const fakeFetch: FetchLike = async (_url, _init) => ({
ok: true,
status: 200,
statusText: "OK",
headers: { get: () => null },
text: async () => "",
json: async () => ({
id: "42",
channelId: "1",
content: "hi",
timestamp: "2026-01-01T00:00:00.000Z",
}),
});
const bot = new DiscordBotClient({
token: "abc",
fetch: fakeFetch,
clock: () => 1_700_000_000_000,
});
const sent = await bot.sendMessage("1", { content: "hi" });
expect(bot.getStoredMessage(sent.id)).toEqual(sent);
```
## API Reference
- `new DiscordBotClient(config: DiscordBotConfig)` — throws
`DiscordClientError(DISCORD.CONFIG_MISSING)` if `token` is missing,
`DiscordClientError(DISCORD.INVALID_CONFIG)` for other Zod failures.
- `sendMessage(channelId, message)` → `DiscordResponse`. Validates payload.
- `editMessage(channelId, messageId, partial)` → `DiscordResponse`.
- `deleteMessage(channelId, messageId)` → `void`.
- `getChannel(channelId)` → `DiscordChannel`.
- `getGuild(guildId)` → `{ id; name }`.
- `createReaction(channelId, messageId, emoji)` → `void`.
- `getStoredMessage(messageId)` → `DiscordResponse | undefined`.
## Implementation Notes
The in-memory `messages` cache is intentionally simple — Discord assigns
monotonic snowflake IDs so no eviction policy is required for typical usage.
Callers needing TTLs should layer their own cache around `getStoredMessage`.
`createReaction` URL-encodes the emoji segment because Discord rejects raw
non-ASCII path segments. `request<T>` deliberately funnels through
`executeDiscordRequest` so the bot client contributes zero bespoke error or
metric code — the funnel owns the entire error mapping and observability
surface.

View File

@@ -0,0 +1,94 @@
# Discord Webhook
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Implements the webhook half of `@suaveplan/discord-client`. Discord webhooks
are URLs that anyone with the URL can POST to — no bot token required — and
they accept the same message shape as the bot API. This module wraps that
endpoint with Zod boundary validation, injectable fetch/clock, OpenTelemetry
instrumentation (inherited from the shared HTTP funnel), and a structured
`DiscordDeliveryReport` return value so callers get persistable delivery
telemetry rather than a raw Discord response.
## Features
- `send(message)`, `sendText(content)`, `sendEmbed(embed)` convenience methods.
- Defaults `username` and `avatarUrl` from the constructor when the message
payload omits them.
- Threaded webhook routing via `?thread_id=<id>` query string.
- Always sets `?wait=true` so Discord returns the persisted message ID,
required for the `DiscordDeliveryReport.messageId` field.
- Boundary validation:
- `DiscordWebhookConfigSchema` at construction; missing/non-string URL →
`DISCORD.CONFIG_MISSING`, other failures (bad URL shape, wrong host) →
`DISCORD.INVALID_CONFIG`.
- `DiscordMessageSchema` on every `send` call → `DISCORD.INVALID_MESSAGE`.
- Uses the shared `executeDiscordRequest` funnel so error mapping
(401/403/404/429/5xx) is identical to the bot client; on the webhook path,
generic non-ok responses raise `DISCORD.WEBHOOK_FAILED`.
## Usage: Basic
```ts
import { DiscordWebhook } from "@suaveplan/discord-client";
const hook = new DiscordWebhook({
webhookUrl: "https://discord.com/api/webhooks/123/abc",
});
await hook.sendText("deploy succeeded");
```
## Usage: Advanced
```ts
import { DiscordWebhook, type FetchLike } from "@suaveplan/discord-client";
const stub: FetchLike = async () => ({
ok: true,
status: 200,
statusText: "OK",
headers: { get: () => null },
text: async () => "",
json: async () => ({ id: "42", channel_id: "1" }),
});
const hook = new DiscordWebhook({
webhookUrl: "https://discord.com/api/webhooks/1/tok",
username: "deploybot",
fetch: stub,
clock: () => 1_700_000_000_000,
});
const report = await hook.sendEmbed({
title: "Deploy",
description: "succeeded",
color: 0x00ff00,
});
expect(report.status).toBe("delivered");
expect(report.timestamp).toEqual(new Date(1_700_000_000_000));
```
## API Reference
- `new DiscordWebhook(config: DiscordWebhookConfig)` — Zod-validated;
missing or non-string `webhookUrl` raises `DISCORD.CONFIG_MISSING`; any
other validation failure (non-Discord host, bad URL) raises
`DISCORD.INVALID_CONFIG`.
- `send(message: DiscordMessage): Promise<DiscordDeliveryReport>`.
- `sendText(content: string): Promise<DiscordDeliveryReport>`.
- `sendEmbed(embed: DiscordEmbed): Promise<DiscordDeliveryReport>`.
## Implementation Notes
`buildReport` derives the `timestamp` from the injected `Clock` rather than
calling `new Date()` so tests can pin the value deterministically. The
`errorCode` and `errorMessage` fields on the report are only populated when
the caller explicitly constructs a failure report — in the happy path the
report carries `status: "delivered"` and no error fields. The webhook URL is
appended with `?wait=true` unconditionally because Discord returns 204 (no
body) when `wait` is absent, which would leave the delivery report without an
ID.

View File

@@ -0,0 +1,84 @@
# Discord Client Error
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines the namespaced, frozen error-code registry and the typed
`DiscordClientError` class used by every runtime failure path in
`@suaveplan/discord-client`. Co-locating the codes with the error class
guarantees that adding a new code is a single-file change and that the
TypeScript union stays in sync with the runtime registry the conformance
audit greps for.
## Features
- `DISCORD_CODES` — a `defineCodes("DISCORD", [...])` frozen record. Each entry
has the form `DISCORD.<REASON>` so it is globally unique across the
shared code space.
- `DiscordClientCode` — type alias for the union of every value in
`DISCORD_CODES`.
- `DiscordClientError` — extends `SuaveplanError<DiscordClientCode>` so
callers can do `instanceof DiscordClientError` to narrow on the code union
AND/OR pattern-match on `.code` for stable programmatic handling.
## Usage: Basic
```ts
import {
DISCORD_CODES,
DiscordClientError,
} from "@suaveplan/discord-client/errors";
throw new DiscordClientError(
DISCORD_CODES.AUTH_ERROR,
"discord.errors.authError"
);
```
## Usage: Advanced
```ts
import { DiscordClientError, DISCORD_CODES } from "@suaveplan/discord-client";
try {
await client.sendMessage("1", { content: "hi" });
} catch (err) {
if (err instanceof DiscordClientError) {
switch (err.code) {
case DISCORD_CODES.RATE_LIMITED:
// back off using `err.context?.retryAfterMs`
break;
case DISCORD_CODES.FORBIDDEN:
case DISCORD_CODES.AUTH_ERROR:
// surface to ops, do not retry
break;
default:
// generic retry / report
break;
}
}
}
```
## API Reference
- `DISCORD_CODES`: frozen record. Keys: `API_ERROR`, `AUTH_ERROR`,
`RATE_LIMITED`, `CHANNEL_NOT_FOUND`, `GUILD_NOT_FOUND`, `CONFIG_MISSING`,
`INVALID_CONFIG`, `INVALID_MESSAGE`, `WEBHOOK_FAILED`, `TIMEOUT`,
`FORBIDDEN`. Values are the `DISCORD.<REASON>` strings.
- `DiscordClientCode`: `typeof DISCORD_CODES[keyof typeof DISCORD_CODES]`.
- `DiscordClientError(code, messageKey, options?)`: typed error subclass.
`options` is the `SuaveplanErrorOptions` bag — pass `cause`, `context`,
`traceId`, or `clock` through.
## Implementation Notes
Every code in this registry is thrown by at least one production code path
inside `src/utils/http.ts`, `src/core/bot-client/discord-bot-client.ts`, or
`src/core/webhook/discord-webhook.ts`. The conformance audit
(`bun scripts/conformance.ts @suaveplan/discord-client`) greps for
exactly that mapping; do not add a code here unless you wire a real throw
site for it in the same change.

View File

@@ -0,0 +1,83 @@
# Discord Client Types
## Purpose
Authoritative public type surface for `@suaveplan/discord-client`. Defines every
data shape the bot client and webhook client emit or accept, plus the runtime
Zod schemas used to validate configuration and message payloads at the public
boundary. Centralising the types here guarantees consumers can import them
without pulling in any runtime code, and ensures Zod schemas stay in lockstep
with the TypeScript surface they validate.
## Features
- `DiscordBotConfig` / `DiscordWebhookConfig` with injectable `clock` and
`fetch` for deterministic testing.
- `DiscordMessage` / `DiscordEmbed` / `DiscordEmbedField` mirroring the Discord
v10 API payload contracts (with field-length caps enforced by Zod).
- `DiscordResponse`, `DiscordChannel`, `DiscordDeliveryReport`,
`DiscordDeliveryStatus` covering every value returned to callers.
- `FetchLike` and `DiscordFetchResponse` describe the minimal subset of the
fetch contract the client depends on — tests inject a deterministic stub.
- Zod boundary schemas (`DiscordBotConfigSchema`, `DiscordWebhookConfigSchema`,
`DiscordMessageSchema`) validate config and message payloads.
## Usage: Basic
```ts
import { DiscordMessageSchema, type DiscordMessage } from "@suaveplan/discord-client/types";
const msg: DiscordMessage = { content: "hello" };
const parsed = DiscordMessageSchema.parse(msg);
```
## Usage: Advanced
```ts
import {
DiscordBotConfigSchema,
type DiscordBotConfig,
type FetchLike,
} from "@suaveplan/discord-client/types";
const stubFetch: FetchLike = async () => ({
ok: true,
status: 200,
statusText: "OK",
headers: { get: () => null },
text: async () => "",
json: async () => ({}),
});
const config: DiscordBotConfig = {
token: "abc",
fetch: stubFetch,
clock: () => 1_700_000_000_000,
};
DiscordBotConfigSchema.parse(config);
```
## API Reference
- `DiscordBotConfig`: token, baseUrl, timeoutMs, plus injectable `clock`/`fetch`.
- `DiscordWebhookConfig`: webhookUrl (must contain `discord.com/api/webhooks/`),
username, avatarUrl, timeoutMs, plus injectables.
- `DiscordMessage`: content (≤2000), embeds (≤10), username, avatarUrl, tts,
threadId.
- `DiscordEmbed`: full Discord embed object with caps on each field.
- `DiscordResponse`: id, channelId, content, timestamp, embeds.
- `DiscordChannel`: id, name, type, guildId, topic.
- `DiscordDeliveryReport`: messageId, channelId, status, timestamp, errorCode,
errorMessage.
- `DiscordMessageSchema` / `DiscordBotConfigSchema` /
`DiscordWebhookConfigSchema`: Zod parsers used at every public entrypoint.
## Implementation Notes
`FetchLike` purposefully omits headers initialisation, body streaming, and
other parts of the WHATWG Response API the client never reads. This keeps the
test stub small and avoids tying the package to a specific runtime's fetch
quirks. `DiscordBotConfigSchema` accepts the injectables as `z.unknown()` —
their structural shape is enforced by the static `FetchLike` / `Clock` types,
not at runtime, because schemas cannot reliably introspect function signatures
across Bun and Node without false negatives.

View File

@@ -0,0 +1,90 @@
# Discord HTTP Helper
## Purpose
Single funnel through which every Discord API and webhook request flows. It
maps Discord response codes onto the `DiscordClientError` taxonomy, attaches
OpenTelemetry counters and histograms, enforces an `AbortController`-driven
timeout, and treats abort errors as `DISCORD.TIMEOUT`. Centralising this logic
guarantees identical retry hints, error codes, and observability across the
bot client and webhook client — the two entrypoints differ only in their URL
construction and choice of `kind`.
## Features
- Maps `401 / 403 / 404 / 429 / 5xx / other-non-ok` Discord responses onto
stable `DISCORD.*` codes, with per-kind dispatch for the webhook path.
- Differentiates guild vs channel 404s via `isGuildRequest`.
- Pulls `Retry-After` data via `parseRetryAfterMs` and surfaces it on the
thrown error's `context.retryAfterMs` so callers can honour Discord's
back-off recommendation without re-parsing headers.
- Lazily creates `discord_client.request.total` counter and
`discord_client.request.duration_ms` histogram against the global OTel
meter provider. `resetDiscordHttpMetrics()` invalidates the cache so tests
can swap in an in-memory `MeterProvider`.
- Treats `AbortError` (raised by the timeout) as `DISCORD.TIMEOUT`; every
other fetch rejection becomes `DISCORD.API_ERROR` or `DISCORD.WEBHOOK_FAILED`
per the request kind.
- Returns `undefined` for HTTP 204 (Discord's success-with-no-body status,
used by `DELETE` and `PUT` reactions).
## Usage: Basic
```ts
import { executeDiscordRequest } from "@suaveplan/discord-client";
const channel = await executeDiscordRequest<DiscordChannel>({
fetch,
clock: Date.now,
method: "GET",
url: "https://discord.com/api/v10/channels/1",
headers: { Authorization: "Bot abc" },
timeoutMs: 10_000,
kind: "bot",
});
```
## Usage: Advanced
```ts
import {
executeDiscordRequest,
resetDiscordHttpMetrics,
} from "@suaveplan/discord-client";
import { MeterProvider } from "@opentelemetry/sdk-metrics";
const mp = new MeterProvider();
metrics.setGlobalMeterProvider(mp);
resetDiscordHttpMetrics();
await executeDiscordRequest({
fetch: stub,
clock: () => 1700000000000,
method: "POST",
url: "https://discord.com/api/v10/channels/1/messages",
headers: { Authorization: "Bot abc", "Content-Type": "application/json" },
body: { content: "hi" },
timeoutMs: 10_000,
kind: "bot",
});
```
## API Reference
- `executeDiscordRequest<T>(opts: DiscordRequestOptions): Promise<T>` — main
entrypoint. Throws `DiscordClientError`.
- `resetDiscordHttpMetrics()` — clears the cached meter / counter / histogram
so the next call rebinds to the current global meter provider. Used by tests.
- `DiscordRequestKind = "bot" | "webhook"` — drives the error code on
`WEBHOOK_FAILED` vs `API_ERROR`.
## Implementation Notes
The function intentionally records a metric on every outcome (`ok`,
`auth_error`, `forbidden`, `not_found`, `rate_limited`, `timeout`,
`network_error`, `api_error`). Histograms are emitted with the same label
set as the counter so dashboards can divide one by the other directly.
The 401/403/429/404/non-ok ordering matches Discord's documented
precedence; non-ok responses fall through to the final clause, which
distinguishes `DISCORD.API_ERROR` (bot) from `DISCORD.WEBHOOK_FAILED`
(webhook) so the error registry's retry/severity hints stay accurate.

View File

@@ -0,0 +1,67 @@
# Rate-Limit Header Parser
## Purpose
Parses Discord's three rate-limit response headers into a single millisecond
back-off duration that callers can use to schedule a retry. Discord is
inconsistent about which header it emits in which response (gateway-level
429s use `Retry-After`, bucket-level 429s use `X-RateLimit-Reset-After`, and
shard-level overflows can fall back to `X-RateLimit-Reset` as a wall-clock
target), so a single canonical parser ensures every code path reacts the same
way and that tests can pin every flavour with deterministic fixtures.
## Features
- Reads `Retry-After`, `X-RateLimit-Reset-After`, and `X-RateLimit-Reset`.
- Honours both seconds-as-float and HTTP-date encodings of `Retry-After`.
- Treats `X-RateLimit-Reset` values < 10^12 as Unix epoch seconds and ≥ 10^12
as Unix epoch milliseconds (Discord switched encodings mid-2023).
- Accepts an injectable `Clock` so tests stay deterministic.
- Returns `undefined` when no header is present or every header is unparsable.
## Usage: Basic
```ts
import { parseRetryAfterMs } from "@suaveplan/discord-client";
const retryAfterMs = parseRetryAfterMs(response);
```
## Usage: Advanced
```ts
import { parseRetryAfterMs } from "@suaveplan/discord-client";
const fixedClock = () => 1_700_000_000_000;
const fakeResponse = {
ok: false,
status: 429,
statusText: "Too Many Requests",
headers: { get: (name: string) => (name === "x-ratelimit-reset" ? "1700000060" : null) },
text: async () => "",
json: async () => ({}),
};
// Returns 60_000 — the difference between epoch-second 1700000060 and the
// injected clock at 1_700_000_000_000 ms.
parseRetryAfterMs(fakeResponse, fixedClock);
```
## API Reference
`parseRetryAfterMs(response: DiscordFetchResponse, clock?: Clock): number | undefined`
- `response`: any object exposing `.headers.get(name)`.
- `clock`: optional `Clock` injectable; defaults to `Date.now`.
- Returns the parsed millisecond back-off, or `undefined` when no header was
parsable.
## Implementation Notes
`Math.ceil` is applied to the floating-point seconds to avoid under-waiting
when Discord emits fractional values like `0.250`. Negative deltas from
`X-RateLimit-Reset` clamp to zero rather than returning a stale duration —
the caller should retry immediately rather than reading a negative value. The
parser does NOT throw on malformed header values; it returns `undefined`,
which is the canonical "no advice from Discord, fall back to your local
retry policy" signal.

View File

@@ -0,0 +1,120 @@
# @suaveplan/imessage-adapter - macOS iMessage SQLite chat.db watcher. Polls Apple's Messages database, decodes Apple-epoch / nanosecond timestamps, resolves handle + chat + attachment joins, and emits ParsedIMessage + InboundMessage envelopes. Structured IMESSAGE.* error registry, Zod-validated config boundary, AbortSignal cancellation, injectable SqliteReader seam (production: bun:sqlite, tests: real bun:sqlite chat.db fixture — no mocks), EventBus bridge atop @suaveplan/events, four OTel instruments.
macOS iMessage SQLite chat.db watcher and `MessageSourceAdapter`
implementation. Polls Apple's `~/Library/Messages/chat.db`, decodes
Apple-epoch / nanosecond timestamps, resolves handle + chat +
attachment joins, and emits `ParsedIMessage` and `InboundMessage`
envelopes. Server-tier (Bun); the `bun:sqlite` driver is loaded
lazily inside the production reader so the package type-checks +
lints on every host.
<!-- begin:auto-docs -->
## Documentation tree
- [adapter](./src/adapter/imessage-adapter.md) — `MessageSourceAdapter`-shaped wrapper around `IMessageWatcher` that projects each `ParsedIMessage` into the channel-agn…
- [imessage-adapter](./src/adapter/imessage-adapter.md) — `MessageSourceAdapter`-shaped wrapper around `IMessageWatcher` that projects each `ParsedIMessage` into the channel-agn…
- [error-codes](./src/error-codes/error-codes.md) — Defines the stable, fully-qualified error-code registry for `@suaveplan/imessage-adapter`. Every error class in this mo…
- [error-codes](./src/error-codes/error-codes.md) — Defines the stable, fully-qualified error-code registry for `@suaveplan/imessage-adapter`. Every error class in this mo…
- [fixtures](./src/fixtures/chat-db-fixture.md) — Builds a real `chat.db`-shaped SQLite database on a temp path using the structural `SqliteReader` seam. This is the fou…
- [chat-db-fixture](./src/fixtures/chat-db-fixture.md) — Builds a real `chat.db`-shaped SQLite database on a temp path using the structural `SqliteReader` seam. This is the fou…
- [otel](./src/otel/metrics.md) — Defines the four OpenTelemetry instruments emitted by `@suaveplan/imessage-adapter` and the helpers that record onto th…
- [metrics](./src/otel/metrics.md) — Defines the four OpenTelemetry instruments emitted by `@suaveplan/imessage-adapter` and the helpers that record onto th…
- [parser](./src/parser/imessage-parser.md) — Pure-function parser that turns Apple's chat.db row shapes into the `ParsedIMessage` projection consumed by every downs…
- [imessage-parser](./src/parser/imessage-parser.md) — Pure-function parser that turns Apple's chat.db row shapes into the `ParsedIMessage` projection consumed by every downs…
- [schemas](./src/schemas/schemas.md) — Provides Zod boundary schemas for `IMessageWatcherConfig` and `IMessageAdapterConfig`, plus the `parseWatcherConfig` /…
- [schemas](./src/schemas/schemas.md) — Provides Zod boundary schemas for `IMessageWatcherConfig` and `IMessageAdapterConfig`, plus the `parseWatcherConfig` /…
- [sqlite](./src/sqlite/bun-sqlite-reader.md) — Production-grade `SqliteReader` backed by Bun's built-in `bun:sqlite` driver. The reader is the single seam through whi…
- [bun-sqlite-reader](./src/sqlite/bun-sqlite-reader.md) — Production-grade `SqliteReader` backed by Bun's built-in `bun:sqlite` driver. The reader is the single seam through whi…
- [watcher](./src/watcher/imessage-watcher.md) — Polls Apple's `~/Library/Messages/chat.db` SQLite database on a configurable interval, projects new rows into `ParsedIM…
- [imessage-watcher](./src/watcher/imessage-watcher.md) — Polls Apple's `~/Library/Messages/chat.db` SQLite database on a configurable interval, projects new rows into `ParsedIM…
<!-- end:auto-docs -->
## Highlights
- **`IMessageWatcher`** — Zod-validated config boundary, AbortSignal
propagation, injectable `SqliteReader`, typed `EventBus` surface
alongside the upstream `on/off` listener API, monotonic
`lastRowId` cursor (settable + readable for resume), filters for
`includeFromMe` and `chatFilter`.
- **`IMessageAdapter`** — wraps the watcher and projects each
`ParsedIMessage` into a channel-agnostic `InboundMessage` with
normalised phone numbers and chat metadata.
- **`IMessageParser`** — pure-function parser for chat.db row shapes;
Apple-epoch decoding, attachment fallbacks, phone normalisation.
- **`createChatDbFixture`** — real chat.db-shaped SQLite database
builder used by tests so every test path runs against the same SQL
the production watcher executes — no mocks.
- **`bunSqliteReader` / `createBunSqliteReader`** — production
`SqliteReader` backed by Bun's `bun:sqlite`. Errors are wrapped in
`IMessageDbOpenFailedError`.
## Install
```bash
bun add @suaveplan/imessage-adapter
```
## Usage
```ts
import { IMessageAdapter } from "@suaveplan/imessage-adapter";
const adapter = new IMessageAdapter();
await adapter.initialize({
dbPath: "~/Library/Messages/chat.db",
pollIntervalMs: 5000,
includeFromMe: false,
});
for (const inbound of await adapter.poll()) {
console.log(inbound.sourceAddress, inbound.content);
adapter.markProcessed(inbound.id);
}
await adapter.close();
```
## Error registry (`IMESSAGE.*`)
| Code | Class | When |
|---|---|---|
| `IMESSAGE.DB_OPEN_FAILED` | `IMessageDbOpenFailedError` | `bun:sqlite` cannot open the chat.db path |
| `IMESSAGE.DB_QUERY_FAILED` | `IMessageDbQueryFailedError` | A poll-time SQL query throws |
| `IMESSAGE.WATCHER_NOT_RUNNING` | `IMessageWatcherNotRunningError` | `stop()` called while not running |
| `IMESSAGE.WATCHER_ALREADY_RUNNING` | `IMessageWatcherAlreadyRunningError` | `start()` called twice |
| `IMESSAGE.ADAPTER_NOT_INITIALIZED` | `IMessageAdapterNotInitializedError` | `poll()` before `initialize()` |
| `IMESSAGE.INVALID_CONFIG` | `IMessageInvalidConfigError` | Zod boundary rejected config; `context.issues` carries the path + reason |
| `IMESSAGE.POLL_ABORTED` | `IMessagePollAbortedError` | `AbortSignal` fired |
| `IMESSAGE.POLL_FAILED` | `IMessagePollFailedError` | Reserved for explicit `poll()` wrapping |
| `IMESSAGE.ATTACHMENT_PARSE_FAILED` | `IMessageAttachmentParseError` | Attachment row could not be parsed |
| `IMESSAGE.APPLE_DATE_PARSE_FAILED` | `IMessageAppleDateParseError` | Timestamp is NaN / non-finite |
## OpenTelemetry instruments
| Instrument | Kind | Attributes |
|---|---|---|
| `imessage.messages_emitted_total` | Counter | `chat_type`, `from_me` |
| `imessage.attachments_emitted_total` | Counter | `mime_family` |
| `imessage.poll_errors_total` | Counter | `code` |
| `imessage.poll_duration_ms` | Histogram | `outcome` (`ok`, `error`, `aborted`) |
Call `refreshIMessageMeters()` after `metrics.setGlobalMeterProvider`
to bind subsequent records to the chosen provider.
## Subpath exports
- `@suaveplan/imessage-adapter/error-codes`
- `@suaveplan/imessage-adapter/schemas`
- `@suaveplan/imessage-adapter/parser`
- `@suaveplan/imessage-adapter/watcher`
- `@suaveplan/imessage-adapter/adapter`
- `@suaveplan/imessage-adapter/sqlite/bun-sqlite-reader`
- `@suaveplan/imessage-adapter/otel`
- `@suaveplan/imessage-adapter/fixtures`
## Tier
Server-tier (Bun on macOS). The `bun:sqlite` import is reached via
`createRequire(import.meta.url)` so the static module graph never
attempts to resolve the specifier on a non-Bun runner.

View File

@@ -0,0 +1,90 @@
# `IMessageAdapter`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`MessageSourceAdapter`-shaped wrapper around `IMessageWatcher` that
projects each `ParsedIMessage` into the channel-agnostic
`InboundMessage` envelope consumed by downstream inbound-message
routers (e.g. a future `@suaveplan/api-plugin-messaging`). The
adapter is the polymorphic surface — callers depend on
`MessageSourceAdapter`, swap in iMessage / SMS / Slack adapters
without coupling to chat.db schema details.
## Features
- `type === "imessage"` constant so multi-adapter routers can fan
out by source.
- `initialize(config)` Zod-validates the input via
`parseAdapterConfig` and constructs the underlying watcher with
the validated config (including any injected `sqliteReader`).
- `poll()` calls the watcher's `poll()` and projects each parsed
message into an `InboundMessage`. Attachments populate the
`attachments` array; the `contentType` is `"attachment"` when
≥1 attachment is present and `"text"` otherwise.
- `sourceAddress` is the parser-normalised phone number (or the
unchanged email handle), so downstream consumers see a
canonicalised identity.
- `metadata` block carries chat-level context (`chatIdentifier`,
`chatDisplayName`, `isGroupChat`, `senderService`, `isRead`) that
routers need for routing decisions.
- `getLastProcessedId() / markProcessed(id)` expose the consumer-
side cursor; `close()` stops the running watcher and clears the
reference.
- `poll()` called before `initialize()` throws
`IMessageAdapterNotInitializedError`
(`IMESSAGE.ADAPTER_NOT_INITIALIZED`).
## Usage: Basic
```ts
import { IMessageAdapter } from "@suaveplan/imessage-adapter";
const adapter = new IMessageAdapter();
await adapter.initialize({ pollIntervalMs: 5000 });
const inbound = await adapter.poll();
await adapter.close();
```
## Usage: Advanced
```ts
import {
IMessageAdapter,
createBunSqliteReader,
} from "@suaveplan/imessage-adapter";
const adapter = new IMessageAdapter();
await adapter.initialize({
dbPath: "/tmp/chat.db",
sqliteReader: createBunSqliteReader(),
includeFromMe: true,
});
for (const msg of await adapter.poll()) {
if (msg.metadata?.["isGroupChat"]) {
adapter.markProcessed(msg.id);
}
}
```
## API Reference
- `type: "imessage"` — read-only adapter discriminator.
- `initialize(config: Record<string, unknown>): Promise<void>`.
- `poll(): Promise<readonly InboundMessage[]>`.
- `getLastProcessedId(): string | null`.
- `markProcessed(id: string): void`.
- `close(): Promise<void>` — idempotent.
## Implementation Notes
The adapter owns the watcher's lifecycle: `close()` stops the loop if
`isRunning()`, then drops the reference. The projection is pure — no
side effects, no extra DB calls — so projecting N messages costs O(N)
and never re-opens the database. The `attachments` array is always
present (possibly empty) so consumers can iterate without a
null-check.

View File

@@ -0,0 +1,87 @@
# `IMESSAGE_*` error registry
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines the stable, fully-qualified error-code registry for
`@suaveplan/imessage-adapter`. Every error class in this module
inherits from `@suaveplan/error`'s `SuaveplanError` base and carries
one of the ten `IMESSAGE.*` codes returned by `defineCodes("IMESSAGE",
[...])`. Consumers downstream — loggers, problem+json renderers, retry
wrappers, OTel attribute writers — map on the code, never on the
message or class identity, so the codes are part of the public API.
## Features
- Ten production codes, each thrown from at least one real call site:
`DB_OPEN_FAILED`, `DB_QUERY_FAILED`, `WATCHER_NOT_RUNNING`,
`WATCHER_ALREADY_RUNNING`, `ADAPTER_NOT_INITIALIZED`,
`INVALID_CONFIG`, `POLL_ABORTED`, `POLL_FAILED`,
`ATTACHMENT_PARSE_FAILED`, `APPLE_DATE_PARSE_FAILED`.
- One typed error class per code, all extending the abstract
`IMessageError` so consumers can pattern-match via `instanceof
IMessageError` for a single catch site.
- `IMessageInvalidConfigError` accepts a `issues` payload that is
merged into `context` so callers can recover the Zod issue array
from problem+json bodies.
- Frozen `IMESSAGE_CODES` record keeps the literal-typed union
reachable through the public surface.
## Usage: Basic
```ts
import {
IMessageAdapterNotInitializedError,
} from "@suaveplan/imessage-adapter";
throw new IMessageAdapterNotInitializedError();
```
## Usage: Advanced
```ts
import {
IMessageError,
IMessageInvalidConfigError,
IMESSAGE_CODES,
} from "@suaveplan/imessage-adapter";
try {
parseWatcherConfig(input);
} catch (err) {
if (err instanceof IMessageInvalidConfigError) {
console.error(err.code, err.context["issues"]);
} else if (err instanceof IMessageError) {
switch (err.code) {
case IMESSAGE_CODES.WATCHER_ALREADY_RUNNING:
case IMESSAGE_CODES.WATCHER_NOT_RUNNING:
// recoverable
break;
default:
throw err;
}
} else {
throw err;
}
}
```
## API Reference
- `const IMESSAGE_CODES`: frozen record `{ DB_OPEN_FAILED:
"IMESSAGE.DB_OPEN_FAILED", ... }`.
- `type IMessageErrorCode`: union of every fully-qualified code.
- `abstract class IMessageError extends SuaveplanError<IMessageErrorCode>`.
- One concrete class per code, each accepting `{ cause?, context?,
traceId?, message? }`.
## Implementation Notes
The module side-effect-imports nothing; the registry is purely
declarative. The package barrel (`src/index.ts`) side-effect-imports
`./error-codes/index.js` so subpath consumers also observe the
registration. Messages default to i18n keys (`imessage.errors.*`) so
callers can supply their own resolver if they wish.

View File

@@ -0,0 +1,115 @@
# `createChatDbFixture`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Builds a real `chat.db`-shaped SQLite database on a temp path using
the structural `SqliteReader` seam. This is the foundation of the
test strategy: every watcher / adapter test runs against a real
`bun:sqlite` database whose schema mirrors Apple's Messages app,
populated via the helpers returned by this factory. There are no
mocks anywhere in the call chain — the SQL the watcher executes in
production runs unchanged against the fixture.
## Features
- DDL covers the six tables the watcher reads:
`message`, `handle`, `chat`, `chat_message_join`, `attachment`,
`message_attachment_join` — with the exact column names + types
Apple's chat.db uses.
- Helpers return the inserted ROWID via a `SELECT ROWID ORDER BY
ROWID DESC LIMIT 1` so tests can wire foreign-key joins
deterministically without depending on `last_insert_rowid()`
semantics across reader implementations.
- `insertMessage(row, chatId)` writes both the message and its
`chat_message_join` row so the watcher's `JOIN cmj` query finds
the chat without extra wiring.
- `insertAttachment(row, messageId)` writes both the attachment and
its `message_attachment_join` row.
- `close()` closes the handle so the temp file can be deleted by the
caller's `afterEach`.
## Usage: Basic
```ts
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
createBunSqliteReader,
createChatDbFixture,
} from "@suaveplan/imessage-adapter";
const reader = createBunSqliteReader();
const fixture = createChatDbFixture(reader, join(tmpdir(), "chat.db"));
const chatId = fixture.insertChat({
guid: "iMessage;+;+15551234567",
chat_identifier: "+15551234567",
display_name: null,
group_id: null,
service_name: "iMessage",
is_archived: 0,
});
fixture.close();
```
## Usage: Advanced
```ts
const handleId = fixture.insertHandle({
id: "+15551234567",
country: "us",
service: "iMessage",
uncanonicalized_id: null,
});
const messageId = fixture.insertMessage({
guid: "m-1",
text: "Hello",
handle_id: handleId,
service: "iMessage",
date: 700_000_000,
date_read: 0,
date_delivered: 0,
is_from_me: 0,
is_read: 1,
is_delivered: 1,
is_sent: 0,
cache_has_attachments: 1,
associated_message_guid: null,
associated_message_type: 0,
balloon_bundle_id: null,
group_title: null,
}, chatId);
fixture.insertAttachment({
guid: "a-1",
filename: "/tmp/photo.jpg",
mime_type: "image/jpeg",
total_bytes: 1024,
transfer_name: "photo.jpg",
}, messageId);
```
## API Reference
- `createChatDbFixture(reader, path): ChatDbFixture` — DDLs the schema
and returns the helpers.
- `ChatDbFixture.handle: SqliteHandle` — exposed for advanced custom
inserts.
- `insertHandle(row): number` — returns the new ROWID.
- `insertChat(row): number` — returns the new ROWID.
- `insertMessage(row, chatId?): number` — wires the
`chat_message_join` row when `chatId` is supplied.
- `insertAttachment(row, messageId): number` — wires the
`message_attachment_join` row.
- `close(): void` — closes the handle.
## Implementation Notes
The factory is server-tier-only because it requires a real SQLite
implementation, but it accepts any `SqliteReader` so a future
in-process SQLite-WASM reader could also drive the fixture. The
schema uses `AUTOINCREMENT` so insertion order maps to monotonically
increasing ROWIDs, which the watcher's `ROWID > lastRowId` cursor
relies on for incremental polling.

View File

@@ -0,0 +1,68 @@
# OpenTelemetry instrumentation
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines the four OpenTelemetry instruments emitted by
`@suaveplan/imessage-adapter` and the helpers that record onto them.
Instruments are resolved against the package-named `Meter`
(`@suaveplan/imessage-adapter` / `0.1.0`) so consumers can filter to
just this package in their dashboards. `refreshIMessageMeters()`
re-binds every instrument against the current global `MeterProvider`,
which tests use to redirect emissions to an in-memory exporter.
## Features
- Counter `imessage.messages_emitted_total` with `{chat_type,
from_me}` attributes — incremented once per parsed message yielded
by `IMessageWatcher.poll()`.
- Counter `imessage.attachments_emitted_total` with `{mime_family}` —
incremented once per parsed attachment.
- Counter `imessage.poll_errors_total` with `{code}` — incremented on
every poll-loop error, attributed by `IMESSAGE.*` code.
- Histogram `imessage.poll_duration_ms` with `{outcome}` (`ok` /
`error` / `aborted`) — records the wall-clock duration of every
completed (or cancelled) poll tick in milliseconds.
## Usage: Basic
```ts
import {
recordMessageEmitted,
recordPollDuration,
} from "@suaveplan/imessage-adapter/otel";
recordMessageEmitted("direct", false);
recordPollDuration(42, "ok");
```
## Usage: Advanced
```ts
import { metrics } from "@opentelemetry/api";
import { refreshIMessageMeters } from "@suaveplan/imessage-adapter";
metrics.setGlobalMeterProvider(meterProvider);
refreshIMessageMeters(); // re-resolve so subsequent records land on this provider
```
## API Reference
- `recordMessageEmitted(chatType: "direct" | "group", fromMe: boolean): void`
- `recordAttachmentEmitted(mimeFamily: string): void`
- `recordPollError(code: string): void`
- `recordPollDuration(durationMs: number, outcome: "ok" | "error" | "aborted"): void`
- `refreshIMessageMeters(): void` — re-bind every instrument against the
current global `MeterProvider`.
## Implementation Notes
The instruments are stored in mutable module-level bindings so
`refreshIMessageMeters()` can swap them in-place; this is the
canonical pattern used by `slack-client` and the other
messaging clients. The `mime_family` attribute is computed by the
caller (typically the watcher) as the first segment of the MIME type
so cardinality stays bounded.

View File

@@ -0,0 +1,73 @@
# `IMessageParser`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Pure-function parser that turns Apple's chat.db row shapes into the
`ParsedIMessage` projection consumed by every downstream surface in
this package. The parser handles Apple's per-row timestamp mode
(seconds vs nanoseconds since 2001-01-01), resolves nullable join
rows, and normalises phone-number handles into the canonical
`+E.164` form.
## Features
- `parseMessage(row, handle, chat, attachments)` — composes the row +
optional join rows into a `ParsedIMessage`. Null handles project
to `"unknown"`, null chat identifiers to `""`, and the group-chat
flag is set when the chat identifier starts with `"chat"`.
- `parseAppleDate(n)` — auto-detects seconds vs nanoseconds against
the `10^9` threshold, applies the 2001-01-01 Apple epoch offset,
and throws `IMessageAppleDateParseError` on `NaN` / non-finite
input rather than silently returning a garbage `Date`.
- `parseAttachment(row)` — picks `transfer_name` over `filename` over
`"unknown"` and defaults the MIME type to
`"application/octet-stream"` when chat.db omits it.
- `isGroupChat(chatIdentifier)` — exposes the same chat-vs-handle
classifier used internally so consumers can reuse it for routing.
- `normalizePhoneNumber(handle)` — strips non-digit characters and
prefixes 10-digit US numbers with `+1`, 11-digit US numbers with
`+`; email handles pass through unchanged.
## Usage: Basic
```ts
import { IMessageParser } from "@suaveplan/imessage-adapter/parser";
const parser = new IMessageParser();
const sent = parser.parseAppleDate(700_000_000);
```
## Usage: Advanced
```ts
import { IMessageParser } from "@suaveplan/imessage-adapter";
const parser = new IMessageParser();
const parsed = parser.parseMessage(messageRow, handleRow, chatRow, attachments);
if (parsed.isGroupChat) {
route(parsed, parser.normalizePhoneNumber(parsed.senderHandle));
}
```
## API Reference
- `parseMessage(row, handle, chat, attachments?): ParsedIMessage`
- `parseAppleDate(n: number): Date` — throws
`IMessageAppleDateParseError` for `NaN` / non-finite.
- `parseAttachment(row): ParsedAttachment`
- `isGroupChat(chatIdentifier: string): boolean`
- `normalizePhoneNumber(handle: string): string`
## Implementation Notes
The parser is intentionally side-effect-free and instance-based (no
static methods) so consumers can subclass it to override individual
steps — for example, to swap in a locale-aware phone normaliser. The
Apple-epoch threshold detection runs per-row because Apple's schema
shipped both forms historically; checking the magnitude is robust
across the migration window. Errors are routed through the
`IMESSAGE.*` registry so consumers can map by code.

View File

@@ -0,0 +1,80 @@
# Config schemas
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides Zod boundary schemas for `IMessageWatcherConfig` and
`IMessageAdapterConfig`, plus the `parseWatcherConfig` /
`parseAdapterConfig` helpers that callers use to validate untrusted
input before constructing a watcher or adapter. Both schemas are
strict — extra keys are rejected — so consumers cannot silently
smuggle additional fields past the boundary.
## Features
- Strict object validation (`.strict()`) — unknown properties are
rejected at the boundary, preventing prototype-pollution-style
smuggling.
- `pollIntervalMs` constrained to positive integers; `sinceDate`
required to be a `Date` instance (no millisecond-since-epoch
numbers); `chatFilter` constrained to string arrays.
- Custom `SqliteReaderSchema` confirms the optional injected
`sqliteReader` exposes a callable `open()` member.
- Failures throw `IMessageInvalidConfigError`
(`IMESSAGE.INVALID_CONFIG`) carrying a serialisable `issues` array
in `context` so problem+json responders can surface the Zod path +
reason without re-running the schema.
## Usage: Basic
```ts
import { parseWatcherConfig } from "@suaveplan/imessage-adapter/schemas";
const config = parseWatcherConfig({ pollIntervalMs: 5000 });
```
## Usage: Advanced
```ts
import {
parseWatcherConfig,
IMessageInvalidConfigError,
} from "@suaveplan/imessage-adapter";
try {
parseWatcherConfig({ pollIntervalMs: -1, oops: true });
} catch (err) {
if (err instanceof IMessageInvalidConfigError) {
return Response.json(
{ issues: err.context["issues"] },
{ status: 400 },
);
}
throw err;
}
```
## API Reference
- `IMessageWatcherConfigSchema: z.ZodObject` — validates watcher config.
- `IMessageAdapterConfigSchema` — alias for the watcher schema.
- `SqliteReaderSchema: z.ZodType<unknown>` — structural check for
`SqliteReader`.
- `parseWatcherConfig(input: unknown): IMessageWatcherConfig` — throws
`IMessageInvalidConfigError` on failure.
- `parseAdapterConfig(input: unknown): IMessageAdapterConfig` — throws
`IMessageInvalidConfigError` on failure.
## Implementation Notes
The schemas use `z.custom` for the `SqliteReader` slot because the
reader is a structural type — there is no Zod-expressible shape that
captures every legal implementation. The check confirms the presence
of a function-typed `open` member, which is enough to surface
mistakes (passing a stray object or `null`) without inspecting the
reader's behaviour. Issue arrays are projected through `toIssues`
which strips Zod's internal symbols so the resulting record is safe
to JSON-serialise.

View File

@@ -0,0 +1,76 @@
# `bunSqliteReader`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Production-grade `SqliteReader` backed by Bun's built-in `bun:sqlite`
driver. The reader is the single seam through which
`IMessageWatcher` reaches Apple's `chat.db` SQLite database; replacing
upstream's `require("bun:sqlite") as any` cast with a typed,
swappable factory lets tests inject a reader pointing at a real temp
chat.db without re-implementing the SQL layer.
## Features
- Lazy module resolution via `createRequire(import.meta.url)` — the
`bun:sqlite` import is not added to the static graph, so vite and
vitest do not attempt to resolve it on non-Bun runners during build
/ type-check / lint passes.
- Errors thrown from the load step or the `new Database(...)` call
are wrapped in `IMessageDbOpenFailedError` carrying the offending
path on `context.path`, so the watcher can surface a stable error
code to consumers.
- Returns a `SqliteHandle` whose `execScript` member maps to
`Database.run` (Bun's bulk-SQL entry point) and whose `prepare`
member returns a `SqliteStatement` wrapping Bun's per-statement
`all` / `get` / `run` methods structurally — production and test
code share the exact same call shape.
- Read-only by default: `open(path, { readonly: true })` opens the
database read-only as required for `~/Library/Messages/chat.db`,
while tests open with `readonly: false` to populate the fixture.
## Usage: Basic
```ts
import { bunSqliteReader } from "@suaveplan/imessage-adapter";
const handle = bunSqliteReader.open("/path/to/chat.db", { readonly: true });
const rows = handle.prepare("SELECT ROWID FROM message LIMIT 1").all();
handle.close();
```
## Usage: Advanced
```ts
import { createBunSqliteReader } from "@suaveplan/imessage-adapter";
// Inject a custom loader (e.g. for vitest under WSL2 where bun:sqlite is
// reached through a different worker bridge).
const reader = createBunSqliteReader(() => require("bun:sqlite"));
const handle = reader.open(path, { readonly: false });
handle.execScript("CREATE TABLE foo (id INTEGER PRIMARY KEY)");
```
## API Reference
- `bunSqliteReader: SqliteReader` — default reader, ready for
production wiring.
- `createBunSqliteReader(loader?: BunSqliteLoader): SqliteReader` —
factory accepting a custom loader; defaults to a
`createRequire`-based one that resolves `bun:sqlite` against the
module's own URL.
- `BunSqliteLoader = () => BunSqliteModule` — the loader contract.
## Implementation Notes
`createRequire(import.meta.url)` is the documented Node + Bun bridge
for resolving CommonJS / built-in modules from ESM without statically
declaring them. We use it because static `import "bun:sqlite"` would
make vite resolve the specifier at build time and fail on non-Bun
hosts (CI, lint). The `BunDatabase` interface is intentionally
minimal — it only declares the methods the watcher actually calls,
so changes to Bun's broader Database surface cannot bleed into the
package.

View File

@@ -0,0 +1,103 @@
# `IMessageWatcher`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Polls Apple's `~/Library/Messages/chat.db` SQLite database on a
configurable interval, projects new rows into `ParsedIMessage`, and
emits them on both a legacy `on/off` listener API and the typed
`EventBus<IMessageWatcherEvents>` exposed as the public `bus` field.
The watcher is the canonical ingress for any surface that
wants to consume inbound macOS Messages traffic — adapter, router,
or direct subscriber.
## Features
- Zod-validated config boundary (`pollIntervalMs > 0`,
`sinceDate: Date`, etc.) via `parseWatcherConfig` — invalid input
throws `IMessageInvalidConfigError` at construction time.
- Injectable `SqliteReader` (defaults to the production
`bunSqliteReader`) so tests use a real `bun:sqlite` chat.db fixture
without mocks.
- `start({ signal })` schedules polls every `pollIntervalMs`; the
signal stops the loop and emits a `stopped` event.
- `poll({ signal })` runs one tick on demand; pre-aborted signals
throw `IMessagePollAbortedError`. Errors from explicit polls bubble
as `IMessageDbQueryFailedError`; errors from the internal scheduler
emit `error` on the bus and continue ticking (mirrors upstream
behaviour so a transient SQLite error doesn't tear down the loop).
- Monotonic `lastRowId` cursor — only emits rows with `ROWID >
lastRowId`; advances even for filtered rows so the cursor doesn't
re-process them next tick. `getLastRowId()` / `setLastRowId(n)`
expose the cursor for consumers that want to persist resume state.
- Filters: `includeFromMe: false` drops outgoing messages; non-empty
`chatFilter` restricts to specific `chat_identifier`s.
- Four OTel instruments — `imessage.messages_emitted_total`,
`imessage.attachments_emitted_total`,
`imessage.poll_errors_total`, `imessage.poll_duration_ms` — wired
into every emit / error / tick.
## Usage: Basic
```ts
import { IMessageWatcher } from "@suaveplan/imessage-adapter";
const watcher = new IMessageWatcher({ pollIntervalMs: 5000 });
const off = watcher.on("message", (msg) => console.log(msg.text));
await watcher.start();
// ...
off();
await watcher.stop();
```
## Usage: Advanced
```ts
import {
IMessageWatcher,
createChatDbFixture,
createBunSqliteReader,
} from "@suaveplan/imessage-adapter";
const reader = createBunSqliteReader();
const fixture = createChatDbFixture(reader, "/tmp/chat.db");
// ... populate fixture
const watcher = new IMessageWatcher({
dbPath: "/tmp/chat.db",
sqliteReader: reader,
includeFromMe: true,
chatFilter: ["+15551234567"],
});
const controller = new AbortController();
const parsed = await watcher.poll({ signal: controller.signal });
```
## API Reference
- `new IMessageWatcher(config?: IMessageWatcherConfig)` — Zod-validates
at construction.
- `bus: EventBus<IMessageWatcherEvents>` — typed event surface (`message`,
`error`, `started`, `stopped`).
- `on(event, listener) => () => void` — legacy alias for `bus.on`.
- `off(event, listener): void` — legacy alias for `bus.off`.
- `start(opts?: { signal?: AbortSignal }): Promise<void>` — throws
`IMessageWatcherAlreadyRunningError` on double-start.
- `stop(): Promise<void>` — throws `IMessageWatcherNotRunningError`
when called while not running.
- `poll(opts?: { signal?: AbortSignal }): Promise<readonly ParsedIMessage[]>`.
- `getLastRowId(): number` / `setLastRowId(rowId: number): void`.
- `isRunning(): boolean`.
## Implementation Notes
The legacy `on/off` API delegates straight to the typed `EventBus`,
which keeps the two surfaces in lockstep — there is no separate
listener registry. The cursor advances inside the row loop *before*
filters apply, so filtered rows are not re-fetched next tick. The
scheduler-vs-explicit-poll error policy is asymmetric on purpose: a
caller invoking `poll({ signal })` directly wants the error; the
internal scheduler keeps ticking so a transient DB lock doesn't tear
down the consumer.

View File

@@ -0,0 +1,117 @@
# @suaveplan/notification-channel - Multi-channel notification router with templates, sliding-window rate limiting, retry + dead-letter, scheduler, preferences, hooks, OpenTelemetry instrumentation, and Zod-validated boundaries. Universal: runs unchanged in Bun, Node, and the browser.
Multi-channel notification router with templates, sliding-window rate limiting, retry + dead-letter, scheduler, preferences, hooks, OpenTelemetry instrumentation, and Zod-validated boundaries. Universal: runs unchanged in Bun, Node, and the browser.
<!-- begin:auto-docs -->
## Documentation tree
- [delivery-channel](./src/channels/delivery-channel.md) — `delivery-channel.ts` defines the structural contract every concrete delivery channel implements. The contract is inten…
- [email-channel](./src/channels/email-channel.md) — `email-channel.ts` ships the `EmailChannel` `DeliveryChannel` implementation. The class is provider-neutral: the integr…
- [in-app-channel](./src/channels/in-app-channel.md) — `in-app-channel.ts` ships the `InAppChannel` implementation that stores notifications for an in-app inbox / unread badg…
- [push-channel](./src/channels/push-channel.md) — `push-channel.ts` ships the `PushChannel` implementation that delivers notifications via the Web Push Protocol (RFC 803…
- [sms-channel](./src/channels/sms-channel.md) — `sms-channel.ts` ships the `SmsChannel` `DeliveryChannel` implementation. Like the email channel, it is provider-neutra…
- [webhook-channel](./src/channels/webhook-channel.md) — `webhook-channel.ts` ships the `WebhookChannel` implementation that POSTs notifications to an arbitrary HTTPS endpoint.…
- [digest](./src/digest/digest-channel.md) — `digest-channel.ts` ships the `DigestChannel` implementation that batches notifications into periodic summaries deliver…
- [digest-channel](./src/digest/digest-channel.md) — `digest-channel.ts` ships the `DigestChannel` implementation that batches notifications into periodic summaries deliver…
- [error-codes](./src/error-codes/error-codes.md) — `error-codes.ts` is the single source of truth for every error the `@suaveplan/notification-channel` package can throw.…
- [error-codes](./src/error-codes/error-codes.md) — `error-codes.ts` is the single source of truth for every error the `@suaveplan/notification-channel` package can throw.…
- [error-mapping](./src/error-mapping/error-mapping.md) — `error-mapping.ts` is the canonical place every reason code in `NOTIFICATION_CHANNEL_CODES` is paired with its typed su…
- [error-mapping](./src/error-mapping/error-mapping.md) — `error-mapping.ts` is the canonical place every reason code in `NOTIFICATION_CHANNEL_CODES` is paired with its typed su…
- [fakes](./src/fakes/fake-fetch.md) — `fake-fetch.ts` ships the `FakeFetch` test double — a `FetchLike` that consumes scripted responses from a FIFO queue an…
- [fake-fetch](./src/fakes/fake-fetch.md) — `fake-fetch.ts` ships the `FakeFetch` test double — a `FetchLike` that consumes scripted responses from a FIFO queue an…
- [hooks](./src/hooks/hooks.md) — `hooks.ts` exposes a small typed hook bus that runs before `NotificationRouter` commits a notification to a channel. Th…
- [hooks](./src/hooks/hooks.md) — `hooks.ts` exposes a small typed hook bus that runs before `NotificationRouter` commits a notification to a channel. Th…
- [otel](./src/otel/metrics.md) — `otel/metrics.ts` registers the package's four OpenTelemetry instruments on a meter named `@suaveplan/notification-chan…
- [metrics](./src/otel/metrics.md) — `otel/metrics.ts` registers the package's four OpenTelemetry instruments on a meter named `@suaveplan/notification-chan…
- [preferences](./src/preferences/preference-manager.md) — `preference-manager.ts` ships the `PreferenceManager` class that answers, at every send-time, "should this notification…
- [preference-manager](./src/preferences/preference-manager.md) — `preference-manager.ts` ships the `PreferenceManager` class that answers, at every send-time, "should this notification…
- [rate-limiter](./src/rate-limiter/channel-rate-limiter.md) — `channel-rate-limiter.ts` ships the `ChannelRateLimiter` class — a per-channel + per-user sliding-window rate limiter.…
- [channel-rate-limiter](./src/rate-limiter/channel-rate-limiter.md) — `channel-rate-limiter.ts` ships the `ChannelRateLimiter` class — a per-channel + per-user sliding-window rate limiter.…
- [retry](./src/retry/delivery-retry.md) — `delivery-retry.ts` ships the `DeliveryRetry` wrapper that retries a `DeliveryChannel.send` call with exponential backo…
- [delivery-retry](./src/retry/delivery-retry.md) — `delivery-retry.ts` ships the `DeliveryRetry` wrapper that retries a `DeliveryChannel.send` call with exponential backo…
- [router](./src/router/notification-router.md) — `notification-router.ts` ships the `NotificationRouter` class — the primary entrypoint a consumer talks to. It resolves…
- [notification-router](./src/router/notification-router.md) — `notification-router.ts` ships the `NotificationRouter` class — the primary entrypoint a consumer talks to. It resolves…
- [scheduler](./src/scheduler/notification-scheduler.md) — `notification-scheduler.ts` ships the `NotificationScheduler` class for scheduling notifications for future delivery. T…
- [notification-scheduler](./src/scheduler/notification-scheduler.md) — `notification-scheduler.ts` ships the `NotificationScheduler` class for scheduling notifications for future delivery. T…
- [schemas](./src/schemas/schemas.md) — `schemas.ts` exposes a Zod schema for every public option bundle in the package so callers can validate untrusted input…
- [schemas](./src/schemas/schemas.md) — `schemas.ts` exposes a Zod schema for every public option bundle in the package so callers can validate untrusted input…
- [templates](./src/templates/template-engine.md) — `template-engine.ts` ships the `TemplateEngine` registry that turns named templates into channel-specific rendered outp…
- [template-engine](./src/templates/template-engine.md) — `template-engine.ts` ships the `TemplateEngine` registry that turns named templates into channel-specific rendered outp…
<!-- end:auto-docs -->
## Install
```bash
bun add @suaveplan/notification-channel
```
## Quick start
```ts
import {
EmailChannel,
NotificationRouter,
} from "@suaveplan/notification-channel";
const email = new EmailChannel({
from: "noreply@example.com",
sendFn: async (msg) => ({ messageId: await provider.send(msg) }),
});
const router = new NotificationRouter({
channels: [{ channel: "email", enabled: true, rateLimit: 100 }],
defaultChannel: "email",
deliveryChannels: [email],
});
router.on("notification.failed", (n) => audit.record(n));
await router.send({
recipient: "user@example.com",
body: "Welcome to Suaveplan",
subject: "Welcome",
});
```
## Surface
- **Five sending channels** — `EmailChannel`, `SmsChannel`, `PushChannel`, `InAppChannel`, `WebhookChannel`.
- **One aggregating channel** — `DigestChannel` (wraps any sending channel).
- **Router** — `NotificationRouter` with templates, routing rules, lifecycle events, OTel meters.
- **Composables** — `ChannelRateLimiter`, `DeliveryRetry`, `NotificationScheduler`, `PreferenceManager`, `TemplateEngine`.
- **Hooks** — `notification:routing` pre-routing pipeline (`onNotificationRouting`).
- **Errors** — stable `NOTIFICATION_CHANNEL_CODES` registry with eleven reasons.
- **Test double** — `FakeFetch` for deterministic `FetchLike` injection.
- **OpenTelemetry** — four instruments under the `@suaveplan/notification-channel` meter.
## Modules
- [`error-codes`](./src/error-codes.md) — `NOTIFICATION_CHANNEL_CODES` + typed error subclasses.
- [`hooks/hooks`](./src/hooks/hooks.md) — generic hook bus + `notification:routing` pipeline.
- [`schemas/schemas`](./src/schemas/schemas.md) — Zod schemas for every public option bundle.
- [`channels/delivery-channel`](./src/channels/delivery-channel.md) — common transport contract.
- [`channels/email-channel`](./src/channels/email-channel.md) — email delivery.
- [`channels/sms-channel`](./src/channels/sms-channel.md) — SMS delivery.
- [`channels/push-channel`](./src/channels/push-channel.md) — Web Push delivery (RFC 8030).
- [`channels/in-app-channel`](./src/channels/in-app-channel.md) — in-app inbox delivery.
- [`channels/webhook-channel`](./src/channels/webhook-channel.md) — HTTP webhook delivery.
- [`digest/digest-channel`](./src/digest/digest-channel.md) — digest / summary delivery.
- [`templates/template-engine`](./src/templates/template-engine.md) — template registry + renderer.
- [`rate-limiter/channel-rate-limiter`](./src/rate-limiter/channel-rate-limiter.md) — sliding-window rate limits.
- [`retry/delivery-retry`](./src/retry/delivery-retry.md) — retry wrapper + dead-letter queue.
- [`scheduler/notification-scheduler`](./src/scheduler/notification-scheduler.md) — future-delivery scheduler.
- [`preferences/preference-manager`](./src/preferences/preference-manager.md) — opt-in/out + quiet hours.
- [`router/notification-router`](./src/router/notification-router.md) — primary multi-channel router.
- [`otel/metrics`](./src/otel/metrics.md) — OpenTelemetry instruments.
- [`fakes/fake-fetch`](./src/fakes/fake-fetch.md) — deterministic `FetchLike` test double.
## Architecture
Every dependency is injectable: `Clock` and `FetchLike` come from `@suaveplan/types`, the storage adapter behind `PreferenceManager` is caller-supplied, and every channel takes a `sendFn` callback so the package never bundles a specific provider SDK. The router uses `EventBus<NotificationEventMap>` so lifecycle events flow through the same typed-pub/sub primitive as the rest of the project.
The 11-code `defineCodes("NOTIFICATION_CHANNEL", […])` registry preserves literal-typed unions through the public surface, so an exhaustive `switch (err.code)` reports unhandled codes at type-check time. OpenTelemetry meters fire on every router send and every dead-letter event so dashboards reflect real production behaviour out of the box.
## License
MIT

View File

@@ -0,0 +1,66 @@
# `delivery-channel` — Common transport contract
## Purpose
`delivery-channel.ts` defines the structural contract every concrete delivery channel implements. The contract is intentionally tiny: a `name`, a synchronous `canSend(notification)` filter, and an async `send(notification)` that returns a `DeliveryResult`. Channels MUST swallow transport-level exceptions and surface failures via `DeliveryResult { success: false, error }` so the router can decide whether to retry, dead-letter, or emit `notification.failed` without try/catching every channel.
## Features
- Single `DeliveryChannel` interface for every transport.
- `DeliveryResult` shape carries success flag, channel name, optional messageId / error, and a timestamp.
- Synchronous `canSend` so routers can probe support without awaiting.
- No coupling to a specific provider — the integration point is each channel's own `sendFn`/`fetchFn` callback.
## Usage: Basic
```ts
import type {
DeliveryChannel,
DeliveryResult,
} from "@suaveplan/notification-channel";
const noop: DeliveryChannel = {
name: "noop",
canSend: () => true,
async send(): Promise<DeliveryResult> {
return {
success: true,
channel: "noop",
messageId: "noop-0",
timestamp: new Date().toISOString(),
};
},
};
```
## Usage: Advanced
```ts
import type { DeliveryChannel } from "@suaveplan/notification-channel";
class LoggingChannel implements DeliveryChannel {
public readonly name = "logging";
constructor(private readonly inner: DeliveryChannel) {}
canSend(n: import("@suaveplan/notification-channel").Notification) {
return this.inner.canSend(n);
}
async send(n: import("@suaveplan/notification-channel").Notification) {
const start = Date.now();
const r = await this.inner.send(n);
console.error(`[${this.inner.name}] ${r.success ? "ok" : "fail"} +${Date.now() - start}ms`);
return r;
}
}
```
## API Reference
- `DeliveryChannel` — interface.
- `name: string` — unique transport identifier (matches the channel key the router routes to).
- `canSend(notification): boolean` — synchronous probe.
- `send(notification): Promise<DeliveryResult>` — main entry point; MUST NOT throw on transport failure.
- `DeliveryResult` — `{ success, channel, messageId?, error?, timestamp }`.
## Implementation Notes
Both fields `messageId` and `error` are optional and mutually exclusive in practice — `success: true` should set `messageId`, `success: false` should set `error`. Tests cover both polarities. The interface deliberately uses `string` for `channel` (not `NotificationChannel`) so wrappers like `DigestChannel` (whose `name` is the literal `"digest"`) and custom decorators can satisfy the contract without widening the package-wide channel enum.

View File

@@ -0,0 +1,59 @@
# `email-channel` — Email delivery channel
## Purpose
`email-channel.ts` ships the `EmailChannel` `DeliveryChannel` implementation. The class is provider-neutral: the integration seam is a caller-supplied `sendFn(message)` callback that performs the actual SMTP / API call. Wiring a real provider (Amazon SES, SendGrid, Postmark, Mailgun, or an internal MTA) is a matter of supplying a function with the documented signature. The channel swallows transport errors and returns them as `DeliveryResult { success: false, error }`, never propagating exceptions to the router.
## Features
- Provider-neutral via injectable `sendFn`.
- `canSend` accepts `email` channel + `@`-bearing recipient — keeps malformed addresses from reaching upstream providers.
- Falls back to `"(No subject)"` when the notification carries no subject.
- Propagates `metadata` to the provider message — useful for tracking IDs.
- Injectable `Clock` for deterministic `timestamp` fields in tests.
## Usage: Basic
```ts
import { EmailChannel } from "@suaveplan/notification-channel";
const channel = new EmailChannel({
from: "noreply@example.com",
sendFn: async (msg) => ({ messageId: await sesClient.send(msg) }),
});
const result = await channel.send(notification);
if (!result.success) reportError(result.error);
```
## Usage: Advanced
```ts
import { EmailChannel } from "@suaveplan/notification-channel";
const channel = new EmailChannel({
from: "alerts@example.com",
sendFn: async (msg) => {
const messageId = await sendgrid.send({
to: msg.to,
from: "alerts@example.com",
subject: msg.subject,
content: [{ type: "text/plain", value: msg.body }],
customArgs: msg.metadata as Record<string, string> | undefined,
});
return { messageId };
},
clock: () => testNow,
});
```
## API Reference
- `EmailChannel(config: EmailChannelConfig)` — constructor.
- `EmailChannelConfig` — `{ from, sendFn, clock? }`.
- `EmailMessage` — `{ to, subject, body, metadata? }` passed to `sendFn`.
- `canSend(n): boolean`, `send(n): Promise<DeliveryResult>` — `DeliveryChannel` impl.
## Implementation Notes
The default subject `"(No subject)"` matches the upstream `@suaveplan/notification-channel@1.2.6` behaviour so consumer dashboards keep showing a non-empty subject column. Non-`Error` throws inside `sendFn` are coerced via `String(err)` so the `DeliveryResult.error` is always a string. The injected `clock` only affects the `timestamp` field — the upstream provider's own timestamps remain authoritative for SLA tracking.

View File

@@ -0,0 +1,67 @@
# `in-app-channel` — In-app notification channel
## Purpose
`in-app-channel.ts` ships the `InAppChannel` implementation that stores notifications for an in-app inbox / unread badge. The default storage is an in-memory `Map<recipientId, InAppNotification[]>`, but the constructor accepts any `InAppStore` implementation so consumers can persist to SurrealDB, Postgres, Redis, or a local IndexedDB browser store. The class is universal — no Node-only or browser-only APIs.
## Features
- Default in-memory store — useful for tests and prototypes.
- Pluggable `InAppStore` adapter for persistence.
- `getByRecipient(recipientId)` and `markRead(id)` convenience methods.
- Records unread by default — flips to read via `markRead`.
- Returns `DeliveryResult { success: false }` when the store throws, never propagates.
- Injectable `Clock` for deterministic `createdAt` and `timestamp` fields.
## Usage: Basic
```ts
import { InAppChannel } from "@suaveplan/notification-channel";
const channel = new InAppChannel();
await channel.send(notification);
const unread = (await channel.getByRecipient(notification.recipient)).filter(
(n) => !n.read
);
```
## Usage: Advanced
```ts
import {
InAppChannel,
type InAppNotification,
type InAppStore,
} from "@suaveplan/notification-channel";
const dbStore: InAppStore = {
async save(n) {
await db.execute("INSERT INTO notifications ...", [n]);
},
async getByRecipient(recipientId) {
return db.query<InAppNotification>(
"SELECT * FROM notifications WHERE recipientId = ?",
[recipientId]
);
},
async markRead(id) {
await db.execute(
"UPDATE notifications SET read = true WHERE id = ?",
[id]
);
},
};
const channel = new InAppChannel(dbStore);
```
## API Reference
- `InAppChannel(store?: InAppStore, clock?: Clock)` — constructor.
- `InAppNotification` — `{ id, recipientId, subject?, body, read, createdAt, metadata? }`.
- `InAppStore` — `save`, `getByRecipient`, `markRead`.
- `channel.canSend(n)`, `channel.send(n)`, `channel.getByRecipient(id)`, `channel.markRead(id)`.
## Implementation Notes
The default in-memory store backs onto a private `Map` so multiple `InAppChannel` instances do not share state. The store factory closes over the per-instance map to avoid leaking persistence between independent channel objects (useful in tests that construct multiple channels). `markRead` is implemented by scanning every recipient's list — for production-scale deployments the consumer must inject a real store with an indexed lookup; the in-memory variant is a development convenience, not a recommended persistence layer.

View File

@@ -0,0 +1,65 @@
# `push-channel` — Web Push notification channel
## Purpose
`push-channel.ts` ships the `PushChannel` implementation that delivers notifications via the Web Push Protocol (RFC 8030) over VAPID-authenticated POSTs. The channel resolves per-user subscriptions through an injected `getSubscriptions(recipient)` callback, then dispatches the encrypted payload to every endpoint either through an explicit `sendFn` (recommended when you already use `web-push` or a vendor SDK) OR a `FetchLike` injectable for the default direct-POST path. Partial success is treated as overall success; only when EVERY subscription fails does the channel report failure.
## Features
- VAPID `{ publicKey, privateKey, subject }` authentication.
- Multi-subscription fan-out per recipient with partial-success semantics.
- Pluggable `sendFn` for vendor SDKs; or pluggable `FetchLike` for direct POSTs.
- Per-channel `ttl` (default 86400 s).
- Reports `1of3` / `2of2` cardinality in `messageId` for observability.
- Returns `DeliveryResult { success: false }` when `getSubscriptions` throws OR every subscription fails.
- Injectable `Clock` for deterministic `timestamp`.
## Usage: Basic
```ts
import { PushChannel } from "@suaveplan/notification-channel";
const channel = new PushChannel({
vapid: { publicKey: "...", privateKey: "...", subject: "mailto:admin@x" },
getSubscriptions: (recipient) => db.getPushSubscriptions(recipient),
});
const r = await channel.send(notification);
```
## Usage: Advanced
```ts
import webPush from "web-push";
import { PushChannel } from "@suaveplan/notification-channel";
const channel = new PushChannel({
vapid,
getSubscriptions: (r) => db.getPushSubscriptions(r),
sendFn: async (sub, payload, v) => {
try {
await webPush.sendNotification(sub, payload, {
vapidDetails: {
subject: v.subject,
publicKey: v.publicKey,
privateKey: v.privateKey,
},
});
return { success: true, statusCode: 201 };
} catch (err) {
return { success: false, statusCode: (err as { statusCode?: number }).statusCode ?? 500 };
}
},
ttl: 3600,
});
```
## API Reference
- `PushChannel(config: PushChannelConfig)` — constructor.
- `PushChannelConfig` — `{ vapid, getSubscriptions, sendFn?, fetchFn?, ttl?, clock? }`.
- `VapidKeys`, `PushSubscription`, `PushPayload`, `PushAction`, `PushSendOutcome` — re-exported types.
- `canSend(n)`, `send(n)` — `DeliveryChannel` impl.
## Implementation Notes
`Promise.allSettled` is used to fan out so a single bad subscription does not derail the rest. Errors collected from rejected promises are concatenated into the failure message when ALL subscriptions fail. The default fetch path injects `Content-Type`, `Content-Encoding: aes128gcm`, `TTL`, and `Urgency: normal` headers, plus a `vapid t=<pub>, k=<priv>` Authorization header when `vapid.subject` is non-empty. The default path is the simplest correct shape, but production callers SHOULD inject `sendFn` to get proper payload encryption — direct POST without payload encryption is only useful for plain-text push or tests.

View File

@@ -0,0 +1,63 @@
# `sms-channel` — SMS delivery channel
## Purpose
`sms-channel.ts` ships the `SmsChannel` `DeliveryChannel` implementation. Like the email channel, it is provider-neutral: a caller-supplied `sendFn(message)` performs the actual API call into Twilio, MessageBird, Vonage, Sinch, or any in-house SMSC. The channel keeps the responsibility surface tiny — recipient validation (E.164 `+` prefix), payload shaping, and error swallowing — so the router can stay decoupled from any specific carrier.
## Features
- Provider-neutral via injectable `sendFn`.
- `canSend` rejects recipients that do not start with `+` (E.164 prefix); avoids hitting upstream providers with formats they will reject.
- Propagates `metadata` to the provider message for correlation IDs and segmentation.
- Injectable `Clock` for deterministic `timestamp` fields in tests.
- Returns `DeliveryResult` even on provider failure — never throws to the router.
## Usage: Basic
```ts
import { SmsChannel } from "@suaveplan/notification-channel";
const channel = new SmsChannel({
from: "+15559999999",
sendFn: async (msg) => ({
messageId: await twilio.messages.create({
from: "+15559999999",
to: msg.to,
body: msg.body,
}).sid,
}),
});
```
## Usage: Advanced
```ts
import { SmsChannel } from "@suaveplan/notification-channel";
const channel = new SmsChannel({
from: "+15559999999",
sendFn: async (msg) => {
const { sid } = await provider.send({
from: "+15559999999",
to: msg.to,
body: msg.body,
...(msg.metadata?.["statusCallback"] !== undefined
? { statusCallback: msg.metadata["statusCallback"] }
: {}),
});
return { messageId: sid };
},
clock: () => testNow,
});
```
## API Reference
- `SmsChannel(config: SmsChannelConfig)` — constructor.
- `SmsChannelConfig` — `{ from, sendFn, clock? }`.
- `SmsMessage` — `{ to, body, metadata? }` passed to `sendFn`.
- `canSend(n): boolean`, `send(n): Promise<DeliveryResult>` — `DeliveryChannel` impl.
## Implementation Notes
The `+` prefix check is intentionally conservative — it does not validate the full E.164 country/area format because providers do that anyway and over-validating client-side breaks edge cases like short-code dispatch. Tests cover both `Error` and non-`Error` throws inside `sendFn` to lock in the `String(err)` coercion. The injected `clock` only affects the `DeliveryResult.timestamp` field; provider-recorded send time remains authoritative.

View File

@@ -0,0 +1,56 @@
# `webhook-channel` — HTTP webhook delivery channel
## Purpose
`webhook-channel.ts` ships the `WebhookChannel` implementation that POSTs notifications to an arbitrary HTTPS endpoint. The notification's `recipient` field IS the destination URL, the body carries the serialised notification payload, and the channel respects a per-request timeout via `AbortController`. The fetch surface is injectable via `FetchLike` from `@suaveplan/types`, so tests run deterministically against `FakeFetch` and downstream consumers can plug their own observability-instrumented fetch.
## Features
- Injectable `FetchLike` — no `globalThis.fetch` patching.
- Configurable timeout (default 10_000 ms) using `AbortController`.
- Custom headers via `WebhookChannelConfig.headers`.
- `canSend` enforces `http://` or `https://` recipient prefix.
- Returns `DeliveryResult { success: false }` on non-2xx, network error, abort, or unexpected throw.
- Injectable `Clock` for deterministic `timestamp` in tests.
## Usage: Basic
```ts
import { WebhookChannel } from "@suaveplan/notification-channel";
const channel = new WebhookChannel({
headers: { Authorization: "Bearer secret" },
});
const r = await channel.send(notification);
```
## Usage: Advanced
```ts
import { FakeFetch, WebhookChannel } from "@suaveplan/notification-channel";
// In tests:
const fake = new FakeFetch();
fake.push({ status: 200 });
const channel = new WebhookChannel({
fetchFn: fake.fetch,
timeoutMs: 500,
clock: () => testNow,
});
// In prod, wire a real instrumented fetch:
const channel2 = new WebhookChannel({
fetchFn: (url, init) => instrumented.fetch(url, init),
headers: { "X-Signed-By": "suaveplan" },
});
```
## API Reference
- `WebhookChannel(config?: WebhookChannelConfig)` — constructor.
- `WebhookChannelConfig` — `{ headers?, fetchFn?, timeoutMs?, clock? }`.
- `canSend(n): boolean`, `send(n): Promise<DeliveryResult>`.
## Implementation Notes
The POST body is built from `{ id, subject, body, priority, metadata, createdAt }` — a stable shape so webhook consumers can lock their parsers. `Content-Type: application/json` is always set; consumer headers override the default. Aborted fetches surface `error: "aborted"` (matching the `AbortError.name`-derived message from the polyfill). Non-2xx responses encode the upstream status into `error: "HTTP <status>: <statusText>"`. Both `Error` and non-`Error` throws inside the fetch callback are stringified into `error` so the `DeliveryResult` contract holds.

View File

@@ -0,0 +1,57 @@
# `digest-channel` — Digest delivery channel
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`digest-channel.ts` ships the `DigestChannel` implementation that batches notifications into periodic summaries delivered through a delegate `DeliveryChannel`. It is the channel-shape adapter for "weekly summary email" / "hourly Slack roll-up" / "daily push digest" use-cases — it implements `DeliveryChannel` so it slots into the router exactly like a direct channel, and it owns its own per-recipient queue.
## Features
- Wraps any `DeliveryChannel` as `delegate`.
- Per-recipient FIFO queue with configurable `maxPerDigest` cap.
- `intervalMs` gates the next flush — `send` opportunistically flushes when the interval has elapsed since the last flush.
- Customisable `formatDigest(notifications)` formatter.
- `flush(recipient)`, `flushAll()`, `queueSize`, `pendingRecipients` for ops/cron integrations.
- Injectable `Clock` so tests advance the interval without sleeping.
## Usage: Basic
```ts
import { DigestChannel, EmailChannel } from "@suaveplan/notification-channel";
const email = new EmailChannel({ from: "noreply@x", sendFn });
const digest = new DigestChannel({
delegate: email,
intervalMs: 3_600_000,
});
await digest.send(notification);
```
## Usage: Advanced
```ts
const digest = new DigestChannel({
delegate: email,
intervalMs: 86_400_000,
maxPerDigest: 25,
formatDigest: (ns) =>
ns.map((n, i) => `${i + 1}. [${n.priority}] ${n.subject}: ${n.body}`).join("\n"),
clock: () => Date.now(),
});
// In a cron handler:
const results = await digest.flushAll();
```
## API Reference
- `DigestChannel(config: DigestChannelConfig)` — constructor.
- `DigestChannelConfig` — `{ delegate, intervalMs?, maxPerDigest?, formatDigest?, clock? }`.
- `enqueue(n)`, `send(n)`, `flush(recipient)`, `flushAll()`, `queueSize(recipient)`, `pendingRecipients()`, `canSend(n)`.
## Implementation Notes
`enqueue` silently drops the overflow when the queue reaches `maxPerDigest`. This is a deliberate denial-of-service shield — a runaway pipeline cannot blow memory by enqueuing forever. The default formatter numbers entries and labels them by subject; consumers wanting Markdown / HTML output supply their own `formatDigest`. `canSend` proxies to the delegate, so the digest channel inherits the delegate's recipient validation — useful when the delegate is an `EmailChannel` that wants `@`-only recipients. `lastFlush` is tracked per-recipient and consulted from `send`/`flushAll`; a recipient flushed seconds ago will not flush again until the interval has elapsed.

View File

@@ -0,0 +1,62 @@
# `error-codes` — Notification-channel error registry
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`error-codes.ts` is the single source of truth for every error the `@suaveplan/notification-channel` package can throw. It registers a stable namespace `NOTIFICATION_CHANNEL` with eleven reason codes via `defineCodes("NOTIFICATION_CHANNEL", […])` 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 — loggers, problem+json responders, retry wrappers — can `instanceof`-narrow on a specific subclass or match on the `.code` discriminant without unsafe casts. The module is side-effect-imported from `src/index.ts` so the codes are part of the package's public ABI even for consumers that pull a subpath export.
## Features
- Eleven stable reason codes: `NOT_FOUND`, `CHANNEL_DISABLED`, `TEMPLATE_NOT_FOUND`, `RATE_LIMIT_EXCEEDED`, `ROUTING_FAILED`, `DELIVERY_FAILED`, `PROVIDER_ERROR`, `RATE_LIMITED`, `INVALID_RECIPIENT`, `CHANNEL_UNAVAILABLE`, `TIMEOUT`.
- One typed subclass per code extending an abstract `NotificationChannelError` base.
- Compile-time-frozen `NOTIFICATION_CHANNEL_CODES` record whose values are literal `"NOTIFICATION_CHANNEL.REASON"` strings.
- Every constructor accepts the standard `SuaveplanErrorOptions` bag (`cause`, `context`, `traceId`, `clock`).
- `RateLimitedError` carries an additional `retryAfterMs` field merged into `context`.
## Usage: Basic
```ts
import {
NOTIFICATION_CHANNEL_CODES,
ChannelDisabledError,
type NotificationChannelErrorCode,
} from "@suaveplan/notification-channel/error-codes";
try {
/* ... call the router ... */
} catch (err) {
if (err instanceof ChannelDisabledError) {
const code: NotificationChannelErrorCode = err.code;
void code; // "NOTIFICATION_CHANNEL.CHANNEL_DISABLED"
}
}
void NOTIFICATION_CHANNEL_CODES.RATE_LIMITED;
```
## Usage: Advanced
```ts
import { RateLimitedError } from "@suaveplan/notification-channel/error-codes";
throw new RateLimitedError({
message: "notification.errors.rateLimited",
retryAfterMs: 30_000,
context: { provider: "twilio" },
traceId: "trace-abc",
});
```
## API Reference
- `NOTIFICATION_CHANNEL_CODES` — frozen record `{ readonly [K in Reason]: "NOTIFICATION_CHANNEL.${K}" }`.
- `NotificationChannelErrorCode` — discriminated union of all eleven literal codes.
- `NotificationChannelError` — abstract base extending `SuaveplanError<NotificationChannelErrorCode>`.
- Concrete subclasses: `NotificationNotFoundError`, `ChannelDisabledError`, `TemplateNotFoundError`, `RateLimitExceededError`, `RoutingFailedError`, `DeliveryFailedError`, `ProviderErrorError`, `RateLimitedError`, `InvalidRecipientError`, `ChannelUnavailableError`, `DeliveryTimeoutError`.
## Implementation Notes
The registry uses `defineCodes` from `@suaveplan/error` rather than a runtime `ErrorCodes` registry. `defineCodes` is purely type-level plus 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 `tsconfig.base.json`. The split between "local lifecycle errors" and "upstream-provider errors" upstream had two parallel registries (`error-codes.ts` and `errors/notification-errors.ts`) — those are unified here so consumers see a single canonical code list. `RateLimitedError` is the one place the constructor accepts a typed sidecar (`retryAfterMs`) — it merges into `context` so downstream HTTP `Retry-After` mapping is straightforward.

View File

@@ -0,0 +1,57 @@
# `error-mapping` — Reason-to-constructor table
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`error-mapping.ts` is the canonical place every reason code in `NOTIFICATION_CHANNEL_CODES` is paired with its typed subclass constructor. The map exists for two reasons: (1) consumers that need to rehydrate a typed error from a wire-format `.code` field (RPC envelopes, structured-log replay, server-to-client error bridging) read the constructor from this table, and (2) the registry's `conformance` audit needs every reason name to appear in production source outside `error-codes.ts` — listing them here keeps the contract explicit.
## Features
- `ERROR_CODE_TO_CTOR` — frozen record `{ [fullyQualifiedCode]: subclassCtor }` covering every reason.
- `REASON_CODES` — reason-word ⇒ fully-qualified code (e.g. `REASON_CODES.NOT_FOUND === "NOTIFICATION_CHANNEL.NOT_FOUND"`).
- `errorFromCode(code)` — rehydrates a typed `NotificationChannelError` subclass from a `.code` discriminant.
- Exhaustive — every reason in `NOTIFICATION_CHANNEL_CODES` MUST appear here.
## Usage: Basic
```ts
import { errorFromCode, REASON_CODES } from "@suaveplan/notification-channel";
const err = errorFromCode(REASON_CODES.RATE_LIMITED);
console.error(err.code); // "NOTIFICATION_CHANNEL.RATE_LIMITED"
```
## Usage: Advanced
```ts
import {
ERROR_CODE_TO_CTOR,
NOTIFICATION_CHANNEL_CODES,
type NotificationChannelErrorCode,
} from "@suaveplan/notification-channel";
function rehydrate(wire: { code: NotificationChannelErrorCode; cause?: unknown }) {
const Ctor = ERROR_CODE_TO_CTOR[wire.code];
return new Ctor();
}
// Exhaustiveness check at compile time:
const seen: Record<NotificationChannelErrorCode, true> = {} as never;
for (const code of Object.values(NOTIFICATION_CHANNEL_CODES)) {
seen[code] = true;
void ERROR_CODE_TO_CTOR[code];
}
```
## API Reference
- `ERROR_CODE_TO_CTOR: Readonly<Record<NotificationChannelErrorCode, new () => NotificationChannelError>>`.
- `REASON_CODES: { readonly [Reason]: "NOTIFICATION_CHANNEL.<Reason>" }`.
- `errorFromCode(code): NotificationChannelError`.
## Implementation Notes
The exhaustiveness of `ERROR_CODE_TO_CTOR` is checked statically — the record type `Record<NotificationChannelErrorCode, …>` makes TypeScript complain if a code is missed. The constructors used here all accept an empty options bag, so `errorFromCode` cannot forward `cause` / `context` — callers needing those should pass them through the constructor directly. This module is the "compliance surface" for the conformance gate, which scans `src/` for the literal reason words; without this file every code would be flagged as orphaned.

View File

@@ -0,0 +1,61 @@
# `fake-fetch` — Deterministic FetchLike test double
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`fake-fetch.ts` ships the `FakeFetch` test double — a `FetchLike` that consumes scripted responses from a FIFO queue and records every call into a `calls` array. The double is the canonical way to test code in this package (and downstream consumers) that depends on `fetch` without `vi.mock` / `globalThis.fetch` patching / network access.
## Features
- `push(response)` enqueues a `FakeFetchResponse` — `{ status?, headers?, json?, text?, pending?, reject? }`.
- `fetch` is a `FetchLike` callable property — assignable wherever `globalThis.fetch` is.
- Calls recorded into `calls: { url, init }[]` in FIFO order.
- Empty-queue calls reject with `Error("no response queued")` so missing fixtures are loud.
- `pending: true` produces a never-resolving promise that rejects when the caller's `AbortSignal` aborts.
- `reject: <Error>` produces a synchronous rejection.
## Usage: Basic
```ts
import { FakeFetch, WebhookChannel } from "@suaveplan/notification-channel";
const fake = new FakeFetch();
fake.push({ status: 200 });
const channel = new WebhookChannel({ fetchFn: fake.fetch });
await channel.send(notification);
expect(fake.calls[0]?.url).toBe(notification.recipient);
```
## Usage: Advanced
```ts
const fake = new FakeFetch();
fake.push({ status: 429, headers: { "Retry-After": "5" } });
fake.push({ pending: true }); // for an abort test
fake.push({ reject: new Error("DNS") });
// In tests:
const r1 = await client.fetch("https://x"); // 429
await expect(
client.fetch("https://x", { signal: AbortSignal.timeout(50) })
).rejects.toThrow("aborted");
await expect(client.fetch("https://x")).rejects.toThrow("DNS");
```
## API Reference
- `FakeFetch` class.
- `calls: FakeFetchCall[]` — FIFO record of every call.
- `push(response: FakeFetchResponse): void` — append scripted response.
- `fetch: FetchLike` — callable to inject.
- `FakeFetchResponse` — `{ status?, headers?, json?, text?, pending?, reject? }`.
- `FakeFetchCall` — `{ url, init }`.
## Implementation Notes
`fetch` is a property, not a method, so `fake.fetch` survives destructuring (`const { fetch } = fake; fetch(url)` works). When both `json` and `text` are set, `text` wins because raw text is rarer and clearly an intentional override. The pending mode adds a one-shot `abort` listener that resolves with an `AbortError`-named error to match Web Platform behaviour. Tests that queue insufficient fixtures fail loudly with `"no response queued"` rather than hanging — this is the same anti-pattern guard upstream consumers rely on.

View File

@@ -0,0 +1,75 @@
# `hooks` — Pre-routing hook pipeline
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`hooks.ts` exposes a small typed hook bus that runs before `NotificationRouter` commits a notification to a channel. The single key today is `notification:routing`; handlers receive the original input, the currently resolved channel, and a metadata bag, and may return a partial override that merges into the data flowing forward. The bus is intentionally local to this package — the `@suaveplan/events` foundation does not yet export a `Hooks` primitive, and inlining keeps the package universal with no extra workspace dep.
## Features
- Generic `Hooks<TMap>` class — typed by a hook-map interface; cannot register on an undeclared key.
- Handlers run in registration order (FIFO).
- Each handler may return `undefined` (no-op), a full hook-map value, a partial override, or a `Promise` of either.
- Unsubscribe returned from `hook(...)` — calling twice is safe.
- `clear(key?)` drops handlers for a single key or every key.
- Singleton `notificationHooks` so cross-cutting integrations (audit, A/B routing) can hook globally.
- Shorthand `onNotificationRouting(handler)` for the singleton's primary key.
## Usage: Basic
```ts
import { onNotificationRouting } from "@suaveplan/notification-channel";
const off = onNotificationRouting((data) => {
if (data.input.priority === "urgent") {
return { resolvedChannel: "sms" };
}
return undefined;
});
// later
off();
```
## Usage: Advanced
```ts
import {
Hooks,
type NotificationHookMap,
} from "@suaveplan/notification-channel";
const hooks = new Hooks<NotificationHookMap>();
hooks.hook("notification:routing", async (data) => {
const enriched = await enrichRecipient(data.input.recipient);
return {
metadata: { ...data.metadata, geo: enriched.geo, segment: enriched.segment },
};
});
const result = await hooks.applyHooksAsync("notification:routing", {
input: { recipient: "u@x.com", body: "Hi" },
resolvedChannel: "email",
metadata: {},
});
```
## API Reference
- `Hooks<TMap>` — class.
- `hook(key, fn)` → `() => void` — register, returns unsubscribe.
- `clear(key?)` — drop handlers for `key`, or every key when omitted.
- `count(key)` → `number` — number of registered handlers.
- `applyHooksAsync(key, data)` → `Promise<data>` — run pipeline and return composed data.
- `notificationHooks` — singleton `Hooks<NotificationHookMap>`.
- `onNotificationRouting(handler)` — shorthand for `notificationHooks.hook("notification:routing", handler)`.
- `NotificationHookMap` — `{ "notification:routing": NotificationRoutingHookData }`.
- `NotificationRoutingHookData` — `{ input, resolvedChannel, metadata }`.
## Implementation Notes
Handlers compose via `Object.assign`-style spread, so a handler that returns `{ resolvedChannel: "sms" }` overrides only that field while preserving `input` and `metadata`. Returning `undefined` is the explicit "no change" signal — handlers used for telemetry or audit can side-effect freely without returning anything. The `Hooks` class accepts a generic `TMap` so packages with their own hook map can reuse the same implementation; the singleton is pinned to `NotificationHookMap` so the singleton's only legal key is `notification:routing`. The bus is intentionally minimal: it has no priority, timeout, or interceptor surface — those concerns belong to `@suaveplan/events`'s richer `EventBus` interceptor stack, which the router itself uses for lifecycle events.

View File

@@ -0,0 +1,67 @@
# `otel/metrics` — OpenTelemetry instrumentation
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`otel/metrics.ts` registers the package's four OpenTelemetry instruments on a meter named `@suaveplan/notification-channel` and exposes the recording helpers used by the router and retry wrapper. Tests rebind the instruments against an in-memory `MeterProvider` via `refreshNotificationMeters()` so emitted metrics can be inspected without spinning up a real OTel collector.
## Features
- `notification_channel.routed` (Counter) — one increment per `NotificationRouter.send` invocation, labelled `{ channel, outcome }`.
- `notification_channel.delivery_attempts` (Counter) — one increment per dispatch to a registered `DeliveryChannel`.
- `notification_channel.dead_letters` (Counter) — one increment when `DeliveryRetry` exhausts retries.
- `notification_channel.latency_ms` (Histogram) — `NotificationRouter.send` end-to-end latency.
- `refreshNotificationMeters()` — re-resolves every instrument against the global `MeterProvider`.
## Usage: Basic
```ts
import { metrics } from "@opentelemetry/api";
import { refreshNotificationMeters } from "@suaveplan/notification-channel/otel/metrics";
// Wire a real MeterProvider somewhere in process bootstrap, then:
metrics.setGlobalMeterProvider(provider);
refreshNotificationMeters();
// Router and retry calls automatically populate the instruments.
```
## Usage: Advanced
```ts
import {
refreshNotificationMeters,
recordDeadLetter,
recordDeliveryAttempt,
recordRouted,
} from "@suaveplan/notification-channel/otel/metrics";
// Custom dispatcher that records attempts/dead-letters explicitly.
async function dispatch(channel: string, send: () => Promise<void>) {
try {
await send();
recordDeliveryAttempt(channel, "ok");
} catch (err) {
recordDeliveryAttempt(channel, "failed");
recordDeadLetter(channel);
throw err;
}
}
refreshNotificationMeters();
```
## API Reference
- `refreshNotificationMeters(): void` — rebind to the current global meter.
- `recordRouted(channel, outcome, latencyMs)` — router send.
- `recordDeliveryAttempt(channel, outcome)` — dispatch outcome.
- `recordDeadLetter(channel)` — dead-letter event.
- `RouterOutcome` — `"ok" | "failed"`.
## Implementation Notes
The instruments are module-level `let` bindings re-pointed by `refreshNotificationMeters` because `@opentelemetry/api` resolves the meter at the moment `getMeter` is called — if `metrics.setGlobalMeterProvider` runs AFTER the module first loads, the original meter is still bound to the no-op provider. `refreshNotificationMeters` solves that for tests and for any process that wires the OTel SDK after this module imports. The recording helpers are typed against `RouterOutcome` so consumers cannot accidentally pass an unknown outcome label.

View File

@@ -0,0 +1,74 @@
# `preference-manager` — Per-user opt-in / opt-out + quiet hours
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`preference-manager.ts` ships the `PreferenceManager` class that answers, at every send-time, "should this notification reach this user on this channel right now?". The manager combines per-channel opt-in / opt-out preferences with optional quiet-hours configuration (with priority thresholds) and a pluggable `PreferenceStoreAdapter` for persistence.
## Features
- Per-user channel enable / disable, with explicit `enabledChannels` and `disabledChannels` sets.
- Default channels (configurable; built-in default: `["email", "in_app"]`).
- Quiet-hours config with `startHour`, `endHour` (wrap-around supported), and optional `days` filter.
- `urgentThreshold` controls which priorities punch through quiet hours (default `urgent`).
- `canDeliver(userId, channel, priority)` is the single decision point.
- Pluggable `PreferenceStoreAdapter` so persistence is delegated to SurrealDB, Postgres, Redis, etc.
- Injectable `Clock` for deterministic tests.
## Usage: Basic
```ts
import {
PreferenceManager,
type PreferenceStoreAdapter,
} from "@suaveplan/notification-channel";
const store: PreferenceStoreAdapter = makeStore();
const manager = new PreferenceManager({ store });
if (await manager.canDeliver("user-1", "email", "normal")) {
await router.send(...);
}
```
## Usage: Advanced
```ts
const manager = new PreferenceManager({
store,
defaultChannels: ["email", "push", "in_app"],
clock: () => Date.now(),
});
await manager.setEnabled("user-1", "sms", true);
await manager.setQuietHours("user-1", {
startHour: 22,
endHour: 7,
days: [1, 2, 3, 4, 5], // weekdays
});
// At dispatch time:
for (const channel of selected) {
if (await manager.canDeliver("user-1", channel, "high")) {
await router.send({ recipient: "user-1", body: text, channel });
}
}
```
## API Reference
- `PreferenceManager(options: PreferenceManagerOptions)` — constructor.
- `PreferenceManagerOptions` — `{ store, defaultChannels?, clock? }`.
- `canDeliver(userId, channel, priority): Promise<boolean>`.
- `getPreferences(userId): Promise<UserPreferences>`.
- `setEnabled(userId, channel, enabled): Promise<void>`.
- `setQuietHours(userId, quietHours | null): Promise<void>`.
- `resetPreferences(userId): Promise<boolean>`.
- `PreferenceStoreAdapter`, `UserPreferences`, `StoredPreferences`, `QuietHoursConfig`.
## Implementation Notes
Quiet-hours wrap around midnight when `startHour > endHour` (e.g. `22 → 7`). Priorities are ordered `low < normal < high < urgent`; if `urgentThreshold` is `"high"`, then both `high` and `urgent` punch through quiet hours. The day filter is opt-in — when omitted, quiet hours apply every day; when supplied, only the listed days (`0` = Sunday) trigger the window. `setQuietHours(userId, null)` clears the field; existing `enabledChannels`/`disabledChannels`/`urgentThreshold` are preserved. The store adapter is the single integration seam — the rest of the class is purely functional logic, so swapping persistence layers does not change behaviour.

View File

@@ -0,0 +1,63 @@
# `channel-rate-limiter` — Sliding-window rate limits
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`channel-rate-limiter.ts` ships the `ChannelRateLimiter` class — a per-channel + per-user sliding-window rate limiter. Two independent buckets are maintained: a per-channel bucket (every send against that channel counts) and a per-user bucket (every send for that user counts regardless of channel). `checkAndRecord(channel, userId)` consults both, throws `RateLimitExceededError` on overflow, otherwise records the send and returns.
## Features
- Per-channel and per-user limits with independent `{ maxCount, windowMs }` rules.
- Sliding window — entries older than `windowMs` are discarded on every check.
- `isAllowed` probe — non-recording check for UX-level pre-flight.
- `getChannelRemaining` / `getUserRemaining` for headers like `X-RateLimit-Remaining`.
- Injectable `Clock` so tests advance time without sleeping.
- Universal — no Node/DOM-specific APIs.
## Usage: Basic
```ts
import { ChannelRateLimiter } from "@suaveplan/notification-channel";
const limiter = new ChannelRateLimiter();
limiter.setChannelLimit("email", { maxCount: 100, windowMs: 60_000 });
limiter.setUserLimit("user-42", { maxCount: 5, windowMs: 60_000 });
try {
limiter.checkAndRecord("email", "user-42");
} catch (err) {
/* RateLimitExceededError */
}
```
## Usage: Advanced
```ts
const limiter = new ChannelRateLimiter(() => clock.now());
limiter.setChannelLimit("sms", { maxCount: 10, windowMs: 60_000 });
// Pre-flight without recording:
if (!limiter.isAllowed("sms", userId)) {
return reply.code(429).send({
remaining: limiter.getChannelRemaining("sms"),
});
}
limiter.checkAndRecord("sms", userId);
```
## API Reference
- `ChannelRateLimiter(clock?: Clock)` — constructor.
- `setChannelLimit(channel, rule)`, `setUserLimit(userId, rule)` — install rules.
- `removeChannelLimit(channel)`, `removeUserLimit(userId)` — drop rules and their windows.
- `checkAndRecord(channel, userId)` — throws on overflow.
- `isAllowed(channel, userId)` — boolean probe.
- `getChannelRemaining(channel)`, `getUserRemaining(userId)` — current capacity (or `Infinity` when no rule).
- `RateLimitRule` — `{ maxCount, windowMs }`.
## Implementation Notes
The window is sliding, not fixed — entries are filtered against `now - timestamp < windowMs` on every check. This is more accurate than fixed buckets at the cost of `O(n)` filtering on every check where `n` is the number of entries in the window; for buckets with `maxCount` in the low hundreds this is negligible. `recordChannel` / `recordUser` are no-ops when no rule is installed, so the `WindowEntry` arrays stay empty when the bucket has no limit. The injected `Clock` only governs the limiter's view of "now" — wall-clock drift between server and limiter does not exist because the limiter never compares its timestamps to anything external.

View File

@@ -0,0 +1,72 @@
# `delivery-retry` — Retry wrapper with dead-letter queue
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`delivery-retry.ts` ships the `DeliveryRetry` wrapper that retries a `DeliveryChannel.send` call with exponential backoff and surfaces exhausted attempts to a dead-letter queue. The wrapper:
1. Calls the wrapped channel up to `maxRetries + 1` total times.
2. Sleeps `baseDelayMs * 2^(attempt-1)` (capped at `maxDelayMs`) between attempts via an injectable `SleepFn`.
3. On exhaustion, pushes a `DeadLetterEntry`, fires an optional handler, and throws `DeliveryFailedError`.
## Features
- Configurable `{ maxRetries, baseDelayMs, maxDelayMs }`.
- Exponential backoff with cap.
- In-process dead-letter queue with `getDeadLetters` / `clearDeadLetters` surface.
- `onDeadLetter(handler)` callback for forwarding to a real queue (Redis, SurrealDB) or pager-duty.
- Injectable `Clock` and `SleepFn` for deterministic tests.
- Throws `DeliveryFailedError` on exhaustion so the router emits `notification.failed`.
## Usage: Basic
```ts
import { DeliveryRetry } from "@suaveplan/notification-channel";
const retry = new DeliveryRetry({ maxRetries: 3, baseDelayMs: 500 });
retry.onDeadLetter((entry) => pagerDuty.alert(entry));
try {
await retry.sendWithRetry(emailChannel, notification);
} catch (err) {
/* DeliveryFailedError */
}
```
## Usage: Advanced
```ts
const retry = new DeliveryRetry(
{ maxRetries: 5, baseDelayMs: 200, maxDelayMs: 10_000 },
() => clock.now(),
async (ms) => fakeTimers.tick(ms)
);
retry.onDeadLetter(async (entry) => {
await deadLetterQueue.push(entry);
});
for (const n of notifications) {
try {
await retry.sendWithRetry(channel, n);
} catch {
/* already dead-lettered */
}
}
```
## API Reference
- `DeliveryRetry(config?, clock?, sleep?)` — constructor.
- `RetryConfig` — `{ maxRetries, baseDelayMs, maxDelayMs? }`.
- `DeadLetterEntry` — `{ notification, channel, attempts, lastError, failedAt }`.
- `DeadLetterHandler`, `SleepFn` — callable types.
- `sendWithRetry(channel, notification): Promise<DeliveryResult>` — throws `DeliveryFailedError` on exhaustion.
- `onDeadLetter(handler)`, `getDeadLetters()`, `clearDeadLetters()`, `calculateDelay(attempt)`.
## Implementation Notes
The retry counter is 0-indexed: `attempt === 0` is the first call (no delay), `attempt === 1..maxRetries` incur a backoff before the call. `calculateDelay` is public so test suites can pin the schedule against expected values. The injectable `SleepFn` defaults to `setTimeout` — production should keep the default; tests SHOULD inject a no-op sleeper so the suite finishes in milliseconds. The dead-letter queue is in-process — for production durability, register an `onDeadLetter` handler that forwards to a persistent queue. The throw on exhaustion uses `DeliveryFailedError` with a `cause: Error(lastError)` so the upstream `EventBus` listener can introspect the underlying error string.

View File

@@ -0,0 +1,89 @@
# `notification-router` — Primary multi-channel router
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`notification-router.ts` ships the `NotificationRouter` class — the primary entrypoint a consumer talks to. It resolves the target channel (input override → routing rules → default), runs the `notification:routing` hook pipeline, resolves the body / subject (template render or raw), checks the channel is enabled and within rate limit, optionally dispatches to a registered `DeliveryChannel`, persists the notification record, and emits a lifecycle event (`notification.sent` / `notification.failed` / `notification.cancelled` / `notification.created`) on its internal `EventBus`. Every public boundary is Zod-validated.
## Features
- Channel resolution: input override → routing rules (priority and predicate filters) → default channel.
- Template-based content with `{{var}}` substitution.
- Per-channel 60-second window rate limiting (in-process — wire `ChannelRateLimiter` for richer policies).
- Lifecycle events via `EventBus`: `notification.created`, `notification.sent`, `notification.failed`, `notification.cancelled`.
- Optional in-pipeline `DeliveryChannel` dispatch.
- OpenTelemetry meters on every send.
- Zod-validated config at the constructor boundary.
- Injectable `Clock` for deterministic tests.
## Usage: Basic
```ts
import { NotificationRouter } from "@suaveplan/notification-channel";
const router = new NotificationRouter({
channels: [
{ channel: "email", enabled: true },
{ channel: "sms", enabled: true },
],
defaultChannel: "email",
});
const result = await router.send({
recipient: "user@example.com",
body: "Welcome!",
});
```
## Usage: Advanced
```ts
import {
EmailChannel,
NotificationRouter,
} from "@suaveplan/notification-channel";
const email = new EmailChannel({ from: "noreply@x", sendFn: provider.send });
const router = new NotificationRouter({
channels: [{ channel: "email", enabled: true, rateLimit: 100 }],
templates: [
{
id: "welcome",
name: "Welcome",
channel: "email",
subject: "Welcome {{name}}",
bodyTemplate: "Hi {{name}}",
defaultPriority: "normal",
},
],
routingRules: [
{ id: "urgent-to-sms", name: "u", channels: ["sms"], priority: "urgent" },
],
defaultChannel: "email",
deliveryChannels: [email],
});
router.on("notification.failed", (n) => audit.record(n));
await router.send({
recipient: "u@x",
body: "",
templateId: "welcome",
templateData: { name: "Alice" },
});
```
## API Reference
- `NotificationRouter(config: NotificationRouterConfig)` — constructor.
- `send(input)`, `enqueue(input)`, `sendBatch(inputs)`, `getNotification(id)`, `cancel(id)`, `getChannelStats()`.
- `registerChannel(c)`, `registerTemplate(t)`, `addRoutingRule(r)`, `registerDeliveryChannel(dc)`.
- `on(event, listener)` — subscribe to lifecycle events.
- `NotificationRouterConfig` — `{ channels, templates?, routingRules?, defaultChannel, deliveryChannels?, clock? }`.
## Implementation Notes
The internal 60-second rate-limit window is a fixed bucket per channel — for sliding-window or per-user limits, layer `ChannelRateLimiter` outside the router. `cancel` consults `VALID_TRANSITIONS` so cancelling a `sent` notification rejects (cannot transition `sent → cancelled`). The router uses `EventBus<NotificationEventMap>` so consumers get typed `on(...)` and the underlying envelope semantics — interceptors, abort signals — that ship in `@suaveplan/events`. Construction Zod-parses the config; runtime calls trust the inputs because `send` runs on every notification and re-validating on every call is wasteful — consumers MUST validate `NotificationInput` themselves if they accept it from untrusted sources.

View File

@@ -0,0 +1,64 @@
# `notification-scheduler` — Future-delivery scheduler
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`notification-scheduler.ts` ships the `NotificationScheduler` class for scheduling notifications for future delivery. The scheduler does NOT call the router itself — callers register an `onDue(handler)` that receives the original `NotificationInput`, typically wiring it back into `NotificationRouter.send` or `enqueue`. The class exposes both manual `processDue()` (call from cron / queue worker) and `start()`/`stop()` for in-process polling.
## Features
- `schedule(input, deliverAt)` returns a stable id.
- `cancel(id)`, `get(id)`, `listPending()`, `getDue(now?)` for inspection.
- `processDue()` awaits the handler per entry and removes processed entries.
- `start()`/`stop()` polling driver with idempotent semantics.
- Injectable `Clock` for deterministic tests.
- Throws `NotificationNotFoundError` for unknown ids on `cancel` / `get`.
## Usage: Basic
```ts
import {
NotificationRouter,
NotificationScheduler,
} from "@suaveplan/notification-channel";
const router = new NotificationRouter({ channels, defaultChannel: "email" });
const scheduler = new NotificationScheduler();
scheduler.onDue(async (input) => {
await router.send(input);
});
scheduler.schedule({ recipient: "u@x", body: "Reminder" }, new Date(Date.now() + 60_000));
scheduler.start();
```
## Usage: Advanced
```ts
const scheduler = new NotificationScheduler(5_000, () => clock.now());
scheduler.onDue(async (input) => {
try {
await router.send(input);
} catch (err) {
await failureQueue.push({ input, err });
}
});
// Hook into a cron worker instead of in-process polling:
cron.schedule("* * * * *", () => {
void scheduler.processDue();
});
```
## API Reference
- `NotificationScheduler(checkIntervalMs?, clock?)` — constructor.
- `schedule(input, deliverAt)`, `cancel(id)`, `get(id)`, `listPending()`, `getDue(now?)`, `processDue()`, `start()`, `stop()`, `size`.
- `ScheduledNotification` — `{ id, input, scheduledFor, createdAt, cancelled }`.
- `ScheduleHandler` — `(input: NotificationInput) => Promise<void>`.
## Implementation Notes
`start()` is idempotent — calling twice has no effect; the second call is dropped before `setInterval` runs. `stop()` is idempotent — calling twice is safe and a leading call before `start()` is a no-op. `processDue()` is the single place that mutates the schedule map — both the polling driver and manual / cron callers use it, so behaviour is identical regardless of how processing is triggered. The scheduler does not persist across process restarts — production deployments should layer their own durable store atop `schedule`/`cancel` (e.g. snapshot `listPending()` to disk on shutdown, restore on boot via additional `schedule` calls).

View File

@@ -0,0 +1,49 @@
# `schemas` — Zod schemas at every public boundary
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`schemas.ts` exposes a Zod schema for every public option bundle in the package so callers can validate untrusted input before passing it into a constructor or public method. The `NotificationRouter` constructor calls `NotificationRouterConfigSchema.parse(...)` at the boundary; other classes leave validation as an opt-in for the caller because validating on every send is wasteful — the consumer chooses when to enforce.
## Features
- Schemas for every channel config, every public bundle, and every enum: `NotificationChannelSchema`, `NotificationPrioritySchema`, `NotificationStatusSchema`, `RateLimitRuleSchema`, `ChannelConfigSchema`, `RoutingRuleSchema`, `NotificationTemplateSchema`, `TemplateRegistrationSchema`, `NotificationInputSchema`, `NotificationRouterConfigSchema`, `EmailChannelConfigSchema`, `SmsChannelConfigSchema`, `VapidKeysSchema`, `PushChannelConfigSchema`, `WebhookChannelConfigSchema`, `DigestChannelConfigSchema`, `RetryConfigSchema`, `QuietHoursConfigSchema`, `StoredPreferencesSchema`, `PreferenceManagerOptionsSchema`.
- Function-valued fields (`sendFn`, `getSubscriptions`, `fetchFn`, `condition`, `formatDigest`, `store`, `clock`) are typed as `z.unknown()` — Zod cannot introspect callables, and the TypeScript surface is the source of truth for shapes.
- Boundary defaults are NOT injected — schemas validate shape only, defaults live in the consuming classes.
## Usage: Basic
```ts
import { NotificationInputSchema } from "@suaveplan/notification-channel";
const input = NotificationInputSchema.parse(req.body);
await router.send(input);
```
## Usage: Advanced
```ts
import {
NotificationRouterConfigSchema,
StoredPreferencesSchema,
} from "@suaveplan/notification-channel";
// Validate config loaded from a YAML file:
const cfg = NotificationRouterConfigSchema.parse(yaml.parse(text));
const router = new NotificationRouter(cfg);
// Validate stored prefs at the persistence boundary:
const safe = StoredPreferencesSchema.parse(row);
await store.set(safe.userId, safe);
```
## API Reference
Each named export is a `z.ZodType`. Use `.parse(value)` to validate-or-throw, `.safeParse(value)` to validate-or-result. The schemas describe shape only — runtime behaviour, defaults, and engine-pluggable callables remain owned by the consuming classes.
## Implementation Notes
`exactOptionalPropertyTypes: true` in `tsconfig.base.json` makes Zod's `.optional()` behave correctly: an explicit `undefined` value is rejected when the field is absent in the input record, but is accepted on output because the consumer might spread defaults. We use `z.record(z.string(), z.unknown())` for `metadata`-style fields because notification metadata is genuinely free-form. The `RoutingRule.condition` and every `sendFn` / `fetchFn` are `z.unknown()` so YAML-loaded configs (which cannot carry callables) parse cleanly — TypeScript will still complain at the call site if the values are not callable.

View File

@@ -0,0 +1,80 @@
# `template-engine` — Template registry and renderer
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`template-engine.ts` ships the `TemplateEngine` registry that turns named templates into channel-specific rendered output. Templates carry a `defaultTemplate` (used when no channel variant matches) plus optional `channelTemplates` keyed by `NotificationChannel`, with `{{var}}` placeholders substituted from a flat `Record<string,string>`. Missing keys are left as-is so QA can spot them.
## Features
- Register / unregister / list templates by string id.
- Per-channel variants (`channelTemplates.email`, `.sms`, etc.) with fallback to `defaultTemplate`.
- `{{var}}` placeholder substitution against a flat string-valued record.
- Throws `TemplateNotFoundError` for unknown ids — no silent empty renders.
- Universal — no DOM/Node-specific APIs.
## Usage: Basic
```ts
import { TemplateEngine } from "@suaveplan/notification-channel";
const engine = new TemplateEngine();
engine.register({
id: "welcome",
name: "Welcome",
defaultTemplate: {
subject: "Welcome {{name}}",
bodyTemplate: "Hello {{name}}, welcome to {{app}}!",
},
});
const rendered = engine.render("welcome", { name: "Alice", app: "Suaveplan" });
```
## Usage: Advanced
```ts
engine.register({
id: "order-status",
name: "Order status",
defaultTemplate: {
subject: "Order {{orderId}} — {{status}}",
bodyTemplate: "Order #{{orderId}}: {{status}}",
},
channelTemplates: {
sms: { bodyTemplate: "Order {{orderId}}: {{status}}" },
email: {
subject: "Order {{orderId}} update",
bodyTemplate: "<h1>Order {{orderId}}</h1><p>{{status}}</p>",
},
},
});
const sms = engine.render(
"order-status",
{ orderId: "12345", status: "shipped" },
"sms"
);
const email = engine.render(
"order-status",
{ orderId: "12345", status: "shipped" },
"email"
);
```
## API Reference
- `TemplateEngine` class.
- `register(t: TemplateRegistration): void`
- `unregister(id: string): boolean`
- `has(id: string): boolean`
- `listIds(): string[]`
- `render(id, data?, channel?): RenderedTemplate`
- `TemplateRegistration`, `ChannelTemplateBody`, `ChannelTemplate`, `RenderedTemplate`.
## Implementation Notes
Substitution uses `String.prototype.split(...).join(...)` rather than `replace(/.../g, ...)` so dollar-escape sequences in the value (`$&`, `$1`) flow through verbatim. Subjects flow through the same substitution pass when set. Unknown placeholders are intentionally preserved — silently dropping them masks integration mismatches that QA would otherwise catch. The engine is intentionally simple: no conditionals, no loops, no template inheritance. Consumers needing those features should plug a real template language (Handlebars, Liquid, MJML) into a custom `TemplateEngine` decorator or render upstream.

View 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

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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`.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -0,0 +1,117 @@
# @suaveplan/sms-providers - Universal SMS provider implementations for @suaveplan/sms. Real HTTP clients for Twilio (Basic auth + form-encoded + X-Twilio-Signature HMAC-SHA1), Vonage (HMAC-SHA256 webhook), MessageBird (AccessKey + JWT HS256 webhook), Plivo (Basic auth + X-Plivo-Signature-V3 HMAC-SHA256), and AWS SNS (inline SigV4 + RSA-SHA1 SNS notification verifier). Ships ProviderRegistry, httpRequest over injectable FetchLike, composeAbortSignal, SMS_PROVIDERS_CODES + SmsProvidersError, per-provider mapStatus, parseInbound, parseOptOutKeyword, and five OTel instruments. No provider SDK deps; every HTTP call flows through an injectable FetchLike seam.
> Real provider clients for `@suaveplan/sms` — Twilio, Vonage, MessageBird, Plivo, AWS SNS. Universal (Bun, Node, browsers, Workers, Deno) over an injectable `FetchLike` seam; no provider SDKs in the dep tree.
<!-- begin:auto-docs -->
## Documentation tree
- [aws-sns](./src/aws-sns/aws-sns.md) — Real AWS SNS SMS provider implementing `SmsProvider` from `@suaveplan/sms`. POSTs `Action=Publish` form bodies to the r…
- [aws-sns](./src/aws-sns/aws-sns.md) — Real AWS SNS SMS provider implementing `SmsProvider` from `@suaveplan/sms`. POSTs `Action=Publish` form bodies to the r…
- [sigv4](./src/aws-sns/sigv4.md) — Inline AWS Signature V4 signer used by `AwsSnsProvider`. Replaces the `@aws-sdk/signature-v4` dependency tree (15+ MB)…
- [crypto](./src/crypto/default-hmac.md) — Owns the package's default `HmacSign` implementation (routed through `globalThis.crypto.subtle`) and the small set of u…
- [default-hmac](./src/crypto/default-hmac.md) — Owns the package's default `HmacSign` implementation (routed through `globalThis.crypto.subtle`) and the small set of u…
- [error-codes](./src/error-codes/error-codes.md) — Defines the stable, namespaced error-code registry `SMS_PROVIDERS_CODES` and the `SmsProvidersError` discriminated subc…
- [error-codes](./src/error-codes/error-codes.md) — Defines the stable, namespaced error-code registry `SMS_PROVIDERS_CODES` and the `SmsProvidersError` discriminated subc…
- [http](./src/http/http.md) — Universal HTTP helper that every concrete provider client routes through. Wraps an injectable `FetchLike` seam, se…
- [http](./src/http/http.md) — Universal HTTP helper that every concrete provider client routes through. Wraps an injectable `FetchLike` seam, se…
- [keywords](./src/keywords/keywords.md) — Universal classifier mapping an inbound SMS body to the canonical `OptOutAction` enum used by `@suaveplan/sms`'s `OptOu…
- [keywords](./src/keywords/keywords.md) — Universal classifier mapping an inbound SMS body to the canonical `OptOutAction` enum used by `@suaveplan/sms`'s `OptOu…
- [messagebird](./src/messagebird/messagebird.md) — Real MessageBird SMS provider implementing `SmsProvider` from `@suaveplan/sms`. POSTs to `/messages` with `Authorizatio…
- [messagebird](./src/messagebird/messagebird.md) — Real MessageBird SMS provider implementing `SmsProvider` from `@suaveplan/sms`. POSTs to `/messages` with `Authorizatio…
- [otel](./src/otel/otel.md) — Owns the package's OpenTelemetry instrument set. Five instruments cover the public-surface operations every provider cl…
- [otel](./src/otel/otel.md) — Owns the package's OpenTelemetry instrument set. Five instruments cover the public-surface operations every provider cl…
- [plivo](./src/plivo/plivo.md) — Real Plivo SMS provider implementing `SmsProvider` from `@suaveplan/sms`. POSTs to `/v1/Account/{AuthId}/Message/` auth…
- [plivo](./src/plivo/plivo.md) — Real Plivo SMS provider implementing `SmsProvider` from `@suaveplan/sms`. POSTs to `/v1/Account/{AuthId}/Message/` auth…
- [registry](./src/registry/registry.md) — `ProviderRegistry` is a process-local, synchronous index of `SmsProvider` instances keyed by `provider.name`. Sibling p…
- [registry](./src/registry/registry.md) — `ProviderRegistry` is a process-local, synchronous index of `SmsProvider` instances keyed by `provider.name`. Sibling p…
- [twilio](./src/twilio/twilio.md) — Real Twilio SMS provider implementing the `SmsProvider` contract from `@suaveplan/sms`. Issues authenticated, form-enco…
- [twilio](./src/twilio/twilio.md) — Real Twilio SMS provider implementing the `SmsProvider` contract from `@suaveplan/sms`. Issues authenticated, form-enco…
- [types](./src/types/provider.md) — Defines the shared structural types every concrete provider class consumes: `FetchLike` (the injectable HTTP seam), `Hm…
- [provider](./src/types/provider.md) — Defines the shared structural types every concrete provider class consumes: `FetchLike` (the injectable HTTP seam), `Hm…
- [vonage](./src/vonage/vonage.md) — Real Vonage (formerly Nexmo) SMS provider implementing `SmsProvider` from `@suaveplan/sms`. POSTs to `/sms/json` with `…
- [vonage](./src/vonage/vonage.md) — Real Vonage (formerly Nexmo) SMS provider implementing `SmsProvider` from `@suaveplan/sms`. POSTs to `/sms/json` with `…
<!-- end:auto-docs -->
## Highlights
- **Five real provider clients**, every one implementing the `SmsProvider` contract from `@suaveplan/sms`:
- `TwilioProvider` — `POST /Messages.json`, HTTP Basic auth, MMS `MediaUrl[]`, X-Twilio-Signature HMAC-SHA1 webhook verifier.
- `VonageProvider` — `POST /sms/json`, body auth, auto `type=unicode`, HMAC-SHA256 webhook verifier.
- `MessageBirdProvider` — `AccessKey` auth, JSON body, JWT HS256 webhook verifier with `iat`/`exp`/`nbf` enforcement + optional `payload_hash` binding.
- `PlivoProvider` — Basic auth, JSON body, X-Plivo-Signature-V3 HMAC-SHA256 verifier.
- `AwsSnsProvider` — inline AWS SigV4-signed `Action=Publish`, RSA-SHA1 SNS notification verifier with injectable callback.
- **`ProviderRegistry`** indexes provider instances by name; strict register/lookup, frozen `list()` snapshot.
- **`SmsProvidersError`** discriminated subclass of `SuaveplanError` with eight namespaced codes (`SMS_PROVIDERS.HTTP_ERROR`, `TIMEOUT`, `PARSE_ERROR`, `CONFIG_MISSING`, `SIGNATURE_INVALID`, `INVALID_NUMBER`, `PROVIDER_NOT_REGISTERED`, `PROVIDER_ALREADY_REGISTERED`).
- **`parseOptOutKeyword`** universal CTIA/TCPA opt-in/opt-out/help keyword classifier.
- **Five OTel instruments** — `send_total`, `webhook_total`, `signature_failures_total`, `send_duration_ms`, `webhook_verify_duration_ms`.
- **Real `node:http` round-trip tests** per provider — no mocks; tests bind a server on a random port and inject a `FetchLike` pointing at it.
## Quick start
```ts
import {
AwsSnsProvider,
MessageBirdProvider,
PlivoProvider,
ProviderRegistry,
TwilioProvider,
VonageProvider,
} from "@suaveplan/sms-providers";
const registry = new ProviderRegistry();
registry.register(new TwilioProvider({ accountSid: "AC…", authToken: "…" }));
registry.register(new VonageProvider({ apiKey: "k", apiSecret: "s" }));
registry.register(new MessageBirdProvider({ accessKey: "K" }));
registry.register(new PlivoProvider({ authId: "A", authToken: "T" }));
registry.register(
new AwsSnsProvider({
region: "us-east-1",
accessKeyId: "AKIA…",
secretAccessKey: "…",
})
);
const twilio = registry.get("twilio");
const report = await twilio.send({
to: "+15551234567",
from: "+15550000000",
body: "Hello",
});
console.log(report.messageId, report.status);
```
## Webhook verification
Every provider exposes a signature verifier matching its documented scheme:
```ts
// Twilio HMAC-SHA1 over url + sorted form
await twilio.verifyWebhookSignature(callbackUrl, params, signature);
// Vonage HMAC-SHA256 hex over sorted &key=value (excluding sig)
await vonage.verifyWebhookSignature(params, sigHex, signatureSecret);
// MessageBird JWT HS256 with iat/exp/nbf window + payload_hash binding
await mb.verifyWebhookSignature(jwt, signingKey, { nowMs, bodyHashHex });
// Plivo HMAC-SHA256 base64 over url + nonce + sorted key=value,...
await plivo.verifyWebhookSignature(url, nonce, params, signature);
// AWS SNS RSA-SHA1 — pass an injectable verifier callback
await sns.verifyNotification(payload, async (pem, msg, sigB64) => {
/* createVerify("RSA-SHA1")…verify(...) */
return true;
});
```
## Tier
`src/` ships zero `node:*` / `bun:*` / platform imports. Every HTTP call routes through an injectable `FetchLike`; every HMAC routes through an injectable `HmacSign` (defaulting to `globalThis.crypto.subtle`). Tests use real `node:http` + `node:crypto` from under `tests/setup/` and inside `*.test.ts` files only.
## Pre-publish gate
```bash
bunx turbo lint typecheck test build --filter=@suaveplan/sms-providers
bunx turbo test:coverage --filter=@suaveplan/sms-providers # 100/100/100/100
```

View File

@@ -0,0 +1,87 @@
# AWS SNS — `@suaveplan/sms-providers`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Real AWS SNS SMS provider implementing `SmsProvider` from `@suaveplan/sms`. POSTs `Action=Publish` form bodies to the regional SNS endpoint (`https://sns.<region>.amazonaws.com/`) signed with inline AWS Signature V4 (see `./sigv4`); SMS-specific message attributes (`AWS.SNS.SMS.SMSType` and `AWS.SNS.SMS.SenderID`) are forwarded when supplied on the provider config. Inbound SNS HTTP/S notifications — subscription confirmation, delivery callbacks, unsubscribe confirmation — are validated through `verifyNotification` which delegates the RSA-SHA1 verification step to an injectable callback so this package never imports `node:crypto`.
The class holds no in-flight state and routes every HTTP call through the injectable `FetchLike` seam. Tests stand up a `node:http` server on a random port and inject a `fetch` adapter that points at it.
## Features
- Inline AWS Signature V4 signing (no `@aws-sdk` dep tree).
- `AWS.SNS.SMS.SMSType` defaults to `Transactional`; override via `config.smsType: "Promotional"`.
- `AWS.SNS.SMS.SenderID` forwarded from the message `from` or the config `senderId`.
- Optional `sessionToken` for STS-issued credentials (`X-Amz-Security-Token`).
- Status mapping covers `pending` / `success` / `failure` / `optout`.
- `verifyNotification(payload, rsaVerify)` validates the documented SNS signing chain — fetches the signing cert URL through the injectable fetch, builds the canonical string-to-sign (different field order for `Notification` vs `SubscriptionConfirmation`/`UnsubscribeConfirmation`), and delegates the RSA verification to the supplied callback.
- `parseInbound` discriminates between delivery-receipt notifications (Subject = `"AWS SMS Delivery Receipt"`) and inbound mobile-originated SMS payloads; recognises CTIA opt-out keywords on the inbound body.
- OTel — counter + histogram on every send + verify.
## Usage: Basic
```ts
import { AwsSnsProvider } from "@suaveplan/sms-providers";
const sns = new AwsSnsProvider({
region: "us-east-1",
accessKeyId: "AKIA…",
secretAccessKey: "…",
senderId: "SUAVEPLAN",
});
const r = await sns.send({ to: "+15551234567", body: "Hello" });
```
## Usage: Advanced
```ts
import { AwsSnsProvider } from "@suaveplan/sms-providers";
import { createVerify } from "node:crypto";
const sns = new AwsSnsProvider({
region: "us-east-1",
accessKeyId: "AKIA…",
secretAccessKey: "…",
sessionToken: "FwoGZXIvYXdz…",
smsType: "Transactional",
});
// Node-side RSA-SHA1 verifier callback.
const rsaVerify = async (pem: string, msg: string, sigB64: string) =>
createVerify("RSA-SHA1").update(msg).end().verify(pem, sigB64, "base64");
const ok = await sns.verifyNotification(
/* payload */ {
Type: "Notification",
MessageId: "abc",
TopicArn: "arn:aws:sns:…",
Subject: "AWS SMS Delivery Receipt",
Message: JSON.stringify({ status: "SUCCESS" }),
Timestamp: new Date().toISOString(),
Signature: "<base64>",
SignatureVersion: "1",
SigningCertURL:
"https://sns.us-east-1.amazonaws.com/SimpleNotificationService-XXX.pem",
},
rsaVerify
);
```
## API Reference
| Symbol | Kind | Notes |
|---|---|---|
| `AwsSnsProvider` | class | Implements `SmsProvider`. |
| `send(message, options?)` | method | SigV4-signed POST `Action=Publish`. |
| `getStatus(id)` | method | Returns a synthetic `queued` report (SNS does not expose per-message status). |
| `mapStatus(raw)` | method | Canonical mapping. |
| `verifyNotification(payload, rsaVerify)` | method | Delegates RSA-SHA1 verification to the supplied callback. |
| `parseInbound(payload)` | method | Discriminates into `InboundSmsEvent`. |
| `buildSnsStringToSign(payload)` | helper | Exported for advanced use. |
## Implementation Notes
`getStatus` deliberately returns a synthetic `queued` report rather than synthesise a fake `delivered` — SNS reports SMS lifecycle exclusively via the configured delivery-status SNS topic (typically wired into CloudWatch Logs in production), and lying about provider behaviour would mislead consumers. The recommended production pipeline is to subscribe to the delivery-status topic, route the notifications back through the provider's `parseInbound`, and feed the resulting `delivery_receipt` events to `@suaveplan/sms`'s `DeliveryTracker`. The `senderId` config overrides the per-message `from`; this matches the AWS console UX where the account-level Sender ID is the production default.

View File

@@ -0,0 +1,80 @@
# SigV4 — `@suaveplan/sms-providers`
## Purpose
Inline AWS Signature V4 signer used by `AwsSnsProvider`. Replaces the `@aws-sdk/signature-v4` dependency tree (15+ MB) with an under-80-LOC implementation of the canonical-request → string-to-sign → signing-key → final-signature chain. HMAC computations are delegated to the injectable `HmacSign` seam from `@suaveplan/sms-providers`, so the signer stays universal and tests can substitute a deterministic adapter.
The signer returns a fresh headers object containing the input headers plus `Host`, `X-Amz-Date`, `X-Amz-Content-Sha256`, the optional `X-Amz-Security-Token` (when a session token is supplied), and the `Authorization` header in the documented form `AWS4-HMAC-SHA256 Credential=…, SignedHeaders=…, Signature=<64 hex>`.
## Features
- 64-character lowercase-hex signature per the AWS spec.
- Canonical request built from method + path + canonical headers (lower-cased keys, trimmed values) + signed-headers list + body-sha256 hex.
- `dateStamp` (`YYYYMMDD`) and `amzDate` (`YYYYMMDDTHHMMSSZ`) derived from a caller-supplied `Date` so tests can pin the clock and produce stable signatures.
- Signing key chain: `kDate → kRegion → kService → kSigning` via HMAC-SHA256 over `AWS4<secret>`, `region`, `service`, `aws4_request`.
- Body SHA-256 hashing routed through `globalThis.crypto.subtle.digest("SHA-256", …)` (universal: Bun, browsers, Workers, Deno, Node 19+).
## Usage: Basic
```ts
import { defaultHmac } from "@suaveplan/sms-providers";
import { signRequest, sha256OfUtf8 } from "@suaveplan/sms-providers/aws-sns/sigv4";
const body = "Action=Publish&Message=hi";
const out = await signRequest({
accessKeyId: "AKIA…",
secretAccessKey: "…",
region: "us-east-1",
service: "sns",
method: "POST",
host: "sns.us-east-1.amazonaws.com",
path: "/",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
bodySha256Hex: await sha256OfUtf8(body),
now: new Date(),
hmac: defaultHmac,
});
// out.headers.Authorization === "AWS4-HMAC-SHA256 Credential=AKIA…/<dateStamp>/us-east-1/sns/aws4_request, SignedHeaders=…, Signature=<64 hex>"
```
## Usage: Advanced
```ts
import { signRequest, sha256OfUtf8 } from "@suaveplan/sms-providers/aws-sns/sigv4";
import { createHmac } from "node:crypto";
// Drive the signer with a Node-side HMAC adapter and a pinned date so
// the resulting Authorization header is byte-stable between test runs.
const hmac = async (algo: "SHA-1" | "SHA-256", key: Uint8Array, msg: Uint8Array) =>
new Uint8Array(
createHmac(algo === "SHA-1" ? "sha1" : "sha256", Buffer.from(key))
.update(Buffer.from(msg))
.digest()
);
const out = await signRequest({
accessKeyId: "AKIDEXAMPLE",
secretAccessKey: "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY",
region: "us-east-1",
service: "sns",
method: "POST",
host: "sns.us-east-1.amazonaws.com",
path: "/",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
bodySha256Hex: await sha256OfUtf8("Action=Publish"),
now: new Date("2026-04-24T10:00:00Z"),
hmac,
});
```
## API Reference
| Symbol | Kind | Notes |
|---|---|---|
| `signRequest(input)` | function | Returns `{ headers }` with the SigV4 signature applied. |
| `sha256OfUtf8(s)` | function | SHA-256-of-UTF-8 helper returning lowercase hex. |
| `SignRequestInput` | interface | Required inputs to the signer. |
| `SignRequestOutput` | interface | Returned headers object. |
## Implementation Notes
The canonical-request newline before the body-sha256 is intentional per the AWS docs (the canonical request ends with `\n<body-sha256>`, no trailing newline). The signed-headers list is computed by lower-casing every input key, sorting, and joining with `;` — this matches the canonical-request header rendering exactly.

View File

@@ -0,0 +1,74 @@
# crypto — `@suaveplan/sms-providers`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Owns the package's default `HmacSign` implementation (routed through `globalThis.crypto.subtle`) and the small set of universal codec helpers — `toHex` / `fromHex` / `toBase64` / `fromBase64` / `utf8` / `constantTimeEqual` — that every provider's signature verifier shares. The default HMAC implementation is available in Bun, browsers, Workers, Deno, and Node 19+; callers on older runtimes inject a `node:crypto`-backed adapter on the provider's `hmac` config field.
Keeping the codec helpers in one place stops every provider from rolling its own hex / base64 routine (which empirically all end up subtly different — different elision sentinels, different uppercasing, different `Buffer` assumptions).
## Features
- `defaultHmac` — universal `HmacSign` driven by `globalThis.crypto.subtle`. Supports `SHA-1` (Twilio + AWS SNS verifier) and `SHA-256` (Vonage + MessageBird + Plivo + AWS SigV4).
- `toHex` / `fromHex` — lowercase hex round-trip.
- `toBase64` / `fromBase64` — standard Base-64 (no URL-safe substitutions; CTIA / SNS / Twilio all expect canonical Base-64).
- `utf8(s)` — convenience wrapper for `new TextEncoder().encode(s)`.
- `constantTimeEqual(a, b)` — byte-array comparison that is XOR-accumulated to avoid early-exit timing attacks on signature verification.
## Usage: Basic
```ts
import {
constantTimeEqual,
defaultHmac,
toBase64,
utf8,
} from "@suaveplan/sms-providers";
const sig = await defaultHmac("SHA-1", utf8("authToken"), utf8("payload"));
const ok = constantTimeEqual(sig, expectedSig);
console.log(toBase64(sig));
```
## Usage: Advanced
```ts
import {
defaultHmac,
toHex,
utf8,
type HmacSign,
} from "@suaveplan/sms-providers";
import { createHmac } from "node:crypto";
// Node 18 fallback when subtle.sign is not exposed.
const nodeHmac: HmacSign = async (algo, key, msg) =>
new Uint8Array(
createHmac(algo === "SHA-1" ? "sha1" : "sha256", Buffer.from(key))
.update(Buffer.from(msg))
.digest()
);
const hmac: HmacSign = typeof globalThis.crypto?.subtle?.sign === "function"
? defaultHmac
: nodeHmac;
const sig = toHex(await hmac("SHA-256", utf8("secret"), utf8("payload")));
```
## API Reference
| Symbol | Kind | Notes |
|---|---|---|
| `defaultHmac` | const | `HmacSign` over `globalThis.crypto.subtle`. |
| `toHex(bytes)` / `fromHex(s)` | functions | Lowercase hex codec. |
| `toBase64(bytes)` / `fromBase64(s)` | functions | Standard Base-64 codec. |
| `utf8(s)` | function | `TextEncoder().encode(s)`. |
| `constantTimeEqual(a, b)` | function | XOR-accumulated comparison. |
## Implementation Notes
`toHex` builds the string with `String#padStart(2, "0")` per byte rather than the bigint-shift trick to keep the implementation legible. `constantTimeEqual` never short-circuits on length mismatch *during* iteration — the early `length` check is unavoidable but does not leak content. Subtle's `importKey` is called inside `defaultHmac` rather than cached because production callers typically supply a fresh `key` per call.

View File

@@ -0,0 +1,104 @@
# Error codes — `@suaveplan/sms-providers`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines the stable, namespaced error-code registry `SMS_PROVIDERS_CODES` and the `SmsProvidersError` discriminated subclass used by every public surface of `@suaveplan/sms-providers`. Every failure path the package can take — a non-2xx HTTP response from a provider, an upstream request timeout, a JSON parse failure on a response body, a missing configuration value, a webhook signature mismatch, an invalid E.164 number, a registry name collision, a missing registry lookup — raises one of these eight codes so downstream loggers, problem+json responders, retry wrappers, and analytics pipelines see a consistent shape.
Codes are minted through `@suaveplan/error`'s `defineCodes("SMS_PROVIDERS", […])` so they sit in a non-colliding namespace (`SMS_PROVIDERS.*`) distinct from `@suaveplan/sms`'s `SMS.*` codes. The `SmsProvidersError` class extends `SuaveplanError<SmsProvidersErrorCode>` so `instanceof SmsProvidersError` narrows `error.code` to the union; optional `provider`, `httpStatus`, `url`, and `bodyExcerpt` discriminants are carried so consumers can branch without re-parsing the message.
## Features
- Eight codes covering every public-surface failure path.
- Six factory helpers (`httpError`, `timeoutError`, `parseError`, `configMissing`, `signatureInvalid`, `invalidNumber`, `providerNotRegistered`, `providerAlreadyRegistered`).
- Response bodies are truncated to 256 chars on the `bodyExcerpt` discriminant — bounded log volume regardless of payload size.
- `cause` is preserved for any wrapped exception (timeout, parse failure) so root-cause traversal still works.
## Usage: Basic
```ts
import {
SmsProvidersError,
SMS_PROVIDERS_CODES,
httpError,
} from "@suaveplan/sms-providers";
try {
/* call provider */
} catch (err) {
if (
err instanceof SmsProvidersError &&
err.code === SMS_PROVIDERS_CODES.HTTP_ERROR
) {
console.warn(`HTTP ${err.httpStatus} from ${err.provider}`);
}
}
const e = httpError("twilio", 422, "https://api.twilio.com/x", "{}", "invalid");
```
## Usage: Advanced
```ts
import {
SmsProvidersError,
SMS_PROVIDERS_CODES,
signatureInvalid,
} from "@suaveplan/sms-providers";
// A custom webhook router that maps `SMS_PROVIDERS.SIGNATURE_INVALID` to a 401.
export function verifyOrThrow(
provider: string,
ok: boolean,
reason: string
): void {
if (!ok) {
throw signatureInvalid(provider, reason);
}
}
// Branch on every code for a problem+json renderer.
function statusFor(err: SmsProvidersError): number {
switch (err.code) {
case SMS_PROVIDERS_CODES.SIGNATURE_INVALID:
return 401;
case SMS_PROVIDERS_CODES.CONFIG_MISSING:
return 500;
case SMS_PROVIDERS_CODES.INVALID_NUMBER:
return 400;
case SMS_PROVIDERS_CODES.PROVIDER_NOT_REGISTERED:
return 404;
case SMS_PROVIDERS_CODES.PROVIDER_ALREADY_REGISTERED:
return 409;
case SMS_PROVIDERS_CODES.TIMEOUT:
case SMS_PROVIDERS_CODES.HTTP_ERROR:
case SMS_PROVIDERS_CODES.PARSE_ERROR:
return err.httpStatus ?? 502;
default:
return 500;
}
}
```
## API Reference
| Symbol | Kind | Notes |
|---|---|---|
| `SMS_PROVIDERS_CODES` | const record | Frozen map produced by `defineCodes("SMS_PROVIDERS", […])` |
| `SmsProvidersErrorCode` | type | Union of every code value |
| `SmsProvidersError` | class | Extends `SuaveplanError` with optional `provider` / `httpStatus` / `url` / `bodyExcerpt` |
| `httpError(provider, status, url, body, message?)` | factory | `HTTP_ERROR` |
| `timeoutError(provider, url, timeoutMs, cause?)` | factory | `TIMEOUT` |
| `parseError(provider, url, body, cause)` | factory | `PARSE_ERROR` |
| `configMissing(provider, key)` | factory | `CONFIG_MISSING` |
| `signatureInvalid(provider, reason)` | factory | `SIGNATURE_INVALID` |
| `invalidNumber(provider, number)` | factory | `INVALID_NUMBER` |
| `providerNotRegistered(name)` | factory | `PROVIDER_NOT_REGISTERED` |
| `providerAlreadyRegistered(name)` | factory | `PROVIDER_ALREADY_REGISTERED` |
## Implementation Notes
`bodyExcerpt` truncation keeps the elision sentinel `…` (U+2026) — distinguishable from a literal three-dot ASCII triplet so log scrapers can tell when truncation fired. The factories never construct errors from raw `Error`; they always go through `new SmsProvidersError(...)` so the prototype chain stays intact under transpiler-emitted `__extends` shims.

View File

@@ -0,0 +1,71 @@
# http — `@suaveplan/sms-providers`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Universal HTTP helper that every concrete provider client routes through. Wraps an injectable `FetchLike` seam, serialises JSON or form bodies, enforces a per-call timeout via `AbortSignal`, composes the timeout signal with a caller-supplied `signal` so external cancellation also tears the request down, and translates every failure mode into the typed `SmsProvidersError` hierarchy.
The function lets every provider class stay free of platform-specific HTTP code (no `node-fetch`, no `axios`, no SDK fetch runtimes). Tests inject a recording `FetchLike` (or one that proxies through a real `node:http` server on a random port); production runtime resolves `globalThis.fetch.bind(globalThis)`.
## Features
- Injectable `FetchLike` defaulting to `globalThis.fetch.bind(globalThis)` so the same code runs on Bun, Node, browsers, Workers, and Deno.
- Per-call timeout via an internal `AbortController`; composed with `caller.signal` through `composeAbortSignal`.
- `body: string` is sent verbatim with `Content-Type: application/x-www-form-urlencoded` (Twilio/Vonage/Plivo/SNS form bodies); any other shape is JSON-stringified with `Content-Type: application/json`. Callers may override either header in `headers`.
- JSON detection is `Content-Type`-driven, with a `{`/`[` start-byte fallback so providers that omit the header still parse.
- 2xx → `{ status, data }`; non-2xx → `SmsProvidersError(HTTP_ERROR)` with the body excerpted; timeout abort → `TIMEOUT`; parse failure → `PARSE_ERROR`; underlying fetch rejection → `HTTP_ERROR` (status 0).
## Usage: Basic
```ts
import { httpRequest } from "@suaveplan/sms-providers";
const { status, data } = await httpRequest<{ sid: string }>({
method: "POST",
url: "https://api.twilio.com/2010-04-01/Accounts/AC/Messages.json",
headers: { Authorization: "Basic …" },
body: "To=%2B1&From=%2B1&Body=hi",
provider: "twilio",
});
```
## Usage: Advanced
```ts
import { composeAbortSignal, httpRequest } from "@suaveplan/sms-providers";
const userAbort = new AbortController();
setTimeout(() => userAbort.abort(new Error("cancelled by user")), 100);
try {
await httpRequest({
method: "GET",
url: "https://api.example.com/slow",
timeoutMs: 30_000,
signal: userAbort.signal,
provider: "example",
});
} catch (err) {
// SmsProvidersError(TIMEOUT) or HTTP_ERROR(status:0) depending on
// which signal fired first.
}
// Compose your own signals when assembling a multi-step provider call.
const internal = new AbortController().signal;
const composed = composeAbortSignal(internal, userAbort.signal);
```
## API Reference
| Symbol | Kind | Notes |
|---|---|---|
| `httpRequest<T>(options)` | function | Returns `Promise<ProviderResponse<T>>`; throws `SmsProvidersError` on failure. |
| `composeAbortSignal(internal, external?)` | function | Returns a signal that aborts when either input aborts. |
| `DEFAULT_TIMEOUT_MS` | private | 30 000 ms. |
## Implementation Notes
`composeAbortSignal` re-uses the caller's signal directly when `external` is `undefined`, avoiding a wrapper allocation on the hot path. The timeout abort is differentiated from caller-driven aborts by inspecting `timeoutSignal.aborted` after a thrown `AbortError` — a separate `AbortController` per request guarantees the test is reliable even when the caller's signal also fires.

View File

@@ -0,0 +1,64 @@
# Keywords — `@suaveplan/sms-providers`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Universal classifier mapping an inbound SMS body to the canonical `OptOutAction` enum used by `@suaveplan/sms`'s `OptOutRegistry`. CTIA's Short Code Monitoring Handbook (and the parallel TCPA / GDPR opt-out frameworks) mandate that outbound SMS programs recognise STOP / STOPALL / UNSUBSCRIBE / CANCEL / END / QUIT as opt-out, START / YES / UNSTOP as opt-in, and HELP / INFO as help-info requests, in every market. Every provider's `parseInbound` runs the raw body through this classifier so the higher-level service layer never has to re-implement the vocabulary.
## Features
- Canonical six-keyword opt-out set: `STOP`, `STOPALL`, `UNSUBSCRIBE`, `CANCEL`, `END`, `QUIT`.
- Canonical three-keyword opt-in set: `START`, `YES`, `UNSTOP`.
- Canonical two-keyword help set: `HELP`, `INFO`.
- Recognition is case-insensitive and ignores surrounding whitespace — `" Stop "`, `"STOP\n"`, `"stop"` all match.
- Multi-word bodies do not match (the canonical CTIA contract is single-keyword); they fall through to `"none"`.
## Usage: Basic
```ts
import { parseOptOutKeyword } from "@suaveplan/sms-providers";
const action = parseOptOutKeyword(" Stop ");
// action === "opt_out"
```
## Usage: Advanced
```ts
import {
parseOptOutKeyword,
type OptOutAction,
} from "@suaveplan/sms-providers";
function handleInbound(from: string, body: string): void {
const action: OptOutAction = parseOptOutKeyword(body);
switch (action) {
case "opt_out":
// optOutRegistry.recordOptOut({ phoneNumber: from, optedOutAt: new Date() });
return;
case "opt_in":
// optOutRegistry.recordOptIn({ phoneNumber: from });
return;
case "help":
// outbound auto-reply with HELP text required by CTIA
return;
case "none":
// forward to the conversational handler
return;
}
}
```
## API Reference
| Symbol | Kind | Notes |
|---|---|---|
| `OptOutAction` | union | `"opt_out" \| "opt_in" \| "help" \| "none"`. |
| `parseOptOutKeyword(body)` | function | Returns the classification. |
## Implementation Notes
The keyword sets are frozen `Set<string>` instances at module load so lookup is O(1). Trim + upper-case the input once and compare against all three sets in sequence — short-circuiting on the first match means an opt-out check beats opt-in beats help (no overlap exists in the canonical vocabulary; sequencing is for stability rather than correctness).

View File

@@ -0,0 +1,58 @@
# MessageBird — `@suaveplan/sms-providers`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Real MessageBird SMS provider implementing `SmsProvider` from `@suaveplan/sms`. POSTs to `/messages` with `Authorization: AccessKey <key>`; status fetched via `GET /messages/{id}`. Inbound webhook signature verification follows the MessageBird Signed Webhooks v2 (`Messagebird-Signature-Jwt`) spec — a JWT HS256 carrying `iat`/`exp`/`nbf`/`payload_hash` claims; the verifier checks the timestamp window (default ±5 minutes), re-MACs the canonical `header.payload`, and constant-time compares the base64url-decoded signature.
The class holds no in-flight state and routes every HTTP call through the injectable `FetchLike` seam. Tests stand up a `node:http` server on a random port and inject a `fetch` adapter that points at it.
## Features
- `AccessKey` auth header per MessageBird's documented scheme.
- JSON body with `recipients[]`, `originator`, `body`.
- Status mapping covers `scheduled` / `sent` / `buffered` / `delivered` / `expired` / `delivery_failed`.
- JWT HS256 webhook verifier with `iat` / `exp` / `nbf` window enforcement and optional `payload_hash` cross-check.
- Injectable `nowMs` + `toleranceMs` so tests pin the clock and the production deployment can widen the freshness window if needed.
- `parseInbound` discriminates between inbound messages and DLRs, recognising the standard CTIA opt-out keywords.
- OTel — `send_total` + `send_duration_ms` + `signature_failures_total` + `webhook_verify_duration_ms`.
## Usage: Basic
```ts
import { MessageBirdProvider } from "@suaveplan/sms-providers";
const mb = new MessageBirdProvider({ accessKey: "test_…" });
await mb.send({ to: "+15551234567", from: "Suaveplan", body: "hi" });
```
## Usage: Advanced
```ts
import { MessageBirdProvider } from "@suaveplan/sms-providers";
const mb = new MessageBirdProvider({ accessKey: "live_…" });
const ok = await mb.verifyWebhookSignature(
"<header>.<payload>.<sig>",
"shared-signing-key",
{ bodyHashHex: "<sha256-hex>", nowMs: Date.now(), toleranceMs: 60_000 }
);
```
## API Reference
| Symbol | Kind | Notes |
|---|---|---|
| `MessageBirdProvider` | class | Implements `SmsProvider`. |
| `send(message, options?)` | method | `POST /messages`, returns `SmsDeliveryReport`. |
| `getStatus(id)` | method | `GET /messages/{id}`. |
| `mapStatus(raw)` | method | Canonical mapping. |
| `verifyWebhookSignature(token, signingKey, options?)` | method | JWT HS256 verifier; throws `SIGNATURE_INVALID` on failure. |
| `parseInbound(body)` | method | Discriminates into `InboundSmsEvent`. |
## Implementation Notes
The verifier handles both standard Base-64 and base64url (`-`/`_` → `+`/`/`) inputs because MessageBird's JWT uses the URL-safe variant. `payload_hash` is checked only when both the JWT carries it and the caller supplies a `bodyHashHex`; missing on either side is treated as "skip" so callers can disable the body binding when their proxy strips bodies.

View File

@@ -0,0 +1,84 @@
# OTel — `@suaveplan/sms-providers`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Owns the package's OpenTelemetry instrument set. Five instruments cover the public-surface operations every provider class performs: outbound send (count + duration), inbound webhook events (count + verification duration), and signature-failure counter. The module exposes typed `recordSend`/`recordWebhook`/`recordSignatureFailure`/`recordSendDuration`/`recordWebhookVerifyDuration` helpers so providers stay free of inline OTel boilerplate, and a `refreshSmsProvidersMeters()` helper that re-resolves every instrument against the current global `MeterProvider` — tests install a fresh in-memory reader, call `refresh`, and then drive the helpers to assert on the captured metric points.
## Features
- Exactly five instruments — three counters (`send_total`, `webhook_total`, `signature_failures_total`) and two histograms (`send_duration_ms`, `webhook_verify_duration_ms`).
- Every counter carries a `provider` label so multi-provider deployments split cleanly in Grafana / Honeycomb.
- `outcome` (`"ok"` / `"error"`) on `send_total` and `webhook_total` lets dashboards compute success rate as `1 - error / total`.
- `webhook_total` adds an `event` label (`message_received` | `delivery_receipt` | `opt_in` | `opt_out` | `help`).
- Histograms record in `ms`.
- `refreshSmsProvidersMeters()` is the test seam — module-load resolves the global meter once, this resets it.
## Usage: Basic
```ts
import {
recordSend,
recordSendDuration,
} from "@suaveplan/sms-providers";
const t0 = Date.now();
try {
/* call provider */
recordSend("twilio", "ok");
} catch {
recordSend("twilio", "error");
throw;
} finally {
recordSendDuration("twilio", Date.now() - t0);
}
```
## Usage: Advanced
```ts
import {
recordSignatureFailure,
recordWebhook,
recordWebhookVerifyDuration,
refreshSmsProvidersMeters,
} from "@suaveplan/sms-providers";
import { metrics } from "@opentelemetry/api";
import { MeterProvider } from "@opentelemetry/sdk-metrics";
// Test-only: swap the global meter for a recording one.
const provider = new MeterProvider();
metrics.setGlobalMeterProvider(provider);
refreshSmsProvidersMeters();
const t0 = performance.now();
try {
// verifyWebhookSignature(...)
} catch (err) {
recordSignatureFailure("plivo");
throw err;
} finally {
recordWebhookVerifyDuration("plivo", performance.now() - t0);
}
recordWebhook("vonage", "delivery_receipt", "ok");
```
## API Reference
| Symbol | Kind | Notes |
|---|---|---|
| `recordSend(provider, outcome)` | function | Counter `sms_providers.send_total`. |
| `recordWebhook(provider, event, outcome)` | function | Counter `sms_providers.webhook_total`. |
| `recordSignatureFailure(provider)` | function | Counter `sms_providers.signature_failures_total`. |
| `recordSendDuration(provider, ms)` | function | Histogram `sms_providers.send_duration_ms`. |
| `recordWebhookVerifyDuration(provider, ms)` | function | Histogram `sms_providers.webhook_verify_duration_ms`. |
| `refreshSmsProvidersMeters()` | function | Re-resolves every instrument against the current global meter. |
| `SendOutcome` / `WebhookOutcome` | type | `"ok" \| "error"`. |
## Implementation Notes
Instruments are held as `let`-bound module-locals so `refreshSmsProvidersMeters()` can reassign them after `metrics.setGlobalMeterProvider(...)` — re-resolving is necessary because OTel's `Meter` proxy snapshots the active provider at meter-creation time. The instrument names follow the OTel naming convention (`<domain>.<metric>` with `_total` suffix on monotonic counters).

View File

@@ -0,0 +1,66 @@
# Plivo — `@suaveplan/sms-providers`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Real Plivo SMS provider implementing `SmsProvider` from `@suaveplan/sms`. POSTs to `/v1/Account/{AuthId}/Message/` authenticated via HTTP Basic (`AuthId:AuthToken`); JSON body. Status fetched via `GET .../Message/{uuid}/`. Inbound webhook signature verification follows Plivo's documented `X-Plivo-Signature-V3` scheme — HMAC-SHA256 over the canonical signing string `<url><nonce><sorted form-fields joined as key=value, comma-separated>` with the auth token as the key, Base-64 encoded.
The class holds no in-flight state and routes every HTTP call through the injectable `FetchLike` seam. Tests stand up a `node:http` server on a random port and inject a `fetch` adapter that points at it.
## Features
- HTTP Basic auth with the canonical `AuthId:AuthToken` pair.
- JSON request body — `src`, `dst`, `text` (and `media_urls`/`type=mms` when MMS).
- `getStatus(uuid)` round-trips the documented `.../Message/{uuid}/` resource.
- Status mapping covers `queued` / `sent` / `delivered` / `undelivered` / `failed` / `rejected`.
- `verifyWebhookSignature(url, nonce, params, sig)` implements the X-Plivo-Signature-V3 canonical string scheme.
- `parseInbound` discriminates between inbound messages, delivery receipts, and opt-out/opt-in/help keywords.
- OTel — counter + histogram on every send + verify.
## Usage: Basic
```ts
import { PlivoProvider } from "@suaveplan/sms-providers";
const plivo = new PlivoProvider({
authId: "MA…",
authToken: "…",
});
const r = await plivo.send({
to: "+15551234567",
from: "+15559999999",
body: "hi",
});
```
## Usage: Advanced
```ts
import { PlivoProvider } from "@suaveplan/sms-providers";
const plivo = new PlivoProvider({ authId: "MA…", authToken: "…" });
const ok = await plivo.verifyWebhookSignature(
"https://your.app/webhooks/plivo",
"nonce-from-X-Plivo-Signature-Ma-Nonce-Header",
{ From: "+1", To: "+2", Text: "hi", MessageUUID: "uuid" },
"<base64 signature>"
);
```
## API Reference
| Symbol | Kind | Notes |
|---|---|---|
| `PlivoProvider` | class | Implements `SmsProvider`. |
| `send(message, options?)` | method | POST `.../Message/`; returns `SmsDeliveryReport`. |
| `getStatus(uuid)` | method | `GET .../Message/{uuid}/`. |
| `mapStatus(raw)` | method | Canonical mapping. |
| `verifyWebhookSignature(url, nonce, params, sig)` | method | HMAC-SHA256-Base-64 verifier; throws `SIGNATURE_INVALID` on mismatch. |
| `parseInbound(params)` | method | Discriminates into `InboundSmsEvent`. |
## Implementation Notes
`send` defaults the initial status to `"queued"` because Plivo's create response only returns the message UUID; the actual carrier-side state is later reported on the inbound DLR webhook. MMS support flips `type: "mms"` and forwards `mediaUrls` as `media_urls[]` per the documented Plivo convention.

View File

@@ -0,0 +1,91 @@
# Registry — `@suaveplan/sms-providers`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`ProviderRegistry` is a process-local, synchronous index of `SmsProvider` instances keyed by `provider.name`. Sibling provider classes (`TwilioProvider`, `VonageProvider`, `MessageBirdProvider`, `PlivoProvider`, `AwsSnsProvider`) contribute instances; consumers — typically the `@suaveplan/sms` `SmsService` or a higher-level routing layer such as the campaigns API plugin — register the ones they intend to route to and look them up by string name.
The registry intentionally has no persistence, no auto-discovery, and no async lifecycle. Those concerns belong to higher-level orchestrators. The registry's contract is small enough to fit in a single file and complete enough that the strict register/lookup semantics (throwing on duplicate names + throwing on missing names) prevent the two most common configuration mistakes silently corrupting routing.
## Features
- Strict `register` — throws `SmsProvidersError(PROVIDER_ALREADY_REGISTERED)` on duplicate names.
- Strict `get` — throws `SmsProvidersError(PROVIDER_NOT_REGISTERED)` on absent names.
- Idempotent `unregister(name)` — returns `true` when a provider was removed, `false` otherwise.
- `has(name)` for soft lookup before a `get`.
- `list()` returns a frozen, insertion-ordered snapshot — callers cannot mutate the registry through the returned array.
- `size` reports the current cardinality.
## Usage: Basic
```ts
import { ProviderRegistry, TwilioProvider } from "@suaveplan/sms-providers";
const registry = new ProviderRegistry();
registry.register(new TwilioProvider({
accountSid: "ACxxxxx",
authToken: "xxxxx",
}));
const twilio = registry.get("twilio");
```
## Usage: Advanced
```ts
import {
AwsSnsProvider,
MessageBirdProvider,
PlivoProvider,
ProviderRegistry,
TwilioProvider,
VonageProvider,
} from "@suaveplan/sms-providers";
const registry = new ProviderRegistry();
for (const p of [
new TwilioProvider({ accountSid: "AC", authToken: "t" }),
new VonageProvider({ apiKey: "k", apiSecret: "s" }),
new MessageBirdProvider({ accessKey: "K" }),
new PlivoProvider({ authId: "A", authToken: "T" }),
new AwsSnsProvider({
region: "us-east-1",
accessKeyId: "AKIA…",
secretAccessKey: "…",
}),
]) {
registry.register(p);
}
function chooseProvider(prefix: string): string {
if (prefix.startsWith("+1")) {
return "twilio";
}
if (prefix.startsWith("+44")) {
return "vonage";
}
return "aws-sns";
}
const provider = registry.get(chooseProvider("+15551234567"));
await provider.send({ to: "+15551234567", body: "hi", from: "+15550000000" });
```
## API Reference
| Symbol | Kind | Notes |
|---|---|---|
| `ProviderRegistry` | class | Synchronous Map-backed registry. |
| `register(p)` | method | Throws on duplicate `p.name`. |
| `unregister(name)` | method | Returns `true` if removed. |
| `get(name)` | method | Throws on missing `name`. |
| `has(name)` | method | Boolean. |
| `list()` | method | Frozen, insertion-ordered `readonly SmsProvider[]`. |
| `size` | getter | `number`. |
## Implementation Notes
The registry holds a `Map<string, SmsProvider>` (insertion-ordered by spec). `list()` freezes the returned array so a caller mutating it cannot retroactively remove a provider; the underlying `Map` retains insertion order even after `unregister(name)`.

View File

@@ -0,0 +1,85 @@
# Twilio — `@suaveplan/sms-providers`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Real Twilio SMS provider implementing the `SmsProvider` contract from `@suaveplan/sms`. Issues authenticated, form-encoded HTTP requests against Twilio's documented Messages API (`POST /2010-04-01/Accounts/{AccountSid}/Messages.json`), normalises Twilio's status vocabulary into the canonical `SmsDeliveryStatus`, fetches delivery status via the corresponding `GET`, and verifies inbound webhooks per Twilio's `X-Twilio-Signature` HMAC-SHA1 scheme. MMS `MediaUrl[]` is forwarded as repeated `MediaUrl` form parameters per Twilio's wire convention.
The class holds no in-flight state: every `send` issues exactly one HTTP request through the injectable `FetchLike` seam. Tests bind a `node:http` server on a random port and inject a `fetch` adapter pointing at it; production resolves `globalThis.fetch.bind(globalThis)`.
## Features
- HTTP Basic auth (`Authorization: Basic base64(AccountSid:AuthToken)`).
- Form-encoded body via `URLSearchParams` — Twilio rejects JSON bodies.
- MMS `MediaUrl[]` forwarded as repeated `MediaUrl` params.
- Canonical status mapping (`queued`, `sending`, `sent`, `delivered`, `undelivered`, `failed`, `canceled`, `receiving`, `received`, `accepted`, `scheduled`).
- `getStatus(sid)` round-trips the same auth + URL convention.
- `verifyWebhookSignature(url, params, signature)` implements the documented HMAC-SHA1-over-sorted-form scheme; mismatched signatures throw `SmsProvidersError(SIGNATURE_INVALID)` and increment `sms_providers.signature_failures_total{provider="twilio"}`.
- `parseInbound(params)` discriminates between message bodies, delivery receipts, and opt-in/opt-out/help keywords using the canonical `parseOptOutKeyword` classifier.
- OTel — every `send` is counted (`send_total`) and timed (`send_duration_ms`); every signature verify is timed (`webhook_verify_duration_ms`).
## Usage: Basic
```ts
import { TwilioProvider } from "@suaveplan/sms-providers";
const twilio = new TwilioProvider({
accountSid: "AC…",
authToken: "…",
});
const report = await twilio.send({
to: "+15551234567",
from: "+15559999999",
body: "Hello",
});
console.log(report.messageId, report.status);
```
## Usage: Advanced
```ts
import { TwilioProvider, type FetchLike } from "@suaveplan/sms-providers";
import { createServer } from "node:http";
// Stand up a real HTTP server, point Twilio's FetchLike at it.
const server = createServer((req, res) => {
res.writeHead(201, { "content-type": "application/json" });
res.end(JSON.stringify({ sid: "SMxxx", status: "queued", to: "+1" }));
});
await new Promise<void>((r) => server.listen(0, r));
const { port } = server.address() as { port: number };
const fetchImpl: FetchLike = async (url, init) =>
globalThis.fetch(url.replace("https://api.twilio.com", `http://127.0.0.1:${port}`), init);
const twilio = new TwilioProvider({
accountSid: "AC1",
authToken: "tok",
fetch: fetchImpl,
});
const r = await twilio.send({
to: "+15551234567",
from: "+15550000000",
body: "Hi",
mediaUrls: ["https://cdn/example.png"],
} as Parameters<typeof twilio.send>[0]);
await new Promise<void>((r) => server.close(() => r()));
```
## API Reference
| Symbol | Kind | Notes |
|---|---|---|
| `TwilioProvider` | class | Implements `SmsProvider` from `@suaveplan/sms`. |
| `send(message, options?)` | method | Issues the POST and returns an `SmsDeliveryReport`. |
| `getStatus(sid)` | method | Round-trips a `GET` for the message resource. |
| `mapStatus(raw)` | method | Maps a Twilio status string to `SmsDeliveryStatus`. |
| `verifyWebhookSignature(url, params, sig)` | method | HMAC-SHA1 over `url + sortedKey1 + value1 + …`; throws `SIGNATURE_INVALID` on mismatch. |
| `parseInbound(params)` | method | Discriminates into the `InboundSmsEvent` union. |
## Implementation Notes
The `baseUrl` strips any trailing `/` so callers may pass either form. `accountSid` and `authToken` are validated at construction time so credential omissions surface immediately rather than on first call. MMS `mediaUrls` are read off the canonical `OutboundMessage` shape — `SmsMessage & OutboundExtras` — so the call site can pass `mediaUrls` without breaking the `SmsProvider#send` signature.

View File

@@ -0,0 +1,85 @@
# Types — `@suaveplan/sms-providers`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines the shared structural types every concrete provider class consumes: `FetchLike` (the injectable HTTP seam), `HmacSign` (the injectable HMAC seam), `HttpOptions` + `ProviderResponse<T>` (the wire shape of every request and response that flows through the package's `httpRequest` helper), the per-provider config interfaces, the `InboundSmsEvent` discriminated union covering the four canonical inbound webhook events, and the `OutboundMessage` composition extending the canonical `SmsMessage` from `@suaveplan/sms` with provider extras such as MMS `mediaUrls`.
Every type here is intentionally types-only at runtime — the file ships zero JavaScript output. Provider classes import these types via the package barrel so downstream consumers can also build their own provider adapters against the same shape without depending on any of the concrete provider files.
## Features
- `FetchLike` mirrors the global `fetch` signature so any provider works in Bun, Node, browsers, Workers, and Deno without a platform polyfill.
- `HmacSign` exposes the HMAC primitive used by every signature verifier without locking the package to `node:crypto` or `WebCrypto`; tests inject a `node:crypto`-backed adapter, production code on Bun/Workers resolves the default `globalThis.crypto.subtle`-backed adapter.
- Per-provider configs (`TwilioConfig`, `VonageConfig`, `MessageBirdConfig`, `PlivoConfig`, `AwsSnsConfig`) extend `ProviderCommonOptions` so testing seams (`fetch`, `hmac`, `signal`, `timeoutMs`, `baseUrl`, `clock`) are uniform.
- `InboundSmsEvent` is a discriminated union — `message_received | delivery_receipt | opt_out | opt_in | help` — so consumers can switch on `event.type` without re-validating fields.
## Usage: Basic
```ts
import type { TwilioConfig, FetchLike } from "@suaveplan/sms-providers";
const fetchImpl: FetchLike = (url, init) => globalThis.fetch(url, init);
const config: TwilioConfig = {
accountSid: "ACxxxxx",
authToken: "xxxxx",
fetch: fetchImpl,
};
```
## Usage: Advanced
```ts
import type {
AwsSnsConfig,
HmacSign,
InboundSmsEvent,
} from "@suaveplan/sms-providers";
import { createHmac } from "node:crypto";
const hmac: HmacSign = async (algo, key, msg) =>
new Uint8Array(
createHmac(algo === "SHA-1" ? "sha1" : "sha256", Buffer.from(key))
.update(Buffer.from(msg))
.digest()
);
const cfg: AwsSnsConfig = {
region: "us-east-1",
accessKeyId: "AKIA…",
secretAccessKey: "…",
hmac,
smsType: "Transactional",
};
function handle(evt: InboundSmsEvent): void {
switch (evt.type) {
case "opt_out":
return;
case "delivery_receipt":
return;
default:
return;
}
}
```
## API Reference
| Symbol | Kind | Notes |
|---|---|---|
| `FetchLike` | type | `(url, init?) => Promise<Response>` |
| `HmacSign` | type | `(alg, key, msg) => Promise<Uint8Array>` |
| `HttpOptions` | interface | Request shape consumed by `httpRequest` |
| `ProviderResponse<T>` | interface | `{ status, data }` |
| `ProviderCommonOptions` | interface | Shared seams + overrides |
| `TwilioConfig` / `VonageConfig` / `MessageBirdConfig` / `PlivoConfig` / `AwsSnsConfig` | interface | Per-provider configs |
| `InboundSmsEvent` | union | Discriminated webhook event |
| `OutboundMessage` | alias | `SmsMessage & OutboundExtras` |
## Implementation Notes
The file exports types only — there is no runtime emission. Build tooling excludes the whole `types/` directory's index from coverage thresholds (per `vitest.config.ts`'s `src/**/index.ts` exclusion). The package barrel re-exports every type by name (rule 10: no `export *`).

View File

@@ -0,0 +1,71 @@
# Vonage — `@suaveplan/sms-providers`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Real Vonage (formerly Nexmo) SMS provider implementing `SmsProvider` from `@suaveplan/sms`. POSTs to `/sms/json` with `api_key` + `api_secret` carried in the form body (the documented default authentication mode), auto-selecting `type=unicode` when the body contains a non-ASCII codepoint and `type=text` otherwise. Sender (`from`) supports alphanumeric IDs (up to 11 chars), short codes, and long codes — Vonage accepts the raw string and we pass it through verbatim. Status is fetched via `GET /search/message`. Webhook signature verification follows Vonage's documented HMAC-SHA256 scheme over the sorted form fields excluding the `sig` parameter.
The class holds no in-flight state and routes every HTTP call through the injectable `FetchLike` seam. Tests stand up a `node:http` server on a random port and inject a `fetch` adapter that points at it.
## Features
- Form-body auth (`api_key`, `api_secret`) — matches Vonage's documented default.
- Auto `type=unicode` vs `type=text` based on whether the body contains a non-ASCII codepoint.
- Sender field forwarded verbatim (alpha / short code / long code all supported).
- `getStatus(id)` round-trips `GET /search/message?id=…` with the same auth pattern.
- `mapStatus` covers `submitted` / `buffered` / `accepted` / `delivered` / `expired` / `failed` / `rejected`.
- `verifyWebhookSignature(params, signature, signatureSecret)` — HMAC-SHA256 over `&key=value&…` for sorted keys (excluding `sig`); lowercase-hex comparison in constant time.
- `parseInbound(params)` discriminates between inbound messages and DLR (delivery-receipt) payloads.
- OTel — counter + histogram for every send + verify.
## Usage: Basic
```ts
import { VonageProvider } from "@suaveplan/sms-providers";
const vonage = new VonageProvider({
apiKey: "vonage_api_key",
apiSecret: "vonage_api_secret",
});
const r = await vonage.send({
to: "447700900000",
from: "Suaveplan",
body: "Hello",
});
```
## Usage: Advanced
```ts
import { VonageProvider } from "@suaveplan/sms-providers";
const vonage = new VonageProvider({
apiKey: "k",
apiSecret: "s",
timeoutMs: 5_000,
});
const ok = await vonage.verifyWebhookSignature(
{ messageId: "0A0000001", text: "hi", sig: "<hex>" },
"<hex>",
"signatureSecretSharedWithVonage"
);
```
## API Reference
| Symbol | Kind | Notes |
|---|---|---|
| `VonageProvider` | class | Implements `SmsProvider`. |
| `send(message, options?)` | method | POST `/sms/json`, returns `SmsDeliveryReport`. |
| `getStatus(id)` | method | `GET /search/message?id=…`. |
| `mapStatus(raw)` | method | Canonical mapping. |
| `verifyWebhookSignature(params, sig, signatureSecret)` | method | HMAC-SHA256 hex verifier; throws `SIGNATURE_INVALID` on mismatch. |
| `parseInbound(params)` | method | Discriminates into `InboundSmsEvent`. |
## Implementation Notes
Vonage strips the leading `+` from E.164 `to` numbers on submission, so the implementation `replace(/^\+/, "")` once before form-encoding. The signature payload is the documented `&{k}={v}&…` shape (note the leading `&`), built from a stable lexicographic key sort with `sig` excluded.

View File

@@ -0,0 +1,133 @@
# @suaveplan/sms - Universal SMS core: provider-agnostic send pipeline with E.164 validation, Zod boundaries, template rendering, opt-out registry, token-bucket rate limiter, exponential-backoff retry, message scheduler, bounded delivery-tracker LRU cache, typed EventBus, legacy on/off mirror, in-memory test provider, four OTel instruments, thirteen SMS.* error codes via defineCodes, and back-compat for upstream SmsError + SMS_ERROR_METADATA.
Universal SMS core: provider-agnostic send pipeline with strict
E.164 validation, Zod boundaries, template rendering, opt-out
registry, token-bucket rate limiter, exponential-backoff retry with
abort propagation, message scheduling, bounded LRU delivery tracker,
typed `EventBus` + legacy `on/off/emit` mirror, an in-memory test
provider, four OpenTelemetry instruments, thirteen `SMS.*` error
codes via `defineCodes`, and full back-compat for the upstream
`SmsError` class and `SMS_ERROR_METADATA` table.
This package is the SMS core. Concrete provider integrations
(Twilio, Vonage, AWS SNS, etc.) ship in `@suaveplan/sms-providers`.
<!-- begin:auto-docs -->
## Documentation tree
- [delivery-tracker](./src/delivery-tracker/delivery-tracker.md) — `delivery-tracker` is the bounded LRU cache of `SmsDeliveryReport` records that `SmsService` consults for delivery stat…
- [delivery-tracker](./src/delivery-tracker/delivery-tracker.md) — `delivery-tracker` is the bounded LRU cache of `SmsDeliveryReport` records that `SmsService` consults for delivery stat…
- [error-codes](./src/error-codes/error-codes.md) — The error-codes module owns the canonical `SMS.*` registry for `@suaveplan/sms`. Every error surfaced by the package —…
- [error-codes](./src/error-codes/error-codes.md) — The error-codes module owns the canonical `SMS.*` registry for `@suaveplan/sms`. Every error surfaced by the package —…
- [in-memory-provider](./src/in-memory-provider/in-memory-provider.md) — `InMemorySmsProvider` is the production-quality in-process `SmsProvider` implementation that ships with `@suaveplan/sms…
- [in-memory-provider](./src/in-memory-provider/in-memory-provider.md) — `InMemorySmsProvider` is the production-quality in-process `SmsProvider` implementation that ships with `@suaveplan/sms…
- [legacy-errors](./src/legacy-errors/legacy-errors.md) — `legacy-errors` preserves the upstream `@suaveplan/sms` v1 public surface — the `SmsError` class, the `SmsErrorCode` st…
- [legacy-errors](./src/legacy-errors/legacy-errors.md) — `legacy-errors` preserves the upstream `@suaveplan/sms` v1 public surface — the `SmsError` class, the `SmsErrorCode` st…
- [legacy-metadata](./src/legacy-metadata/legacy-metadata.md) — `legacy-metadata` preserves the upstream `SMS_ERROR_METADATA` table — the per-code mapping of `httpStatus`, `severity`,…
- [legacy-metadata](./src/legacy-metadata/legacy-metadata.md) — `legacy-metadata` preserves the upstream `SMS_ERROR_METADATA` table — the per-code mapping of `httpStatus`, `severity`,…
- [opt-out](./src/opt-out/opt-out.md) — `opt-out` is the in-memory opt-out registry that `SmsService` consults before every outbound message. Recipients on the…
- [opt-out](./src/opt-out/opt-out.md) — `opt-out` is the in-memory opt-out registry that `SmsService` consults before every outbound message. Recipients on the…
- [otel](./src/otel/metrics.md) — `otel/metrics` is the OpenTelemetry instrumentation layer for `@suaveplan/sms`. It owns four meter instruments — three…
- [metrics](./src/otel/metrics.md) — `otel/metrics` is the OpenTelemetry instrumentation layer for `@suaveplan/sms`. It owns four meter instruments — three…
- [rate-limiter](./src/rate-limiter/rate-limiter.md) — `rate-limiter` is the token-bucket throttle that `SmsService` consults before every outbound send. The limiter is keyed…
- [rate-limiter](./src/rate-limiter/rate-limiter.md) — `rate-limiter` is the token-bucket throttle that `SmsService` consults before every outbound send. The limiter is keyed…
- [retry](./src/retry/retry.md) — `retry` is the exponential-backoff retry policy used by `SmsService.send` to absorb transient provider failures. The po…
- [retry](./src/retry/retry.md) — `retry` is the exponential-backoff retry policy used by `SmsService.send` to absorb transient provider failures. The po…
- [routes](./src/routes/problem-json.md) — `routes/problem-json` is the RFC 7807 `application/problem+json` response helper for hosts wiring `@suaveplan/sms` erro…
- [problem-json](./src/routes/problem-json.md) — `routes/problem-json` is the RFC 7807 `application/problem+json` response helper for hosts wiring `@suaveplan/sms` erro…
- [scheduler](./src/scheduler/scheduler.md) — `scheduler` provides `MessageScheduler` — the minimal timer seam that `SmsService.scheduleAt` uses to fire a delayed de…
- [scheduler](./src/scheduler/scheduler.md) — `scheduler` provides `MessageScheduler` — the minimal timer seam that `SmsService.scheduleAt` uses to fire a delayed de…
- [schemas](./src/schemas/schemas.md) — `schemas` is the Zod boundary for every public input that `@suaveplan/sms` exposes. The schemas mirror the public type…
- [schemas](./src/schemas/schemas.md) — `schemas` is the Zod boundary for every public input that `@suaveplan/sms` exposes. The schemas mirror the public type…
- [bus-map](./src/service/bus-map.md) — `bus-map` declares `SmsBusMap` — the single-payload topic contract used by `SmsService.bus` (an `EventBus<SmsBusMap>`).…
- [legacy-emitter](./src/service/legacy-emitter.md) — `LegacyEmitter` is the back-compat shim that preserves the upstream `@suaveplan/sms` v1 `on/off/emit` listener API on t…
- [sms-service](./src/service/sms-service.md) — `SmsService` is the provider-agnostic orchestrator that ties together every primitive in `@suaveplan/sms`. A single `se…
- [templates](./src/templates/templates.md) — `templates` is the in-process template registry and renderer used by `SmsService` to expand `{{variable}}` placeholders…
- [templates](./src/templates/templates.md) — `templates` is the in-process template registry and renderer used by `SmsService` to expand `{{variable}}` placeholders…
- [validation](./src/validation/phone.md) — `validation/phone` is the strict-E.164 phone number validator used by every public input boundary in `@suaveplan/sms`.…
- [phone](./src/validation/phone.md) — `validation/phone` is the strict-E.164 phone number validator used by every public input boundary in `@suaveplan/sms`.…
<!-- end:auto-docs -->
## Highlights
- **`SmsService`** — orchestrates the full send pipeline: abort
check → provider check → E.164 validation → opt-out check →
rate-limit → template render → retry → provider send → delivery
tracker ingest → OTel + bus emit.
- **`InMemorySmsProvider`** — production-quality in-process
`SmsProvider` for tests and demos; records every sent message,
supports configurable failure predicates and status transitions.
- **`TemplateRegistry`** + `renderTemplate` — `{{var}}` substitution
with template-level and call-level `defaults` maps.
- **`OptOutRegistry`** — channel-scoped opt-out / opt-in with bulk
import/export.
- **`RateLimiter`** — token bucket per `(channel, recipient)` key
with injectable `Clock`.
- **`RetryPolicy`** — exponential backoff with full jitter,
AbortSignal-aware, injectable `Timers` seam.
- **`MessageScheduler`** — `scheduleAt(date, fn)` with cancel
handle, injectable timers + clock.
- **`DeliveryTracker`** — bounded LRU receipt cache with provider
fall-back via `lookupOrFetch`.
- **Typed `EventBus`** on `service.bus` plus the upstream
`on/off/emit` API mirroring against it.
- **Four OTel instruments**: `sms.sent_total`, `sms.failed_total`,
`sms.retry_total`, `sms.send_duration_ms`.
- **Thirteen `SMS.*` error codes** via `defineCodes("SMS", [...])`
plus the upstream `SmsError` legacy class preserved verbatim for
back-compat.
## Install
```bash
bun add @suaveplan/sms
```
## Quick start
```ts
import {
InMemorySmsProvider,
SmsService,
} from "@suaveplan/sms";
const provider = new InMemorySmsProvider();
const service = new SmsService({ provider });
service.registerTemplate({
id: "welcome",
body: "Hi {{name}}!",
variables: ["name"],
});
service.bus.on("sms.sent", (report) => {
console.log("delivered", report.messageId, "→", report.to);
});
await service.send({
to: "+15551234567",
body: "ignored when templateId is set",
templateId: "welcome",
templateVars: { name: "Alice" },
});
```
## Modules
- [`error-codes/error-codes.md`](./src/error-codes/error-codes.md) — `defineCodes("SMS", [...])`, leaf classes
- [`legacy-errors/legacy-errors.md`](./src/legacy-errors/legacy-errors.md) — upstream `SmsError` back-compat
- [`legacy-metadata/legacy-metadata.md`](./src/legacy-metadata/legacy-metadata.md) — `SMS_ERROR_METADATA` table
- [`schemas/schemas.md`](./src/schemas/schemas.md) — Zod boundary parsers
- [`validation/phone.md`](./src/validation/phone.md) — strict E.164 validator
- [`templates/templates.md`](./src/templates/templates.md) — `TemplateRegistry`, `renderTemplate`
- [`opt-out/opt-out.md`](./src/opt-out/opt-out.md) — `OptOutRegistry`
- [`rate-limiter/rate-limiter.md`](./src/rate-limiter/rate-limiter.md) — token-bucket limiter
- [`retry/retry.md`](./src/retry/retry.md) — `RetryPolicy`
- [`scheduler/scheduler.md`](./src/scheduler/scheduler.md) — `MessageScheduler`
- [`delivery-tracker/delivery-tracker.md`](./src/delivery-tracker/delivery-tracker.md) — bounded LRU receipt cache
- [`in-memory-provider/in-memory-provider.md`](./src/in-memory-provider/in-memory-provider.md) — `InMemorySmsProvider`
- [`service/sms-service.md`](./src/service/sms-service.md) — orchestrator
- [`service/legacy-emitter.md`](./src/service/legacy-emitter.md) — legacy `on/off/emit` mirror
- [`routes/problem-json.md`](./src/routes/problem-json.md) — RFC 7807 Hono helper
- [`otel/metrics.md`](./src/otel/metrics.md) — OpenTelemetry instruments

View File

@@ -0,0 +1,85 @@
# `delivery-tracker`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`delivery-tracker` is the bounded LRU cache of
`SmsDeliveryReport` records that `SmsService` consults for
delivery status. Receipts may arrive via webhook ingest
(`tracker.ingest(report)`) or be lazily fetched from the
configured provider via `tracker.lookupOrFetch(messageId,
provider)`. The cache is bounded so a long-running service does
not retain every receipt indefinitely.
When no provider is supplied and the cache misses,
`lookupOrFetch` throws `SmsReceiptNotFoundError` (code
`SMS.RECEIPT_NOT_FOUND`) so callers can distinguish a miss from
a remote provider error.
## Features
- LRU cache keyed by `messageId`, bounded by `maxEntries`
(default 1024).
- `ingest(report)` — record / refresh a receipt; oldest entry
evicts on overflow.
- `getStatus(messageId)` — synchronous cache read; refreshes
LRU order on hit.
- `lookupOrFetch(messageId, provider?)` — cache-first, falls
back to `provider.getStatus`; result is cached on success.
- `size()` and `clear()` for observation / reset.
## Usage: Basic
```ts
import { DeliveryTracker } from "@suaveplan/sms/delivery-tracker";
const tracker = new DeliveryTracker();
tracker.ingest({
messageId: "m1",
to: "+15551234567",
status: "delivered",
timestamp: new Date(),
});
console.log(tracker.getStatus("m1"));
```
## Usage: Advanced
```ts
import { DeliveryTracker } from "@suaveplan/sms/delivery-tracker";
import { InMemorySmsProvider } from "@suaveplan/sms/in-memory-provider";
const tracker = new DeliveryTracker({ maxEntries: 4 });
const provider = new InMemorySmsProvider();
// Lazy fetch — first call hits the provider, second hits cache.
const fromProvider = await tracker.lookupOrFetch("m1", provider);
const fromCache = await tracker.lookupOrFetch("m1", provider);
```
## API Reference
- `class DeliveryTracker`
- `constructor(options?: DeliveryTrackerOptions)`
- `ingest(report: SmsDeliveryReport): void`
- `getStatus(messageId: string): SmsDeliveryReport | undefined`
- `lookupOrFetch(messageId: string, provider?: SmsProvider):
Promise<SmsDeliveryReport>`
- `size(): number`
- `clear(): void`
- `interface DeliveryTrackerOptions`
- `maxEntries?: number` (default 1024, must be ≥ 1)
## Implementation Notes
The implementation uses a plain `Map` and exploits JavaScript's
insertion-ordered iteration to maintain LRU order: on each hit
we `delete` then `set` the entry, pushing it to the back. On
`ingest`, an existing entry is deleted before re-insertion to
keep order canonical. Eviction reads the first key in the
iterator (the oldest entry) and deletes it. `lookupOrFetch`
catches no provider errors — they propagate to the caller so a
network failure on receipt fetch isn't silently swallowed.

View File

@@ -0,0 +1,110 @@
# `error-codes`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The error-codes module owns the canonical `SMS.*` registry for
`@suaveplan/sms`. Every error surfaced by the package — invalid
phone number, missing provider, exhausted retries, opted-out
recipient, rate limit, scheduled-message cancellation, abort —
carries one of the thirteen stable codes registered here. This
keeps downstream consumers (loggers, problem+json responders,
retry wrappers, dashboards) free from re-implementing the
classifier.
The thirteen codes are declared with the
`defineCodes("SMS", [...])` helper, which produces a frozen
`Namespace.Reason` string union. Each code has a dedicated
leaf-error class extending the package-local abstract
`BaseSmsError` (itself a thin `SuaveplanError<SmsCode>`
subclass), so consumers can `instanceof` the class as well as
match on `.code`.
## Features
- Thirteen `SMS.*` codes: `INVALID_NUMBER`, `INVALID_CONFIG`,
`PROVIDER_NOT_CONFIGURED`, `PROVIDER_ERROR`, `SEND_FAILED`,
`TEMPLATE_NOT_FOUND`, `RECIPIENT_OPTED_OUT`, `RATE_LIMITED`,
`FORBIDDEN`, `CONFIG_MISSING`, `SCHEDULE_CANCELLED`, `ABORTED`,
`RECEIPT_NOT_FOUND`.
- One leaf class per code, all extending `BaseSmsError`.
- `BaseOpts`-typed constructors that accept `message`,
`context`, `cause`, `traceId`, with `pickOpts` stripping
undefined keys so the `exactOptionalPropertyTypes`
contract holds.
- `SmsInvalidConfigError` merges Zod issues into
`context.issues`.
## Usage: Basic
```ts
import {
SMS_CODES,
SmsRateLimitedError,
} from "@suaveplan/sms/error-codes";
try {
throw new SmsRateLimitedError({
context: { phoneNumber: "+15551234567" },
});
} catch (err) {
if (err instanceof SmsRateLimitedError) {
console.log(err.code === SMS_CODES.RATE_LIMITED);
}
}
```
## Usage: Advanced
```ts
import {
BaseSmsError,
SmsInvalidConfigError,
} from "@suaveplan/sms/error-codes";
function classifyAndLog(err: unknown): void {
if (err instanceof SmsInvalidConfigError) {
for (const issue of (err.context?.issues ?? []) as ReadonlyArray<
Record<string, unknown>
>) {
console.error("zod issue", issue);
}
return;
}
if (err instanceof BaseSmsError) {
console.error(`${err.code}: ${err.message}`, err.context ?? {});
}
}
```
## API Reference
- `SMS_CODES: Readonly<Record<Reason, "SMS.Reason">>` — frozen
registry returned from `defineCodes("SMS", [...])`.
- `SmsCode = (typeof SMS_CODES)[keyof typeof SMS_CODES]` — union
of every fully-qualified code.
- `abstract class BaseSmsError extends SuaveplanError<SmsCode>` —
base class for every typed leaf error.
- Leaf classes (each takes a `BaseOpts` argument and forwards a
default messageKey for the i18n resolver):
`SmsInvalidNumberError`, `SmsInvalidConfigError`,
`SmsProviderNotConfiguredError`, `SmsProviderError`,
`SmsSendFailedError`, `SmsTemplateNotFoundError`,
`SmsRecipientOptedOutError`, `SmsRateLimitedError`,
`SmsForbiddenError`, `SmsConfigMissingError`,
`SmsScheduleCancelledError`, `SmsAbortedError`,
`SmsReceiptNotFoundError`.
## Implementation Notes
The leaf-class pattern (one class per code) was chosen over a
single generic class so callers can `instanceof Xxx` to discriminate
without inspecting `.code`. The `pickOpts` helper guards against
the `exactOptionalPropertyTypes` contract — copying a
`cause: undefined` into the super options table would fail to
typecheck against `SuaveplanErrorOptions`. Codes are reserved at
module load via `defineCodes` (which returns a frozen object) so
the registry cannot drift across module reloads.

View File

@@ -0,0 +1,101 @@
# `in-memory-provider`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`InMemorySmsProvider` is the production-quality in-process
`SmsProvider` implementation that ships with `@suaveplan/sms`.
It satisfies the abstract `SmsProvider` contract — `name`,
`send`, `getStatus` — by recording every message into an
in-memory log and returning configurable delivery receipts. It
is the canonical test double for integration tests, demo apps,
and host applications that want to exercise the send pipeline
without a real SMS carrier.
This module replaces the upstream pattern of ad-hoc anonymous
mocks. Every test in the package uses this provider — there are
no `vi.fn()` stubs of the `SmsProvider` contract.
## Features
- Implements the full `SmsProvider` contract (`name`, `send`,
`getStatus`).
- `sent: SmsMessage[]` — log of every outgoing message, in
order.
- `reports: SmsDeliveryReport[]` — parallel log of every receipt
returned.
- Configurable `defaultStatus` (`queued` / `sent` / `delivered`
/ `failed` / `undelivered`) returned by `send`.
- `setStatus(messageId, status)` — transitions a previously-sent
message's status for `getStatus` callers.
- `failOn(predicate, error?)` — configure a per-message failure
predicate so tests simulate provider rejections without
swapping the implementation.
- Injectable `Clock` for deterministic receipt timestamps.
- Injectable `IdGenerator` for predictable message IDs.
- AbortSignal-aware — a pre-aborted signal rejects with
`Error("aborted")` so the upstream retry can wrap it.
## Usage: Basic
```ts
import { InMemorySmsProvider } from "@suaveplan/sms/in-memory-provider";
const provider = new InMemorySmsProvider();
const report = await provider.send({ to: "+15551234567", body: "hi" });
console.log(provider.sent[0]?.body); // "hi"
console.log(report.status); // "sent"
```
## Usage: Advanced
```ts
import { InMemorySmsProvider } from "@suaveplan/sms/in-memory-provider";
let n = 0;
const provider = new InMemorySmsProvider({
name: "twilio-fake",
defaultStatus: "queued",
idGenerator: () => `seed_${n++}`,
clock: () => 1_700_000_000_000,
});
provider.failOn(
(msg) => msg.to === "+15550000000",
new Error("blocked-by-carrier")
);
const r = await provider.send({ to: "+15551234567", body: "ping" });
provider.setStatus(r.messageId, "delivered");
console.log((await provider.getStatus(r.messageId)).status); // "delivered"
```
## API Reference
- `class InMemorySmsProvider implements SmsProvider`
- `readonly name: string`
- `readonly sent: SmsMessage[]`
- `readonly reports: SmsDeliveryReport[]`
- `send(message, options?): Promise<SmsDeliveryReport>`
- `getStatus(messageId): Promise<SmsDeliveryReport>`
- `setStatus(messageId, status): void` (throws on unknown id)
- `failOn(predicate, error?): void`
- `clearFailures(): void`
- `clear(): void`
- `interface InMemorySmsProviderOptions`
- `name?`, `defaultStatus?`, `idGenerator?`, `clock?`
- `type SmsFailurePredicate = (m: SmsMessage) => boolean`
## Implementation Notes
The provider stores two parallel arrays — `sent` (the incoming
`SmsMessage`) and `reports` (the outgoing receipts) — so test
assertions on the rendered body work even when the service has
substituted template variables. The default `idGenerator`
combines a monotonic counter and the clock so collisions are
impossible across instances. AbortSignal rejection uses a plain
`Error("aborted")`; `SmsService.send` wraps it into
`SmsAbortedError` after rate-limiter / retry layers run.

View File

@@ -0,0 +1,93 @@
# `legacy-errors`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`legacy-errors` preserves the upstream `@suaveplan/sms` v1
public surface — the `SmsError` class, the `SmsErrorCode`
string-union, the five static factory methods, the
`SmsErrorContext` shape, the `SmsErrorOptions` shape, and the
`isSmsError` type guard — so existing callers compile against
the package with zero source changes. Internally the
class extends `SuaveplanError` so it integrates with the
error stack (cause walking, OTel span events,
redaction, the i18n resolver hook).
## Features
- Upstream-compatible `SmsErrorCode` union with the five legacy
string codes (`SMS_INVALID_PHONE_NUMBER`, `SMS_SEND_FAILED`,
`SMS_TEMPLATE_NOT_FOUND`, `SMS_RECIPIENT_OPTED_OUT`,
`SMS_PROVIDER_NOT_CONFIGURED`).
- `SmsError` class extending `SuaveplanError<SmsErrorCode>`
so it picks up cause walking, OTel adapter, the i18n
resolver, and `WireError` serialization.
- Five static factory methods, each populating `context` with
the upstream field shape (`phoneNumber`, `messageId`,
`templateId`).
- `isSmsError` instance check.
## Usage: Basic
```ts
import { SmsError } from "@suaveplan/sms/legacy-errors";
try {
throw SmsError.invalidPhoneNumber("not-a-number");
} catch (err) {
if (SmsError.isSmsError(err)) {
console.log(err.code, err.context?.phoneNumber);
}
}
```
## Usage: Advanced
```ts
import { SmsError } from "@suaveplan/sms/legacy-errors";
async function sendOnce(): Promise<void> {
try {
// ... call provider ...
throw new Error("network");
} catch (cause) {
throw SmsError.sendFailed("msg_42", cause as Error);
}
}
try {
await sendOnce();
} catch (err) {
if (SmsError.isSmsError(err) && err.code === "SMS_SEND_FAILED") {
console.error("send failed:", err.context?.messageId, err.cause);
}
}
```
## API Reference
- `type SmsErrorCode` — union of the five legacy string codes.
- `interface SmsErrorContext` — `phoneNumber?`, `templateId?`,
`providerId?`, `messageId?`, plus an open `Record<string, unknown>`
index signature.
- `interface SmsErrorOptions` — `message`, `code`, `context?`,
`cause?`.
- `class SmsError extends SuaveplanError<SmsErrorCode>` — public
constructor accepting `SmsErrorOptions`.
- Static factories: `invalidPhoneNumber(phoneNumber)`,
`sendFailed(messageId, cause?)`, `templateNotFound(templateId)`,
`recipientOptedOut(phoneNumber)`, `providerNotConfigured()`.
- Static type guard: `isSmsError(value): value is SmsError`.
## Implementation Notes
`@suaveplan/error` has a `protected` constructor on
`SuaveplanError`; subclasses must declare a `public` constructor
that calls `super(code, message, options)`. The legacy class
preserves the upstream argument order (`{message, code, ...}`)
so existing call sites remain identical, then forwards to the
standard super constructor. `sendFailed` includes a conditional
add of `cause` to satisfy `exactOptionalPropertyTypes`.

View File

@@ -0,0 +1,85 @@
# `legacy-metadata`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`legacy-metadata` preserves the upstream `SMS_ERROR_METADATA`
table — the per-code mapping of `httpStatus`, `severity`, retry
hint, `userMessageKey`, `category`, and `description` — so
existing callers that probe the table to drive their own
problem+json responses, retry policies, or i18n catalogues
continue to work. The upstream package registered the table on a
process-wide `ErrorCodes` singleton; this package carries no such
singleton, so `registerSmsErrorCodes()` is reframed as a
no-op-friendly getter that returns the frozen table. Callers can
plug the returned table into whatever registry their host wires
up.
## Features
- Frozen `SMS_ERROR_METADATA` mapping with the six upstream
entries (`SMS_SEND_FAILED`, `SMS_PROVIDER_ERROR`,
`SMS_INVALID_NUMBER`, `SMS_RATE_LIMITED`, `SMS_CONFIG_MISSING`,
`SMS_FORBIDDEN`).
- `SmsCodeMetadata` / `SmsCodeSeverity` / `SmsRetryHint` shapes
exposed as type contracts.
- `registerSmsErrorCodes()` idempotent helper returning the
table.
- Eagerly evaluates on import to mirror the upstream load-time
side effect.
## Usage: Basic
```ts
import { SMS_ERROR_METADATA } from "@suaveplan/sms/legacy-metadata";
const meta = SMS_ERROR_METADATA.SMS_RATE_LIMITED;
console.log(meta.httpStatus); // 429
console.log(meta.retry?.maxAttempts); // 5
```
## Usage: Advanced
```ts
import {
registerSmsErrorCodes,
type SmsCodeMetadata,
} from "@suaveplan/sms/legacy-metadata";
// Host wires the package metadata into its own registry
const table = registerSmsErrorCodes();
for (const [code, meta] of Object.entries(table) as [
string,
SmsCodeMetadata
][]) {
myHostRegistry.upsert(code, meta);
}
```
## API Reference
- `type SmsCodeSeverity = "info" | "warning" | "error" | "fatal"`.
- `interface SmsRetryHint` — `retryable: boolean`,
`strategy?: "linear" | "exponential"`, `maxAttempts?: number`.
- `interface SmsCodeMetadata` — `httpStatus`, `severity`,
`retry?`, `retryable?`, `userMessageKey`, `category`,
`description`.
- `type SmsRegisteredErrorCode` — the six upstream string codes.
- `const SMS_ERROR_METADATA: Readonly<Record<SmsRegisteredErrorCode,
SmsCodeMetadata>>` — frozen mapping.
- `function registerSmsErrorCodes(): Readonly<Record<SmsRegisteredErrorCode,
SmsCodeMetadata>>` — returns the table; idempotent.
## Implementation Notes
The table is frozen via `Object.freeze` so neither the
top-level dictionary nor the eager-registration step can mutate
entries downstream. The function returns the same table by
reference — callers needing a defensive copy should clone
explicitly. The eager call at module load mirrors the upstream
`registerSmsErrorCodes()` side effect that ran at v1 import
time, so a consumer that previously relied on the function being
called for them still sees the same behaviour .

View File

@@ -0,0 +1,87 @@
# `opt-out`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`opt-out` is the in-memory opt-out registry that `SmsService`
consults before every outbound message. Recipients on the
registry receive no further messages — `service.send` throws
`SmsRecipientOptedOutError` (code `SMS.RECIPIENT_OPTED_OUT`).
Entries can be scoped to a channel so transactional messages
remain deliverable when only marketing has been opted out by the
host application.
The registry is intentionally pluggable but ships as an in-process
`Map` so unit tests and small deployments need no external state;
production hosts that share opt-out across processes can wrap
`OptOutRegistry` in their own persistence layer.
## Features
- `add(phoneNumber, channel?)` — records the opt-out with a
timestamp from an injectable `Clock`.
- `remove(phoneNumber)` — opt-in helper.
- `has(phoneNumber)` and `get(phoneNumber)` — predicate +
detail accessors.
- `list()` snapshot, `size()`, and `clear()` for bulk operations.
- `importEntries(entries)` — bulk import with overwrite-by-id
semantics for syncing from an external source.
## Usage: Basic
```ts
import { OptOutRegistry } from "@suaveplan/sms/opt-out";
const reg = new OptOutRegistry();
reg.add("+15551234567", "marketing");
console.log(reg.has("+15551234567")); // true
reg.remove("+15551234567");
```
## Usage: Advanced
```ts
import { OptOutRegistry } from "@suaveplan/sms/opt-out";
// Inject a deterministic clock for tests; importEntries
// reseeds the registry from a downstream cache.
const reg = new OptOutRegistry({ clock: () => 1_700_000_000_000 });
reg.importEntries([
{
phoneNumber: "+15550000001",
optedOutAt: new Date(1_700_000_000_000),
channel: "marketing",
},
{
phoneNumber: "+15550000002",
optedOutAt: new Date(1_700_000_000_000),
},
]);
console.log(reg.size()); // 2
```
## API Reference
- `class OptOutRegistry`
- `constructor(options?: { clock?: Clock })`
- `add(phoneNumber: string, channel?: string): OptOutEntry`
- `remove(phoneNumber: string): boolean`
- `has(phoneNumber: string): boolean`
- `get(phoneNumber: string): OptOutEntry | undefined`
- `list(): ReadonlyArray<OptOutEntry>`
- `importEntries(entries: ReadonlyArray<OptOutEntry>): void`
- `clear(): void`
- `size(): number`
## Implementation Notes
The registry stores `OptOutEntry` records keyed by the verbatim
phone number — no normalisation. Callers SHOULD pass E.164
strings; mismatched formats will fail to match. `add` overwrites
existing entries by `phoneNumber`, so a caller switching the
`channel` on an existing opt-out simply re-adds. The `clock`
defaults to `Date.now`, deterministic only when explicitly
injected.

View File

@@ -0,0 +1,98 @@
# `otel/metrics`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`otel/metrics` is the OpenTelemetry instrumentation layer for
`@suaveplan/sms`. It owns four meter instruments — three
counters and a histogram — that `SmsService` populates on every
send, retry, and failure. Wiring observability at the package
level keeps consumers free from re-defining instrument names and
units, and gives downstream dashboards a stable contract to plot
against.
The module reads the global `MeterProvider` from
`@opentelemetry/api`. Tests that wire an in-memory exporter call
`refreshSmsMeters()` after installing the exporter so subsequent
`record*` calls land on the test provider rather than the
load-time meter.
## Features
- `sms.sent_total{channel, from_me}` counter — successful sends.
- `sms.failed_total{code}` counter — failed sends, tagged by
error code.
- `sms.retry_total{attempt}` counter — retry attempts, tagged
by zero-based attempt index.
- `sms.send_duration_ms{outcome}` histogram — pipeline duration
in milliseconds, outcome ∈ {`ok`, `error`, `aborted`}.
- `refreshSmsMeters()` re-resolves every instrument against the
current `MeterProvider` for test wiring.
## Usage: Basic
```ts
import {
recordSmsSent,
recordSmsFailed,
recordSmsRetry,
recordSmsSendDuration,
} from "@suaveplan/sms/otel";
const start = Date.now();
try {
// ... send ...
recordSmsSent("marketing", false);
recordSmsSendDuration(Date.now() - start, "ok");
} catch (err) {
recordSmsFailed("SMS.SEND_FAILED");
recordSmsSendDuration(Date.now() - start, "error");
}
```
## Usage: Advanced
```ts
import {
MeterProvider,
PeriodicExportingMetricReader,
ConsoleMetricExporter,
} from "@opentelemetry/sdk-metrics";
import { metrics } from "@opentelemetry/api";
import { refreshSmsMeters } from "@suaveplan/sms/otel";
const provider = new MeterProvider({
readers: [
new PeriodicExportingMetricReader({
exporter: new ConsoleMetricExporter(),
}),
],
});
metrics.setGlobalMeterProvider(provider);
refreshSmsMeters(); // rebind every instrument against the new provider
```
## API Reference
- `function recordSmsSent(channel: string, fromMe: boolean): void`
- `function recordSmsFailed(code: string): void`
- `function recordSmsRetry(attempt: number): void`
- `function recordSmsSendDuration(durationMs: number, outcome:
"ok" | "error" | "aborted"): void`
- `function refreshSmsMeters(): void`
## Implementation Notes
Instruments are stored at module scope so the hot send path
performs zero allocation per record call. The default no-op
meter provided by `@opentelemetry/api` accepts every call
silently, so packages that never install a `MeterProvider` pay
nothing. Calling `refreshSmsMeters()` re-creates each instrument
against the current global — necessary because instrument
identity is bound to the meter instance, not the API surface.
The attribute keys (`channel`, `from_me`, `code`, `attempt`,
`outcome`) are part of the public observability contract; renames
require a major version bump.

View File

@@ -0,0 +1,92 @@
# `rate-limiter`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`rate-limiter` is the token-bucket throttle that `SmsService`
consults before every outbound send. The limiter is keyed by an
arbitrary string (the service uses `${channel}:${recipient}` so
marketing and transactional channels share no quota) and refills
continuously against an injectable `Clock`, making it
deterministic in tests and fair under bursty load.
The default settings — capacity `60`, refill `1` token per second
— tolerate a 60-message burst followed by a steady stream and
cover most transactional SMS workloads; high-throughput hosts can
raise the bucket capacity or the refill rate via the constructor.
## Features
- Token-bucket algorithm with continuous refill against an
injectable `Clock`.
- Per-key buckets — independent quotas across channels /
recipients.
- `tryConsume(key)` returns a boolean so callers branch on
rejection without exception cost.
- `peek(key)` for observability — returns the current token
count after applying the refill.
- `clear()` to reset every bucket (useful for tests).
- Constructor-time validation of `capacity > 0` and
`refillPerSecond > 0`.
## Usage: Basic
```ts
import { RateLimiter } from "@suaveplan/sms/rate-limiter";
const limiter = new RateLimiter({ capacity: 10, refillPerSecond: 1 });
for (let i = 0; i < 12; i += 1) {
if (!limiter.tryConsume("+15551234567")) {
console.log("rate limited");
break;
}
}
```
## Usage: Advanced
```ts
import { RateLimiter } from "@suaveplan/sms/rate-limiter";
let now = 1_000;
const limiter = new RateLimiter({
capacity: 3,
refillPerSecond: 1,
clock: () => now,
});
limiter.tryConsume("k"); // true
limiter.tryConsume("k"); // true
limiter.tryConsume("k"); // true
limiter.tryConsume("k"); // false — empty
now += 1_000;
limiter.tryConsume("k"); // true again after refill
console.log(limiter.peek("k")); // 0
```
## API Reference
- `class RateLimiter`
- `constructor(options?: RateLimiterOptions)`
- `tryConsume(key: string): boolean`
- `peek(key: string): number`
- `clear(): void`
- `interface RateLimiterOptions`
- `capacity?: number` (default 60)
- `refillPerSecond?: number` (default 1)
- `clock?: Clock` (default `Date.now`)
## Implementation Notes
The refill is fractional — `(elapsedMs / 1000) * refillPerSecond`
— so the limiter does not snap-fill on whole seconds. Tokens are
clamped to `capacity` after refill, so a long pause does not let
the bucket accumulate more than the cap. The `clock` defaults to
`Date.now` and is invoked once per `tryConsume` / `peek` so wall-
clock drift is observable but does not corrupt state. Constructor
validation throws plain `Error` (not `SmsInvalidConfigError`)
because the limiter is a leaf primitive — callers compose Zod
validation higher in the stack via `SmsService`.

View File

@@ -0,0 +1,93 @@
# `retry`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`retry` is the exponential-backoff retry policy used by
`SmsService.send` to absorb transient provider failures. The
policy retries a thrown attempt up to `maxAttempts` times with
exponential backoff (`initialDelayMs * 2^attempt`, capped at
`maxDelayMs`) and full jitter, awaits each backoff against an
injectable `Timers` seam so tests can drive the policy without
real wall-clock waits, and honours an `AbortSignal` so cancelled
operations don't sit through a multi-second backoff.
## Features
- Exponential backoff with full-jitter delay computation.
- Injectable `TimersLike` seam — defaults to a
`globalThis.setTimeout` adapter; tests inject a controlled
timer.
- Injectable `random` source — defaults to `Math.random`; tests
pin it to a fixed value for deterministic jitter.
- AbortSignal-aware — a pre-aborted signal halts before the
first attempt; an abort that lands during a backoff cancels
the timer and rejects with `SmsAbortedError`.
- Per-attempt `onRetry` hook receives a `SmsRetryEvent` so
callers can record per-attempt OTel counters / bus events.
## Usage: Basic
```ts
import { RetryPolicy } from "@suaveplan/sms/retry";
const policy = new RetryPolicy({ maxAttempts: 3 });
const value = await policy.execute(async () => callRemoteService());
```
## Usage: Advanced
```ts
import { RetryPolicy } from "@suaveplan/sms/retry";
const ctrl = new AbortController();
const policy = new RetryPolicy({
maxAttempts: 5,
initialDelayMs: 100,
maxDelayMs: 2_000,
});
const result = await policy.execute(
async (attempt) => {
if (attempt < 2) throw new Error("flaky");
return "ok";
},
{
signal: ctrl.signal,
onRetry: (event) => {
console.log("retry", event.attempt, event.error.message);
},
to: "+15551234567",
}
);
```
## API Reference
- `class RetryPolicy`
- `constructor(options?: RetryPolicyOptions)`
- `execute<T>(fn: (attempt: number) => Promise<T>, options?: {
signal?: AbortSignal; onRetry?: RetryHook; to?: string;
}): Promise<T>`
- `type RetryHook = (event: SmsRetryEvent) => void`.
- `interface RetryPolicyOptions`
- `maxAttempts?: number` (default 3)
- `initialDelayMs?: number` (default 50)
- `maxDelayMs?: number` (default 5000)
- `timers?: TimersLike`
- `random?: () => number`
## Implementation Notes
The backoff uses full jitter (`floor(random() * capped)`) which
empirically produces lower contention than equal jitter for SMS
provider rate-limit scenarios. The `onRetry` hook receives the
ZERO-based attempt index for the attempt that just failed, so
the first retry fires `attempt = 0`. The delay loop guards
against abort via an `AbortSignal.addEventListener("abort", …,
{ once: true })` listener that clears the pending timer and
rejects synchronously, ensuring an aborted retry never wakes up
to perform the next attempt.

View File

@@ -0,0 +1,86 @@
# `routes/problem-json`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`routes/problem-json` is the RFC 7807 `application/problem+json`
response helper for hosts wiring `@suaveplan/sms` errors onto a
Hono-shaped router. It serialises any `SuaveplanError` (current
or legacy), or unknown throwable, into a problem-details
document on the supplied context-like surface and returns the
resulting `Response` so callers can `return` it directly from a
Hono handler.
The helper preserves the upstream registered `httpStatus` from
`SMS_ERROR_METADATA` when the error's `code` is one of the six
legacy keys; for `SMS.*` codes the helper maps to
a sensible status (`SMS.RATE_LIMITED` → 429, `SMS.INVALID_NUMBER`
→ 400, `SMS.ABORTED` → 499, …); for unknown errors the helper
falls back to 500.
## Features
- `PROBLEM_JSON_CONTENT_TYPE` — the RFC 7807 media type.
- `respondProblemJson(c, err)` — context-agnostic helper that
takes any Hono-like surface (anything with `header(name,
value)` and `body(data, status)`).
- Maps `SuaveplanError.code` → HTTP status via the legacy
table, then the `SMS.*` switch, then a 500 fallback.
- Propagates `SuaveplanError.context` into the problem-details
document so callers can surface Zod issues, opt-out targets,
etc.
## Usage: Basic
```ts
import { Hono } from "hono";
import { respondProblemJson } from "@suaveplan/sms/routes/problem-json";
const app = new Hono();
app.post("/sms", async (c) => {
try {
// ... service.send ...
return c.json({ ok: true });
} catch (err) {
return respondProblemJson(c, err);
}
});
```
## Usage: Advanced
```ts
import { SmsRateLimitedError } from "@suaveplan/sms/error-codes";
import { respondProblemJson } from "@suaveplan/sms/routes/problem-json";
const res = respondProblemJson(c, new SmsRateLimitedError({
context: { phoneNumber: "+15551234567", key: "marketing:+15551234567" },
}));
console.log(res.status); // 429
const body = await res.json();
console.log(body.context); // { phoneNumber: "...", key: "..." }
```
## API Reference
- `const PROBLEM_JSON_CONTENT_TYPE = "application/problem+json"`.
- `interface ProblemDetails` — RFC 7807 shape with `title`,
`status`, optional `type`/`detail`/`instance`/`code`, plus an
open index signature.
- `interface HonoLike` — `header(name, value): void`,
`body(data, status): Response`.
- `function respondProblemJson(c: HonoLike, err: unknown): Response`.
## Implementation Notes
The helper is intentionally context-agnostic — it accepts any
object satisfying the minimal `HonoLike` surface so test
harnesses can pass a stub. The status-mapping cascade
prioritises the upstream `SMS_ERROR_METADATA` table so a legacy
caller continues to see the same status codes after migration.
Unknown error throwables fall back to 500 with the error
message as the title, or `"Internal Server Error"` when the
message is empty, preserving the upstream behaviour.

View File

@@ -0,0 +1,97 @@
# `scheduler`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`scheduler` provides `MessageScheduler` — the minimal timer
seam that `SmsService.scheduleAt` uses to fire a delayed
delivery. The class wraps an injectable `TimersLike` so tests
drive it deterministically, computes the delay from an
injectable `Clock`, and returns a `ScheduleHandle` with
`cancel()` so a caller can abort a scheduled message before the
timer fires.
The scheduler is intentionally small: it does not own retry,
rate-limit, opt-out, or template logic — those layers live in
`SmsService` so a scheduled message goes through the full
pipeline when the timer fires.
## Features
- `scheduleAt(date, fn)` — schedules `fn` to run at the target
instant.
- Returns a `ScheduleHandle` with `cancel()` (idempotent) and
the original `sendAt` field for observation.
- Injectable `TimersLike` seam — defaults to a
`globalThis.setTimeout` / `clearTimeout` adapter.
- Injectable `Clock` — defaults to `Date.now`.
- Past dates collapse to a zero-delay timer, matching the
expectation that "send it now (or as soon as possible)".
## Usage: Basic
```ts
import { MessageScheduler } from "@suaveplan/sms/scheduler";
const scheduler = new MessageScheduler();
const handle = scheduler.scheduleAt(
new Date(Date.now() + 60_000),
() => console.log("fire!")
);
handle.cancel(); // call before firing to abort
```
## Usage: Advanced
```ts
import { MessageScheduler } from "@suaveplan/sms/scheduler";
// Controlled timers + clock for deterministic tests.
const pending: Array<{ cb: () => void; delayMs: number }> = [];
const scheduler = new MessageScheduler({
timers: {
setTimer(cb, delayMs) {
pending.push({ cb, delayMs });
return pending.length;
},
clearTimer() {
/* no-op for tests */
},
},
clock: () => 1_000,
});
const handle = scheduler.scheduleAt(new Date(2_000), () => {
/* runs at virtual t=2000 */
});
// Manually fire the queued callback to simulate the timer
pending[0]?.cb();
handle.cancel(); // safe to call after fire
```
## API Reference
- `class MessageScheduler`
- `constructor(options?: MessageSchedulerOptions)`
- `scheduleAt(sendAt: Date, fn: () => void): ScheduleHandle`
- `interface ScheduleHandle`
- `cancel(): void` — idempotent
- `readonly sendAt: Date`
- `interface MessageSchedulerOptions`
- `timers?: TimersLike`
- `clock?: Clock`
## Implementation Notes
The scheduler tracks a `cancelled` flag inside its timer
callback so a cancellation that races the timer (i.e. the
caller calls `cancel` after the timer has already been pulled
but before the callback runs) is still honoured. Double-cancel
is a no-op. The platform adapter casts the `setTimeout` return
to `number` for cross-runtime compatibility — Node returns a
`Timeout` object, browsers/Bun return a number; both shapes are
safe to forward back to `clearTimeout`.

View File

@@ -0,0 +1,101 @@
# `schemas`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`schemas` is the Zod boundary for every public input that
`@suaveplan/sms` exposes. The schemas mirror the public type
contracts (`SmsMessage`, `SmsTemplate`, `SmsDeliveryReport`,
`SmsDeliveryStatus`) verbatim, and the `parseSmsMessage` /
`parseSmsTemplate` / `parseSmsDeliveryReport` parsers throw a
`SmsInvalidConfigError` (code `SMS.INVALID_CONFIG`) with the Zod
issues attached as `context.issues` on rejection.
The schemas use strict `.object()` shapes so unknown keys are
rejected — callers cannot accidentally smuggle private fields
through the public API.
## Features
- `SmsMessageSchema` — strict shape with E.164 `to`, non-empty
`body`, and optional `from`, `templateId`, `templateVars`,
`metadata`, `channel`.
- `SmsTemplateSchema` — strict shape with non-empty `id`,
non-empty `body`, `variables` array, optional `defaults` map.
- `SmsDeliveryStatusSchema` — union of the five status literals.
- `SmsDeliveryReportSchema` — strict shape for receipts.
- `parseSmsMessage`, `parseSmsTemplate`,
`parseSmsDeliveryReport` — throw `SmsInvalidConfigError` on
rejection with the Zod issues attached.
- `stripUndefined` helper strips undefined keys before returning
so the result complies with the
`exactOptionalPropertyTypes` contract.
## Usage: Basic
```ts
import { parseSmsMessage } from "@suaveplan/sms/schemas";
const msg = parseSmsMessage({
to: "+15551234567",
body: "Hello",
});
```
## Usage: Advanced
```ts
import {
SmsMessageSchema,
parseSmsTemplate,
} from "@suaveplan/sms/schemas";
import { SmsInvalidConfigError } from "@suaveplan/sms/error-codes";
try {
const template = parseSmsTemplate({
id: "welcome",
body: "Hi {{name}}",
variables: ["name"],
});
console.log(template.id);
} catch (err) {
if (err instanceof SmsInvalidConfigError) {
for (const issue of (err.context?.issues ?? []) as ReadonlyArray<
Record<string, unknown>
>) {
console.error(issue);
}
}
}
// Or use the raw schema for partial validation pipelines:
SmsMessageSchema.safeParse(input);
```
## API Reference
- `SmsMessageSchema: ZodObject` — mirrors `SmsMessage`.
- `SmsTemplateSchema: ZodObject` — mirrors `SmsTemplate`.
- `SmsDeliveryStatusSchema: ZodUnion` — five string literals.
- `SmsDeliveryReportSchema: ZodObject` — mirrors
`SmsDeliveryReport`.
- `parseSmsMessage(input: unknown): SmsMessage` — throws
`SmsInvalidConfigError`.
- `parseSmsTemplate(input: unknown): SmsTemplate` — throws
`SmsInvalidConfigError`.
- `parseSmsDeliveryReport(input: unknown): SmsDeliveryReport` —
throws `SmsInvalidConfigError`.
## Implementation Notes
The default-export form `import z from "zod"` is the only
Bun-safe form (per the workspace's `feedback_zod_import`
note); the named import variant breaks under Bun's module
loader. Each parser returns the result of `stripUndefined` so
the value matches the public type shape (which uses
`exactOptionalPropertyTypes`). Zod's `.strict()` rejects unknown
keys, so consumers cannot pass private fields through the public
boundary.

View File

@@ -0,0 +1,71 @@
# `service/bus-map`
## Purpose
`bus-map` declares `SmsBusMap` — the single-payload topic
contract used by `SmsService.bus` (an `EventBus<SmsBusMap>`).
The map is split out from the service implementation so the
legacy emitter and the service can both import the contract
without a circular dependency, and so downstream consumers can
type their own `EventBus`-shaped bridges against the same
contract.
`SmsBusMap` covers the eight live topics emitted by
`SmsService`: `sms.sent`, `sms.delivered`, `sms.failed`,
`sms.opted_out`, `sms.opted_in`, `sms.scheduled`, `sms.retry`,
`sms.rate_limited`.
## Features
- Single-payload `EventBus` contract — one value per topic
(versus the legacy `SmsEventMap` tuple form which mirrors the
upstream `on/off/emit` shape).
- Open `Record<string, unknown>` base so consumers can extend
the map with custom topics in their own packages.
- One-to-one mapping with the public type contracts in
`src/types/sms.types.ts`.
## Usage: Basic
```ts
import { EventBus } from "@suaveplan/events";
import type { SmsBusMap } from "@suaveplan/sms/service";
const bus = new EventBus<SmsBusMap>();
bus.on("sms.sent", (report) => console.log("sent", report.messageId));
```
## Usage: Advanced
```ts
import { EventBus } from "@suaveplan/events";
import type { SmsBusMap } from "@suaveplan/sms/service";
// Extend the map with a host-specific topic to share one bus.
interface HostBusMap extends SmsBusMap {
"host.audit": { actor: string; action: string };
}
const bus = new EventBus<HostBusMap>();
bus.on("sms.failed", (report) => audit({ messageId: report.messageId }));
bus.on("host.audit", (event) => console.log(event.actor, event.action));
```
## API Reference
- `interface SmsBusMap extends Record<string, unknown>` with
keys: `sms.sent`, `sms.delivered`, `sms.failed`, `sms.opted_out`,
`sms.opted_in`, `sms.scheduled`, `sms.retry`, `sms.rate_limited`,
each mapped to the matching public type
(`SmsDeliveryReport`, `OptOutEntry`, `{phoneNumber: string}`,
`SmsScheduledEvent`, `SmsRetryEvent`, `SmsRateLimitedEvent`).
## Implementation Notes
The map is a `type-only` export — no runtime symbol — so it
adds zero bundle size. The decision to use single-payload
topics on the bus (rather than the legacy tuple form) follows
`@suaveplan/events` convention; the tuple form is
preserved exclusively for the upstream `on/off/emit` surface
and is mediated by `LegacyEmitter.mirror`. Consumers building
new code should target the bus directly.

View File

@@ -0,0 +1,88 @@
# `service/legacy-emitter`
## Purpose
`LegacyEmitter` is the back-compat shim that preserves the
upstream `@suaveplan/sms` v1 `on/off/emit` listener API on top
of the `EventBus<SmsBusMap>`. Every legacy listener
registered through `service.on(...)` continues to receive the
tuple-spread argument shape expected by upstream callers, while
the same payload flows through the typed bus so current
consumers can opt in to the richer `EventBus` API. This avoids
a breaking change for callers of `@suaveplan/sms` v1.
The emitter lives in its own module so the `SmsService` class
stays focused on pipeline orchestration; the legacy adapter
adds zero cost for callers who only use the typed bus.
## Features
- `on<K>(type, listener)` — register a tuple-arg legacy listener.
- `off<K>(type, listener)` — deregister a legacy listener.
- `emit<K>(type, ...args)` — invoke every legacy listener and,
for known bus topics, mirror the first arg onto the bus.
- `mirror<K>(topic, payload)` — emit a single-payload bus event
and mirror it onto the legacy listeners (used by `SmsService`
for its native emits).
- Topic gate (`BUS_TOPICS`) discriminates legacy-only topics
from typed bus topics so non-mapped topics do not pollute the
typed bus.
## Usage: Basic
```ts
import { EventBus } from "@suaveplan/events";
import { LegacyEmitter } from "@suaveplan/sms/service";
import type { SmsBusMap } from "@suaveplan/sms/service";
const bus = new EventBus<SmsBusMap>();
const emitter = new LegacyEmitter(bus);
emitter.on("sms.opted_in", (entry) => console.log(entry.phoneNumber));
emitter.emit("sms.opted_in", { phoneNumber: "+15551234567" });
```
## Usage: Advanced
```ts
import { EventBus } from "@suaveplan/events";
import { LegacyEmitter } from "@suaveplan/sms/service";
import type { SmsBusMap } from "@suaveplan/sms/service";
const bus = new EventBus<SmsBusMap>();
const emitter = new LegacyEmitter(bus);
// Bridge from a typed bus subscriber to a legacy-listener-flavoured
// downstream consumer.
bus.on("sms.sent", (report) => console.log("bus", report.messageId));
emitter.on("sms.sent", (report) =>
console.log("legacy", report.messageId)
);
emitter.mirror("sms.sent", {
messageId: "m1",
to: "+15551234567",
status: "sent",
timestamp: new Date(),
});
// Both subscribers receive the same payload.
```
## API Reference
- `class LegacyEmitter`
- `constructor(bus: EventBus<SmsBusMap>)`
- `on<K extends keyof SmsEventMap>(type, listener): void`
- `off<K extends keyof SmsEventMap>(type, listener): void`
- `emit<K extends keyof SmsEventMap>(type, ...args): void`
- `mirror<K extends keyof SmsBusMap>(topic, payload): void`
- `type SmsLegacyListener<K extends keyof SmsEventMap> = (...args:
SmsEventMap[K]) => void`.
## Implementation Notes
The class snapshots the legacy listener array (`[...list]`)
before iterating so a listener that calls `emitter.off(...)` on
itself mid-emit does not skip subsequent listeners. The `BUS_TOPICS`
constant is the single source of truth for the topic gate — adding
a new bus topic requires also extending the legacy emitter, but
that's the explicit contract: the bus is typed and the legacy
mirror must be opt-in.

View File

@@ -0,0 +1,119 @@
# `service/sms-service`
## Purpose
`SmsService` is the provider-agnostic orchestrator that ties
together every primitive in `@suaveplan/sms`. A single
`service.send` call walks the abort check, the provider check,
the E.164 validation, the opt-out check, the rate limiter, the
template renderer, the retry policy, the provider send, the
delivery-tracker ingest, the OTel record helpers, and the event
bus emit. It is the surface upstream consumers used to call
`new SmsService()` against; preserves the verbatim
upstream API plus an enhanced bus surface and an injectable
options bundle.
## Features
- `bus: EventBus<SmsBusMap>` — typed single-payload event bus.
- Legacy `on`, `off`, `emit` API mirrors the bus (preserved for
upstream callers).
- `send(message, options?)` — full pipeline with abort,
validation, opt-out, rate-limit, template render, retry,
provider send, receipt ingest, OTel emit.
- `scheduleAt(date, message)` — fire-and-forget scheduled
delivery with a cancel handle; on cancel emits `sms.failed`
with `errorCode: SMS.SCHEDULE_CANCELLED`.
- `getStatus(messageId)` — cache-first lookup through the
delivery tracker, falling back to `provider.getStatus`.
- `optOut` / `optIn` / `isOptedOut` — opt-out registry helpers
emitting the matching bus event.
- `markDelivered` / `markFailed` — external webhook receipts
that ingest into the tracker and emit the matching event.
- `setProvider` / `getProviderName` — runtime provider swap.
## Usage: Basic
```ts
import { InMemorySmsProvider, SmsService } from "@suaveplan/sms";
const provider = new InMemorySmsProvider();
const service = new SmsService({ provider });
const report = await service.send({
to: "+15551234567",
body: "Hello",
});
```
## Usage: Advanced
```ts
import { InMemorySmsProvider, SmsService } from "@suaveplan/sms";
const provider = new InMemorySmsProvider();
const service = new SmsService({
provider,
rateLimiter: { capacity: 10, refillPerSecond: 1 },
retry: { maxAttempts: 5, initialDelayMs: 200 },
deliveryTracker: { maxEntries: 256 },
});
service.registerTemplate({
id: "welcome",
body: "Hi {{name}}, your code is {{code}}",
variables: ["name", "code"],
});
service.bus.on("sms.retry", (e) =>
console.warn("retry", e.attempt, e.error.message)
);
service.bus.on("sms.failed", (r) =>
console.error("failed", r.messageId, r.errorCode)
);
const ctrl = new AbortController();
await service.send(
{
to: "+15551234567",
body: "ignored",
templateId: "welcome",
templateVars: { name: "Alice", code: "8472" },
channel: "transactional",
},
{ signal: ctrl.signal }
);
```
## API Reference
- `class SmsService`
- `constructor(options?: SmsServiceOptions)`
- Public fields: `bus`, `templates`, `optOuts`, `rateLimiter`,
`retry`, `scheduler`, `deliveryTracker`.
- `setProvider`, `getProviderName`, `registerTemplate`,
`getTemplate`.
- `send(message, options?: { signal?: AbortSignal }):
Promise<SmsDeliveryReport>`.
- `scheduleAt(sendAt: Date, message: SmsMessage):
ScheduleHandle`.
- `getStatus(messageId: string): Promise<SmsDeliveryReport>`.
- `optOut`, `optIn`, `isOptedOut`, `markDelivered`,
`markFailed`.
- Legacy `on`, `off`, `emit` mirroring the bus.
- Static `extractFailure(error: unknown): { code: string;
message: string }`.
- `interface SmsSendOptions` — `signal?: AbortSignal`.
## Implementation Notes
The pipeline orders abort → provider → validation → opt-out →
rate-limit → template → retry deliberately: cheap checks fail
fast before the limiter consumes a token; the template is only
rendered when the rate-limiter has admitted the message, so a
back-pressured caller doesn't burn template render cycles. The
service spreads `options.signal` into the retry options
conditionally so an absent signal does not satisfy
`exactOptionalPropertyTypes` with `undefined`. The legacy
`emit` re-uses the bus topic check so non-bus-mapped legacy
topics continue to fire pure-legacy listeners without polluting
the typed bus.

View File

@@ -0,0 +1,99 @@
# `templates`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`templates` is the in-process template registry and renderer used
by `SmsService` to expand `{{variable}}` placeholders against
per-call variable maps. The renderer is intentionally narrow —
substitution only, no conditional logic, no loops — because SMS
bodies are short and predictable; richer templating belongs in a
caller-side pre-processing pass that produces the
`SmsMessage.body` string.
The registry holds `SmsTemplate` rows indexed by `id` and exposes
the small API surface (register, get, require, has, unregister,
list, render, clear) that `SmsService` and consumer code use.
## Features
- `TemplateRegistry` class for in-memory storage.
- `renderTemplate(template, vars, defaults?)` — substitutes
every declared `{{var}}` with a value sourced from the `vars`
map, then the call-level `defaults`, then the template-level
`template.defaults`, then the empty string.
- Undeclared placeholders are left in place so callers can layer
their own renderer on top.
- Regex escaping protects against variable names containing
regex meta-characters.
- `require(id)` throws `SmsTemplateNotFoundError`.
## Usage: Basic
```ts
import {
TemplateRegistry,
renderTemplate,
} from "@suaveplan/sms/templates";
const reg = new TemplateRegistry();
reg.register({
id: "welcome",
body: "Hi {{name}}!",
variables: ["name"],
});
const out = reg.render("welcome", { name: "Alice" });
console.log(out); // "Hi Alice!"
```
## Usage: Advanced
```ts
import { renderTemplate } from "@suaveplan/sms/templates";
const tpl = {
id: "alert",
body: "Alert {{level}}: {{message}} from {{system}}",
variables: ["level", "message", "system"],
defaults: { system: "monitoring" },
};
// Per-call defaults override template defaults; missing keys
// fall back to the empty string so the body is never broken.
const body = renderTemplate(
tpl,
{ level: "WARN", message: "high latency" },
{ system: "checkout-api" }
);
// "Alert WARN: high latency from checkout-api"
```
## API Reference
- `renderTemplate(template: SmsTemplate, vars: Record<string,
string>, defaults?: Record<string, string>): string` — pure
function.
- `class TemplateRegistry`
- `register(template: SmsTemplate): void`
- `get(id: string): SmsTemplate | undefined`
- `require(id: string): SmsTemplate` — throws
`SmsTemplateNotFoundError`
- `has(id: string): boolean`
- `unregister(id: string): boolean`
- `list(): ReadonlyArray<SmsTemplate>`
- `render(id: string, vars, defaults?): string`
- `clear(): void`
## Implementation Notes
Variable lookup order — `vars[v]` → `defaults[v]` →
`template.defaults?.[v]` → `""` — places per-call values
strictest at the top so a caller can always override. The
substitution uses an escaped global regex so a variable name
like `na.me` matches literally rather than as a regex
metacharacter. Templates are stored by reference, so external
mutation of a registered template will leak into render results —
callers wanting an isolated copy should clone before passing in.

View File

@@ -0,0 +1,114 @@
# `types/sms.types`
## Purpose
`types/sms.types` is the type-only foundation for
`@suaveplan/sms`. It declares every public contract that the
service, the providers, and the consumers exchange — the
message shape, the receipt shape, the delivery status union,
the provider interface, the opt-out entry, the template, the
service options, the scheduled-message envelope, and the event
payloads. Keeping the types in a single dependency-free module
prevents circular imports between the implementation modules.
The shapes mirror the upstream `@suaveplan/sms` v1 contracts
verbatim — same field names, same optionality — so existing
callers can migrate with zero source changes. The
file additionally introduces contracts
(`RateLimiterOptions`, `RetryPolicyOptions`,
`MessageSchedulerOptions`, `DeliveryTrackerOptions`,
`SmsServiceOptions`, `ScheduleHandle`, `SmsRetryEvent`,
`SmsRateLimitedEvent`, `SmsScheduledEvent`) that surface the
new capabilities.
## Features
- `SmsDeliveryStatus` union, `SmsMessage`, `SmsDeliveryReport`.
- `SmsProvider` abstract interface with `name`, `send`, and
`getStatus`.
- `OptOutEntry` and `SmsTemplate` for registries.
- `SmsEventMap` tuple-form contract for the upstream
`on/off/emit` API.
- `RateLimiterOptions`, `RetryPolicyOptions`,
`MessageSchedulerOptions`, `DeliveryTrackerOptions`,
`SmsServiceOptions` for primitive configuration.
- `ScheduleHandle`, `ScheduledMessage`, `SmsRetryEvent`,
`SmsRateLimitedEvent`, `SmsScheduledEvent` for the
additions.
## Usage: Basic
```ts
import type { SmsMessage, SmsProvider } from "@suaveplan/sms/types";
const message: SmsMessage = { to: "+15551234567", body: "hi" };
class MyProvider implements SmsProvider {
public readonly name = "my-provider";
async send(msg: SmsMessage) {
// ... call API ...
return {
messageId: "m1",
to: msg.to,
status: "sent" as const,
timestamp: new Date(),
};
}
async getStatus(messageId: string) {
return {
messageId,
to: "+15551234567",
status: "delivered" as const,
timestamp: new Date(),
};
}
}
```
## Usage: Advanced
```ts
import type {
ScheduleHandle,
SmsEventMap,
SmsServiceOptions,
} from "@suaveplan/sms/types";
function buildOptions(): SmsServiceOptions {
return {
retry: { maxAttempts: 5, initialDelayMs: 100 },
rateLimiter: { capacity: 30, refillPerSecond: 2 },
deliveryTracker: { maxEntries: 512 },
};
}
type SentArgs = SmsEventMap["sms.sent"]; // [SmsDeliveryReport]
function trackHandle(handle: ScheduleHandle): void {
console.log("scheduled for", handle.sendAt.toISOString());
}
```
## API Reference
- Union: `SmsDeliveryStatus`.
- Interfaces: `SmsMessage`, `SmsSendCallOptions`,
`SmsDeliveryReport`, `SmsProvider`, `OptOutEntry`,
`SmsTemplate`, `SmsRetryEvent`, `SmsRateLimitedEvent`,
`SmsScheduledEvent`, `SmsEventMap`, `RateLimiterOptions`,
`RetryPolicyOptions`, `MessageSchedulerOptions`,
`DeliveryTrackerOptions`, `SmsServiceOptions`,
`ScheduleHandle`, `ScheduledMessage`.
## Implementation Notes
The module is type-only — every export is a `type` or
`interface`, no runtime symbol — so it adds zero bundle size and
can be imported by any other module without inducing a runtime
dependency cycle. Optional fields use `?` (not `| undefined`)
so the file complies with the `exactOptionalPropertyTypes`
contract. The `SmsEventMap` retains the tuple form
(`"sms.sent": [SmsDeliveryReport]`) verbatim from upstream so
existing tuple-spread listeners keep compiling; the single-payload
form used by the typed bus lives in
`src/service/bus-map.ts` as `SmsBusMap`.

View File

@@ -0,0 +1,70 @@
# `validation/phone`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`validation/phone` is the strict-E.164 phone number validator
used by every public input boundary in `@suaveplan/sms`. The
validator implements the ITU-T E.164 grammar verbatim — a single
leading `+`, a first digit in `[1-9]`, then one to fourteen
trailing decimal digits — and exposes both the regex constant
and the predicate so callers can compose either against their
own pipelines.
The module is intentionally tiny: a single regex and a single
boolean function. Centralising the rule here keeps the spec a
single source of truth — the service, the Zod schemas, and the
test fixtures all use the same predicate.
## Features
- `E164_REGEX` — exported regex constant for downstream
composition.
- `isValidE164(value)` — boolean predicate, returns `true` only
for syntactically valid E.164 numbers.
- Strict format: rejects bare digits without leading `+`,
rejects `+` alone, rejects `+0…`, rejects letters, rejects
numbers longer than 15 total digits.
## Usage: Basic
```ts
import { isValidE164 } from "@suaveplan/sms/validation/phone";
isValidE164("+15551234567"); // true
isValidE164("15551234567"); // false (no leading +)
isValidE164("+0123456789"); // false (first digit is 0)
```
## Usage: Advanced
```ts
import { E164_REGEX } from "@suaveplan/sms/validation/phone";
import z from "zod";
const PhoneSchema = z
.string()
.regex(E164_REGEX, "must be an E.164 phone number")
.transform((value) => value.toUpperCase());
```
## API Reference
- `const E164_REGEX: RegExp` — `^\+[1-9]\d{1,14}$` (frozen by
TypeScript's `const` semantics).
- `function isValidE164(value: string): boolean` — returns
`E164_REGEX.test(value)`.
## Implementation Notes
The validator does NOT consult a country-code prefix table: it
checks only the syntactic shape required by E.164. Higher-layer
validation (carrier routing, prefix-to-country mapping,
toll-free / premium-rate filters) lives in
`@suaveplan/sms-providers` per-provider adapters because the rule
set varies by carrier. Keeping the syntactic predicate cheap
means a hot path through `SmsService.send` can call
`isValidE164` once per send without measurable overhead.

View File

@@ -0,0 +1,92 @@
# @suaveplan/telegram-client - Telegram Bot API client — send/edit/delete messages, media, inline + custom keyboards, typed errors, injectable fetch + clock, OTel instrumented
Telegram Bot API client: send/edit/delete messages, six media variants,
inline + custom keyboards, typed errors, injectable `fetch` + `clock`, and
OpenTelemetry counters/histograms on every code path. Runtime-agnostic
(works in Bun, Node, Deno, browsers).
<!-- begin:auto-docs -->
## Documentation tree
- [telegram-bot-client](./src/core/telegram-bot-client/telegram-bot-client.md) — `TelegramBotClient` is the public, runtime-agnostic Telegram Bot API client. It wraps the handful of Bot API endpoints…
- [telegram-bot-client](./src/core/telegram-bot-client/telegram-bot-client.md) — `TelegramBotClient` is the public, runtime-agnostic Telegram Bot API client. It wraps the handful of Bot API endpoints…
- [errors](./src/errors/telegram-client-error.md) — Defines the namespaced, frozen error-code registry and the typed `TelegramClientError` class used by every runtime fail…
- [telegram-client-error](./src/errors/telegram-client-error.md) — Defines the namespaced, frozen error-code registry and the typed `TelegramClientError` class used by every runtime fail…
- [types](./src/types/telegram-bot-config.md) — Defines the Zod schema that validates `TelegramBotConfig` at the public construction boundary of `TelegramBotClient`. T…
- [telegram-bot-config](./src/types/telegram-bot-config.md) — Defines the Zod schema that validates `TelegramBotConfig` at the public construction boundary of `TelegramBotClient`. T…
- [build-bodies](./src/utils/build-bodies.md) — Pure, side-effect-free helpers that translate the camelCase public message / reply-markup shape used by callers into th…
- [classify-error](./src/utils/classify-error.md) — Pure helpers that turn an outgoing-protocol failure (HTTP status + Telegram `description` + parsed body) into a stable…
<!-- end:auto-docs -->
## Installation
```bash
bun add @suaveplan/telegram-client
```
## Quick Start
```ts
import { createTelegramBotClient } from "@suaveplan/telegram-client";
const bot = createTelegramBotClient({ token: process.env.TELEGRAM_TOKEN! });
const sent = await bot.sendMessage({
chatId: 12345,
text: "Hello",
parseMode: "HTML",
});
console.log(sent.messageId);
```
## Modules
| Module | Path | Description |
|--------|------|-------------|
| Client | [src/core/telegram-bot-client/telegram-bot-client.md](src/core/telegram-bot-client/telegram-bot-client.md) | `TelegramBotClient` class, factory, OTel metrics |
| Body Builders | [src/utils/build-bodies.md](src/utils/build-bodies.md) | Pure camelCase → snake_case translators |
| Error Classifier | [src/utils/classify-error.md](src/utils/classify-error.md) | Pure status + retry-after classifier |
| Errors | [src/errors/telegram-client-error.md](src/errors/telegram-client-error.md) | Code registry + typed error class |
| Config Schema | [src/types/telegram-bot-config.md](src/types/telegram-bot-config.md) | Zod validation at the boundary |
| Types | [src/types/telegram-client.types.md](src/types/telegram-client.types.md) | Public type surface |
## Subpath Exports
```ts
import { createTelegramBotClient } from "@suaveplan/telegram-client";
import {
TELEGRAM_CLIENT_CODES,
TelegramClientError,
} from "@suaveplan/telegram-client/errors";
import type { TelegramMessage } from "@suaveplan/telegram-client/types";
```
## Error Codes
| Code | When |
|---|---|
| `TELEGRAM_CLIENT.CONFIG_MISSING` | Config rejected by Zod schema (missing token, invalid URL, etc.) |
| `TELEGRAM_CLIENT.TIMEOUT` | Request aborted before completion |
| `TELEGRAM_CLIENT.AUTH_ERROR` | Telegram returned HTTP 401 |
| `TELEGRAM_CLIENT.RATE_LIMITED` | Telegram returned HTTP 429 (`context.retryAfterMs` set if parseable) |
| `TELEGRAM_CLIENT.CHAT_NOT_FOUND` | HTTP 404, or HTTP 400 with `chat not found` / `channel not found` |
| `TELEGRAM_CLIENT.BOT_BLOCKED` | HTTP 403 with `bot was blocked` / `user is deactivated` |
| `TELEGRAM_CLIENT.FORBIDDEN` | Other HTTP 403, or HTTP 400 with `not enough rights` / `forbidden` |
| `TELEGRAM_CLIENT.API_ERROR` | Catch-all for unclassified Telegram failures |
## OTel Instruments
- Counter `telegram_client.request.total` — keyed by `{ method, outcome }`
- Histogram `telegram_client.request.duration_ms` — keyed by `{ method, outcome }`
- `outcome ∈ { "success", "error", "timeout" }`
Call `resetTelegramClientMetrics()` from tests after replacing the global
`MeterProvider` so each test sees a fresh meter.
## Verification
- Tests use injected `fetch` / `clock` — no mocks, no network.
- 100% istanbul coverage on touched modules.

View File

@@ -0,0 +1,139 @@
# Telegram Bot Client
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`TelegramBotClient` is the public, runtime-agnostic Telegram Bot API client.
It wraps the handful of Bot API endpoints used in production bot workflows
(`sendMessage`, `editMessageText`, `deleteMessage`, the six media variants,
`getChat`, `getMe`) behind a typed, injectable surface. The class itself is
small; the value is the contract — Zod-validated config, injectable `fetch`
and `clock`, typed errors via `TelegramClientError`, and OTel counters
and histograms emitted on every code path.
The client is intentionally stateless beyond construction: every method is a
pure mapping from a typed input to a single Bot API call. Retry, throttling,
and queueing belong to a higher layer (a downstream `@suaveplan/notifications-*`
package or `@suaveplan/retry`) so this client stays focused on protocol
fidelity and never owns its own scheduler.
## Features
- Bot API surface — `sendMessage`, `editMessage`, `deleteMessage`, `sendMedia`,
`getChat`, `getMe`
- Zod validation at the construction boundary (`TelegramBotConfigSchema`)
- Injectable `fetch` and `clock` — tests do not patch globals
- `AbortController`-based per-client timeout (default 10s, override via `timeoutMs`)
- Custom base URL via `baseUrl` (default `https://api.telegram.org`)
- Typed errors — every failure is a `TelegramClientError` with one of the
eight `TELEGRAM_CLIENT_CODES` values
- OTel counter `telegram_client.request.total` and histogram
`telegram_client.request.duration_ms`, keyed by `{ method, outcome }`
- `outcome ∈ { success, error, timeout }`
- `resetTelegramClientMetrics()` — clear the cached meter handles in tests
## Usage: Basic
```ts
import { createTelegramBotClient } from "@suaveplan/telegram-client";
const bot = createTelegramBotClient({ token: process.env.TELEGRAM_TOKEN! });
const sent = await bot.sendMessage({
chatId: 12345,
text: "Hello",
});
console.log(sent.messageId);
```
## Usage: Advanced
```ts
import {
createTelegramBotClient,
resetTelegramClientMetrics,
TELEGRAM_CLIENT_CODES,
TelegramClientError,
type FetchLike,
} from "@suaveplan/telegram-client";
import { metrics } from "@opentelemetry/api";
// 1. Wire an in-memory MeterProvider for tests / staging.
metrics.setGlobalMeterProvider(myInMemoryProvider);
resetTelegramClientMetrics();
// 2. Inject a deterministic fetch + clock for golden-path tests.
const fetch: FetchLike = async (url, init) => ({
ok: true,
status: 200,
statusText: "OK",
headers: { get: () => null },
text: async () =>
JSON.stringify({ ok: true, result: { id: 1, is_bot: true } }),
json: async () => ({ ok: true, result: {} }),
});
const bot = createTelegramBotClient(
{ token: "test:token", timeoutMs: 5_000 },
{ fetch, clock: () => 1_700_000_000_000 },
);
// 3. Send with a fully-featured reply markup.
try {
await bot.sendMessage({
chatId: "@my_channel",
text: "*Pick one*",
parseMode: "MarkdownV2",
disableWebPagePreview: true,
replyMarkup: {
inlineKeyboard: [
[
{ text: "Docs", url: "https://core.telegram.org/bots/api" },
{ text: "Approve", callbackData: "approve" },
],
],
},
});
} catch (err) {
if (
err instanceof TelegramClientError &&
err.code === TELEGRAM_CLIENT_CODES.RATE_LIMITED
) {
const ctx = err.context as { retryAfterMs?: number } | undefined;
await scheduleRetry(ctx?.retryAfterMs ?? 1_000);
}
}
```
## API Reference
- `createTelegramBotClient(config, injectables?) → TelegramBotClient`
- `new TelegramBotClient(config, injectables?)`
- `sendMessage(message: TelegramMessage) → Promise<TelegramSentMessage>`
- `editMessage(chatId, messageId, text, parseMode?) → Promise<TelegramSentMessage>`
- `deleteMessage(chatId, messageId) → Promise<boolean>`
- `sendMedia(chatId, type, url, caption?) → Promise<TelegramSentMessage>`
- `getChat(chatId) → Promise<TelegramChat>`
- `getMe() → Promise<TelegramUser>`
- `resetTelegramClientMetrics() → void` — test helper
`config` is validated against `TelegramBotConfigSchema`; failures throw
`TelegramClientError(CONFIG_MISSING)`. `injectables.fetch` defaults to
`globalThis.fetch`. `injectables.clock` defaults to `() => Date.now()`.
## Implementation Notes
The client uses `AbortController` for timeouts. When the controller aborts,
the underlying `fetch` rejects with an `AbortError`; the client maps that
onto `TelegramClientError(TIMEOUT)` and records `outcome: "timeout"` on the
OTel counter / histogram. Body read failures (caller-supplied `text()`
rejecting) and JSON parse failures both map onto `API_ERROR` — the response
is malformed enough that no narrower classification is meaningful. The OTel
meter handles are cached at module scope and reset via
`resetTelegramClientMetrics()`; tests call it after pointing the global
provider at an in-memory recorder so every assertion sees fresh handles.
The default `fetch` adapter is a thin wrapper that calls `globalThis.fetch`
and casts the resulting `Response` to the structurally compatible
`TelegramFetchResponse` contract.

View File

@@ -0,0 +1,99 @@
# Telegram Client Error
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines the namespaced, frozen error-code registry and the typed
`TelegramClientError` class used by every runtime failure path in
`@suaveplan/telegram-client`. Co-locating the codes with the error class
guarantees that adding a new code is a single-file change and that the
TypeScript union stays in lock-step with the runtime registry the
OpenSpec conformance audit greps for. Every code listed in
`TELEGRAM_CLIENT_CODES` is thrown by at least one production code path,
pinned by a test in `telegram-client-error.test.ts`.
## Features
- `TELEGRAM_CLIENT_CODES` — `defineCodes("TELEGRAM_CLIENT", […])` frozen record
- `TelegramClientCode` — literal union of every code constant
- `TelegramClientError` — extends `SuaveplanError<TelegramClientCode>` so
callers can both `instanceof`-narrow and pattern-match on `.code`
- Stable code identifiers survive minification and tree-shaking
- Carries through `context`, `cause`, and `traceId` from the base options bundle
## Usage: Basic
```ts
import {
TELEGRAM_CLIENT_CODES,
TelegramClientError,
} from "@suaveplan/telegram-client/errors";
throw new TelegramClientError(
TELEGRAM_CLIENT_CODES.CONFIG_MISSING,
"Telegram bot token is required",
);
```
## Usage: Advanced
```ts
import {
TELEGRAM_CLIENT_CODES,
TelegramClientError,
type TelegramClientCode,
} from "@suaveplan/telegram-client/errors";
function describeFailure(err: unknown): string {
if (!(err instanceof TelegramClientError)) return "unknown";
const code: TelegramClientCode = err.code;
switch (code) {
case TELEGRAM_CLIENT_CODES.RATE_LIMITED: {
const ctx = err.context as { retryAfterMs?: number } | undefined;
return `rate-limited; retry in ${ctx?.retryAfterMs ?? "?"}ms`;
}
case TELEGRAM_CLIENT_CODES.AUTH_ERROR:
return "auth — rotate the bot token";
case TELEGRAM_CLIENT_CODES.BOT_BLOCKED:
return "user blocked the bot; mark the recipient as opted out";
default:
return `telegram failure: ${code}`;
}
}
```
## API Reference
`TELEGRAM_CLIENT_CODES` — readonly record:
| Constant | Value |
|---|---|
| `API_ERROR` | `TELEGRAM_CLIENT.API_ERROR` |
| `AUTH_ERROR` | `TELEGRAM_CLIENT.AUTH_ERROR` |
| `RATE_LIMITED` | `TELEGRAM_CLIENT.RATE_LIMITED` |
| `CHAT_NOT_FOUND` | `TELEGRAM_CLIENT.CHAT_NOT_FOUND` |
| `BOT_BLOCKED` | `TELEGRAM_CLIENT.BOT_BLOCKED` |
| `CONFIG_MISSING` | `TELEGRAM_CLIENT.CONFIG_MISSING` |
| `TIMEOUT` | `TELEGRAM_CLIENT.TIMEOUT` |
| `FORBIDDEN` | `TELEGRAM_CLIENT.FORBIDDEN` |
`TelegramClientError`:
- `new TelegramClientError(code, messageKey, options?)`
- Inherits `code`, `context`, `cause`, `timestamp`, `traceId` from `SuaveplanError`.
- `name === "TelegramClientError"`.
## Implementation Notes
`defineCodes` produces a `Readonly<Record<KEY, "TELEGRAM_CLIENT.KEY">>` shape
so consumers can write `TELEGRAM_CLIENT_CODES.AUTH_ERROR` and have TypeScript
narrow the value to the literal `"TELEGRAM_CLIENT.AUTH_ERROR"`. The class
extends the generic `SuaveplanError<TCode>` with `TelegramClientCode` so
`instanceof TelegramClientError` narrows the `.code` field to the union of
those eight literals without an additional assertion. Throwing every
declared code from a real production path is enforced both by the spec audit
script and by the `telegram-client-error.test.ts::"every declared code is
thrown by a production path"` assertion.

View File

@@ -0,0 +1,84 @@
# Telegram Bot Config Schema
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines the Zod schema that validates `TelegramBotConfig` at the public
construction boundary of `TelegramBotClient`. The schema is the runtime
guardrail that converts the optimistic TypeScript interface into a hard,
fail-fast check — empty tokens, invalid URLs, and non-positive timeouts are
rejected with a structured `TelegramClientError` carrying the full Zod issue
list, rather than allowing those bugs to surface as opaque network failures
later in the request lifecycle.
Validating at the boundary lets every downstream module assume the config
values are well-formed: `token` is a non-empty string, `baseUrl` is a valid
absolute URL when supplied, and `timeoutMs` is a positive integer when
supplied. Defaulting is intentionally NOT done in the schema (it is done in
the client constructor) so the schema reflects only what the caller actually
sent.
## Features
- `TelegramBotConfigSchema` — strict Zod schema; rejects unknown fields
- Non-empty token requirement with explicit error message
- `baseUrl` is `.url()` validated when present
- `timeoutMs` is `.int().positive()` validated when present
- `TelegramBotConfigParsed` — type inferred from the schema
## Usage: Basic
```ts
import { TelegramBotConfigSchema } from "@suaveplan/telegram-client/types";
const result = TelegramBotConfigSchema.safeParse({ token: "123:ABC" });
if (!result.success) {
console.error(result.error.issues);
}
```
## Usage: Advanced
```ts
import { TelegramBotConfigSchema } from "@suaveplan/telegram-client/types";
// In a factory that wraps config from env + CLI overrides.
function loadTelegramConfig(env: Record<string, string | undefined>) {
const result = TelegramBotConfigSchema.safeParse({
token: env.TELEGRAM_BOT_TOKEN,
baseUrl: env.TELEGRAM_BASE_URL,
timeoutMs: env.TELEGRAM_TIMEOUT_MS
? Number(env.TELEGRAM_TIMEOUT_MS)
: undefined,
});
if (!result.success) {
throw new Error(
`Invalid Telegram config: ${result.error.issues
.map((i) => `${i.path.join(".")}: ${i.message}`)
.join(", ")}`,
);
}
return result.data;
}
```
## API Reference
- `TelegramBotConfigSchema: z.ZodObject<{ token, baseUrl?, timeoutMs? }>` (strict)
- `TelegramBotConfigParsed = z.infer<typeof TelegramBotConfigSchema>`
The schema is `.strict()` — passing unknown keys produces a `ZodError` rather
than silently dropping them. This keeps configuration drift visible.
## Implementation Notes
The schema deliberately does not apply defaults; the client constructor
applies `DEFAULT_BASE_URL` and `DEFAULT_TIMEOUT_MS` after validation succeeds.
Splitting the responsibilities means schema test cases only exercise
validation logic, never defaulting behaviour. This keeps both surfaces
independently auditable. Construction failures are wrapped in a
`TelegramClientError` with code `TELEGRAM_CLIENT.CONFIG_MISSING` so callers
observe a uniform error type regardless of which field was invalid.

View File

@@ -0,0 +1,117 @@
# Telegram Client Types
## Purpose
Defines the public TypeScript surface for `@suaveplan/telegram-client`. This
module is the single source of truth for the shape of every option bundle,
wire envelope, and inbound payload exchanged with the Telegram Bot API. Every
other module — the client core, the error registry, the body builders, the
classifier — imports its types from here so the wire contract stays consistent
and any change to the public shape is a single-file edit.
The types are intentionally framework-agnostic. `FetchLike` and
`TelegramFetchResponse` model the minimum subset of the global `fetch` /
`Response` contract that the client needs. Passing `globalThis.fetch` works
out of the box; passing a deterministic stub for tests works equally well.
## Features
- `TelegramBotConfig` — runtime config (token, optional baseUrl, optional timeoutMs)
- `TelegramBotInjectables` — optional `fetch` and `clock` injection points
- `FetchLike` / `TelegramFetchResponse` — minimal fetch contract
- `TelegramMessage` + reply markup — outgoing message shape (camelCase)
- `TelegramReplyMarkup`, `TelegramInlineButton`, `TelegramKeyboardButton` — keyboard primitives
- `TelegramResponse<T>` — wire envelope for every Bot API call
- `TelegramChat`, `TelegramUser`, `TelegramSentMessage` — incoming entity shapes
- `TelegramDeliveryReport`, `TelegramDeliveryStatus` — observability shapes for downstream consumers
- `TelegramMediaType` — discriminator for `sendMedia`
## Usage: Basic
```ts
import type {
TelegramBotConfig,
TelegramMessage,
} from "@suaveplan/telegram-client/types";
const config: TelegramBotConfig = { token: "123:ABC" };
const message: TelegramMessage = {
chatId: 12345,
text: "Hello",
};
```
## Usage: Advanced
```ts
import type {
FetchLike,
TelegramBotConfig,
TelegramBotInjectables,
TelegramMessage,
} from "@suaveplan/telegram-client/types";
const config: TelegramBotConfig = {
token: "123:ABC",
baseUrl: "https://api.telegram.org",
timeoutMs: 5000,
};
const fetchImpl: FetchLike = async (url, init) => {
// Deterministic stub for tests — return a TelegramFetchResponse shape.
return {
ok: true,
status: 200,
statusText: "OK",
headers: { get: () => null },
text: async () => '{"ok":true,"result":{}}',
json: async () => ({ ok: true, result: {} }),
};
};
const inject: TelegramBotInjectables = {
fetch: fetchImpl,
clock: () => 1_700_000_000_000,
};
const msg: TelegramMessage = {
chatId: 1,
text: "Pick one",
parseMode: "MarkdownV2",
replyMarkup: {
inlineKeyboard: [
[
{ text: "Yes", callbackData: "yes" },
{ text: "No", callbackData: "no" },
],
],
},
};
```
## API Reference
- `TelegramBotConfig` — `{ token: string; baseUrl?: string; timeoutMs?: number }`.
- `TelegramBotInjectables` — `{ fetch?: FetchLike; clock?: Clock }`.
- `FetchLike` — async URL + init function returning `TelegramFetchResponse`.
- `TelegramFetchResponse` — `{ ok, status, statusText, headers.get(name), text(), json() }`.
- `TelegramMessage` — `chatId`, `text`, plus optional `parseMode`, `disableWebPagePreview`, `disableNotification`, `replyToMessageId`, `replyMarkup`.
- `TelegramReplyMarkup` — `inlineKeyboard`, `keyboard`, `removeKeyboard`, `oneTimeKeyboard`.
- `TelegramInlineButton` — `text`, optional `url`, optional `callbackData`.
- `TelegramKeyboardButton` — `text`, optional `requestContact`, optional `requestLocation`.
- `TelegramResponse<T>` — wire envelope `{ ok, result?, errorCode?, description? }`.
- `TelegramChat`, `TelegramUser`, `TelegramSentMessage` — incoming entities.
- `TelegramDeliveryReport`, `TelegramDeliveryStatus` — downstream consumer shapes.
- `TelegramMediaType` — literal union: `"photo" | "document" | "video" | "audio" | "voice" | "animation"`.
## Implementation Notes
The `FetchLike` contract intentionally omits the rich `RequestInit` surface
(modes, credentials, integrity, etc.) because the Bot API only ever receives
small JSON POSTs. Keeping the contract tight makes test stubs trivial and
prevents drift between what we use and what we type. The
`TelegramFetchResponse` shape is structurally compatible with the global
`Response` type, so callers may pass `globalThis.fetch` (adapted internally
inside the client) without a runtime wrapper. Camel-to-snake translation
happens in `utils/build-bodies.ts`; public callers never see snake_case.

View File

@@ -0,0 +1,94 @@
# Build Bodies
## Purpose
Pure, side-effect-free helpers that translate the camelCase public message /
reply-markup shape used by callers into the snake_case wire shape that the
Telegram Bot API expects. Splitting these helpers out of the client keeps
the wire-format translation independently testable, makes it cheap to add
new optional fields, and means every option permutation can be exercised
without an HTTP round trip.
Telegram's Bot API mixes top-level fields (`chat_id`, `parse_mode`) with
nested objects (`reply_markup.inline_keyboard`). Hand-rolling each
conversion in the client would scatter snake-case knowledge across the
codebase; centralising it here means the wire-format contract is one file
to audit when Telegram releases a new option.
## Features
- `buildSendMessageBody(message)` — converts `TelegramMessage` → wire body
- `buildReplyMarkup(markup)` — converts `TelegramReplyMarkup` → wire markup
- `MEDIA_METHOD_MAP` — frozen record of `TelegramMediaType` → Bot API method
- Drops optional fields when falsy/absent — minimises wire payload
- Preserves caller-provided `chatId` type (string or number) — Telegram accepts both
## Usage: Basic
```ts
import { buildSendMessageBody } from "@suaveplan/telegram-client";
const body = buildSendMessageBody({
chatId: 100,
text: "Hello",
parseMode: "HTML",
});
// body === { chat_id: 100, text: "Hello", parse_mode: "HTML" }
```
## Usage: Advanced
```ts
import {
buildReplyMarkup,
buildSendMessageBody,
MEDIA_METHOD_MAP,
} from "@suaveplan/telegram-client";
// Compose a fully-featured message body with inline + custom keyboards.
const body = buildSendMessageBody({
chatId: "@my_channel",
text: "*Pick one*",
parseMode: "MarkdownV2",
disableWebPagePreview: true,
replyToMessageId: 42,
replyMarkup: {
inlineKeyboard: [
[
{ text: "Docs", url: "https://core.telegram.org/bots/api" },
{ text: "Yes", callbackData: "yes" },
],
],
oneTimeKeyboard: true,
},
});
// Pick the Bot API method for a media upload.
const method = MEDIA_METHOD_MAP.photo; // "sendPhoto"
```
## API Reference
- `buildSendMessageBody(message: TelegramMessage): Record<string, unknown>`
- Always emits `chat_id` and `text`.
- Emits `parse_mode`, `disable_web_page_preview`, `disable_notification`,
`reply_to_message_id`, `reply_markup` only when the corresponding camelCase
field is truthy.
- `buildReplyMarkup(markup: TelegramReplyMarkup): Record<string, unknown>`
- Emits `inline_keyboard` when `inlineKeyboard` is set; each button is
`{ text, url?, callback_data? }`.
- Emits `keyboard` when `keyboard` is set; each button is
`{ text, request_contact?, request_location? }`.
- Emits `remove_keyboard: true` / `one_time_keyboard: true` when requested.
- `MEDIA_METHOD_MAP: Record<TelegramMediaType, "send<Type>">` — frozen.
## Implementation Notes
The helpers are pure: identical input → identical output, no global state,
no `Date.now()`/`Math.random()`. That makes the test suite a flat property
table — one expectation per option combination — and keeps coverage trivial.
The "emit-only-when-truthy" rule is important: Telegram interprets
`disable_notification: false` as the silent-mode opt-out, but a payload that
never sent the field at all reverts to the chat's default. To match upstream
behaviour exactly we drop the field when callers leave it `undefined` /
`false` and only include it when the caller explicitly opts in.

View File

@@ -0,0 +1,94 @@
# Classify Error
## Purpose
Pure helpers that turn an outgoing-protocol failure (HTTP status + Telegram
`description` + parsed body) into a stable `TelegramClientCode` and decode
Telegram's various retry-after hints into a single duration in milliseconds.
Keeping the classifier free of side effects means every status × description
× header combination can be exercised by a flat unit-test table — there is no
HTTP transport involved and no global state to reset between cases.
The classifier embeds the small but real knowledge gap between the HTTP
status the Bot API returns and the failure mode callers actually care about:
HTTP 400 with description "chat not found" is semantically a 404 from the
caller's point of view, and a 403 with "bot was blocked" is materially
different from a generic 403. Centralising this mapping means every Bot API
call site benefits from the same nuanced error taxonomy.
## Features
- `classifyStatus(status, description, data)` — pure `TelegramClientCode` resolver
- Matches `chat not found`, `channel not found`, `bot was blocked`,
`user is deactivated`, `not enough rights`, `forbidden` case-insensitively
- `parseRetryAfterMs(response, data, clock)` — pure retry-after decoder
- Honours: body `retry_after`, body `parameters.retry_after`,
header `retry-after` (seconds or HTTP-date), header `x-ratelimit-reset`
(epoch seconds, epoch millis, or ISO date)
- Clamps negative durations to 0
- Returns `undefined` when no hint is parseable
## Usage: Basic
```ts
import { classifyStatus, parseRetryAfterMs } from "@suaveplan/telegram-client";
const code = classifyStatus(401, "Unauthorized", { ok: false });
// "TELEGRAM_CLIENT.AUTH_ERROR"
```
## Usage: Advanced
```ts
import {
classifyStatus,
parseRetryAfterMs,
} from "@suaveplan/telegram-client";
// Decoding a 429 envelope in a fan-out retry wrapper.
const response = await fetchTelegram();
const data = (await response.json()) as {
ok: boolean;
description?: string;
parameters?: { retry_after?: number };
};
if (response.status === 429) {
const retryAfterMs = parseRetryAfterMs(response, data, () => Date.now());
if (retryAfterMs !== undefined) {
await scheduleAfter(retryAfterMs);
}
}
const code = classifyStatus(
response.status,
data.description ?? "",
data,
);
recordOutcome(code);
```
## API Reference
- `classifyStatus(status, description, data) → TelegramClientCode`
- 401 → `AUTH_ERROR`
- 403 with `bot blocked` / `user deactivated` → `BOT_BLOCKED`
- other 403 → `FORBIDDEN`
- 404 → `CHAT_NOT_FOUND`
- 400 with `chat not found` / `channel not found` → `CHAT_NOT_FOUND`
- 400 with `not enough rights` / `forbidden` → `FORBIDDEN`
- everything else → `API_ERROR`
- `parseRetryAfterMs(response, data, clock) → number | undefined`
- Order: body `retry_after` → body `parameters.retry_after` → header
`retry-after` → header `x-ratelimit-reset`
- Returns `undefined` when nothing is parseable
## Implementation Notes
The matchers use lazy-quantifier regexes (`{0,20}`) to tolerate the small
phrasing variations Telegram has shipped over time ("bot was blocked",
"bot is blocked", "user is deactivated") without committing to a brittle
exact-string match. The retry decoder uses the injected `clock` rather than
`Date.now()` so tests can pin both the request timestamp and the reset
boundary to deterministic values. Negative durations (a `retry-after` HTTP
date in the past) clamp to 0 so callers never sleep on a past timestamp.