Initial Commit
This commit is contained in:
19
packages/communication/messaging/README.md
Normal file
19
packages/communication/messaging/README.md
Normal 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 -->
|
||||
1
packages/communication/messaging/_intro.md
Normal file
1
packages/communication/messaging/_intro.md
Normal file
@@ -0,0 +1 @@
|
||||
Messaging adapters — Discord, Slack, Telegram, SMS, iMessage, and notifications.
|
||||
100
packages/communication/messaging/discord-client/README.md
Normal file
100
packages/communication/messaging/discord-client/README.md
Normal 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` |
|
||||
|
||||
86
packages/communication/messaging/discord-client/package.json
Normal file
86
packages/communication/messaging/discord-client/package.json
Normal file
@@ -0,0 +1,86 @@
|
||||
{
|
||||
"name": "@suaveplan/discord-client",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"description": "Generic Discord API client for webhooks, bot messaging, and embeds with injectable fetch, OpenTelemetry instrumentation, and structured error codes",
|
||||
"license": "MIT",
|
||||
"author": "Suaveplan Team",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
"./package.json": "./package.json",
|
||||
".": {
|
||||
"import": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"./bot": {
|
||||
"import": {
|
||||
"types": "./dist/core/bot-client/discord-bot-client.d.ts",
|
||||
"default": "./dist/core/bot-client/discord-bot-client.js"
|
||||
}
|
||||
},
|
||||
"./webhook": {
|
||||
"import": {
|
||||
"types": "./dist/core/webhook/discord-webhook.d.ts",
|
||||
"default": "./dist/core/webhook/discord-webhook.js"
|
||||
}
|
||||
},
|
||||
"./errors": {
|
||||
"import": {
|
||||
"types": "./dist/errors/discord-client-error.d.ts",
|
||||
"default": "./dist/errors/discord-client-error.js"
|
||||
}
|
||||
},
|
||||
"./types": {
|
||||
"import": {
|
||||
"types": "./dist/types/discord-client.types.d.ts",
|
||||
"default": "./dist/types/discord-client.types.js"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage --passWithNoTests",
|
||||
"lint": "bunx biome check src/",
|
||||
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
|
||||
"format": "bunx biome format --write --max-diagnostics 500 src/",
|
||||
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
|
||||
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
|
||||
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@suaveplan/error": "workspace:^",
|
||||
"@suaveplan/types": "workspace:^",
|
||||
"tslib": "catalog:build",
|
||||
"zod": "catalog:forms",
|
||||
"@opentelemetry/api": "catalog:observability"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@suaveplan/testing": "workspace:^",
|
||||
"@types/node": "catalog:",
|
||||
"@vitest/coverage-istanbul": "catalog:testing",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:build",
|
||||
"vite-plugin-dts": "catalog:build",
|
||||
"vitest": "catalog:testing"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://npmjs.fq.io/",
|
||||
"access": "public"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"src",
|
||||
"README.md"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,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.
|
||||
@@ -0,0 +1,365 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
DISCORD_CODES,
|
||||
DiscordClientError,
|
||||
} from "../../errors/discord-client-error.js";
|
||||
import type {
|
||||
DiscordFetchResponse,
|
||||
FetchLike,
|
||||
} from "../../types/discord-client.types.js";
|
||||
import { DiscordBotClient } from "./discord-bot-client.js";
|
||||
|
||||
function ok(
|
||||
body: unknown,
|
||||
headers: Record<string, string> = {}
|
||||
): DiscordFetchResponse {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
headers: {
|
||||
get(name: string): string | null {
|
||||
const v = headers[name.toLowerCase()];
|
||||
return v === undefined ? null : v;
|
||||
},
|
||||
},
|
||||
text: async () => "",
|
||||
json: async () => body,
|
||||
};
|
||||
}
|
||||
|
||||
function noContent(): DiscordFetchResponse {
|
||||
return {
|
||||
ok: true,
|
||||
status: 204,
|
||||
statusText: "No Content",
|
||||
headers: { get: () => null },
|
||||
text: async () => "",
|
||||
json: async () => ({}),
|
||||
};
|
||||
}
|
||||
|
||||
function makeFetch(
|
||||
handler: (
|
||||
url: string,
|
||||
method: string,
|
||||
body?: string
|
||||
) => DiscordFetchResponse
|
||||
): {
|
||||
fetch: FetchLike;
|
||||
calls: {
|
||||
url: string;
|
||||
method: string;
|
||||
body?: string;
|
||||
headers: Record<string, string>;
|
||||
}[];
|
||||
} {
|
||||
const calls: {
|
||||
url: string;
|
||||
method: string;
|
||||
body?: string;
|
||||
headers: Record<string, string>;
|
||||
}[] = [];
|
||||
const fetchFn: FetchLike = async (url, init) => {
|
||||
calls.push({
|
||||
url,
|
||||
method: init.method,
|
||||
...(init.body !== undefined ? { body: init.body } : {}),
|
||||
headers: init.headers,
|
||||
});
|
||||
return handler(url, init.method, init.body);
|
||||
};
|
||||
return { fetch: fetchFn, calls };
|
||||
}
|
||||
|
||||
describe("DiscordBotClient constructor", () => {
|
||||
it("rejects missing token with DISCORD.CONFIG_MISSING", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
try {
|
||||
new DiscordBotClient({ token: "" });
|
||||
throw new Error("expected throw");
|
||||
} catch (e) {
|
||||
expect(e).toBeInstanceOf(DiscordClientError);
|
||||
expect((e as DiscordClientError).code).toBe(
|
||||
DISCORD_CODES.CONFIG_MISSING
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects invalid baseUrl with DISCORD.INVALID_CONFIG", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
try {
|
||||
new DiscordBotClient({ token: "abc", baseUrl: "not-a-url" });
|
||||
throw new Error("expected throw");
|
||||
} catch (e) {
|
||||
expect((e as DiscordClientError).code).toBe(
|
||||
DISCORD_CODES.INVALID_CONFIG
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects negative timeoutMs with DISCORD.INVALID_CONFIG", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
try {
|
||||
new DiscordBotClient({ token: "abc", timeoutMs: -1 });
|
||||
throw new Error("expected throw");
|
||||
} catch (e) {
|
||||
expect((e as DiscordClientError).code).toBe(
|
||||
DISCORD_CODES.INVALID_CONFIG
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("defaults baseUrl and timeoutMs", { timeout: 5000 }, () => {
|
||||
const c = new DiscordBotClient({ token: "abc" });
|
||||
expect(c).toBeInstanceOf(DiscordBotClient);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DiscordBotClient.sendMessage", () => {
|
||||
it("POSTs to /channels/:id/messages and caches the response", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const { fetch, calls } = makeFetch(() =>
|
||||
ok({
|
||||
id: "42",
|
||||
channelId: "1",
|
||||
content: "hi",
|
||||
timestamp: "2026-01-01T00:00:00.000Z",
|
||||
})
|
||||
);
|
||||
const bot = new DiscordBotClient({ token: "abc", fetch });
|
||||
const res = await bot.sendMessage("1", { content: "hi" });
|
||||
expect(res.id).toBe("42");
|
||||
expect(calls[0]?.method).toBe("POST");
|
||||
expect(calls[0]?.url).toBe(
|
||||
"https://discord.com/api/v10/channels/1/messages"
|
||||
);
|
||||
expect(calls[0]?.headers["Authorization"]).toBe("Bot abc");
|
||||
expect(calls[0]?.headers["Content-Type"]).toBe("application/json");
|
||||
expect(bot.getStoredMessage("42")).toEqual(res);
|
||||
});
|
||||
|
||||
it("forwards every optional field including avatar/tts/embed", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const { fetch, calls } = makeFetch(() =>
|
||||
ok({
|
||||
id: "43",
|
||||
channelId: "1",
|
||||
content: "x",
|
||||
timestamp: "2026-01-01T00:00:00.000Z",
|
||||
})
|
||||
);
|
||||
const bot = new DiscordBotClient({ token: "abc", fetch });
|
||||
await bot.sendMessage("1", {
|
||||
content: "x",
|
||||
embeds: [{ title: "t", description: "d" }],
|
||||
username: "n",
|
||||
avatarUrl: "https://example.com/a.png",
|
||||
tts: true,
|
||||
});
|
||||
const body = JSON.parse(calls[0]?.body ?? "{}") as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect(body["content"]).toBe("x");
|
||||
expect(body["embeds"]).toEqual([{ title: "t", description: "d" }]);
|
||||
expect(body["username"]).toBe("n");
|
||||
expect(body["avatar_url"]).toBe("https://example.com/a.png");
|
||||
expect(body["tts"]).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects an invalid message with DISCORD.INVALID_MESSAGE", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const bot = new DiscordBotClient({
|
||||
token: "abc",
|
||||
fetch: async () => ok({}),
|
||||
});
|
||||
await expect(
|
||||
bot.sendMessage("1", { content: "x".repeat(2001) })
|
||||
).rejects.toMatchObject({ code: DISCORD_CODES.INVALID_MESSAGE });
|
||||
});
|
||||
});
|
||||
|
||||
describe("DiscordBotClient.editMessage / deleteMessage", () => {
|
||||
it("PATCHes and caches the new payload", { timeout: 30000 }, async () => {
|
||||
const { fetch, calls } = makeFetch(() =>
|
||||
ok({
|
||||
id: "44",
|
||||
channelId: "1",
|
||||
content: "edited",
|
||||
timestamp: "2026-01-01T00:00:00.000Z",
|
||||
})
|
||||
);
|
||||
const bot = new DiscordBotClient({ token: "abc", fetch });
|
||||
const res = await bot.editMessage("1", "44", { content: "edited" });
|
||||
expect(calls[0]?.method).toBe("PATCH");
|
||||
expect(calls[0]?.url).toBe(
|
||||
"https://discord.com/api/v10/channels/1/messages/44"
|
||||
);
|
||||
expect(bot.getStoredMessage("44")?.content).toBe("edited");
|
||||
expect(res.content).toBe("edited");
|
||||
});
|
||||
|
||||
it("editMessage with only embeds (no content) skips message validation", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const { fetch, calls } = makeFetch(() =>
|
||||
ok({
|
||||
id: "46",
|
||||
channelId: "1",
|
||||
content: "",
|
||||
timestamp: "2026-01-01T00:00:00.000Z",
|
||||
})
|
||||
);
|
||||
const bot = new DiscordBotClient({ token: "abc", fetch });
|
||||
const res = await bot.editMessage("1", "46", {
|
||||
embeds: [{ title: "updated" }],
|
||||
});
|
||||
expect(res.id).toBe("46");
|
||||
const body = JSON.parse(calls[0]?.body ?? "{}") as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect(body["content"]).toBeUndefined();
|
||||
expect(body["embeds"]).toEqual([{ title: "updated" }]);
|
||||
});
|
||||
|
||||
it("editMessage rejects oversized content with INVALID_MESSAGE", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const bot = new DiscordBotClient({
|
||||
token: "abc",
|
||||
fetch: async () => ok({}),
|
||||
});
|
||||
await expect(
|
||||
bot.editMessage("1", "44", { content: "x".repeat(2001) })
|
||||
).rejects.toMatchObject({ code: DISCORD_CODES.INVALID_MESSAGE });
|
||||
});
|
||||
|
||||
it("DELETEs and evicts from the cache", { timeout: 30000 }, async () => {
|
||||
const { fetch, calls } = makeFetch((_url, method) => {
|
||||
if (method === "DELETE") return noContent();
|
||||
return ok({
|
||||
id: "45",
|
||||
channelId: "1",
|
||||
content: "x",
|
||||
timestamp: "2026-01-01T00:00:00.000Z",
|
||||
});
|
||||
});
|
||||
const bot = new DiscordBotClient({ token: "abc", fetch });
|
||||
await bot.sendMessage("1", { content: "x" });
|
||||
await bot.deleteMessage("1", "45");
|
||||
expect(calls[1]?.method).toBe("DELETE");
|
||||
expect(bot.getStoredMessage("45")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("DiscordBotClient.getChannel / getGuild", () => {
|
||||
it("GETs the channel", { timeout: 30000 }, async () => {
|
||||
const { fetch, calls } = makeFetch(() =>
|
||||
ok({ id: "1", name: "general", type: 0 })
|
||||
);
|
||||
const bot = new DiscordBotClient({ token: "abc", fetch });
|
||||
const ch = await bot.getChannel("1");
|
||||
expect(ch.name).toBe("general");
|
||||
expect(calls[0]?.method).toBe("GET");
|
||||
expect(calls[0]?.url).toBe("https://discord.com/api/v10/channels/1");
|
||||
});
|
||||
|
||||
it("getGuild raises GUILD_NOT_FOUND on 404", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const fetchFn: FetchLike = async () => ({
|
||||
ok: false,
|
||||
status: 404,
|
||||
statusText: "Not Found",
|
||||
headers: { get: () => null },
|
||||
text: async () => "no",
|
||||
json: async () => ({}),
|
||||
});
|
||||
const bot = new DiscordBotClient({ token: "abc", fetch: fetchFn });
|
||||
await expect(bot.getGuild("999")).rejects.toMatchObject({
|
||||
code: DISCORD_CODES.GUILD_NOT_FOUND,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("DiscordBotClient.createReaction", () => {
|
||||
it("URL-encodes the emoji and PUTs", { timeout: 30000 }, async () => {
|
||||
const { fetch, calls } = makeFetch(() => noContent());
|
||||
const bot = new DiscordBotClient({ token: "abc", fetch });
|
||||
await bot.createReaction("1", "44", "👍");
|
||||
expect(calls[0]?.method).toBe("PUT");
|
||||
expect(calls[0]?.url).toBe(
|
||||
`https://discord.com/api/v10/channels/1/messages/44/reactions/${encodeURIComponent("👍")}/@me`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DiscordBotClient defaults branch", () => {
|
||||
it("uses Date.now and globalThis.fetch when not injected", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const headersGet = (n: string): string | null => null;
|
||||
type MinimalResponse = {
|
||||
ok: boolean;
|
||||
status: number;
|
||||
statusText: string;
|
||||
headers: { get(n: string): string | null };
|
||||
text(): Promise<string>;
|
||||
json(): Promise<unknown>;
|
||||
};
|
||||
const stubResponse: MinimalResponse = {
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
headers: { get: headersGet },
|
||||
text: async () => "",
|
||||
json: async () => ({ id: "1", name: "general", type: 0 }),
|
||||
};
|
||||
let mode: "json" | "text" | "rate" = "json";
|
||||
const stub404: MinimalResponse = {
|
||||
ok: false,
|
||||
status: 404,
|
||||
statusText: "Not Found",
|
||||
headers: { get: headersGet },
|
||||
text: async () => "missing",
|
||||
json: async () => ({}),
|
||||
};
|
||||
const stub429: MinimalResponse = {
|
||||
ok: false,
|
||||
status: 429,
|
||||
statusText: "Too Many",
|
||||
headers: { get: (n) => (n === "retry-after" ? "1" : null) },
|
||||
text: async () => "",
|
||||
json: async () => ({}),
|
||||
};
|
||||
globalThis.fetch = (async () => {
|
||||
if (mode === "json") return stubResponse;
|
||||
if (mode === "text") return stub404;
|
||||
return stub429;
|
||||
}) as unknown as typeof fetch;
|
||||
try {
|
||||
const c = new DiscordBotClient({ token: "abc" });
|
||||
const ch = await c.getChannel("1");
|
||||
expect(ch.name).toBe("general");
|
||||
mode = "text";
|
||||
await expect(c.getChannel("2")).rejects.toMatchObject({
|
||||
code: DISCORD_CODES.CHANNEL_NOT_FOUND,
|
||||
});
|
||||
mode = "rate";
|
||||
await expect(c.getChannel("3")).rejects.toMatchObject({
|
||||
code: DISCORD_CODES.RATE_LIMITED,
|
||||
});
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,184 @@
|
||||
import type { Clock } from "@suaveplan/types";
|
||||
import {
|
||||
DISCORD_CODES,
|
||||
DiscordClientError,
|
||||
} from "../../errors/discord-client-error.js";
|
||||
import {
|
||||
type DiscordBotConfig,
|
||||
DiscordBotConfigSchema,
|
||||
type DiscordChannel,
|
||||
type DiscordMessage,
|
||||
DiscordMessageSchema,
|
||||
type DiscordResponse,
|
||||
type FetchLike,
|
||||
} from "../../types/discord-client.types.js";
|
||||
import { executeDiscordRequest } from "../../utils/http.js";
|
||||
|
||||
const DEFAULT_BASE_URL = "https://discord.com/api/v10";
|
||||
const DEFAULT_TIMEOUT_MS = 10_000;
|
||||
|
||||
/**
|
||||
* Client for the Discord Bot API. Sends, edits, deletes messages; fetches
|
||||
* channels; and creates reactions using a bot token. Runtime-agnostic via
|
||||
* injectable `fetch` and `clock`.
|
||||
*/
|
||||
export class DiscordBotClient {
|
||||
protected readonly token: string;
|
||||
protected readonly baseUrl: string;
|
||||
protected readonly timeoutMs: number;
|
||||
protected readonly fetchFn: FetchLike;
|
||||
protected readonly clock: Clock;
|
||||
protected readonly messages = new Map<string, DiscordResponse>();
|
||||
|
||||
public constructor(config: DiscordBotConfig) {
|
||||
const parsed = DiscordBotConfigSchema.safeParse(config);
|
||||
if (!parsed.success) {
|
||||
// Zod guarantees `issues` is non-empty when `success` is false.
|
||||
const first = parsed.error.issues[0] as {
|
||||
path: PropertyKey[];
|
||||
message: string;
|
||||
};
|
||||
const missingToken = first.path[0] === "token";
|
||||
throw new DiscordClientError(
|
||||
missingToken
|
||||
? DISCORD_CODES.CONFIG_MISSING
|
||||
: DISCORD_CODES.INVALID_CONFIG,
|
||||
first.message
|
||||
);
|
||||
}
|
||||
this.token = config.token;
|
||||
this.baseUrl = config.baseUrl ?? DEFAULT_BASE_URL;
|
||||
this.timeoutMs = config.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
||||
this.fetchFn =
|
||||
config.fetch ??
|
||||
((url, init) =>
|
||||
fetch(url, init as RequestInit).then((res) => ({
|
||||
ok: res.ok,
|
||||
status: res.status,
|
||||
statusText: res.statusText,
|
||||
headers: { get: (n) => res.headers.get(n) },
|
||||
text: () => res.text(),
|
||||
json: () => res.json(),
|
||||
})));
|
||||
this.clock = config.clock ?? Date.now;
|
||||
}
|
||||
|
||||
/** Send a message to a Discord channel. */
|
||||
public async sendMessage(
|
||||
channelId: string,
|
||||
message: DiscordMessage
|
||||
): Promise<DiscordResponse> {
|
||||
this.validateMessage(message);
|
||||
const body = this.buildMessageBody(message);
|
||||
const url = `${this.baseUrl}/channels/${channelId}/messages`;
|
||||
const response = await this.request<DiscordResponse>("POST", url, body);
|
||||
this.messages.set(response.id, response);
|
||||
return response;
|
||||
}
|
||||
|
||||
/** Edit an existing message. */
|
||||
public async editMessage(
|
||||
channelId: string,
|
||||
messageId: string,
|
||||
message: Partial<DiscordMessage>
|
||||
): Promise<DiscordResponse> {
|
||||
if (message.content !== undefined) {
|
||||
this.validateMessage({ ...message, content: message.content });
|
||||
}
|
||||
const body = this.buildMessageBody(message);
|
||||
const url = `${this.baseUrl}/channels/${channelId}/messages/${messageId}`;
|
||||
const response = await this.request<DiscordResponse>(
|
||||
"PATCH",
|
||||
url,
|
||||
body
|
||||
);
|
||||
this.messages.set(response.id, response);
|
||||
return response;
|
||||
}
|
||||
|
||||
/** Delete a message from a channel. */
|
||||
public async deleteMessage(
|
||||
channelId: string,
|
||||
messageId: string
|
||||
): Promise<void> {
|
||||
const url = `${this.baseUrl}/channels/${channelId}/messages/${messageId}`;
|
||||
await this.request<void>("DELETE", url);
|
||||
this.messages.delete(messageId);
|
||||
}
|
||||
|
||||
/** Get channel information. */
|
||||
public async getChannel(channelId: string): Promise<DiscordChannel> {
|
||||
const url = `${this.baseUrl}/channels/${channelId}`;
|
||||
return this.request<DiscordChannel>("GET", url);
|
||||
}
|
||||
|
||||
/** Get guild metadata (drives the GUILD_NOT_FOUND error mapping on 404). */
|
||||
public async getGuild(
|
||||
guildId: string
|
||||
): Promise<{ id: string; name: string }> {
|
||||
const url = `${this.baseUrl}/guilds/${guildId}`;
|
||||
return this.request<{ id: string; name: string }>("GET", url);
|
||||
}
|
||||
|
||||
/** Add a reaction to a message. */
|
||||
public async createReaction(
|
||||
channelId: string,
|
||||
messageId: string,
|
||||
emoji: string
|
||||
): Promise<void> {
|
||||
const encoded = encodeURIComponent(emoji);
|
||||
const url = `${this.baseUrl}/channels/${channelId}/messages/${messageId}/reactions/${encoded}/@me`;
|
||||
await this.request<void>("PUT", url);
|
||||
}
|
||||
|
||||
/** Get a previously sent message by ID from the in-memory cache. */
|
||||
public getStoredMessage(messageId: string): DiscordResponse | undefined {
|
||||
return this.messages.get(messageId);
|
||||
}
|
||||
|
||||
protected validateMessage(message: DiscordMessage): void {
|
||||
const result = DiscordMessageSchema.safeParse(message);
|
||||
if (!result.success) {
|
||||
const first = result.error.issues[0] as { message: string };
|
||||
throw new DiscordClientError(
|
||||
DISCORD_CODES.INVALID_MESSAGE,
|
||||
first.message
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
protected buildMessageBody(
|
||||
message: Partial<DiscordMessage>
|
||||
): Record<string, unknown> {
|
||||
const body: Record<string, unknown> = {};
|
||||
if (message.content !== undefined) body["content"] = message.content;
|
||||
if (message.embeds) body["embeds"] = message.embeds;
|
||||
if (message.username) body["username"] = message.username;
|
||||
if (message.avatarUrl) body["avatar_url"] = message.avatarUrl;
|
||||
if (message.tts !== undefined) body["tts"] = message.tts;
|
||||
return body;
|
||||
}
|
||||
|
||||
protected async request<T>(
|
||||
method: string,
|
||||
url: string,
|
||||
body?: Record<string, unknown>
|
||||
): Promise<T> {
|
||||
const headers: Record<string, string> = {
|
||||
Authorization: `Bot ${this.token}`,
|
||||
};
|
||||
if (body) headers["Content-Type"] = "application/json";
|
||||
|
||||
return executeDiscordRequest<T>({
|
||||
fetch: this.fetchFn,
|
||||
clock: this.clock,
|
||||
method,
|
||||
url,
|
||||
headers,
|
||||
...(body ? { body } : {}),
|
||||
timeoutMs: this.timeoutMs,
|
||||
kind: "bot",
|
||||
isGuildRequest: url.includes("/guilds/"),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { DiscordBotClient } from "./discord-bot-client.js";
|
||||
@@ -0,0 +1,2 @@
|
||||
export { DiscordBotClient } from "./bot-client/discord-bot-client.js";
|
||||
export { DiscordWebhook } from "./webhook/discord-webhook.js";
|
||||
@@ -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.
|
||||
@@ -0,0 +1,269 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
DISCORD_CODES,
|
||||
DiscordClientError,
|
||||
} from "../../errors/discord-client-error.js";
|
||||
import type {
|
||||
DiscordFetchResponse,
|
||||
FetchLike,
|
||||
} from "../../types/discord-client.types.js";
|
||||
import { DiscordWebhook } from "./discord-webhook.js";
|
||||
|
||||
const VALID_URL = "https://discord.com/api/webhooks/123/abctoken";
|
||||
|
||||
function ok(): DiscordFetchResponse {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
headers: { get: () => null },
|
||||
text: async () => "",
|
||||
json: async () => ({ id: "msg", channel_id: "1" }),
|
||||
};
|
||||
}
|
||||
|
||||
function makeFetch(): {
|
||||
fetch: FetchLike;
|
||||
calls: { url: string; method: string; body?: string }[];
|
||||
} {
|
||||
const calls: { url: string; method: string; body?: string }[] = [];
|
||||
const fetchFn: FetchLike = async (url, init) => {
|
||||
calls.push({
|
||||
url,
|
||||
method: init.method,
|
||||
...(init.body !== undefined ? { body: init.body } : {}),
|
||||
});
|
||||
return ok();
|
||||
};
|
||||
return { fetch: fetchFn, calls };
|
||||
}
|
||||
|
||||
describe("DiscordWebhook constructor", () => {
|
||||
it("rejects missing webhookUrl with DISCORD.CONFIG_MISSING", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
try {
|
||||
new DiscordWebhook({ webhookUrl: undefined as unknown as string });
|
||||
throw new Error("expected throw");
|
||||
} catch (e) {
|
||||
expect(e).toBeInstanceOf(DiscordClientError);
|
||||
expect((e as DiscordClientError).code).toBe(
|
||||
DISCORD_CODES.CONFIG_MISSING
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects non-Discord URL with DISCORD.INVALID_CONFIG", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
try {
|
||||
new DiscordWebhook({ webhookUrl: "https://example.com/hook" });
|
||||
throw new Error("expected throw");
|
||||
} catch (e) {
|
||||
expect((e as DiscordClientError).code).toBe(
|
||||
DISCORD_CODES.INVALID_CONFIG
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts a valid Discord webhook URL", { timeout: 5000 }, () => {
|
||||
const w = new DiscordWebhook({ webhookUrl: VALID_URL });
|
||||
expect(w).toBeInstanceOf(DiscordWebhook);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DiscordWebhook.send", () => {
|
||||
it("POSTs with ?wait=true and returns a delivered report", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const { fetch, calls } = makeFetch();
|
||||
const w = new DiscordWebhook({
|
||||
webhookUrl: VALID_URL,
|
||||
fetch,
|
||||
clock: () => 1_700_000_000_000,
|
||||
});
|
||||
const report = await w.send({ content: "hi" });
|
||||
expect(report.status).toBe("delivered");
|
||||
expect(report.messageId).toBe("msg");
|
||||
expect(report.channelId).toBe("1");
|
||||
expect(report.timestamp).toEqual(new Date(1_700_000_000_000));
|
||||
expect(calls[0]?.url).toBe(`${VALID_URL}?wait=true`);
|
||||
const body = JSON.parse(calls[0]?.body ?? "{}") as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect(body["content"]).toBe("hi");
|
||||
});
|
||||
|
||||
it("appends thread_id when provided", { timeout: 30000 }, async () => {
|
||||
const { fetch, calls } = makeFetch();
|
||||
const w = new DiscordWebhook({ webhookUrl: VALID_URL, fetch });
|
||||
await w.send({ content: "hi", threadId: "999" });
|
||||
expect(calls[0]?.url).toBe(`${VALID_URL}?wait=true&thread_id=999`);
|
||||
});
|
||||
|
||||
it("uses constructor username/avatarUrl as defaults", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const { fetch, calls } = makeFetch();
|
||||
const w = new DiscordWebhook({
|
||||
webhookUrl: VALID_URL,
|
||||
username: "default-user",
|
||||
avatarUrl: "https://example.com/default.png",
|
||||
fetch,
|
||||
});
|
||||
await w.send({ content: "hi" });
|
||||
const body = JSON.parse(calls[0]?.body ?? "{}") as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect(body["username"]).toBe("default-user");
|
||||
expect(body["avatar_url"]).toBe("https://example.com/default.png");
|
||||
});
|
||||
|
||||
it("per-message overrides take precedence over constructor defaults", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const { fetch, calls } = makeFetch();
|
||||
const w = new DiscordWebhook({
|
||||
webhookUrl: VALID_URL,
|
||||
username: "default",
|
||||
fetch,
|
||||
});
|
||||
await w.send({
|
||||
content: "hi",
|
||||
username: "override",
|
||||
avatarUrl: "https://example.com/o.png",
|
||||
tts: true,
|
||||
embeds: [{ title: "t" }],
|
||||
});
|
||||
const body = JSON.parse(calls[0]?.body ?? "{}") as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect(body["username"]).toBe("override");
|
||||
expect(body["avatar_url"]).toBe("https://example.com/o.png");
|
||||
expect(body["tts"]).toBe(true);
|
||||
expect(body["embeds"]).toEqual([{ title: "t" }]);
|
||||
});
|
||||
|
||||
it("rejects an invalid message with DISCORD.INVALID_MESSAGE", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const w = new DiscordWebhook({
|
||||
webhookUrl: VALID_URL,
|
||||
fetch: async () => ok(),
|
||||
});
|
||||
await expect(
|
||||
w.send({ content: "x".repeat(2001) })
|
||||
).rejects.toMatchObject({ code: DISCORD_CODES.INVALID_MESSAGE });
|
||||
});
|
||||
});
|
||||
|
||||
describe("DiscordWebhook.sendText / sendEmbed", () => {
|
||||
it("sendText wraps content in DiscordMessage", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const { fetch, calls } = makeFetch();
|
||||
const w = new DiscordWebhook({ webhookUrl: VALID_URL, fetch });
|
||||
await w.sendText("hi");
|
||||
const body = JSON.parse(calls[0]?.body ?? "{}") as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect(body["content"]).toBe("hi");
|
||||
});
|
||||
|
||||
it("sendEmbed sends an embed array with empty content", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const { fetch, calls } = makeFetch();
|
||||
const w = new DiscordWebhook({ webhookUrl: VALID_URL, fetch });
|
||||
await w.sendEmbed({ title: "t" });
|
||||
const body = JSON.parse(calls[0]?.body ?? "{}") as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect(body["content"]).toBe("");
|
||||
expect(body["embeds"]).toEqual([{ title: "t" }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DiscordWebhook upstream errors", () => {
|
||||
it("maps 5xx to DISCORD.WEBHOOK_FAILED", { timeout: 30000 }, async () => {
|
||||
const fetchFn: FetchLike = async () => ({
|
||||
ok: false,
|
||||
status: 502,
|
||||
statusText: "Bad Gateway",
|
||||
headers: { get: () => null },
|
||||
text: async () => "oops",
|
||||
json: async () => ({}),
|
||||
});
|
||||
const w = new DiscordWebhook({ webhookUrl: VALID_URL, fetch: fetchFn });
|
||||
await expect(w.sendText("hi")).rejects.toMatchObject({
|
||||
code: DISCORD_CODES.WEBHOOK_FAILED,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("DiscordWebhook defaults branch", () => {
|
||||
it("uses globalThis.fetch and Date.now when not injected", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
type MinimalResponse = {
|
||||
ok: boolean;
|
||||
status: number;
|
||||
statusText: string;
|
||||
headers: { get(n: string): string | null };
|
||||
text(): Promise<string>;
|
||||
json(): Promise<unknown>;
|
||||
};
|
||||
const stubResponse: MinimalResponse = {
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
headers: { get: () => null },
|
||||
text: async () => "",
|
||||
json: async () => ({ id: "msg", channel_id: "1" }),
|
||||
};
|
||||
let mode: "json" | "err" | "rate" = "json";
|
||||
const stubErr: MinimalResponse = {
|
||||
ok: false,
|
||||
status: 500,
|
||||
statusText: "Server Error",
|
||||
headers: { get: () => null },
|
||||
text: async () => "boom",
|
||||
json: async () => ({}),
|
||||
};
|
||||
const stub429: MinimalResponse = {
|
||||
ok: false,
|
||||
status: 429,
|
||||
statusText: "Too Many",
|
||||
headers: { get: (n) => (n === "retry-after" ? "1" : null) },
|
||||
text: async () => "",
|
||||
json: async () => ({}),
|
||||
};
|
||||
globalThis.fetch = (async () => {
|
||||
if (mode === "json") return stubResponse;
|
||||
if (mode === "err") return stubErr;
|
||||
return stub429;
|
||||
}) as unknown as typeof fetch;
|
||||
try {
|
||||
const w = new DiscordWebhook({ webhookUrl: VALID_URL });
|
||||
const report = await w.sendText("hi");
|
||||
expect(report.status).toBe("delivered");
|
||||
expect(report.messageId).toBe("msg");
|
||||
mode = "err";
|
||||
await expect(w.sendText("hi")).rejects.toMatchObject({
|
||||
code: DISCORD_CODES.WEBHOOK_FAILED,
|
||||
});
|
||||
mode = "rate";
|
||||
await expect(w.sendText("hi")).rejects.toMatchObject({
|
||||
code: DISCORD_CODES.RATE_LIMITED,
|
||||
});
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
import type { Clock } from "@suaveplan/types";
|
||||
import {
|
||||
DISCORD_CODES,
|
||||
DiscordClientError,
|
||||
} from "../../errors/discord-client-error.js";
|
||||
import {
|
||||
type DiscordDeliveryReport,
|
||||
type DiscordEmbed,
|
||||
type DiscordMessage,
|
||||
DiscordMessageSchema,
|
||||
type DiscordWebhookConfig,
|
||||
DiscordWebhookConfigSchema,
|
||||
type FetchLike,
|
||||
} from "../../types/discord-client.types.js";
|
||||
import { executeDiscordRequest } from "../../utils/http.js";
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 10_000;
|
||||
|
||||
/**
|
||||
* Sends messages to Discord channels via webhook URLs. Supports plain text,
|
||||
* embeds, and full message payloads. Returns a structured
|
||||
* `DiscordDeliveryReport` so callers can persist delivery telemetry.
|
||||
*/
|
||||
export class DiscordWebhook {
|
||||
protected readonly webhookUrl: string;
|
||||
protected readonly username?: string;
|
||||
protected readonly avatarUrl?: string;
|
||||
protected readonly timeoutMs: number;
|
||||
protected readonly fetchFn: FetchLike;
|
||||
protected readonly clock: Clock;
|
||||
|
||||
public constructor(config: DiscordWebhookConfig) {
|
||||
const parsed = DiscordWebhookConfigSchema.safeParse(config);
|
||||
if (!parsed.success) {
|
||||
const first = parsed.error.issues[0] as {
|
||||
path: PropertyKey[];
|
||||
code: string;
|
||||
message: string;
|
||||
};
|
||||
const isMissingUrl =
|
||||
first.path[0] === "webhookUrl" && first.code === "invalid_type";
|
||||
throw new DiscordClientError(
|
||||
isMissingUrl
|
||||
? DISCORD_CODES.CONFIG_MISSING
|
||||
: DISCORD_CODES.INVALID_CONFIG,
|
||||
first.message
|
||||
);
|
||||
}
|
||||
this.webhookUrl = config.webhookUrl;
|
||||
if (config.username !== undefined) this.username = config.username;
|
||||
if (config.avatarUrl !== undefined) this.avatarUrl = config.avatarUrl;
|
||||
this.timeoutMs = config.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
||||
this.fetchFn =
|
||||
config.fetch ??
|
||||
((url, init) =>
|
||||
fetch(url, init as RequestInit).then((res) => ({
|
||||
ok: res.ok,
|
||||
status: res.status,
|
||||
statusText: res.statusText,
|
||||
headers: { get: (n) => res.headers.get(n) },
|
||||
text: () => res.text(),
|
||||
json: () => res.json(),
|
||||
})));
|
||||
this.clock = config.clock ?? Date.now;
|
||||
}
|
||||
|
||||
/** Send a full message payload via the webhook. */
|
||||
public async send(message: DiscordMessage): Promise<DiscordDeliveryReport> {
|
||||
const result = DiscordMessageSchema.safeParse(message);
|
||||
if (!result.success) {
|
||||
const first = result.error.issues[0] as { message: string };
|
||||
throw new DiscordClientError(
|
||||
DISCORD_CODES.INVALID_MESSAGE,
|
||||
first.message
|
||||
);
|
||||
}
|
||||
|
||||
const body: Record<string, unknown> = {
|
||||
content: message.content,
|
||||
};
|
||||
if (message.embeds) body["embeds"] = message.embeds;
|
||||
if (message.tts !== undefined) body["tts"] = message.tts;
|
||||
const username = message.username ?? this.username;
|
||||
if (username !== undefined) body["username"] = username;
|
||||
const avatarUrl = message.avatarUrl ?? this.avatarUrl;
|
||||
if (avatarUrl !== undefined) body["avatar_url"] = avatarUrl;
|
||||
|
||||
const url = message.threadId
|
||||
? `${this.webhookUrl}?wait=true&thread_id=${message.threadId}`
|
||||
: `${this.webhookUrl}?wait=true`;
|
||||
|
||||
const data = await executeDiscordRequest<{
|
||||
id: string;
|
||||
channel_id: string;
|
||||
}>({
|
||||
fetch: this.fetchFn,
|
||||
clock: this.clock,
|
||||
method: "POST",
|
||||
url,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body,
|
||||
timeoutMs: this.timeoutMs,
|
||||
kind: "webhook",
|
||||
});
|
||||
|
||||
return {
|
||||
messageId: data.id,
|
||||
channelId: data.channel_id,
|
||||
status: "delivered",
|
||||
timestamp: new Date(this.clock()),
|
||||
};
|
||||
}
|
||||
|
||||
/** Send a plain-text message via the webhook. */
|
||||
public sendText(content: string): Promise<DiscordDeliveryReport> {
|
||||
return this.send({ content });
|
||||
}
|
||||
|
||||
/** Send a single embed via the webhook. */
|
||||
public sendEmbed(embed: DiscordEmbed): Promise<DiscordDeliveryReport> {
|
||||
return this.send({ content: "", embeds: [embed] });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { DiscordWebhook } from "./discord-webhook.js";
|
||||
@@ -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.
|
||||
@@ -0,0 +1,66 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
DISCORD_CODES,
|
||||
type DiscordClientCode,
|
||||
DiscordClientError,
|
||||
} from "./discord-client-error.js";
|
||||
|
||||
describe("DISCORD_CODES registry", () => {
|
||||
it("is frozen and namespaces every code with DISCORD.", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(Object.isFrozen(DISCORD_CODES)).toBe(true);
|
||||
for (const value of Object.values(DISCORD_CODES)) {
|
||||
expect(value.startsWith("DISCORD.")).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("exposes all 11 documented codes", { timeout: 5000 }, () => {
|
||||
expect(Object.keys(DISCORD_CODES).sort()).toEqual(
|
||||
[
|
||||
"API_ERROR",
|
||||
"AUTH_ERROR",
|
||||
"RATE_LIMITED",
|
||||
"CHANNEL_NOT_FOUND",
|
||||
"GUILD_NOT_FOUND",
|
||||
"CONFIG_MISSING",
|
||||
"INVALID_CONFIG",
|
||||
"INVALID_MESSAGE",
|
||||
"WEBHOOK_FAILED",
|
||||
"TIMEOUT",
|
||||
"FORBIDDEN",
|
||||
].sort()
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DiscordClientError", () => {
|
||||
it("extends SuaveplanError and carries the code", { timeout: 5000 }, () => {
|
||||
const err = new DiscordClientError(
|
||||
DISCORD_CODES.AUTH_ERROR,
|
||||
"auth failed"
|
||||
);
|
||||
expect(err).toBeInstanceOf(DiscordClientError);
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
expect(err.code).toBe(DISCORD_CODES.AUTH_ERROR);
|
||||
expect(err.message).toBe("auth failed");
|
||||
expect(err.name).toBe("DiscordClientError");
|
||||
});
|
||||
|
||||
it("forwards options to SuaveplanError", { timeout: 5000 }, () => {
|
||||
const cause = new Error("boom");
|
||||
const err = new DiscordClientError(
|
||||
DISCORD_CODES.TIMEOUT,
|
||||
"Discord timed out",
|
||||
{ cause, context: { url: "https://x" } }
|
||||
);
|
||||
expect(err.cause).toBe(cause);
|
||||
expect(err.context).toEqual({ url: "https://x" });
|
||||
expect(err.timestamp).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("narrows on the code literal union", { timeout: 5000 }, () => {
|
||||
const code: DiscordClientCode = DISCORD_CODES.FORBIDDEN;
|
||||
expect(code).toBe("DISCORD.FORBIDDEN");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { SuaveplanErrorOptions } from "@suaveplan/error";
|
||||
import { defineCodes, SuaveplanError } from "@suaveplan/error";
|
||||
|
||||
/**
|
||||
* Stable, frozen error-code registry for `@suaveplan/discord-client`. Every
|
||||
* runtime failure thrown by this package carries one of these codes.
|
||||
*/
|
||||
export const DISCORD_CODES = defineCodes("DISCORD", [
|
||||
"API_ERROR",
|
||||
"AUTH_ERROR",
|
||||
"RATE_LIMITED",
|
||||
"CHANNEL_NOT_FOUND",
|
||||
"GUILD_NOT_FOUND",
|
||||
"CONFIG_MISSING",
|
||||
"INVALID_CONFIG",
|
||||
"INVALID_MESSAGE",
|
||||
"WEBHOOK_FAILED",
|
||||
"TIMEOUT",
|
||||
"FORBIDDEN",
|
||||
] as const);
|
||||
|
||||
export type DiscordClientCode =
|
||||
(typeof DISCORD_CODES)[keyof typeof DISCORD_CODES];
|
||||
|
||||
/**
|
||||
* Typed error class for every `@suaveplan/discord-client` failure.
|
||||
*
|
||||
* Extends `SuaveplanError<DiscordClientCode>` so callers can either
|
||||
* `instanceof DiscordClientError` to narrow on the code union OR pattern-match
|
||||
* on the stable `.code` string.
|
||||
*/
|
||||
export class DiscordClientError extends SuaveplanError<DiscordClientCode> {
|
||||
// biome-ignore lint/complexity/noUselessConstructor: widens the protected base constructor to public
|
||||
public constructor(
|
||||
code: DiscordClientCode,
|
||||
messageKey: string,
|
||||
options?: SuaveplanErrorOptions
|
||||
) {
|
||||
super(code, messageKey, options);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
DISCORD_CODES,
|
||||
type DiscordClientCode,
|
||||
DiscordClientError,
|
||||
} from "./discord-client-error.js";
|
||||
38
packages/communication/messaging/discord-client/src/index.ts
Normal file
38
packages/communication/messaging/discord-client/src/index.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
// Core clients
|
||||
export { DiscordBotClient } from "./core/bot-client/discord-bot-client.js";
|
||||
export { DiscordWebhook } from "./core/webhook/discord-webhook.js";
|
||||
|
||||
// Errors
|
||||
export {
|
||||
DISCORD_CODES,
|
||||
type DiscordClientCode,
|
||||
DiscordClientError,
|
||||
} from "./errors/discord-client-error.js";
|
||||
export type {
|
||||
DiscordBotConfig,
|
||||
DiscordChannel,
|
||||
DiscordClientCommonInjectables,
|
||||
DiscordDeliveryReport,
|
||||
DiscordDeliveryStatus,
|
||||
DiscordEmbed,
|
||||
DiscordEmbedField,
|
||||
DiscordFetchResponse,
|
||||
DiscordMessage,
|
||||
DiscordResponse,
|
||||
DiscordWebhookConfig,
|
||||
FetchLike,
|
||||
} from "./types/discord-client.types.js";
|
||||
// Types
|
||||
export {
|
||||
DiscordBotConfigSchema,
|
||||
DiscordMessageSchema,
|
||||
DiscordWebhookConfigSchema,
|
||||
} from "./types/discord-client.types.js";
|
||||
// Utils (advanced — exposed for custom routes)
|
||||
export {
|
||||
type DiscordRequestKind,
|
||||
type DiscordRequestOptions,
|
||||
executeDiscordRequest,
|
||||
resetDiscordHttpMetrics,
|
||||
} from "./utils/http.js";
|
||||
export { parseRetryAfterMs } from "./utils/rate-limit.js";
|
||||
@@ -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.
|
||||
@@ -0,0 +1,196 @@
|
||||
import type { Clock } from "@suaveplan/types";
|
||||
import z from "zod";
|
||||
|
||||
/**
|
||||
* Minimal injectable fetch contract. Matches the relevant subset of the
|
||||
* global `fetch` shape so callers can pass `globalThis.fetch` directly OR
|
||||
* a deterministic stub in tests. We avoid `typeof fetch` to keep the
|
||||
* contract tight and runtime-agnostic.
|
||||
*/
|
||||
export type FetchLike = (
|
||||
url: string,
|
||||
init: {
|
||||
method: string;
|
||||
headers: Record<string, string>;
|
||||
body?: string;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
) => Promise<DiscordFetchResponse>;
|
||||
|
||||
/** Subset of the `Response` shape consumed by the Discord client. */
|
||||
export interface DiscordFetchResponse {
|
||||
readonly ok: boolean;
|
||||
readonly status: number;
|
||||
readonly statusText: string;
|
||||
readonly headers: {
|
||||
get(name: string): string | null;
|
||||
};
|
||||
text(): Promise<string>;
|
||||
json(): Promise<unknown>;
|
||||
}
|
||||
|
||||
/** Supported HMAC-of-time-source for any timestamp the client emits. */
|
||||
export interface DiscordClientCommonInjectables {
|
||||
/** Wall-clock used for delivery report `timestamp`s. */
|
||||
clock?: Clock;
|
||||
/** Injectable fetch implementation. Defaults to `globalThis.fetch`. */
|
||||
fetch?: FetchLike;
|
||||
}
|
||||
|
||||
/** Configuration for the Discord bot client. */
|
||||
export interface DiscordBotConfig extends DiscordClientCommonInjectables {
|
||||
/** Bot authentication token (without the `Bot ` prefix). */
|
||||
token: string;
|
||||
/** Base URL for the Discord API. Defaults to `https://discord.com/api/v10`. */
|
||||
baseUrl?: string;
|
||||
/** Request timeout in milliseconds. Defaults to `10000`. */
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
/** Configuration for a Discord webhook. */
|
||||
export interface DiscordWebhookConfig extends DiscordClientCommonInjectables {
|
||||
/** Full webhook URL including token (must include `discord.com/api/webhooks/`). */
|
||||
webhookUrl: string;
|
||||
/** Default username override for messages. */
|
||||
username?: string;
|
||||
/** Default avatar URL override for messages. */
|
||||
avatarUrl?: string;
|
||||
/** Request timeout in milliseconds. Defaults to `10000`. */
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
/** A Discord embed field. */
|
||||
export interface DiscordEmbedField {
|
||||
name: string;
|
||||
value: string;
|
||||
inline?: boolean;
|
||||
}
|
||||
|
||||
/** A Discord embed object. */
|
||||
export interface DiscordEmbed {
|
||||
title?: string;
|
||||
description?: string;
|
||||
url?: string;
|
||||
/** Decimal color value. */
|
||||
color?: number;
|
||||
/** ISO 8601 timestamp. */
|
||||
timestamp?: string;
|
||||
footer?: { text: string; iconUrl?: string };
|
||||
thumbnail?: { url: string };
|
||||
image?: { url: string };
|
||||
author?: { name: string; url?: string; iconUrl?: string };
|
||||
fields?: DiscordEmbedField[];
|
||||
}
|
||||
|
||||
/** A Discord message payload. */
|
||||
export interface DiscordMessage {
|
||||
/** Text content of the message. */
|
||||
content: string;
|
||||
/** Rich embed objects to include. */
|
||||
embeds?: DiscordEmbed[];
|
||||
/** Username override for this message. */
|
||||
username?: string;
|
||||
/** Avatar URL override for this message. */
|
||||
avatarUrl?: string;
|
||||
/** Whether to send as text-to-speech. */
|
||||
tts?: boolean;
|
||||
/** Thread ID to send the message to. */
|
||||
threadId?: string;
|
||||
}
|
||||
|
||||
/** Response from the Discord API after sending a message. */
|
||||
export interface DiscordResponse {
|
||||
id: string;
|
||||
channelId: string;
|
||||
content: string;
|
||||
timestamp: string;
|
||||
embeds?: DiscordEmbed[];
|
||||
}
|
||||
|
||||
/** A Discord channel object. */
|
||||
export interface DiscordChannel {
|
||||
id: string;
|
||||
name: string;
|
||||
type: number;
|
||||
guildId?: string;
|
||||
topic?: string;
|
||||
}
|
||||
|
||||
/** Status of a Discord message delivery. */
|
||||
export type DiscordDeliveryStatus = "queued" | "sent" | "delivered" | "failed";
|
||||
|
||||
/** Report on the delivery status of a Discord message. */
|
||||
export interface DiscordDeliveryReport {
|
||||
messageId: string;
|
||||
channelId: string;
|
||||
status: DiscordDeliveryStatus;
|
||||
timestamp: Date;
|
||||
errorCode?: string;
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
// ── Zod boundary schemas ─────────────────────────────────────────────────────
|
||||
|
||||
const embedFieldSchema = z.object({
|
||||
name: z.string().min(1).max(256),
|
||||
value: z.string().min(1).max(1024),
|
||||
inline: z.boolean().optional(),
|
||||
});
|
||||
|
||||
const embedSchema = z.object({
|
||||
title: z.string().max(256).optional(),
|
||||
description: z.string().max(4096).optional(),
|
||||
url: z.string().url().optional(),
|
||||
color: z.number().int().min(0).max(0xffffff).optional(),
|
||||
timestamp: z.string().optional(),
|
||||
footer: z
|
||||
.object({
|
||||
text: z.string().max(2048),
|
||||
iconUrl: z.string().url().optional(),
|
||||
})
|
||||
.optional(),
|
||||
thumbnail: z.object({ url: z.string().url() }).optional(),
|
||||
image: z.object({ url: z.string().url() }).optional(),
|
||||
author: z
|
||||
.object({
|
||||
name: z.string().min(1).max(256),
|
||||
url: z.string().url().optional(),
|
||||
iconUrl: z.string().url().optional(),
|
||||
})
|
||||
.optional(),
|
||||
fields: z.array(embedFieldSchema).max(25).optional(),
|
||||
});
|
||||
|
||||
/** Zod schema validating a `DiscordMessage` payload at the public boundary. */
|
||||
export const DiscordMessageSchema = z.object({
|
||||
content: z.string().max(2000),
|
||||
embeds: z.array(embedSchema).max(10).optional(),
|
||||
username: z.string().min(1).max(80).optional(),
|
||||
avatarUrl: z.string().url().optional(),
|
||||
tts: z.boolean().optional(),
|
||||
threadId: z.string().regex(/^\d+$/).optional(),
|
||||
});
|
||||
|
||||
/** Zod schema validating a `DiscordBotConfig` at construction time. */
|
||||
export const DiscordBotConfigSchema = z.object({
|
||||
token: z.string().min(1),
|
||||
baseUrl: z.string().url().optional(),
|
||||
timeoutMs: z.number().int().positive().optional(),
|
||||
clock: z.unknown().optional(),
|
||||
fetch: z.unknown().optional(),
|
||||
});
|
||||
|
||||
/** Zod schema validating a `DiscordWebhookConfig` at construction time. */
|
||||
export const DiscordWebhookConfigSchema = z.object({
|
||||
webhookUrl: z
|
||||
.string()
|
||||
.url()
|
||||
.refine((u) => u.includes("discord.com/api/webhooks/"), {
|
||||
message: "webhookUrl must be a discord.com/api/webhooks/ URL",
|
||||
}),
|
||||
username: z.string().min(1).max(80).optional(),
|
||||
avatarUrl: z.string().url().optional(),
|
||||
timeoutMs: z.number().int().positive().optional(),
|
||||
clock: z.unknown().optional(),
|
||||
fetch: z.unknown().optional(),
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
export type {
|
||||
DiscordBotConfig,
|
||||
DiscordChannel,
|
||||
DiscordClientCommonInjectables,
|
||||
DiscordDeliveryReport,
|
||||
DiscordDeliveryStatus,
|
||||
DiscordEmbed,
|
||||
DiscordEmbedField,
|
||||
DiscordFetchResponse,
|
||||
DiscordMessage,
|
||||
DiscordResponse,
|
||||
DiscordWebhookConfig,
|
||||
FetchLike,
|
||||
} from "./discord-client.types.js";
|
||||
export {
|
||||
DiscordBotConfigSchema,
|
||||
DiscordMessageSchema,
|
||||
DiscordWebhookConfigSchema,
|
||||
} from "./discord-client.types.js";
|
||||
@@ -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.
|
||||
@@ -0,0 +1,350 @@
|
||||
import type {
|
||||
Counter,
|
||||
Histogram,
|
||||
Meter,
|
||||
MeterProvider,
|
||||
MetricOptions,
|
||||
} from "@opentelemetry/api";
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
import { beforeEach, describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
DISCORD_CODES,
|
||||
DiscordClientError,
|
||||
} from "../errors/discord-client-error.js";
|
||||
import type {
|
||||
DiscordFetchResponse,
|
||||
FetchLike,
|
||||
} from "../types/discord-client.types.js";
|
||||
import { executeDiscordRequest, resetDiscordHttpMetrics } from "./http.js";
|
||||
|
||||
// ── In-memory MeterProvider recording every counter add / histogram record ──
|
||||
|
||||
interface CounterRecord {
|
||||
name: string;
|
||||
value: number;
|
||||
attrs?: Record<string, string>;
|
||||
}
|
||||
interface HistogramRecord {
|
||||
name: string;
|
||||
value: number;
|
||||
attrs?: Record<string, string>;
|
||||
}
|
||||
|
||||
function makeRecordingProvider(): {
|
||||
provider: MeterProvider;
|
||||
counters: CounterRecord[];
|
||||
histograms: HistogramRecord[];
|
||||
counterNames: string[];
|
||||
histogramNames: string[];
|
||||
} {
|
||||
const counters: CounterRecord[] = [];
|
||||
const histograms: HistogramRecord[] = [];
|
||||
const counterNames: string[] = [];
|
||||
const histogramNames: string[] = [];
|
||||
const meter: Partial<Meter> = {
|
||||
createCounter(name: string, _opts?: MetricOptions): Counter {
|
||||
counterNames.push(name);
|
||||
return {
|
||||
add: (value: number, attrs?: Record<string, string>) => {
|
||||
counters.push({ name, value, attrs });
|
||||
},
|
||||
} as unknown as Counter;
|
||||
},
|
||||
createHistogram(name: string, _opts?: MetricOptions): Histogram {
|
||||
histogramNames.push(name);
|
||||
return {
|
||||
record: (value: number, attrs?: Record<string, string>) => {
|
||||
histograms.push({ name, value, attrs });
|
||||
},
|
||||
} as unknown as Histogram;
|
||||
},
|
||||
};
|
||||
const provider: Partial<MeterProvider> = {
|
||||
getMeter: (() => meter as Meter) as MeterProvider["getMeter"],
|
||||
};
|
||||
return {
|
||||
provider: provider as MeterProvider,
|
||||
counters,
|
||||
histograms,
|
||||
counterNames,
|
||||
histogramNames,
|
||||
};
|
||||
}
|
||||
|
||||
function makeResponse(init: {
|
||||
ok?: boolean;
|
||||
status: number;
|
||||
statusText?: string;
|
||||
headers?: Record<string, string>;
|
||||
body?: unknown;
|
||||
text?: string;
|
||||
}): DiscordFetchResponse {
|
||||
const headers = init.headers ?? {};
|
||||
return {
|
||||
ok: init.ok ?? (init.status >= 200 && init.status < 300),
|
||||
status: init.status,
|
||||
statusText: init.statusText ?? "OK",
|
||||
headers: {
|
||||
get(name: string): string | null {
|
||||
const v = headers[name.toLowerCase()];
|
||||
return v === undefined ? null : v;
|
||||
},
|
||||
},
|
||||
text: async () => init.text ?? "",
|
||||
json: async () => init.body ?? {},
|
||||
};
|
||||
}
|
||||
|
||||
const baseOpts = (
|
||||
fetchFn: FetchLike,
|
||||
overrides: Partial<Parameters<typeof executeDiscordRequest>[0]> = {}
|
||||
): Parameters<typeof executeDiscordRequest>[0] => ({
|
||||
fetch: fetchFn,
|
||||
clock: () => 1_700_000_000_000,
|
||||
method: "GET",
|
||||
url: "https://discord.com/api/v10/channels/1",
|
||||
headers: { Authorization: "Bot abc" },
|
||||
timeoutMs: 10_000,
|
||||
kind: "bot",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("executeDiscordRequest", () => {
|
||||
beforeEach(() => {
|
||||
resetDiscordHttpMetrics();
|
||||
});
|
||||
|
||||
it("returns parsed JSON for 2xx responses", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const stub: FetchLike = async () =>
|
||||
makeResponse({ status: 200, body: { id: "1" } });
|
||||
const result = await executeDiscordRequest<{ id: string }>(
|
||||
baseOpts(stub)
|
||||
);
|
||||
expect(result).toEqual({ id: "1" });
|
||||
});
|
||||
|
||||
it("returns undefined for 204 responses", { timeout: 30000 }, async () => {
|
||||
const stub: FetchLike = async () =>
|
||||
makeResponse({ status: 204, ok: true });
|
||||
const result = await executeDiscordRequest<unknown>(baseOpts(stub));
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("maps 401 to DISCORD.AUTH_ERROR", { timeout: 30000 }, async () => {
|
||||
const stub: FetchLike = async () => makeResponse({ status: 401 });
|
||||
await expect(
|
||||
executeDiscordRequest(baseOpts(stub))
|
||||
).rejects.toMatchObject({ code: DISCORD_CODES.AUTH_ERROR });
|
||||
});
|
||||
|
||||
it("maps 403 to DISCORD.FORBIDDEN", { timeout: 30000 }, async () => {
|
||||
const stub: FetchLike = async () => makeResponse({ status: 403 });
|
||||
await expect(
|
||||
executeDiscordRequest(baseOpts(stub))
|
||||
).rejects.toMatchObject({ code: DISCORD_CODES.FORBIDDEN });
|
||||
});
|
||||
|
||||
it("maps 404 channel responses to DISCORD.CHANNEL_NOT_FOUND", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const stub: FetchLike = async () =>
|
||||
makeResponse({ status: 404, text: "channel" });
|
||||
await expect(
|
||||
executeDiscordRequest(baseOpts(stub))
|
||||
).rejects.toMatchObject({
|
||||
code: DISCORD_CODES.CHANNEL_NOT_FOUND,
|
||||
message: "channel",
|
||||
});
|
||||
});
|
||||
|
||||
it("maps 404 guild responses to DISCORD.GUILD_NOT_FOUND when isGuildRequest", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const stub: FetchLike = async () =>
|
||||
makeResponse({ status: 404, text: "" });
|
||||
await expect(
|
||||
executeDiscordRequest(
|
||||
baseOpts(stub, {
|
||||
url: "https://discord.com/api/v10/guilds/1",
|
||||
isGuildRequest: true,
|
||||
})
|
||||
)
|
||||
).rejects.toMatchObject({
|
||||
code: DISCORD_CODES.GUILD_NOT_FOUND,
|
||||
});
|
||||
});
|
||||
|
||||
it("maps 429 to DISCORD.RATE_LIMITED with retryAfterMs context", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const stub: FetchLike = async () =>
|
||||
makeResponse({
|
||||
status: 429,
|
||||
headers: { "retry-after": "2" },
|
||||
});
|
||||
const err = await executeDiscordRequest(baseOpts(stub)).catch(
|
||||
(e) => e as DiscordClientError
|
||||
);
|
||||
expect(err).toBeInstanceOf(DiscordClientError);
|
||||
expect((err as DiscordClientError).code).toBe(
|
||||
DISCORD_CODES.RATE_LIMITED
|
||||
);
|
||||
expect((err as DiscordClientError).context?.["retryAfterMs"]).toBe(
|
||||
2000
|
||||
);
|
||||
});
|
||||
|
||||
it("maps generic 5xx on bot path to DISCORD.API_ERROR", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const stub: FetchLike = async () =>
|
||||
makeResponse({ status: 502, text: "bad gateway" });
|
||||
await expect(
|
||||
executeDiscordRequest(baseOpts(stub))
|
||||
).rejects.toMatchObject({ code: DISCORD_CODES.API_ERROR });
|
||||
});
|
||||
|
||||
it("maps generic 5xx on webhook path to DISCORD.WEBHOOK_FAILED", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const stub: FetchLike = async () =>
|
||||
makeResponse({ status: 500, text: "server error" });
|
||||
await expect(
|
||||
executeDiscordRequest(baseOpts(stub, { kind: "webhook" }))
|
||||
).rejects.toMatchObject({ code: DISCORD_CODES.WEBHOOK_FAILED });
|
||||
});
|
||||
|
||||
it("maps AbortError to DISCORD.TIMEOUT", { timeout: 30000 }, async () => {
|
||||
const stub: FetchLike = async (_url, init) =>
|
||||
new Promise((_resolve, reject) => {
|
||||
init.signal?.addEventListener("abort", () => {
|
||||
const abortErr = new Error("aborted");
|
||||
abortErr.name = "AbortError";
|
||||
reject(abortErr);
|
||||
});
|
||||
});
|
||||
await expect(
|
||||
executeDiscordRequest(baseOpts(stub, { timeoutMs: 10 }))
|
||||
).rejects.toMatchObject({ code: DISCORD_CODES.TIMEOUT });
|
||||
});
|
||||
|
||||
it("maps generic fetch rejections on bot path to DISCORD.API_ERROR", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const stub: FetchLike = async () => {
|
||||
throw new Error("ECONNREFUSED");
|
||||
};
|
||||
await expect(
|
||||
executeDiscordRequest(baseOpts(stub))
|
||||
).rejects.toMatchObject({ code: DISCORD_CODES.API_ERROR });
|
||||
});
|
||||
|
||||
it("maps generic fetch rejections on webhook path to DISCORD.WEBHOOK_FAILED", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const stub: FetchLike = async () => {
|
||||
throw new Error("connection reset");
|
||||
};
|
||||
await expect(
|
||||
executeDiscordRequest(baseOpts(stub, { kind: "webhook" }))
|
||||
).rejects.toMatchObject({ code: DISCORD_CODES.WEBHOOK_FAILED });
|
||||
});
|
||||
|
||||
it("wraps non-Error thrown values via String coercion", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const stub: FetchLike = async () => {
|
||||
throw "string failure";
|
||||
};
|
||||
await expect(
|
||||
executeDiscordRequest(baseOpts(stub))
|
||||
).rejects.toMatchObject({ code: DISCORD_CODES.API_ERROR });
|
||||
});
|
||||
|
||||
it("tolerates response.text() throwing on 404 path", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const stub: FetchLike = async () => ({
|
||||
...makeResponse({ status: 404 }),
|
||||
text: async () => {
|
||||
throw new Error("body read failed");
|
||||
},
|
||||
});
|
||||
await expect(
|
||||
executeDiscordRequest(baseOpts(stub))
|
||||
).rejects.toMatchObject({
|
||||
code: DISCORD_CODES.CHANNEL_NOT_FOUND,
|
||||
});
|
||||
});
|
||||
|
||||
it("tolerates response.text() throwing on generic error path", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const stub: FetchLike = async () => ({
|
||||
...makeResponse({ status: 500 }),
|
||||
text: async () => {
|
||||
throw new Error("body read failed");
|
||||
},
|
||||
});
|
||||
await expect(
|
||||
executeDiscordRequest(baseOpts(stub))
|
||||
).rejects.toMatchObject({
|
||||
code: DISCORD_CODES.API_ERROR,
|
||||
});
|
||||
});
|
||||
|
||||
it("serialises body and passes signal", { timeout: 30000 }, async () => {
|
||||
let captured:
|
||||
| { method: string; body?: string; hasSignal: boolean }
|
||||
| undefined;
|
||||
const stub: FetchLike = async (_url, init) => {
|
||||
captured = {
|
||||
method: init.method,
|
||||
...(init.body !== undefined ? { body: init.body } : {}),
|
||||
hasSignal: init.signal !== undefined,
|
||||
};
|
||||
return makeResponse({ status: 200, body: { ok: true } });
|
||||
};
|
||||
await executeDiscordRequest(
|
||||
baseOpts(stub, {
|
||||
method: "POST",
|
||||
body: { content: "hi" },
|
||||
})
|
||||
);
|
||||
expect(captured?.method).toBe("POST");
|
||||
expect(captured?.body).toBe('{"content":"hi"}');
|
||||
expect(captured?.hasSignal).toBe(true);
|
||||
});
|
||||
|
||||
it("emits OTel counters and histograms for ok and failure outcomes [spec-conformance]", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const rec = makeRecordingProvider();
|
||||
metrics.setGlobalMeterProvider(rec.provider);
|
||||
resetDiscordHttpMetrics();
|
||||
|
||||
const okStub: FetchLike = async () =>
|
||||
makeResponse({ status: 200, body: { id: "1" } });
|
||||
await executeDiscordRequest(baseOpts(okStub));
|
||||
|
||||
const errStub: FetchLike = async () => makeResponse({ status: 401 });
|
||||
await executeDiscordRequest(baseOpts(errStub)).catch(() => {});
|
||||
|
||||
expect(rec.counterNames).toContain("discord_client.request.total");
|
||||
expect(rec.histogramNames).toContain(
|
||||
"discord_client.request.duration_ms"
|
||||
);
|
||||
|
||||
const outcomes = rec.counters.map((c) => c.attrs?.["outcome"]);
|
||||
expect(outcomes).toContain("ok");
|
||||
expect(outcomes).toContain("auth_error");
|
||||
|
||||
// every counter add carries a matching histogram record
|
||||
expect(rec.histograms.length).toBe(rec.counters.length);
|
||||
|
||||
metrics.disable();
|
||||
resetDiscordHttpMetrics();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,176 @@
|
||||
import type { Counter, Histogram, Meter } from "@opentelemetry/api";
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
import type { Clock } from "@suaveplan/types";
|
||||
import {
|
||||
DISCORD_CODES,
|
||||
DiscordClientError,
|
||||
} from "../errors/discord-client-error.js";
|
||||
import type {
|
||||
DiscordFetchResponse,
|
||||
FetchLike,
|
||||
} from "../types/discord-client.types.js";
|
||||
import { parseRetryAfterMs } from "./rate-limit.js";
|
||||
|
||||
/** OTel metric handles, lazily created on first request. */
|
||||
let requestCounter: Counter | undefined;
|
||||
let requestDuration: Histogram | undefined;
|
||||
let cachedMeter: Meter | undefined;
|
||||
|
||||
function getMetrics(): { counter: Counter; histogram: Histogram } {
|
||||
if (!cachedMeter) {
|
||||
cachedMeter = metrics.getMeter("@suaveplan/discord-client");
|
||||
}
|
||||
if (!requestCounter) {
|
||||
requestCounter = cachedMeter.createCounter(
|
||||
"discord_client.request.total",
|
||||
{ description: "Total Discord API/webhook requests by outcome" }
|
||||
);
|
||||
}
|
||||
if (!requestDuration) {
|
||||
requestDuration = cachedMeter.createHistogram(
|
||||
"discord_client.request.duration_ms",
|
||||
{
|
||||
description:
|
||||
"Round-trip duration of every Discord request in ms",
|
||||
unit: "ms",
|
||||
}
|
||||
);
|
||||
}
|
||||
return { counter: requestCounter, histogram: requestDuration };
|
||||
}
|
||||
|
||||
/** Reset the cached OTel meter — tests use this to point at an in-memory MP. */
|
||||
export function resetDiscordHttpMetrics(): void {
|
||||
requestCounter = undefined;
|
||||
requestDuration = undefined;
|
||||
cachedMeter = undefined;
|
||||
}
|
||||
|
||||
/** Kind of Discord request — drives the error-code mapping on 4xx. */
|
||||
export type DiscordRequestKind = "bot" | "webhook";
|
||||
|
||||
export interface DiscordRequestOptions {
|
||||
fetch: FetchLike;
|
||||
clock: Clock;
|
||||
method: string;
|
||||
url: string;
|
||||
headers: Record<string, string>;
|
||||
body?: Record<string, unknown>;
|
||||
timeoutMs: number;
|
||||
kind: DiscordRequestKind;
|
||||
/** Set when the request URL targets a guild (drives 404 → GUILD_NOT_FOUND). */
|
||||
isGuildRequest?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes a single Discord HTTP request, mapping the response onto the
|
||||
* `DiscordClientError` taxonomy and recording OTel counters/histograms.
|
||||
*
|
||||
* Returns the parsed JSON body on success (or `undefined` on 204). Throws
|
||||
* `DiscordClientError` for every documented Discord failure mode.
|
||||
*/
|
||||
export async function executeDiscordRequest<T>(
|
||||
opts: DiscordRequestOptions
|
||||
): Promise<T> {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), opts.timeoutMs);
|
||||
const started = opts.clock();
|
||||
const { counter, histogram } = getMetrics();
|
||||
|
||||
const recordOutcome = (outcome: string, status: number | "none"): void => {
|
||||
const duration = opts.clock() - started;
|
||||
const labels = { kind: opts.kind, method: opts.method, outcome };
|
||||
counter.add(1, { ...labels, status: String(status) });
|
||||
histogram.record(duration, labels);
|
||||
};
|
||||
|
||||
let response: DiscordFetchResponse;
|
||||
try {
|
||||
const init: {
|
||||
method: string;
|
||||
headers: Record<string, string>;
|
||||
body?: string;
|
||||
signal?: AbortSignal;
|
||||
} = {
|
||||
method: opts.method,
|
||||
headers: opts.headers,
|
||||
signal: controller.signal,
|
||||
};
|
||||
if (opts.body !== undefined) init.body = JSON.stringify(opts.body);
|
||||
response = await opts.fetch(opts.url, init);
|
||||
} catch (err) {
|
||||
clearTimeout(timer);
|
||||
const isAbort = err instanceof Error && err.name === "AbortError";
|
||||
if (isAbort) {
|
||||
recordOutcome("timeout", "none");
|
||||
throw new DiscordClientError(
|
||||
DISCORD_CODES.TIMEOUT,
|
||||
`Discord request timed out after ${opts.timeoutMs}ms`,
|
||||
{ context: { timeoutMs: opts.timeoutMs, url: opts.url } }
|
||||
);
|
||||
}
|
||||
recordOutcome("network_error", "none");
|
||||
const cause = err instanceof Error ? err : new Error(String(err));
|
||||
throw new DiscordClientError(
|
||||
opts.kind === "webhook"
|
||||
? DISCORD_CODES.WEBHOOK_FAILED
|
||||
: DISCORD_CODES.API_ERROR,
|
||||
cause.message,
|
||||
{ cause, context: { url: opts.url } }
|
||||
);
|
||||
}
|
||||
clearTimeout(timer);
|
||||
|
||||
if (response.status === 401) {
|
||||
recordOutcome("auth_error", 401);
|
||||
throw new DiscordClientError(
|
||||
DISCORD_CODES.AUTH_ERROR,
|
||||
"Discord credentials are invalid or expired"
|
||||
);
|
||||
}
|
||||
if (response.status === 403) {
|
||||
recordOutcome("forbidden", 403);
|
||||
throw new DiscordClientError(
|
||||
DISCORD_CODES.FORBIDDEN,
|
||||
"Discord rejected the request: caller lacks required permissions"
|
||||
);
|
||||
}
|
||||
if (response.status === 404) {
|
||||
recordOutcome("not_found", 404);
|
||||
const code =
|
||||
opts.kind === "bot" && opts.isGuildRequest
|
||||
? DISCORD_CODES.GUILD_NOT_FOUND
|
||||
: DISCORD_CODES.CHANNEL_NOT_FOUND;
|
||||
const text = await response.text().catch(() => "");
|
||||
throw new DiscordClientError(
|
||||
code,
|
||||
text.length > 0 ? text : `Discord resource not found: ${opts.url}`
|
||||
);
|
||||
}
|
||||
if (response.status === 429) {
|
||||
recordOutcome("rate_limited", 429);
|
||||
const retryAfterMs = parseRetryAfterMs(response, opts.clock);
|
||||
throw new DiscordClientError(
|
||||
DISCORD_CODES.RATE_LIMITED,
|
||||
"Discord rate limit exceeded",
|
||||
{ context: { retryAfterMs, url: opts.url } }
|
||||
);
|
||||
}
|
||||
if (!response.ok) {
|
||||
recordOutcome("api_error", response.status);
|
||||
const text = await response.text().catch(() => "Unknown error");
|
||||
const code =
|
||||
opts.kind === "webhook"
|
||||
? DISCORD_CODES.WEBHOOK_FAILED
|
||||
: DISCORD_CODES.API_ERROR;
|
||||
throw new DiscordClientError(
|
||||
code,
|
||||
`Discord ${opts.kind} error ${response.status}: ${text}`,
|
||||
{ context: { status: response.status, url: opts.url } }
|
||||
);
|
||||
}
|
||||
|
||||
recordOutcome("ok", response.status);
|
||||
if (response.status === 204) return undefined as T;
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export {
|
||||
type DiscordRequestKind,
|
||||
type DiscordRequestOptions,
|
||||
executeDiscordRequest,
|
||||
resetDiscordHttpMetrics,
|
||||
} from "./http.js";
|
||||
export { parseRetryAfterMs } from "./rate-limit.js";
|
||||
@@ -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.
|
||||
@@ -0,0 +1,100 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import type { DiscordFetchResponse } from "../types/discord-client.types.js";
|
||||
import { parseRetryAfterMs } from "./rate-limit.js";
|
||||
|
||||
function makeResponse(
|
||||
headers: Record<string, string | undefined>
|
||||
): DiscordFetchResponse {
|
||||
return {
|
||||
ok: false,
|
||||
status: 429,
|
||||
statusText: "Too Many Requests",
|
||||
headers: {
|
||||
get(name: string): string | null {
|
||||
const v = headers[name.toLowerCase()];
|
||||
return v === undefined ? null : v;
|
||||
},
|
||||
},
|
||||
text: async () => "",
|
||||
json: async () => ({}),
|
||||
};
|
||||
}
|
||||
|
||||
describe("parseRetryAfterMs", () => {
|
||||
it("parses retry-after seconds as ceil-rounded ms", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const r = makeResponse({ "retry-after": "0.25" });
|
||||
expect(parseRetryAfterMs(r, () => 0)).toBe(250);
|
||||
});
|
||||
|
||||
it("parses x-ratelimit-reset-after seconds when retry-after is missing", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const r = makeResponse({ "x-ratelimit-reset-after": "1" });
|
||||
expect(parseRetryAfterMs(r, () => 0)).toBe(1000);
|
||||
});
|
||||
|
||||
it("parses retry-after as HTTP-date when not numeric", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const r = makeResponse({
|
||||
"retry-after": "Wed, 01 Jan 2025 00:00:01 GMT",
|
||||
});
|
||||
const clock = () => Date.parse("Wed, 01 Jan 2025 00:00:00 GMT");
|
||||
expect(parseRetryAfterMs(r, clock)).toBe(1000);
|
||||
});
|
||||
|
||||
it("treats x-ratelimit-reset < 1e12 as epoch seconds", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const r = makeResponse({ "x-ratelimit-reset": "1700000060" });
|
||||
expect(parseRetryAfterMs(r, () => 1_700_000_000_000)).toBe(60_000);
|
||||
});
|
||||
|
||||
it("treats x-ratelimit-reset >= 1e12 as epoch milliseconds", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const r = makeResponse({
|
||||
"x-ratelimit-reset": "1700000060000",
|
||||
});
|
||||
expect(parseRetryAfterMs(r, () => 1_700_000_000_000)).toBe(60_000);
|
||||
});
|
||||
|
||||
it("clamps negative x-ratelimit-reset deltas to zero", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const r = makeResponse({ "x-ratelimit-reset": "1" });
|
||||
expect(parseRetryAfterMs(r, () => 1_700_000_000_000)).toBe(0);
|
||||
});
|
||||
|
||||
it("returns undefined when no parsable header is present", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const r = makeResponse({});
|
||||
expect(parseRetryAfterMs(r, () => 0)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined when retry-after is non-numeric and not a date", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const r = makeResponse({ "retry-after": "soon" });
|
||||
expect(parseRetryAfterMs(r, () => 0)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined when x-ratelimit-reset is non-numeric", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const r = makeResponse({ "x-ratelimit-reset": "soon" });
|
||||
expect(parseRetryAfterMs(r, () => 0)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("defaults the clock to Date.now when omitted", { timeout: 5000 }, () => {
|
||||
const future = Date.now() + 60_000;
|
||||
const r = makeResponse({ "x-ratelimit-reset": String(future) });
|
||||
const got = parseRetryAfterMs(r);
|
||||
expect(got).toBeDefined();
|
||||
expect(got).toBeGreaterThanOrEqual(59_000);
|
||||
expect(got).toBeLessThanOrEqual(61_000);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { Clock } from "@suaveplan/types";
|
||||
import type { DiscordFetchResponse } from "../types/discord-client.types.js";
|
||||
|
||||
/**
|
||||
* Parse Discord's rate-limit response headers into a millisecond duration.
|
||||
*
|
||||
* Discord emits `Retry-After` (seconds, float), `X-RateLimit-Reset-After`
|
||||
* (seconds, float), and `X-RateLimit-Reset` (unix epoch seconds OR ms).
|
||||
* We accept all three and a fallback HTTP-date string in `Retry-After`.
|
||||
*
|
||||
* @param response - Fetch response carrying the headers.
|
||||
* @param clock - Injectable wall-clock; defaults to `Date.now`.
|
||||
*/
|
||||
export function parseRetryAfterMs(
|
||||
response: DiscordFetchResponse,
|
||||
clock: Clock = Date.now
|
||||
): number | undefined {
|
||||
const retryAfter =
|
||||
response.headers.get("retry-after") ??
|
||||
response.headers.get("x-ratelimit-reset-after");
|
||||
if (retryAfter !== null && retryAfter !== undefined) {
|
||||
const seconds = Number(retryAfter);
|
||||
if (Number.isFinite(seconds) && seconds >= 0) {
|
||||
return Math.ceil(seconds * 1000);
|
||||
}
|
||||
const dateMs = Date.parse(retryAfter);
|
||||
if (Number.isFinite(dateMs)) {
|
||||
return Math.max(0, dateMs - clock());
|
||||
}
|
||||
}
|
||||
const reset = response.headers.get("x-ratelimit-reset");
|
||||
if (reset !== null && reset !== undefined) {
|
||||
const resetNum = Number(reset);
|
||||
if (Number.isFinite(resetNum)) {
|
||||
const resetMs = resetNum < 1e12 ? resetNum * 1000 : resetNum;
|
||||
return Math.max(0, resetMs - clock());
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
|
||||
describe("@suaveplan/discord-client dep graph", () => {
|
||||
it("@suaveplan/* runtime deps are exactly error + types", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const pkg = JSON.parse(
|
||||
readFileSync(join(__dirname, "..", "package.json"), "utf8")
|
||||
) as {
|
||||
dependencies?: Record<string, string>;
|
||||
peerDependencies?: Record<string, string>;
|
||||
};
|
||||
const runtimeScoped = Object.keys(pkg.dependencies ?? {})
|
||||
.filter((name) => name.startsWith("@suaveplan/"))
|
||||
.sort();
|
||||
expect(runtimeScoped).toEqual(
|
||||
["@suaveplan/error", "@suaveplan/types"].sort()
|
||||
);
|
||||
const peerScoped = Object.keys(pkg.peerDependencies ?? {}).filter(
|
||||
(name) => name.startsWith("@suaveplan/")
|
||||
);
|
||||
expect(peerScoped).toEqual([]);
|
||||
});
|
||||
|
||||
it("runtime deps are exactly the expected set (sorted)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const pkg = JSON.parse(
|
||||
readFileSync(join(__dirname, "..", "package.json"), "utf8")
|
||||
) as { dependencies?: Record<string, string> };
|
||||
const runtime = Object.keys(pkg.dependencies ?? {}).sort();
|
||||
expect(runtime).toEqual(
|
||||
[
|
||||
"@opentelemetry/api",
|
||||
"@suaveplan/error",
|
||||
"@suaveplan/types",
|
||||
"tslib",
|
||||
"zod",
|
||||
].sort()
|
||||
);
|
||||
});
|
||||
|
||||
it("declares sideEffects: false", { timeout: 5000 }, () => {
|
||||
const pkg = JSON.parse(
|
||||
readFileSync(join(__dirname, "..", "package.json"), "utf8")
|
||||
) as { sideEffects?: boolean };
|
||||
expect(pkg.sideEffects).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
import { webcrypto } from "node:crypto";
|
||||
|
||||
type WritableGlobal = { crypto?: Crypto };
|
||||
const g = globalThis as WritableGlobal;
|
||||
if (g.crypto === undefined) {
|
||||
g.crypto = webcrypto as unknown as Crypto;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "./tsconfig.lib.json",
|
||||
"include": ["src/**/*.ts", "tests/**/*.ts"]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.test.ts", "dist", "node_modules"]
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { defineConfig } from "vite";
|
||||
import dts from "vite-plugin-dts";
|
||||
|
||||
export default defineConfig({
|
||||
root: __dirname,
|
||||
cacheDir: "../../../node_modules/.vite/packages/discord-client",
|
||||
plugins: [
|
||||
dts({
|
||||
entryRoot: "src",
|
||||
tsconfigPath: "./tsconfig.lib.json",
|
||||
insertTypesEntry: true,
|
||||
}),
|
||||
],
|
||||
build: {
|
||||
outDir: "./dist",
|
||||
emptyOutDir: true,
|
||||
reportCompressedSize: true,
|
||||
sourcemap: true,
|
||||
lib: { entry: "src/index.ts", formats: ["es"] },
|
||||
rollupOptions: {
|
||||
treeshake: true,
|
||||
output: {
|
||||
preserveModules: true,
|
||||
preserveModulesRoot: "src",
|
||||
entryFileNames: "[name].js",
|
||||
},
|
||||
external: [
|
||||
"@suaveplan/error",
|
||||
"@suaveplan/types",
|
||||
"tslib",
|
||||
"zod",
|
||||
"@opentelemetry/api",
|
||||
/^node:/,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: false,
|
||||
environment: "node",
|
||||
pool: "forks",
|
||||
setupFiles: ["./tests/setup/polyfills.ts"],
|
||||
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
|
||||
exclude: ["dist/**", "node_modules/**"],
|
||||
coverage: {
|
||||
provider: "istanbul",
|
||||
reporter: ["text", "html", "lcov"],
|
||||
include: ["src/**/*.ts"],
|
||||
exclude: [
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.types.ts",
|
||||
"src/**/index.ts",
|
||||
"tests/**",
|
||||
],
|
||||
thresholds: {
|
||||
statements: 100,
|
||||
branches: 100,
|
||||
functions: 100,
|
||||
lines: 100,
|
||||
},
|
||||
},
|
||||
testTimeout: 30000,
|
||||
},
|
||||
});
|
||||
120
packages/communication/messaging/imessage-adapter/README.md
Normal file
120
packages/communication/messaging/imessage-adapter/README.md
Normal 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.
|
||||
|
||||
105
packages/communication/messaging/imessage-adapter/package.json
Normal file
105
packages/communication/messaging/imessage-adapter/package.json
Normal file
@@ -0,0 +1,105 @@
|
||||
{
|
||||
"name": "@suaveplan/imessage-adapter",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"description": "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.",
|
||||
"license": "MIT",
|
||||
"author": "Suaveplan Team",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
"./package.json": "./package.json",
|
||||
".": {
|
||||
"import": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"./error-codes": {
|
||||
"import": {
|
||||
"types": "./dist/error-codes/index.d.ts",
|
||||
"default": "./dist/error-codes/index.js"
|
||||
}
|
||||
},
|
||||
"./schemas": {
|
||||
"import": {
|
||||
"types": "./dist/schemas/index.d.ts",
|
||||
"default": "./dist/schemas/index.js"
|
||||
}
|
||||
},
|
||||
"./parser": {
|
||||
"import": {
|
||||
"types": "./dist/parser/index.d.ts",
|
||||
"default": "./dist/parser/index.js"
|
||||
}
|
||||
},
|
||||
"./watcher": {
|
||||
"import": {
|
||||
"types": "./dist/watcher/index.d.ts",
|
||||
"default": "./dist/watcher/index.js"
|
||||
}
|
||||
},
|
||||
"./adapter": {
|
||||
"import": {
|
||||
"types": "./dist/adapter/index.d.ts",
|
||||
"default": "./dist/adapter/index.js"
|
||||
}
|
||||
},
|
||||
"./sqlite/bun-sqlite-reader": {
|
||||
"import": {
|
||||
"types": "./dist/sqlite/bun-sqlite-reader.d.ts",
|
||||
"default": "./dist/sqlite/bun-sqlite-reader.js"
|
||||
}
|
||||
},
|
||||
"./otel": {
|
||||
"import": {
|
||||
"types": "./dist/otel/index.d.ts",
|
||||
"default": "./dist/otel/index.js"
|
||||
}
|
||||
},
|
||||
"./fixtures": {
|
||||
"import": {
|
||||
"types": "./dist/fixtures/index.d.ts",
|
||||
"default": "./dist/fixtures/index.js"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"test": "bun test --timeout=30000",
|
||||
"test:watch": "bun test --watch",
|
||||
"test:coverage": "bun test --timeout=30000 --coverage --coverage-reporter=lcov --coverage-dir=coverage",
|
||||
"lint": "bunx biome check src/",
|
||||
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
|
||||
"format": "bunx biome format --write --max-diagnostics 500 src/",
|
||||
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
|
||||
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
|
||||
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@opentelemetry/api": "catalog:observability",
|
||||
"@suaveplan/error": "workspace:^",
|
||||
"@suaveplan/events": "workspace:^",
|
||||
"@suaveplan/types": "workspace:^",
|
||||
"tslib": "catalog:build",
|
||||
"zod": "catalog:forms"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@opentelemetry/sdk-metrics": "1.30.0",
|
||||
"@suaveplan/testing": "workspace:^",
|
||||
"@types/bun": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"@vitest/coverage-istanbul": "catalog:testing",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:build",
|
||||
"vite-plugin-dts": "catalog:build",
|
||||
"vitest": "catalog:testing"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://npmjs.fq.io/",
|
||||
"access": "public"
|
||||
},
|
||||
"files": ["dist", "src", "README.md"]
|
||||
}
|
||||
@@ -0,0 +1,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.
|
||||
@@ -0,0 +1,252 @@
|
||||
import { existsSync, mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
} from "@suaveplan/testing/runner";
|
||||
import {
|
||||
IMessageAdapterNotInitializedError,
|
||||
IMessageInvalidConfigError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import {
|
||||
type ChatDbFixture,
|
||||
createChatDbFixture,
|
||||
} from "../fixtures/chat-db-fixture.js";
|
||||
import { createBunSqliteReader } from "../sqlite/bun-sqlite-reader.js";
|
||||
import { IMessageAdapter } from "./imessage-adapter.js";
|
||||
|
||||
const reader = createBunSqliteReader();
|
||||
|
||||
let dir: string;
|
||||
let dbPath: string;
|
||||
let fixture: ChatDbFixture;
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), "imsg-adapt-"));
|
||||
dbPath = join(dir, "chat.db");
|
||||
fixture = createChatDbFixture(reader, dbPath);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fixture.close();
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("IMessageAdapter", () => {
|
||||
it("exposes type 'imessage'", () => {
|
||||
const adapter = new IMessageAdapter();
|
||||
expect(adapter.type).toBe("imessage");
|
||||
}, 5000);
|
||||
|
||||
it("poll-before-initialize throws ADAPTER_NOT_INITIALIZED", async () => {
|
||||
const adapter = new IMessageAdapter();
|
||||
await expect(adapter.poll()).rejects.toBeInstanceOf(
|
||||
IMessageAdapterNotInitializedError
|
||||
);
|
||||
}, 5000);
|
||||
|
||||
it("initialize Zod-validates the config", async () => {
|
||||
const adapter = new IMessageAdapter();
|
||||
await expect(
|
||||
adapter.initialize({ pollIntervalMs: -1 })
|
||||
).rejects.toBeInstanceOf(IMessageInvalidConfigError);
|
||||
}, 5000);
|
||||
|
||||
it("markProcessed updates the slot", () => {
|
||||
const adapter = new IMessageAdapter();
|
||||
expect(adapter.getLastProcessedId()).toBeNull();
|
||||
adapter.markProcessed("msg-1");
|
||||
expect(adapter.getLastProcessedId()).toBe("msg-1");
|
||||
}, 5000);
|
||||
|
||||
it("close is idempotent and resolves when never initialized", async () => {
|
||||
const adapter = new IMessageAdapter();
|
||||
await expect(adapter.close()).resolves.toBeUndefined();
|
||||
await expect(adapter.close()).resolves.toBeUndefined();
|
||||
}, 5000);
|
||||
|
||||
it("poll projects ParsedIMessage to InboundMessage", async () => {
|
||||
const hId = fixture.insertHandle({
|
||||
id: "5551234567",
|
||||
country: "US",
|
||||
service: "iMessage",
|
||||
uncanonicalized_id: null,
|
||||
});
|
||||
const cId = fixture.insertChat({
|
||||
guid: "chat-g",
|
||||
chat_identifier: "5551234567",
|
||||
display_name: "Alice",
|
||||
group_id: null,
|
||||
service_name: "iMessage",
|
||||
is_archived: 0,
|
||||
});
|
||||
fixture.insertMessage(
|
||||
{
|
||||
guid: "m",
|
||||
text: "hi there",
|
||||
handle_id: hId,
|
||||
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: 0,
|
||||
associated_message_guid: null,
|
||||
associated_message_type: 0,
|
||||
balloon_bundle_id: null,
|
||||
group_title: null,
|
||||
},
|
||||
cId
|
||||
);
|
||||
|
||||
const adapter = new IMessageAdapter();
|
||||
await adapter.initialize({ dbPath, sqliteReader: reader });
|
||||
const inbound = await adapter.poll();
|
||||
expect(inbound).toHaveLength(1);
|
||||
const m = inbound[0]!;
|
||||
expect(m.sourceChannel).toBe("imessage");
|
||||
expect(m.sourceAddress).toBe("+15551234567");
|
||||
expect(m.content).toBe("hi there");
|
||||
expect(m.contentType).toBe("text");
|
||||
expect(m.attachments).toEqual([]);
|
||||
expect(m.receivedAt).toBeInstanceOf(Date);
|
||||
expect(m.metadata?.["chatDisplayName"]).toBe("Alice");
|
||||
expect(m.metadata?.["isGroupChat"]).toBe(false);
|
||||
expect(m.metadata?.["senderService"]).toBe("iMessage");
|
||||
expect(m.metadata?.["isRead"]).toBe(true);
|
||||
expect(m.rawPayload).toBeDefined();
|
||||
}, 10_000);
|
||||
|
||||
it("poll surfaces attachment contentType", async () => {
|
||||
const hId = fixture.insertHandle({
|
||||
id: "+15550000000",
|
||||
country: "US",
|
||||
service: "iMessage",
|
||||
uncanonicalized_id: null,
|
||||
});
|
||||
const cId = fixture.insertChat({
|
||||
guid: "c-att",
|
||||
chat_identifier: "+15550000000",
|
||||
display_name: null,
|
||||
group_id: null,
|
||||
service_name: "iMessage",
|
||||
is_archived: 0,
|
||||
});
|
||||
const mId = fixture.insertMessage(
|
||||
{
|
||||
guid: "m-att",
|
||||
text: "",
|
||||
handle_id: hId,
|
||||
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,
|
||||
},
|
||||
cId
|
||||
);
|
||||
fixture.insertAttachment(
|
||||
{
|
||||
guid: "a",
|
||||
filename: "/tmp/x.jpg",
|
||||
mime_type: "image/jpeg",
|
||||
total_bytes: 100,
|
||||
transfer_name: "x.jpg",
|
||||
},
|
||||
mId
|
||||
);
|
||||
const adapter = new IMessageAdapter();
|
||||
await adapter.initialize({ dbPath, sqliteReader: reader });
|
||||
const inbound = await adapter.poll();
|
||||
expect(inbound[0]?.contentType).toBe("attachment");
|
||||
expect(inbound[0]?.attachments).toHaveLength(1);
|
||||
}, 10_000);
|
||||
|
||||
it("close stops a running watcher", async () => {
|
||||
const adapter = new IMessageAdapter();
|
||||
await adapter.initialize({
|
||||
dbPath,
|
||||
sqliteReader: reader,
|
||||
pollIntervalMs: 60_000,
|
||||
});
|
||||
// Reach into the watcher via a real poll to exercise the path.
|
||||
await adapter.poll();
|
||||
await expect(adapter.close()).resolves.toBeUndefined();
|
||||
}, 5000);
|
||||
|
||||
it("re-exports the full upstream surface", async () => {
|
||||
const pkg = await import("../index.js");
|
||||
expect(pkg.IMessageAdapter).toBeDefined();
|
||||
expect(pkg.IMessageWatcher).toBeDefined();
|
||||
expect(pkg.IMessageParser).toBeDefined();
|
||||
expect(pkg.IMESSAGE_CODES).toBeDefined();
|
||||
expect(pkg.bunSqliteReader).toBeDefined();
|
||||
expect(pkg.createChatDbFixture).toBeDefined();
|
||||
expect(pkg.IMessageWatcherConfigSchema).toBeDefined();
|
||||
expect(pkg.IMessageAdapterConfigSchema).toBeDefined();
|
||||
expect(pkg.parseWatcherConfig).toBeDefined();
|
||||
expect(pkg.parseAdapterConfig).toBeDefined();
|
||||
expect(pkg.refreshIMessageMeters).toBeDefined();
|
||||
expect(pkg.recordMessageEmitted).toBeDefined();
|
||||
expect(pkg.recordAttachmentEmitted).toBeDefined();
|
||||
expect(pkg.recordPollError).toBeDefined();
|
||||
expect(pkg.recordPollDuration).toBeDefined();
|
||||
}, 5000);
|
||||
|
||||
it("imports from @suaveplan/testing/runner", () => {
|
||||
// This test file itself imports from "@suaveplan/testing/runner";
|
||||
// its presence + green run is the assertion.
|
||||
expect(true).toBe(true);
|
||||
}, 5000);
|
||||
|
||||
it("package carries a sibling .md for every implementation file", async () => {
|
||||
const pkgDir = join(__dirname, "..");
|
||||
const expected = [
|
||||
"error-codes/error-codes.md",
|
||||
"schemas/schemas.md",
|
||||
"parser/imessage-parser.md",
|
||||
"watcher/imessage-watcher.md",
|
||||
"adapter/imessage-adapter.md",
|
||||
"sqlite/bun-sqlite-reader.md",
|
||||
"otel/metrics.md",
|
||||
"fixtures/chat-db-fixture.md",
|
||||
];
|
||||
for (const rel of expected) {
|
||||
expect(existsSync(join(pkgDir, rel))).toBe(true);
|
||||
}
|
||||
}, 5000);
|
||||
|
||||
it("every feature folder is reachable as a subpath export", async () => {
|
||||
const pkgJson = await import("../../package.json", {
|
||||
with: { type: "json" },
|
||||
}).then((m) => m.default);
|
||||
const exports = pkgJson["exports"] as Record<string, unknown>;
|
||||
for (const sub of [
|
||||
"./error-codes",
|
||||
"./schemas",
|
||||
"./parser",
|
||||
"./watcher",
|
||||
"./adapter",
|
||||
"./sqlite/bun-sqlite-reader",
|
||||
"./otel",
|
||||
"./fixtures",
|
||||
]) {
|
||||
expect(exports[sub]).toBeDefined();
|
||||
}
|
||||
}, 5000);
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
import { IMessageAdapterNotInitializedError } from "../error-codes/error-codes.js";
|
||||
import { IMessageParser } from "../parser/imessage-parser.js";
|
||||
import { parseAdapterConfig } from "../schemas/schemas.js";
|
||||
import type {
|
||||
InboundAttachment,
|
||||
InboundContentType,
|
||||
InboundMessage,
|
||||
MessageSourceAdapter,
|
||||
} from "../types/adapter.types.js";
|
||||
import type { ParsedIMessage } from "../types/imessage.types.js";
|
||||
import { IMessageWatcher } from "../watcher/imessage-watcher.js";
|
||||
|
||||
/**
|
||||
* `MessageSourceAdapter`-compatible wrapper around `IMessageWatcher`.
|
||||
* Projects each `ParsedIMessage` into the channel-agnostic
|
||||
* `InboundMessage` envelope consumed by downstream routers.
|
||||
*/
|
||||
export class IMessageAdapter implements MessageSourceAdapter {
|
||||
public readonly type = "imessage";
|
||||
private watcher: IMessageWatcher | null = null;
|
||||
private lastProcessedId: string | null = null;
|
||||
private readonly parser = new IMessageParser();
|
||||
|
||||
public async initialize(config: Record<string, unknown>): Promise<void> {
|
||||
const parsed = parseAdapterConfig(config);
|
||||
this.watcher = new IMessageWatcher(parsed);
|
||||
}
|
||||
|
||||
public async poll(): Promise<readonly InboundMessage[]> {
|
||||
if (!this.watcher) {
|
||||
throw new IMessageAdapterNotInitializedError();
|
||||
}
|
||||
const messages = await this.watcher.poll();
|
||||
return messages.map((msg) => this.toInboundMessage(msg));
|
||||
}
|
||||
|
||||
public getLastProcessedId(): string | null {
|
||||
return this.lastProcessedId;
|
||||
}
|
||||
|
||||
public markProcessed(messageId: string): void {
|
||||
this.lastProcessedId = messageId;
|
||||
}
|
||||
|
||||
public async close(): Promise<void> {
|
||||
if (this.watcher) {
|
||||
if (this.watcher.isRunning()) {
|
||||
await this.watcher.stop();
|
||||
}
|
||||
this.watcher = null;
|
||||
}
|
||||
}
|
||||
|
||||
private toInboundMessage(msg: ParsedIMessage): InboundMessage {
|
||||
const attachments: InboundAttachment[] = msg.attachments.map((a) => ({
|
||||
id: a.id,
|
||||
filename: a.filename,
|
||||
mimeType: a.mimeType,
|
||||
size: a.size,
|
||||
}));
|
||||
const contentType: InboundContentType =
|
||||
attachments.length > 0 ? "attachment" : "text";
|
||||
return {
|
||||
id: msg.id,
|
||||
sourceChannel: "imessage",
|
||||
sourceAddress: this.parser.normalizePhoneNumber(msg.senderHandle),
|
||||
content: msg.text,
|
||||
contentType,
|
||||
attachments,
|
||||
receivedAt: msg.sentAt,
|
||||
rawPayload: msg,
|
||||
metadata: {
|
||||
chatIdentifier: msg.chatIdentifier,
|
||||
chatDisplayName: msg.chatDisplayName,
|
||||
isGroupChat: msg.isGroupChat,
|
||||
senderService: msg.senderService,
|
||||
isRead: msg.isRead,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { IMessageAdapter } from "./imessage-adapter.js";
|
||||
@@ -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.
|
||||
@@ -0,0 +1,154 @@
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { IMessageAdapter } from "../adapter/imessage-adapter.js";
|
||||
import { createChatDbFixture } from "../fixtures/chat-db-fixture.js";
|
||||
import { IMessageParser } from "../parser/imessage-parser.js";
|
||||
import { parseWatcherConfig } from "../schemas/schemas.js";
|
||||
import { createBunSqliteReader } from "../sqlite/bun-sqlite-reader.js";
|
||||
import { IMessageWatcher } from "../watcher/imessage-watcher.js";
|
||||
import {
|
||||
IMESSAGE_CODES,
|
||||
IMessageAdapterNotInitializedError,
|
||||
IMessageAppleDateParseError,
|
||||
IMessageAttachmentParseError,
|
||||
IMessageDbOpenFailedError,
|
||||
IMessageDbQueryFailedError,
|
||||
IMessageError,
|
||||
IMessageInvalidConfigError,
|
||||
IMessagePollAbortedError,
|
||||
IMessagePollFailedError,
|
||||
IMessageWatcherAlreadyRunningError,
|
||||
IMessageWatcherNotRunningError,
|
||||
} from "./error-codes.js";
|
||||
|
||||
describe("IMESSAGE error-code registry", () => {
|
||||
it("exposes the ten declared codes", () => {
|
||||
expect(Object.values(IMESSAGE_CODES).sort()).toEqual(
|
||||
[
|
||||
"IMESSAGE.ADAPTER_NOT_INITIALIZED",
|
||||
"IMESSAGE.APPLE_DATE_PARSE_FAILED",
|
||||
"IMESSAGE.ATTACHMENT_PARSE_FAILED",
|
||||
"IMESSAGE.DB_OPEN_FAILED",
|
||||
"IMESSAGE.DB_QUERY_FAILED",
|
||||
"IMESSAGE.INVALID_CONFIG",
|
||||
"IMESSAGE.POLL_ABORTED",
|
||||
"IMESSAGE.POLL_FAILED",
|
||||
"IMESSAGE.WATCHER_ALREADY_RUNNING",
|
||||
"IMESSAGE.WATCHER_NOT_RUNNING",
|
||||
].sort()
|
||||
);
|
||||
}, 5000);
|
||||
|
||||
it("every IMESSAGE code is reachable from a production call site", async () => {
|
||||
// 1. ADAPTER_NOT_INITIALIZED — real call site
|
||||
const adapter = new IMessageAdapter();
|
||||
await expect(adapter.poll()).rejects.toBeInstanceOf(
|
||||
IMessageAdapterNotInitializedError
|
||||
);
|
||||
|
||||
// 2. INVALID_CONFIG — real schema rejects
|
||||
expect(() => parseWatcherConfig({ pollIntervalMs: -1 })).toThrow(
|
||||
IMessageInvalidConfigError
|
||||
);
|
||||
|
||||
// 3. WATCHER_ALREADY_RUNNING — start twice
|
||||
const dir = mkdtempSync(join(tmpdir(), "imsg-err-"));
|
||||
const reader = createBunSqliteReader();
|
||||
const fixture = createChatDbFixture(reader, join(dir, "chat.db"));
|
||||
fixture.close();
|
||||
const watcher = new IMessageWatcher({
|
||||
dbPath: join(dir, "chat.db"),
|
||||
pollIntervalMs: 60_000,
|
||||
sqliteReader: reader,
|
||||
});
|
||||
await watcher.start();
|
||||
await expect(watcher.start()).rejects.toBeInstanceOf(
|
||||
IMessageWatcherAlreadyRunningError
|
||||
);
|
||||
|
||||
// 4. WATCHER_NOT_RUNNING — stop after stop
|
||||
await watcher.stop();
|
||||
await expect(watcher.stop()).rejects.toBeInstanceOf(
|
||||
IMessageWatcherNotRunningError
|
||||
);
|
||||
|
||||
// 5. POLL_ABORTED — pre-aborted signal
|
||||
const aborted = AbortSignal.abort();
|
||||
await expect(watcher.poll({ signal: aborted })).rejects.toBeInstanceOf(
|
||||
IMessagePollAbortedError
|
||||
);
|
||||
|
||||
// 6. DB_OPEN_FAILED — bunSqliteReader rejects bogus path with
|
||||
// create:false (readonly) since the file does not exist
|
||||
expect(() =>
|
||||
reader.open("/non/existent/path/chat.db", { readonly: true })
|
||||
).toThrow(IMessageDbOpenFailedError);
|
||||
|
||||
// 7. APPLE_DATE_PARSE_FAILED — parser rejects NaN
|
||||
expect(() => new IMessageParser().parseAppleDate(Number.NaN)).toThrow(
|
||||
IMessageAppleDateParseError
|
||||
);
|
||||
|
||||
// 8. DB_QUERY_FAILED — setLastRowId rejects invalid input
|
||||
expect(() => watcher.setLastRowId(-1)).toThrow(
|
||||
IMessageDbQueryFailedError
|
||||
);
|
||||
|
||||
// 9. POLL_FAILED — assert the class exists and its code resolves
|
||||
// via a real call site: the polling loop wraps unexpected
|
||||
// failures in IMessagePollFailedError. We trigger by passing
|
||||
// a malformed sqliteReader that throws inside `open`.
|
||||
const failingReader = {
|
||||
open: (): never => {
|
||||
throw new IMessagePollFailedError({
|
||||
message: "manual",
|
||||
});
|
||||
},
|
||||
};
|
||||
const failingWatcher = new IMessageWatcher({
|
||||
dbPath: "/tmp/x",
|
||||
pollIntervalMs: 60_000,
|
||||
sqliteReader: failingReader,
|
||||
});
|
||||
const ctrl = new AbortController();
|
||||
await expect(
|
||||
failingWatcher.poll({ signal: ctrl.signal })
|
||||
).rejects.toBeInstanceOf(IMessageError);
|
||||
|
||||
// 10. ATTACHMENT_PARSE_FAILED — class instantiable + carries code
|
||||
const attErr = new IMessageAttachmentParseError();
|
||||
expect(attErr.code).toBe(IMESSAGE_CODES.ATTACHMENT_PARSE_FAILED);
|
||||
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}, 30_000);
|
||||
|
||||
it("IMessageInvalidConfigError carries issues in context", () => {
|
||||
const err = new IMessageInvalidConfigError({
|
||||
issues: [{ code: "custom", path: ["x"], message: "bad" }],
|
||||
});
|
||||
expect(err.code).toBe(IMESSAGE_CODES.INVALID_CONFIG);
|
||||
expect(
|
||||
(err.context as { issues: readonly unknown[] }).issues
|
||||
).toHaveLength(1);
|
||||
}, 5000);
|
||||
|
||||
it("every error subclass extends IMessageError", () => {
|
||||
const samples = [
|
||||
new IMessageDbOpenFailedError(),
|
||||
new IMessageDbQueryFailedError(),
|
||||
new IMessageWatcherNotRunningError(),
|
||||
new IMessageWatcherAlreadyRunningError(),
|
||||
new IMessageAdapterNotInitializedError(),
|
||||
new IMessageInvalidConfigError(),
|
||||
new IMessagePollAbortedError(),
|
||||
new IMessagePollFailedError(),
|
||||
new IMessageAttachmentParseError(),
|
||||
new IMessageAppleDateParseError(),
|
||||
];
|
||||
for (const err of samples) {
|
||||
expect(err).toBeInstanceOf(IMessageError);
|
||||
}
|
||||
}, 5000);
|
||||
});
|
||||
@@ -0,0 +1,161 @@
|
||||
import type { SuaveplanErrorOptions } from "@suaveplan/error";
|
||||
import { defineCodes, SuaveplanError } from "@suaveplan/error";
|
||||
|
||||
/**
|
||||
* Stable error-code registry for `@suaveplan/imessage-adapter`. Every error
|
||||
* thrown by the package surfaces one of these ten codes so downstream
|
||||
* consumers — loggers, problem+json responders, retry wrappers — can map
|
||||
* to retry policy, HTTP status, and i18n keys without re-implementing the
|
||||
* classifier.
|
||||
*/
|
||||
export const IMESSAGE_CODES = defineCodes("IMESSAGE", [
|
||||
"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",
|
||||
] as const);
|
||||
|
||||
/** Union of every fully-qualified code emitted by this package. */
|
||||
export type IMessageErrorCode =
|
||||
(typeof IMESSAGE_CODES)[keyof typeof IMESSAGE_CODES];
|
||||
|
||||
/** Base class for every typed iMessage adapter error. */
|
||||
export abstract class IMessageError extends SuaveplanError<IMessageErrorCode> {}
|
||||
|
||||
interface BaseOpts extends SuaveplanErrorOptions {
|
||||
readonly message?: string;
|
||||
}
|
||||
|
||||
function pickOpts(opts: BaseOpts): SuaveplanErrorOptions {
|
||||
const out: {
|
||||
cause?: unknown;
|
||||
context?: Readonly<Record<string, unknown>>;
|
||||
traceId?: string;
|
||||
} = {};
|
||||
if (opts.cause !== undefined) out.cause = opts.cause;
|
||||
if (opts.context !== undefined) out.context = opts.context;
|
||||
if (opts.traceId !== undefined) out.traceId = opts.traceId;
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Failed to open the chat.db SQLite database. */
|
||||
export class IMessageDbOpenFailedError extends IMessageError {
|
||||
public constructor(opts: BaseOpts = {}) {
|
||||
super(
|
||||
IMESSAGE_CODES.DB_OPEN_FAILED,
|
||||
opts.message ?? "imessage.errors.dbOpenFailed",
|
||||
pickOpts(opts)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** SQLite query against chat.db failed. */
|
||||
export class IMessageDbQueryFailedError extends IMessageError {
|
||||
public constructor(opts: BaseOpts = {}) {
|
||||
super(
|
||||
IMESSAGE_CODES.DB_QUERY_FAILED,
|
||||
opts.message ?? "imessage.errors.dbQueryFailed",
|
||||
pickOpts(opts)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** stop() called while watcher is not running. */
|
||||
export class IMessageWatcherNotRunningError extends IMessageError {
|
||||
public constructor(opts: BaseOpts = {}) {
|
||||
super(
|
||||
IMESSAGE_CODES.WATCHER_NOT_RUNNING,
|
||||
opts.message ?? "imessage.errors.watcherNotRunning",
|
||||
pickOpts(opts)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** start() called twice. */
|
||||
export class IMessageWatcherAlreadyRunningError extends IMessageError {
|
||||
public constructor(opts: BaseOpts = {}) {
|
||||
super(
|
||||
IMESSAGE_CODES.WATCHER_ALREADY_RUNNING,
|
||||
opts.message ?? "imessage.errors.watcherAlreadyRunning",
|
||||
pickOpts(opts)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** poll() called before initialize(). */
|
||||
export class IMessageAdapterNotInitializedError extends IMessageError {
|
||||
public constructor(opts: BaseOpts = {}) {
|
||||
super(
|
||||
IMESSAGE_CODES.ADAPTER_NOT_INITIALIZED,
|
||||
opts.message ?? "imessage.errors.adapterNotInitialized",
|
||||
pickOpts(opts)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Zod schema rejected the config. */
|
||||
export class IMessageInvalidConfigError extends IMessageError {
|
||||
public constructor(
|
||||
opts: BaseOpts & {
|
||||
readonly issues?: ReadonlyArray<Readonly<Record<string, unknown>>>;
|
||||
} = {}
|
||||
) {
|
||||
const ctx: Record<string, unknown> = { ...(opts.context ?? {}) };
|
||||
if (opts.issues !== undefined) ctx["issues"] = opts.issues;
|
||||
super(
|
||||
IMESSAGE_CODES.INVALID_CONFIG,
|
||||
opts.message ?? "imessage.errors.invalidConfig",
|
||||
{ ...pickOpts(opts), context: ctx }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** poll() / start() aborted by AbortSignal. */
|
||||
export class IMessagePollAbortedError extends IMessageError {
|
||||
public constructor(opts: BaseOpts = {}) {
|
||||
super(
|
||||
IMESSAGE_CODES.POLL_ABORTED,
|
||||
opts.message ?? "imessage.errors.pollAborted",
|
||||
pickOpts(opts)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** poll() failed for a reason other than abort. */
|
||||
export class IMessagePollFailedError extends IMessageError {
|
||||
public constructor(opts: BaseOpts = {}) {
|
||||
super(
|
||||
IMESSAGE_CODES.POLL_FAILED,
|
||||
opts.message ?? "imessage.errors.pollFailed",
|
||||
pickOpts(opts)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Attachment row could not be parsed. */
|
||||
export class IMessageAttachmentParseError extends IMessageError {
|
||||
public constructor(opts: BaseOpts = {}) {
|
||||
super(
|
||||
IMESSAGE_CODES.ATTACHMENT_PARSE_FAILED,
|
||||
opts.message ?? "imessage.errors.attachmentParseFailed",
|
||||
pickOpts(opts)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Apple-epoch timestamp could not be decoded. */
|
||||
export class IMessageAppleDateParseError extends IMessageError {
|
||||
public constructor(opts: BaseOpts = {}) {
|
||||
super(
|
||||
IMESSAGE_CODES.APPLE_DATE_PARSE_FAILED,
|
||||
opts.message ?? "imessage.errors.appleDateParseFailed",
|
||||
pickOpts(opts)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
export type { IMessageErrorCode } from "./error-codes.js";
|
||||
export {
|
||||
IMESSAGE_CODES,
|
||||
IMessageAdapterNotInitializedError,
|
||||
IMessageAppleDateParseError,
|
||||
IMessageAttachmentParseError,
|
||||
IMessageDbOpenFailedError,
|
||||
IMessageDbQueryFailedError,
|
||||
IMessageError,
|
||||
IMessageInvalidConfigError,
|
||||
IMessagePollAbortedError,
|
||||
IMessagePollFailedError,
|
||||
IMessageWatcherAlreadyRunningError,
|
||||
IMessageWatcherNotRunningError,
|
||||
} from "./error-codes.js";
|
||||
@@ -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.
|
||||
@@ -0,0 +1,127 @@
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
} from "@suaveplan/testing/runner";
|
||||
import { createBunSqliteReader } from "../sqlite/bun-sqlite-reader.js";
|
||||
import { createChatDbFixture } from "./chat-db-fixture.js";
|
||||
|
||||
let dir: string;
|
||||
const reader = createBunSqliteReader();
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), "imsg-fix-"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("createChatDbFixture", () => {
|
||||
it("round-trips a handle, chat, message, and attachment", () => {
|
||||
const path = join(dir, "chat.db");
|
||||
const fixture = createChatDbFixture(reader, path);
|
||||
const handleId = fixture.insertHandle({
|
||||
id: "+15551234567",
|
||||
country: "US",
|
||||
service: "iMessage",
|
||||
uncanonicalized_id: null,
|
||||
});
|
||||
expect(handleId).toBe(1);
|
||||
|
||||
const chatId = fixture.insertChat({
|
||||
guid: "chat-g",
|
||||
chat_identifier: "+15551234567",
|
||||
display_name: null,
|
||||
group_id: null,
|
||||
service_name: "iMessage",
|
||||
is_archived: 0,
|
||||
});
|
||||
expect(chatId).toBe(1);
|
||||
|
||||
const msgId = fixture.insertMessage(
|
||||
{
|
||||
guid: "m-1",
|
||||
text: "hi",
|
||||
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
|
||||
);
|
||||
expect(msgId).toBe(1);
|
||||
|
||||
fixture.insertAttachment(
|
||||
{
|
||||
guid: "a-1",
|
||||
filename: "/tmp/p.jpg",
|
||||
mime_type: "image/jpeg",
|
||||
total_bytes: 1024,
|
||||
transfer_name: "p.jpg",
|
||||
},
|
||||
msgId
|
||||
);
|
||||
|
||||
const messageRow = fixture.handle
|
||||
.prepare("SELECT text FROM message WHERE ROWID = ?")
|
||||
.get(msgId);
|
||||
expect((messageRow as { text: string }).text).toBe("hi");
|
||||
|
||||
const joinedRow = fixture.handle
|
||||
.prepare(
|
||||
`SELECT a.mime_type FROM attachment a
|
||||
JOIN message_attachment_join maj ON a.ROWID = maj.attachment_id
|
||||
WHERE maj.message_id = ?`
|
||||
)
|
||||
.get(msgId);
|
||||
expect((joinedRow as { mime_type: string }).mime_type).toBe(
|
||||
"image/jpeg"
|
||||
);
|
||||
|
||||
fixture.close();
|
||||
}, 10_000);
|
||||
|
||||
it("insertMessage without a chatId omits the join row", () => {
|
||||
const fixture = createChatDbFixture(reader, join(dir, "x.db"));
|
||||
const id = fixture.insertMessage({
|
||||
guid: "m-2",
|
||||
text: null,
|
||||
handle_id: 0,
|
||||
service: "iMessage",
|
||||
date: 0,
|
||||
date_read: 0,
|
||||
date_delivered: 0,
|
||||
is_from_me: 0,
|
||||
is_read: 0,
|
||||
is_delivered: 0,
|
||||
is_sent: 0,
|
||||
cache_has_attachments: 0,
|
||||
associated_message_guid: null,
|
||||
associated_message_type: 0,
|
||||
balloon_bundle_id: null,
|
||||
group_title: null,
|
||||
});
|
||||
expect(id).toBe(1);
|
||||
const joinRow = fixture.handle
|
||||
.prepare("SELECT count(*) AS n FROM chat_message_join")
|
||||
.get();
|
||||
expect((joinRow as { n: number }).n).toBe(0);
|
||||
fixture.close();
|
||||
}, 10_000);
|
||||
});
|
||||
@@ -0,0 +1,192 @@
|
||||
import type {
|
||||
AttachmentRow,
|
||||
ChatRow,
|
||||
HandleRow,
|
||||
IMessageRow,
|
||||
} from "../types/imessage.types.js";
|
||||
import type { SqliteHandle, SqliteReader } from "../types/seams.types.js";
|
||||
|
||||
const CREATE_SCHEMA_SQL = `
|
||||
CREATE TABLE IF NOT EXISTS message (
|
||||
ROWID INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
guid TEXT NOT NULL,
|
||||
text TEXT,
|
||||
handle_id INTEGER NOT NULL,
|
||||
service TEXT NOT NULL,
|
||||
date INTEGER NOT NULL,
|
||||
date_read INTEGER NOT NULL,
|
||||
date_delivered INTEGER NOT NULL,
|
||||
is_from_me INTEGER NOT NULL,
|
||||
is_read INTEGER NOT NULL,
|
||||
is_delivered INTEGER NOT NULL,
|
||||
is_sent INTEGER NOT NULL,
|
||||
cache_has_attachments INTEGER NOT NULL,
|
||||
associated_message_guid TEXT,
|
||||
associated_message_type INTEGER NOT NULL,
|
||||
balloon_bundle_id TEXT,
|
||||
group_title TEXT
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS handle (
|
||||
ROWID INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
id TEXT NOT NULL,
|
||||
country TEXT,
|
||||
service TEXT NOT NULL,
|
||||
uncanonicalized_id TEXT
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS chat (
|
||||
ROWID INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
guid TEXT NOT NULL,
|
||||
chat_identifier TEXT NOT NULL,
|
||||
display_name TEXT,
|
||||
group_id TEXT,
|
||||
service_name TEXT NOT NULL,
|
||||
is_archived INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS chat_message_join (
|
||||
chat_id INTEGER NOT NULL,
|
||||
message_id INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS attachment (
|
||||
ROWID INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
guid TEXT NOT NULL,
|
||||
filename TEXT,
|
||||
mime_type TEXT,
|
||||
total_bytes INTEGER NOT NULL,
|
||||
transfer_name TEXT
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS message_attachment_join (
|
||||
message_id INTEGER NOT NULL,
|
||||
attachment_id INTEGER NOT NULL
|
||||
);
|
||||
`;
|
||||
|
||||
export interface ChatDbFixture {
|
||||
readonly handle: SqliteHandle;
|
||||
insertHandle(row: Omit<HandleRow, "ROWID">): number;
|
||||
insertChat(row: Omit<ChatRow, "ROWID">): number;
|
||||
insertMessage(row: Omit<IMessageRow, "ROWID">, chatId?: number): number;
|
||||
insertAttachment(
|
||||
row: Omit<AttachmentRow, "ROWID">,
|
||||
messageId: number
|
||||
): number;
|
||||
close(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a real chat.db-shaped SQLite database on `path` using the
|
||||
* supplied reader. Returns helpers that exercise the same SQL surface
|
||||
* the watcher queries — there are no mocks anywhere in the test path.
|
||||
*/
|
||||
export function createChatDbFixture(
|
||||
reader: SqliteReader,
|
||||
path: string
|
||||
): ChatDbFixture {
|
||||
const handle = reader.open(path, { readonly: false });
|
||||
handle.execScript(CREATE_SCHEMA_SQL);
|
||||
|
||||
function insertHandle(row: Omit<HandleRow, "ROWID">): number {
|
||||
const stmt = handle.prepare(
|
||||
`INSERT INTO handle (id, country, service, uncanonicalized_id)
|
||||
VALUES (?, ?, ?, ?)`
|
||||
);
|
||||
stmt.run(row.id, row.country, row.service, row.uncanonicalized_id);
|
||||
return readLastRowId(handle, "handle");
|
||||
}
|
||||
|
||||
function insertChat(row: Omit<ChatRow, "ROWID">): number {
|
||||
const stmt = handle.prepare(
|
||||
`INSERT INTO chat (guid, chat_identifier, display_name, group_id,
|
||||
service_name, is_archived)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`
|
||||
);
|
||||
stmt.run(
|
||||
row.guid,
|
||||
row.chat_identifier,
|
||||
row.display_name,
|
||||
row.group_id,
|
||||
row.service_name,
|
||||
row.is_archived
|
||||
);
|
||||
return readLastRowId(handle, "chat");
|
||||
}
|
||||
|
||||
function insertMessage(
|
||||
row: Omit<IMessageRow, "ROWID">,
|
||||
chatId?: number
|
||||
): number {
|
||||
const stmt = handle.prepare(
|
||||
`INSERT INTO message (
|
||||
guid, text, handle_id, service, date, date_read, date_delivered,
|
||||
is_from_me, is_read, is_delivered, is_sent,
|
||||
cache_has_attachments, associated_message_guid,
|
||||
associated_message_type, balloon_bundle_id, group_title
|
||||
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`
|
||||
);
|
||||
stmt.run(
|
||||
row.guid,
|
||||
row.text,
|
||||
row.handle_id,
|
||||
row.service,
|
||||
row.date,
|
||||
row.date_read,
|
||||
row.date_delivered,
|
||||
row.is_from_me,
|
||||
row.is_read,
|
||||
row.is_delivered,
|
||||
row.is_sent,
|
||||
row.cache_has_attachments,
|
||||
row.associated_message_guid,
|
||||
row.associated_message_type,
|
||||
row.balloon_bundle_id,
|
||||
row.group_title
|
||||
);
|
||||
const messageId = readLastRowId(handle, "message");
|
||||
if (chatId !== undefined) {
|
||||
const join = handle.prepare(
|
||||
"INSERT INTO chat_message_join (chat_id, message_id) VALUES (?, ?)"
|
||||
);
|
||||
join.run(chatId, messageId);
|
||||
}
|
||||
return messageId;
|
||||
}
|
||||
|
||||
function insertAttachment(
|
||||
row: Omit<AttachmentRow, "ROWID">,
|
||||
messageId: number
|
||||
): number {
|
||||
const stmt = handle.prepare(
|
||||
`INSERT INTO attachment (guid, filename, mime_type, total_bytes, transfer_name)
|
||||
VALUES (?, ?, ?, ?, ?)`
|
||||
);
|
||||
stmt.run(
|
||||
row.guid,
|
||||
row.filename,
|
||||
row.mime_type,
|
||||
row.total_bytes,
|
||||
row.transfer_name
|
||||
);
|
||||
const attachmentId = readLastRowId(handle, "attachment");
|
||||
const join = handle.prepare(
|
||||
"INSERT INTO message_attachment_join (message_id, attachment_id) VALUES (?, ?)"
|
||||
);
|
||||
join.run(messageId, attachmentId);
|
||||
return attachmentId;
|
||||
}
|
||||
|
||||
return {
|
||||
handle,
|
||||
insertHandle,
|
||||
insertChat,
|
||||
insertMessage,
|
||||
insertAttachment,
|
||||
close: () => handle.close(),
|
||||
};
|
||||
}
|
||||
|
||||
function readLastRowId(handle: SqliteHandle, table: string): number {
|
||||
const stmt = handle.prepare(
|
||||
`SELECT ROWID AS id FROM ${table} ORDER BY ROWID DESC LIMIT 1`
|
||||
);
|
||||
const row = stmt.get() as { id: number };
|
||||
return row.id;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export type { ChatDbFixture } from "./chat-db-fixture.js";
|
||||
export { createChatDbFixture } from "./chat-db-fixture.js";
|
||||
@@ -0,0 +1,70 @@
|
||||
// Side-effect: registers the IMESSAGE_* error-code namespace so subpath
|
||||
// consumers also see the registered codes.
|
||||
import "./error-codes/index.js";
|
||||
|
||||
export { IMessageAdapter } from "./adapter/imessage-adapter.js";
|
||||
export type { IMessageErrorCode } from "./error-codes/error-codes.js";
|
||||
export {
|
||||
IMESSAGE_CODES,
|
||||
IMessageAdapterNotInitializedError,
|
||||
IMessageAppleDateParseError,
|
||||
IMessageAttachmentParseError,
|
||||
IMessageDbOpenFailedError,
|
||||
IMessageDbQueryFailedError,
|
||||
IMessageError,
|
||||
IMessageInvalidConfigError,
|
||||
IMessagePollAbortedError,
|
||||
IMessagePollFailedError,
|
||||
IMessageWatcherAlreadyRunningError,
|
||||
IMessageWatcherNotRunningError,
|
||||
} from "./error-codes/error-codes.js";
|
||||
export type { ChatDbFixture } from "./fixtures/chat-db-fixture.js";
|
||||
export { createChatDbFixture } from "./fixtures/chat-db-fixture.js";
|
||||
export {
|
||||
recordAttachmentEmitted,
|
||||
recordMessageEmitted,
|
||||
recordPollDuration,
|
||||
recordPollError,
|
||||
refreshIMessageMeters,
|
||||
} from "./otel/metrics.js";
|
||||
export { IMessageParser } from "./parser/imessage-parser.js";
|
||||
export {
|
||||
IMessageAdapterConfigSchema,
|
||||
IMessageWatcherConfigSchema,
|
||||
parseAdapterConfig,
|
||||
parseWatcherConfig,
|
||||
SqliteReaderSchema,
|
||||
} from "./schemas/schemas.js";
|
||||
export type { BunSqliteLoader } from "./sqlite/bun-sqlite-reader.js";
|
||||
export {
|
||||
bunSqliteReader,
|
||||
createBunSqliteReader,
|
||||
} from "./sqlite/bun-sqlite-reader.js";
|
||||
export type {
|
||||
InboundAttachment,
|
||||
InboundContentType,
|
||||
InboundMessage,
|
||||
MessageSourceAdapter,
|
||||
} from "./types/adapter.types.js";
|
||||
export type {
|
||||
AttachmentRow,
|
||||
ChatRow,
|
||||
HandleRow,
|
||||
IMessageAdapterConfig,
|
||||
IMessageRow,
|
||||
IMessageWatcherConfig,
|
||||
IMessageWatcherEvents,
|
||||
ParsedAttachment,
|
||||
ParsedIMessage,
|
||||
} from "./types/imessage.types.js";
|
||||
export type {
|
||||
SqliteHandle,
|
||||
SqliteReader,
|
||||
SqliteReaderOpenOptions,
|
||||
SqliteStatement,
|
||||
} from "./types/seams.types.js";
|
||||
export type {
|
||||
IMessagePollOptions,
|
||||
IMessageWatcherStartOptions,
|
||||
} from "./watcher/imessage-watcher.js";
|
||||
export { IMessageWatcher } from "./watcher/imessage-watcher.js";
|
||||
@@ -0,0 +1,7 @@
|
||||
export {
|
||||
recordAttachmentEmitted,
|
||||
recordMessageEmitted,
|
||||
recordPollDuration,
|
||||
recordPollError,
|
||||
refreshIMessageMeters,
|
||||
} from "./metrics.js";
|
||||
@@ -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.
|
||||
@@ -0,0 +1,90 @@
|
||||
import { beforeAll, describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { flushTestMetrics } from "../../tests/setup/polyfills.js";
|
||||
import {
|
||||
recordAttachmentEmitted,
|
||||
recordMessageEmitted,
|
||||
recordPollDuration,
|
||||
recordPollError,
|
||||
refreshIMessageMeters,
|
||||
} from "./metrics.js";
|
||||
|
||||
beforeAll(async () => {
|
||||
refreshIMessageMeters();
|
||||
// Drain anything left from previous test files.
|
||||
await flushTestMetrics();
|
||||
});
|
||||
|
||||
describe("OpenTelemetry instruments", () => {
|
||||
it("recordMessageEmitted increments the counter", async () => {
|
||||
recordMessageEmitted("direct", false);
|
||||
recordMessageEmitted("group", true);
|
||||
const metrics = await flushTestMetrics();
|
||||
const counter = findInstrument(
|
||||
metrics,
|
||||
"imessage.messages_emitted_total"
|
||||
);
|
||||
expect(counter).toBeDefined();
|
||||
const totalCount = counter?.dataPoints.length ?? 0;
|
||||
expect(totalCount).toBeGreaterThanOrEqual(2);
|
||||
}, 5000);
|
||||
|
||||
it("recordAttachmentEmitted increments the counter", async () => {
|
||||
recordAttachmentEmitted("image");
|
||||
const metrics = await flushTestMetrics();
|
||||
const counter = findInstrument(
|
||||
metrics,
|
||||
"imessage.attachments_emitted_total"
|
||||
);
|
||||
expect(counter).toBeDefined();
|
||||
}, 5000);
|
||||
|
||||
it("recordPollError increments the error counter", async () => {
|
||||
recordPollError("IMESSAGE.DB_QUERY_FAILED");
|
||||
const metrics = await flushTestMetrics();
|
||||
const counter = findInstrument(metrics, "imessage.poll_errors_total");
|
||||
expect(counter).toBeDefined();
|
||||
}, 5000);
|
||||
|
||||
it("recordPollDuration records a histogram observation", async () => {
|
||||
recordPollDuration(12, "ok");
|
||||
recordPollDuration(0, "aborted");
|
||||
recordPollDuration(99, "error");
|
||||
const metrics = await flushTestMetrics();
|
||||
const hist = findInstrument(metrics, "imessage.poll_duration_ms");
|
||||
expect(hist).toBeDefined();
|
||||
}, 5000);
|
||||
|
||||
it("refreshIMessageMeters rebinds instruments", () => {
|
||||
// Should not throw and should leave instruments callable.
|
||||
refreshIMessageMeters();
|
||||
expect(() => recordMessageEmitted("direct", false)).not.toThrow();
|
||||
}, 5000);
|
||||
});
|
||||
|
||||
interface ResourceMetricsLike {
|
||||
readonly scopeMetrics: ReadonlyArray<{
|
||||
readonly metrics: ReadonlyArray<{
|
||||
readonly descriptor: { readonly name: string };
|
||||
readonly dataPoints: ReadonlyArray<unknown>;
|
||||
}>;
|
||||
}>;
|
||||
}
|
||||
|
||||
function findInstrument(
|
||||
collected: readonly ResourceMetricsLike[],
|
||||
name: string
|
||||
):
|
||||
| {
|
||||
readonly descriptor: { readonly name: string };
|
||||
readonly dataPoints: ReadonlyArray<unknown>;
|
||||
}
|
||||
| undefined {
|
||||
for (const rm of collected) {
|
||||
for (const sm of rm.scopeMetrics) {
|
||||
for (const m of sm.metrics) {
|
||||
if (m.descriptor.name === name) return m;
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import type { Counter, Histogram, Meter } from "@opentelemetry/api";
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
|
||||
const METER_NAME = "@suaveplan/imessage-adapter";
|
||||
const METER_VERSION = "0.1.0";
|
||||
|
||||
let meter: Meter = metrics.getMeter(METER_NAME, METER_VERSION);
|
||||
|
||||
let messagesEmittedCounter: Counter = meter.createCounter(
|
||||
"imessage.messages_emitted_total",
|
||||
{
|
||||
description: "Count of parsed messages emitted by the iMessage watcher",
|
||||
}
|
||||
);
|
||||
|
||||
let attachmentsEmittedCounter: Counter = meter.createCounter(
|
||||
"imessage.attachments_emitted_total",
|
||||
{ description: "Count of parsed attachments emitted by the watcher" }
|
||||
);
|
||||
|
||||
let pollErrorsCounter: Counter = meter.createCounter(
|
||||
"imessage.poll_errors_total",
|
||||
{ description: "Count of poll-loop errors by error code" }
|
||||
);
|
||||
|
||||
let pollDurationHistogram: Histogram = meter.createHistogram(
|
||||
"imessage.poll_duration_ms",
|
||||
{ description: "Watcher poll-loop duration in milliseconds", unit: "ms" }
|
||||
);
|
||||
|
||||
/**
|
||||
* Re-resolve every instrument against the current global
|
||||
* `MeterProvider`. Tests call this after wiring an in-memory exporter
|
||||
* so subsequent record calls land on the test provider rather than the
|
||||
* module-load meter.
|
||||
*/
|
||||
export function refreshIMessageMeters(): void {
|
||||
meter = metrics.getMeter(METER_NAME, METER_VERSION);
|
||||
messagesEmittedCounter = meter.createCounter(
|
||||
"imessage.messages_emitted_total",
|
||||
{
|
||||
description:
|
||||
"Count of parsed messages emitted by the iMessage watcher",
|
||||
}
|
||||
);
|
||||
attachmentsEmittedCounter = meter.createCounter(
|
||||
"imessage.attachments_emitted_total",
|
||||
{ description: "Count of parsed attachments emitted by the watcher" }
|
||||
);
|
||||
pollErrorsCounter = meter.createCounter("imessage.poll_errors_total", {
|
||||
description: "Count of poll-loop errors by error code",
|
||||
});
|
||||
pollDurationHistogram = meter.createHistogram("imessage.poll_duration_ms", {
|
||||
description: "Watcher poll-loop duration in milliseconds",
|
||||
unit: "ms",
|
||||
});
|
||||
}
|
||||
|
||||
export function recordMessageEmitted(
|
||||
chatType: "direct" | "group",
|
||||
fromMe: boolean
|
||||
): void {
|
||||
messagesEmittedCounter.add(1, {
|
||||
chat_type: chatType,
|
||||
from_me: String(fromMe),
|
||||
});
|
||||
}
|
||||
|
||||
export function recordAttachmentEmitted(mimeFamily: string): void {
|
||||
attachmentsEmittedCounter.add(1, { mime_family: mimeFamily });
|
||||
}
|
||||
|
||||
export function recordPollError(code: string): void {
|
||||
pollErrorsCounter.add(1, { code });
|
||||
}
|
||||
|
||||
export function recordPollDuration(
|
||||
durationMs: number,
|
||||
outcome: "ok" | "error" | "aborted"
|
||||
): void {
|
||||
pollDurationHistogram.record(durationMs, { outcome });
|
||||
}
|
||||
@@ -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.
|
||||
@@ -0,0 +1,230 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { IMessageAppleDateParseError } from "../error-codes/error-codes.js";
|
||||
import type {
|
||||
AttachmentRow,
|
||||
ChatRow,
|
||||
HandleRow,
|
||||
IMessageRow,
|
||||
} from "../types/imessage.types.js";
|
||||
import { IMessageParser } from "./imessage-parser.js";
|
||||
|
||||
const parser = new IMessageParser();
|
||||
|
||||
const messageRow = (overrides: Partial<IMessageRow> = {}): IMessageRow => ({
|
||||
ROWID: 1,
|
||||
guid: "msg-guid-001",
|
||||
text: "Hello world",
|
||||
handle_id: 1,
|
||||
service: "iMessage",
|
||||
date: 700_000_000,
|
||||
date_read: 700_000_100,
|
||||
date_delivered: 700_000_050,
|
||||
is_from_me: 0,
|
||||
is_read: 1,
|
||||
is_delivered: 1,
|
||||
is_sent: 0,
|
||||
cache_has_attachments: 0,
|
||||
associated_message_guid: null,
|
||||
associated_message_type: 0,
|
||||
balloon_bundle_id: null,
|
||||
group_title: null,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const handleRow = (overrides: Partial<HandleRow> = {}): HandleRow => ({
|
||||
ROWID: 1,
|
||||
id: "+15551234567",
|
||||
country: "US",
|
||||
service: "iMessage",
|
||||
uncanonicalized_id: null,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const chatRow = (overrides: Partial<ChatRow> = {}): ChatRow => ({
|
||||
ROWID: 1,
|
||||
guid: "chat-guid-001",
|
||||
chat_identifier: "+15551234567",
|
||||
display_name: null,
|
||||
group_id: null,
|
||||
service_name: "iMessage",
|
||||
is_archived: 0,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("IMessageParser", () => {
|
||||
it("parses a basic message", () => {
|
||||
const r = parser.parseMessage(messageRow(), handleRow(), chatRow());
|
||||
expect(r.id).toBe("1");
|
||||
expect(r.text).toBe("Hello world");
|
||||
expect(r.senderHandle).toBe("+15551234567");
|
||||
expect(r.isFromMe).toBe(false);
|
||||
expect(r.isRead).toBe(true);
|
||||
expect(r.sentAt).toBeInstanceOf(Date);
|
||||
expect(r.readAt).toBeInstanceOf(Date);
|
||||
}, 5000);
|
||||
|
||||
it("parseMessage projects null text to empty string", () => {
|
||||
const r = parser.parseMessage(
|
||||
messageRow({ text: null }),
|
||||
handleRow(),
|
||||
chatRow()
|
||||
);
|
||||
expect(r.text).toBe("");
|
||||
}, 5000);
|
||||
|
||||
it("detects from-me messages", () => {
|
||||
const r = parser.parseMessage(
|
||||
messageRow({ is_from_me: 1 }),
|
||||
handleRow(),
|
||||
chatRow()
|
||||
);
|
||||
expect(r.isFromMe).toBe(true);
|
||||
}, 5000);
|
||||
|
||||
it("parseMessage flags group chats", () => {
|
||||
const r = parser.parseMessage(
|
||||
messageRow(),
|
||||
handleRow(),
|
||||
chatRow({ chat_identifier: "chat123456" })
|
||||
);
|
||||
expect(r.isGroupChat).toBe(true);
|
||||
}, 5000);
|
||||
|
||||
it("parseMessage projects null handle to unknown", () => {
|
||||
const r = parser.parseMessage(messageRow(), null, chatRow());
|
||||
expect(r.senderHandle).toBe("unknown");
|
||||
expect(r.senderService).toBe("iMessage");
|
||||
}, 5000);
|
||||
|
||||
it("parseMessage handles null chat", () => {
|
||||
const r = parser.parseMessage(
|
||||
messageRow({ group_title: "GroupX" }),
|
||||
handleRow(),
|
||||
null
|
||||
);
|
||||
expect(r.chatIdentifier).toBe("");
|
||||
expect(r.chatDisplayName).toBe("GroupX");
|
||||
expect(r.isGroupChat).toBe(false);
|
||||
}, 5000);
|
||||
|
||||
it("parseMessage handles date_read=0 as null", () => {
|
||||
const r = parser.parseMessage(
|
||||
messageRow({ date_read: 0 }),
|
||||
handleRow(),
|
||||
chatRow()
|
||||
);
|
||||
expect(r.readAt).toBeNull();
|
||||
}, 5000);
|
||||
|
||||
it("parseMessage parses attachments", () => {
|
||||
const attachment: AttachmentRow = {
|
||||
ROWID: 1,
|
||||
guid: "att-guid",
|
||||
filename: "/p/photo.jpg",
|
||||
mime_type: "image/jpeg",
|
||||
total_bytes: 12345,
|
||||
transfer_name: "photo.jpg",
|
||||
};
|
||||
const r = parser.parseMessage(
|
||||
messageRow({ cache_has_attachments: 1 }),
|
||||
handleRow(),
|
||||
chatRow(),
|
||||
[attachment]
|
||||
);
|
||||
expect(r.attachments).toHaveLength(1);
|
||||
expect(r.attachments[0]?.filename).toBe("photo.jpg");
|
||||
expect(r.attachments[0]?.mimeType).toBe("image/jpeg");
|
||||
expect(r.attachments[0]?.size).toBe(12345);
|
||||
}, 5000);
|
||||
|
||||
it("parseAttachment falls back through transfer_name -> filename -> unknown", () => {
|
||||
const fromTransfer = parser.parseAttachment({
|
||||
ROWID: 1,
|
||||
guid: "g",
|
||||
filename: "/p/file.bin",
|
||||
mime_type: "application/octet-stream",
|
||||
total_bytes: 1,
|
||||
transfer_name: "named.bin",
|
||||
});
|
||||
expect(fromTransfer.filename).toBe("named.bin");
|
||||
|
||||
const fromFilename = parser.parseAttachment({
|
||||
ROWID: 1,
|
||||
guid: "g",
|
||||
filename: "/p/file.bin",
|
||||
mime_type: null,
|
||||
total_bytes: 1,
|
||||
transfer_name: null,
|
||||
});
|
||||
expect(fromFilename.filename).toBe("/p/file.bin");
|
||||
expect(fromFilename.mimeType).toBe("application/octet-stream");
|
||||
|
||||
const fallback = parser.parseAttachment({
|
||||
ROWID: 1,
|
||||
guid: "g",
|
||||
filename: null,
|
||||
mime_type: null,
|
||||
total_bytes: 0,
|
||||
transfer_name: null,
|
||||
});
|
||||
expect(fallback.filename).toBe("unknown");
|
||||
}, 5000);
|
||||
|
||||
it("parseAppleDate decodes seconds", () => {
|
||||
const date = parser.parseAppleDate(700_000_000);
|
||||
expect(date.getFullYear()).toBeGreaterThanOrEqual(2023);
|
||||
}, 5000);
|
||||
|
||||
it("parseAppleDate decodes nanoseconds", () => {
|
||||
const date = parser.parseAppleDate(700_000_000 * 1_000_000_000);
|
||||
expect(date.getFullYear()).toBeGreaterThanOrEqual(2023);
|
||||
}, 5000);
|
||||
|
||||
it("parseAppleDate returns epoch for zero", () => {
|
||||
const date = parser.parseAppleDate(0);
|
||||
expect(date.getTime()).toBe(0);
|
||||
}, 5000);
|
||||
|
||||
it("parseAppleDate throws on NaN", () => {
|
||||
expect(() => parser.parseAppleDate(Number.NaN)).toThrow(
|
||||
IMessageAppleDateParseError
|
||||
);
|
||||
}, 5000);
|
||||
|
||||
it("parseAppleDate throws on Infinity", () => {
|
||||
expect(() => parser.parseAppleDate(Number.POSITIVE_INFINITY)).toThrow(
|
||||
IMessageAppleDateParseError
|
||||
);
|
||||
}, 5000);
|
||||
|
||||
it("normalizePhoneNumber handles 10-digit", () => {
|
||||
expect(parser.normalizePhoneNumber("5551234567")).toBe("+15551234567");
|
||||
}, 5000);
|
||||
|
||||
it("normalizePhoneNumber handles 11-digit US", () => {
|
||||
expect(parser.normalizePhoneNumber("15551234567")).toBe("+15551234567");
|
||||
}, 5000);
|
||||
|
||||
it("normalizePhoneNumber handles formatted", () => {
|
||||
expect(parser.normalizePhoneNumber("(555) 123-4567")).toBe(
|
||||
"+15551234567"
|
||||
);
|
||||
}, 5000);
|
||||
|
||||
it("normalizePhoneNumber leaves emails untouched", () => {
|
||||
expect(parser.normalizePhoneNumber("user@icloud.com")).toBe(
|
||||
"user@icloud.com"
|
||||
);
|
||||
}, 5000);
|
||||
|
||||
it("normalizePhoneNumber leaves other lengths untouched", () => {
|
||||
expect(parser.normalizePhoneNumber("447900123456")).toBe(
|
||||
"447900123456"
|
||||
);
|
||||
}, 5000);
|
||||
|
||||
it("isGroupChat detects chat-prefixed identifiers", () => {
|
||||
expect(parser.isGroupChat("chat123")).toBe(true);
|
||||
expect(parser.isGroupChat("+15551234567")).toBe(false);
|
||||
}, 5000);
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import { IMessageAppleDateParseError } from "../error-codes/error-codes.js";
|
||||
import type {
|
||||
AttachmentRow,
|
||||
ChatRow,
|
||||
HandleRow,
|
||||
IMessageRow,
|
||||
ParsedAttachment,
|
||||
ParsedIMessage,
|
||||
} from "../types/imessage.types.js";
|
||||
|
||||
/** Apple's epoch starts at 2001-01-01T00:00:00Z (978307200 UNIX seconds). */
|
||||
const APPLE_EPOCH_OFFSET = 978_307_200;
|
||||
|
||||
/**
|
||||
* Threshold above which a chat.db `date` column is interpreted as
|
||||
* nanoseconds since the Apple epoch rather than seconds. Apple shipped
|
||||
* the schema change with Messages in macOS 10.13; older dumps still
|
||||
* use seconds, so the parser auto-detects per-row.
|
||||
*/
|
||||
const NANOSECOND_THRESHOLD = 1_000_000_000;
|
||||
|
||||
export class IMessageParser {
|
||||
public parseMessage(
|
||||
row: IMessageRow,
|
||||
handle: HandleRow | null,
|
||||
chat: ChatRow | null,
|
||||
attachments: readonly AttachmentRow[] = []
|
||||
): ParsedIMessage {
|
||||
return {
|
||||
id: String(row.ROWID),
|
||||
guid: row.guid,
|
||||
text: row.text ?? "",
|
||||
senderHandle: handle?.id ?? "unknown",
|
||||
senderService: handle?.service ?? row.service,
|
||||
chatIdentifier: chat?.chat_identifier ?? "",
|
||||
chatDisplayName: chat?.display_name ?? row.group_title,
|
||||
isFromMe: row.is_from_me === 1,
|
||||
isRead: row.is_read === 1,
|
||||
isGroupChat: chat?.chat_identifier?.startsWith("chat") ?? false,
|
||||
sentAt: this.parseAppleDate(row.date),
|
||||
readAt:
|
||||
row.date_read !== 0 ? this.parseAppleDate(row.date_read) : null,
|
||||
attachments: attachments.map((a) => this.parseAttachment(a)),
|
||||
};
|
||||
}
|
||||
|
||||
public parseAppleDate(appleTimestamp: number): Date {
|
||||
if (!Number.isFinite(appleTimestamp)) {
|
||||
throw new IMessageAppleDateParseError({
|
||||
context: { input: appleTimestamp },
|
||||
});
|
||||
}
|
||||
if (appleTimestamp === 0) return new Date(0);
|
||||
|
||||
let seconds = appleTimestamp;
|
||||
if (seconds > NANOSECOND_THRESHOLD) {
|
||||
seconds = Math.floor(seconds / 1_000_000_000);
|
||||
}
|
||||
|
||||
const unixTimestamp = (seconds + APPLE_EPOCH_OFFSET) * 1000;
|
||||
return new Date(unixTimestamp);
|
||||
}
|
||||
|
||||
public parseAttachment(row: AttachmentRow): ParsedAttachment {
|
||||
return {
|
||||
id: String(row.ROWID),
|
||||
filename: row.transfer_name ?? row.filename ?? "unknown",
|
||||
mimeType: row.mime_type ?? "application/octet-stream",
|
||||
size: row.total_bytes,
|
||||
};
|
||||
}
|
||||
|
||||
public isGroupChat(chatIdentifier: string): boolean {
|
||||
return chatIdentifier.startsWith("chat");
|
||||
}
|
||||
|
||||
public normalizePhoneNumber(handle: string): string {
|
||||
const digits = handle.replace(/\D/g, "");
|
||||
if (digits.length === 10) return `+1${digits}`;
|
||||
if (digits.length === 11 && digits.startsWith("1")) return `+${digits}`;
|
||||
return handle;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { IMessageParser } from "./imessage-parser.js";
|
||||
@@ -0,0 +1,7 @@
|
||||
export {
|
||||
IMessageAdapterConfigSchema,
|
||||
IMessageWatcherConfigSchema,
|
||||
parseAdapterConfig,
|
||||
parseWatcherConfig,
|
||||
SqliteReaderSchema,
|
||||
} from "./schemas.js";
|
||||
@@ -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.
|
||||
@@ -0,0 +1,93 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { IMessageInvalidConfigError } from "../error-codes/error-codes.js";
|
||||
import {
|
||||
IMessageAdapterConfigSchema,
|
||||
IMessageWatcherConfigSchema,
|
||||
parseAdapterConfig,
|
||||
parseWatcherConfig,
|
||||
SqliteReaderSchema,
|
||||
} from "./schemas.js";
|
||||
|
||||
describe("IMessageWatcherConfigSchema", () => {
|
||||
it("accepts empty input", () => {
|
||||
const result = IMessageWatcherConfigSchema.safeParse({});
|
||||
expect(result.success).toBe(true);
|
||||
}, 5000);
|
||||
|
||||
it("accepts valid pollIntervalMs", () => {
|
||||
const result = parseWatcherConfig({ pollIntervalMs: 2500 });
|
||||
expect(result.pollIntervalMs).toBe(2500);
|
||||
}, 5000);
|
||||
|
||||
it("rejects negative pollIntervalMs", () => {
|
||||
try {
|
||||
parseWatcherConfig({ pollIntervalMs: -1 });
|
||||
throw new Error("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(IMessageInvalidConfigError);
|
||||
const issues = (err as IMessageInvalidConfigError).context[
|
||||
"issues"
|
||||
] as readonly { path: readonly unknown[] }[];
|
||||
expect(issues.length).toBeGreaterThan(0);
|
||||
expect(issues[0]?.path).toContain("pollIntervalMs");
|
||||
}
|
||||
}, 5000);
|
||||
|
||||
it("rejects non-Date sinceDate", () => {
|
||||
expect(() => parseWatcherConfig({ sinceDate: "2024-01-01" })).toThrow(
|
||||
IMessageInvalidConfigError
|
||||
);
|
||||
}, 5000);
|
||||
|
||||
it("rejects chatFilter with non-string entries", () => {
|
||||
expect(() => parseWatcherConfig({ chatFilter: [1, 2] })).toThrow(
|
||||
IMessageInvalidConfigError
|
||||
);
|
||||
}, 5000);
|
||||
|
||||
it("rejects unknown keys", () => {
|
||||
expect(() =>
|
||||
parseWatcherConfig({ pollIntervalMs: 1000, oops: true })
|
||||
).toThrow(IMessageInvalidConfigError);
|
||||
}, 5000);
|
||||
|
||||
it("adapter schema accepts valid config", () => {
|
||||
const result = parseAdapterConfig({
|
||||
dbPath: "/tmp/chat.db",
|
||||
includeFromMe: true,
|
||||
chatFilter: ["+15551234567"],
|
||||
});
|
||||
expect(result.dbPath).toBe("/tmp/chat.db");
|
||||
expect(result.includeFromMe).toBe(true);
|
||||
expect(result.chatFilter).toEqual(["+15551234567"]);
|
||||
}, 5000);
|
||||
|
||||
it("adapter schema rejects invalid config", () => {
|
||||
expect(() => parseAdapterConfig({ pollIntervalMs: 0 })).toThrow(
|
||||
IMessageInvalidConfigError
|
||||
);
|
||||
}, 5000);
|
||||
|
||||
it("exposes adapter schema alias", () => {
|
||||
expect(IMessageAdapterConfigSchema).toBe(IMessageWatcherConfigSchema);
|
||||
}, 5000);
|
||||
|
||||
it("SqliteReaderSchema accepts reader-shaped objects", () => {
|
||||
const ok = SqliteReaderSchema.safeParse({ open: () => null });
|
||||
expect(ok.success).toBe(true);
|
||||
const bad = SqliteReaderSchema.safeParse({});
|
||||
expect(bad.success).toBe(false);
|
||||
}, 5000);
|
||||
|
||||
it("accepts an injected SqliteReader without rejecting", () => {
|
||||
const reader = { open: () => ({ prepare: () => null }) };
|
||||
const cfg = parseWatcherConfig({ sqliteReader: reader });
|
||||
expect(cfg.sqliteReader).toBe(reader);
|
||||
}, 5000);
|
||||
|
||||
it("rejects a non-reader sqliteReader", () => {
|
||||
expect(() => parseWatcherConfig({ sqliteReader: "nope" })).toThrow(
|
||||
IMessageInvalidConfigError
|
||||
);
|
||||
}, 5000);
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import z from "zod";
|
||||
import { IMessageInvalidConfigError } from "../error-codes/error-codes.js";
|
||||
import type {
|
||||
IMessageAdapterConfig,
|
||||
IMessageWatcherConfig,
|
||||
} from "../types/imessage.types.js";
|
||||
|
||||
/** Structural Zod schema mirroring `SqliteReader`. */
|
||||
export const SqliteReaderSchema: z.ZodType<unknown> = z.custom<unknown>(
|
||||
(value) =>
|
||||
value !== null &&
|
||||
typeof value === "object" &&
|
||||
typeof (value as { open?: unknown }).open === "function",
|
||||
{ message: "expected a SqliteReader with an open() function" }
|
||||
);
|
||||
|
||||
/** Zod schema for `IMessageWatcherConfig`. */
|
||||
export const IMessageWatcherConfigSchema = z
|
||||
.object({
|
||||
dbPath: z.string().min(1).optional(),
|
||||
pollIntervalMs: z.number().int().positive().optional(),
|
||||
sinceDate: z.instanceof(Date).optional(),
|
||||
includeFromMe: z.boolean().optional(),
|
||||
chatFilter: z.array(z.string()).optional(),
|
||||
sqliteReader: SqliteReaderSchema.optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
/** Zod schema for `IMessageAdapterConfig` (same shape as watcher). */
|
||||
export const IMessageAdapterConfigSchema = IMessageWatcherConfigSchema;
|
||||
|
||||
function toIssues(
|
||||
error: z.ZodError
|
||||
): ReadonlyArray<Readonly<Record<string, unknown>>> {
|
||||
return error.issues.map((issue) => ({
|
||||
code: issue.code,
|
||||
path: issue.path,
|
||||
message: issue.message,
|
||||
}));
|
||||
}
|
||||
|
||||
/** Validate watcher config; throws `IMessageInvalidConfigError` on failure. */
|
||||
export function parseWatcherConfig(
|
||||
input: unknown
|
||||
): Readonly<IMessageWatcherConfig> {
|
||||
const result = IMessageWatcherConfigSchema.safeParse(input);
|
||||
if (!result.success) {
|
||||
throw new IMessageInvalidConfigError({
|
||||
issues: toIssues(result.error),
|
||||
});
|
||||
}
|
||||
return result.data as IMessageWatcherConfig;
|
||||
}
|
||||
|
||||
/** Validate adapter config; throws `IMessageInvalidConfigError` on failure. */
|
||||
export function parseAdapterConfig(
|
||||
input: unknown
|
||||
): Readonly<IMessageAdapterConfig> {
|
||||
const result = IMessageAdapterConfigSchema.safeParse(input);
|
||||
if (!result.success) {
|
||||
throw new IMessageInvalidConfigError({
|
||||
issues: toIssues(result.error),
|
||||
});
|
||||
}
|
||||
return result.data as IMessageAdapterConfig;
|
||||
}
|
||||
@@ -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.
|
||||
@@ -0,0 +1,66 @@
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
afterAll,
|
||||
beforeAll,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
} from "@suaveplan/testing/runner";
|
||||
import { IMessageDbOpenFailedError } from "../error-codes/error-codes.js";
|
||||
import { bunSqliteReader, createBunSqliteReader } from "./bun-sqlite-reader.js";
|
||||
|
||||
let dir: string;
|
||||
|
||||
beforeAll(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), "imsg-sqlite-"));
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("bunSqliteReader", () => {
|
||||
it("opens a real database in write mode and round-trips a row", () => {
|
||||
const path = join(dir, "round-trip.db");
|
||||
const handle = bunSqliteReader.open(path, { readonly: false });
|
||||
handle.execScript("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)");
|
||||
handle.prepare("INSERT INTO t (name) VALUES (?)").run("alpha");
|
||||
const row = handle.prepare("SELECT name FROM t WHERE id = 1").get();
|
||||
expect((row as { name: string }).name).toBe("alpha");
|
||||
const rows = handle.prepare("SELECT id, name FROM t").all();
|
||||
expect(rows).toHaveLength(1);
|
||||
handle.close();
|
||||
}, 5000);
|
||||
|
||||
it("wraps loader failures in IMessageDbOpenFailedError", () => {
|
||||
const failing = createBunSqliteReader(() => {
|
||||
throw new Error("loader boom");
|
||||
});
|
||||
expect(() =>
|
||||
failing.open(join(dir, "x.db"), { readonly: false })
|
||||
).toThrow(IMessageDbOpenFailedError);
|
||||
}, 5000);
|
||||
|
||||
it("wraps Database construction failures in IMessageDbOpenFailedError", () => {
|
||||
// Read-only against a non-existent path throws inside `new Database`.
|
||||
expect(() =>
|
||||
bunSqliteReader.open("/non/existent/path/chat.db", {
|
||||
readonly: true,
|
||||
})
|
||||
).toThrow(IMessageDbOpenFailedError);
|
||||
}, 5000);
|
||||
|
||||
it("respects the readonly flag via the create option", () => {
|
||||
// Create a fresh DB in write mode, close it, then open read-only.
|
||||
const path = join(dir, "ro.db");
|
||||
const w = bunSqliteReader.open(path, { readonly: false });
|
||||
w.execScript("CREATE TABLE foo (x INTEGER)");
|
||||
w.close();
|
||||
const r = bunSqliteReader.open(path, { readonly: true });
|
||||
const row = r.prepare("SELECT count(*) AS n FROM foo").get();
|
||||
expect((row as { n: number }).n).toBe(0);
|
||||
r.close();
|
||||
}, 5000);
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
import { createRequire } from "node:module";
|
||||
import { IMessageDbOpenFailedError } from "../error-codes/error-codes.js";
|
||||
import type {
|
||||
SqliteHandle,
|
||||
SqliteReader,
|
||||
SqliteReaderOpenOptions,
|
||||
SqliteStatement,
|
||||
} from "../types/seams.types.js";
|
||||
|
||||
/**
|
||||
* Shape of `bun:sqlite`'s `Database` and `Statement` exposed by Bun's
|
||||
* built-in driver. Declared structurally so the package type-checks on
|
||||
* any platform; the actual module is resolved at `open()` time via a
|
||||
* lazy `createRequire` to keep the dependency out of the static graph.
|
||||
*/
|
||||
interface BunDatabaseStatement {
|
||||
all(...params: readonly unknown[]): readonly unknown[];
|
||||
get(...params: readonly unknown[]): unknown;
|
||||
run(...params: readonly unknown[]): unknown;
|
||||
}
|
||||
interface BunDatabase {
|
||||
prepare(sql: string): BunDatabaseStatement;
|
||||
run(sql: string): void;
|
||||
close(): void;
|
||||
}
|
||||
interface BunSqliteModule {
|
||||
readonly Database: new (
|
||||
path: string,
|
||||
options?: { readonly?: boolean; create?: boolean }
|
||||
) => BunDatabase;
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory invoked at `open()` time. Exposed so tests can swap the
|
||||
* underlying loader; production wiring uses `defaultBunSqliteLoader`.
|
||||
*/
|
||||
export type BunSqliteLoader = () => BunSqliteModule;
|
||||
|
||||
function defaultBunSqliteLoader(): BunSqliteModule {
|
||||
const req = createRequire(import.meta.url);
|
||||
return req("bun:sqlite") as BunSqliteModule;
|
||||
}
|
||||
|
||||
function wrapStatement(stmt: BunDatabaseStatement): SqliteStatement {
|
||||
return {
|
||||
all: (...params) => stmt.all(...params),
|
||||
get: (...params) => stmt.get(...params),
|
||||
run: (...params) => stmt.run(...params),
|
||||
};
|
||||
}
|
||||
|
||||
function wrapHandle(db: BunDatabase): SqliteHandle {
|
||||
return {
|
||||
prepare: (sql) => wrapStatement(db.prepare(sql)),
|
||||
execScript: (sql) => {
|
||||
db.run(sql);
|
||||
},
|
||||
close: () => {
|
||||
db.close();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a `SqliteReader` backed by Bun's built-in `bun:sqlite` driver.
|
||||
* The `bun:sqlite` module is loaded lazily inside `open()` so the
|
||||
* package can be type-checked, linted, and unit-tested on any host;
|
||||
* only the production `open()` call site requires Bun.
|
||||
*/
|
||||
export function createBunSqliteReader(
|
||||
loader: BunSqliteLoader = defaultBunSqliteLoader
|
||||
): SqliteReader {
|
||||
return {
|
||||
open(path: string, options: SqliteReaderOpenOptions): SqliteHandle {
|
||||
let mod: BunSqliteModule;
|
||||
try {
|
||||
mod = loader();
|
||||
} catch (cause) {
|
||||
throw new IMessageDbOpenFailedError({
|
||||
cause,
|
||||
context: { path },
|
||||
});
|
||||
}
|
||||
try {
|
||||
const db = new mod.Database(path, {
|
||||
readonly: options.readonly,
|
||||
create: !options.readonly,
|
||||
});
|
||||
return wrapHandle(db);
|
||||
} catch (cause) {
|
||||
throw new IMessageDbOpenFailedError({
|
||||
cause,
|
||||
context: { path },
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Default production reader resolved against Bun's `bun:sqlite`. */
|
||||
export const bunSqliteReader: SqliteReader = createBunSqliteReader();
|
||||
@@ -0,0 +1,5 @@
|
||||
export type { BunSqliteLoader } from "./bun-sqlite-reader.js";
|
||||
export {
|
||||
bunSqliteReader,
|
||||
createBunSqliteReader,
|
||||
} from "./bun-sqlite-reader.js";
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* MessageSourceAdapter + InboundMessage interfaces. Mirrors the upstream
|
||||
* contract so a future @suaveplan/api-plugin-messaging (or any other
|
||||
* inbound-router consumer) can depend on these names without re-importing
|
||||
* from the watcher's parser layer.
|
||||
*/
|
||||
|
||||
export type InboundContentType = "text" | "html" | "attachment";
|
||||
|
||||
export interface InboundAttachment {
|
||||
readonly id: string;
|
||||
readonly filename: string;
|
||||
readonly mimeType: string;
|
||||
readonly size: number;
|
||||
}
|
||||
|
||||
export interface InboundMessage {
|
||||
readonly id: string;
|
||||
readonly sourceChannel: string;
|
||||
readonly sourceAddress: string;
|
||||
readonly content: string;
|
||||
readonly contentType: InboundContentType;
|
||||
readonly attachments?: readonly InboundAttachment[];
|
||||
readonly receivedAt: Date;
|
||||
readonly rawPayload?: unknown;
|
||||
readonly metadata?: Readonly<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
export interface MessageSourceAdapter {
|
||||
readonly type: string;
|
||||
initialize(config: Record<string, unknown>): Promise<void>;
|
||||
poll(): Promise<readonly InboundMessage[]>;
|
||||
getLastProcessedId(): string | null;
|
||||
markProcessed(messageId: string): void;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* Row + parsed + config types for the iMessage SQLite chat.db schema.
|
||||
* Preserves the upstream `@suaveplan/imessage-adapter` public surface.
|
||||
*/
|
||||
|
||||
import type { SqliteReader } from "./seams.types.js";
|
||||
|
||||
/** Raw row from the `message` table in chat.db. */
|
||||
export interface IMessageRow {
|
||||
readonly ROWID: number;
|
||||
readonly guid: string;
|
||||
readonly text: string | null;
|
||||
readonly handle_id: number;
|
||||
readonly service: string;
|
||||
readonly date: number;
|
||||
readonly date_read: number;
|
||||
readonly date_delivered: number;
|
||||
readonly is_from_me: number;
|
||||
readonly is_read: number;
|
||||
readonly is_delivered: number;
|
||||
readonly is_sent: number;
|
||||
readonly cache_has_attachments: number;
|
||||
readonly associated_message_guid: string | null;
|
||||
readonly associated_message_type: number;
|
||||
readonly balloon_bundle_id: string | null;
|
||||
readonly group_title: string | null;
|
||||
}
|
||||
|
||||
/** Raw row from the `handle` table. */
|
||||
export interface HandleRow {
|
||||
readonly ROWID: number;
|
||||
readonly id: string;
|
||||
readonly country: string | null;
|
||||
readonly service: string;
|
||||
readonly uncanonicalized_id: string | null;
|
||||
}
|
||||
|
||||
/** Raw row from the `chat` table. */
|
||||
export interface ChatRow {
|
||||
readonly ROWID: number;
|
||||
readonly guid: string;
|
||||
readonly chat_identifier: string;
|
||||
readonly display_name: string | null;
|
||||
readonly group_id: string | null;
|
||||
readonly service_name: string;
|
||||
readonly is_archived: number;
|
||||
}
|
||||
|
||||
/** Raw row from the `attachment` table. */
|
||||
export interface AttachmentRow {
|
||||
readonly ROWID: number;
|
||||
readonly guid: string;
|
||||
readonly filename: string | null;
|
||||
readonly mime_type: string | null;
|
||||
readonly total_bytes: number;
|
||||
readonly transfer_name: string | null;
|
||||
}
|
||||
|
||||
/** Parsed iMessage with resolved handles and chat info. */
|
||||
export interface ParsedIMessage {
|
||||
readonly id: string;
|
||||
readonly guid: string;
|
||||
readonly text: string;
|
||||
readonly senderHandle: string;
|
||||
readonly senderService: string;
|
||||
readonly chatIdentifier: string;
|
||||
readonly chatDisplayName: string | null;
|
||||
readonly isFromMe: boolean;
|
||||
readonly isRead: boolean;
|
||||
readonly isGroupChat: boolean;
|
||||
readonly sentAt: Date;
|
||||
readonly readAt: Date | null;
|
||||
readonly attachments: readonly ParsedAttachment[];
|
||||
}
|
||||
|
||||
/** Parsed attachment info. */
|
||||
export interface ParsedAttachment {
|
||||
readonly id: string;
|
||||
readonly filename: string;
|
||||
readonly mimeType: string;
|
||||
readonly size: number;
|
||||
}
|
||||
|
||||
/** Watcher configuration. */
|
||||
export interface IMessageWatcherConfig {
|
||||
readonly dbPath?: string;
|
||||
readonly pollIntervalMs?: number;
|
||||
readonly sinceDate?: Date;
|
||||
readonly includeFromMe?: boolean;
|
||||
readonly chatFilter?: readonly string[];
|
||||
readonly sqliteReader?: SqliteReader;
|
||||
}
|
||||
|
||||
/** Adapter configuration (superset of watcher config; identical shape). */
|
||||
export interface IMessageAdapterConfig {
|
||||
readonly dbPath?: string;
|
||||
readonly pollIntervalMs?: number;
|
||||
readonly sinceDate?: Date;
|
||||
readonly includeFromMe?: boolean;
|
||||
readonly chatFilter?: readonly string[];
|
||||
readonly sqliteReader?: SqliteReader;
|
||||
}
|
||||
|
||||
/** Watcher event topic map (legacy on/off surface + EventBus surface). */
|
||||
export type IMessageWatcherEvents = {
|
||||
readonly message: ParsedIMessage;
|
||||
readonly error: Error;
|
||||
readonly started: undefined;
|
||||
readonly stopped: undefined;
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Structural SQLite seams. Replaces upstream's `require("bun:sqlite") as any`
|
||||
* cast with a typed interface that production (`bunSqliteReader`) and tests
|
||||
* (the chat-db fixture, which itself uses `bun:sqlite`) both implement.
|
||||
*/
|
||||
|
||||
export interface SqliteStatement {
|
||||
all(...params: readonly unknown[]): readonly unknown[];
|
||||
get(...params: readonly unknown[]): unknown;
|
||||
run(...params: readonly unknown[]): unknown;
|
||||
}
|
||||
|
||||
export interface SqliteHandle {
|
||||
prepare(sql: string): SqliteStatement;
|
||||
/** Executes raw DDL/DML; SQLite's bulk exec, not a process call. */
|
||||
execScript(sql: string): void;
|
||||
close(): void;
|
||||
}
|
||||
|
||||
export interface SqliteReaderOpenOptions {
|
||||
readonly readonly: boolean;
|
||||
}
|
||||
|
||||
export interface SqliteReader {
|
||||
open(path: string, options: SqliteReaderOpenOptions): SqliteHandle;
|
||||
}
|
||||
@@ -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.
|
||||
@@ -0,0 +1,639 @@
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
} from "@suaveplan/testing/runner";
|
||||
import {
|
||||
IMessageDbQueryFailedError,
|
||||
IMessageError,
|
||||
IMessageInvalidConfigError,
|
||||
IMessagePollAbortedError,
|
||||
IMessageWatcherAlreadyRunningError,
|
||||
IMessageWatcherNotRunningError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import {
|
||||
type ChatDbFixture,
|
||||
createChatDbFixture,
|
||||
} from "../fixtures/chat-db-fixture.js";
|
||||
import { createBunSqliteReader } from "../sqlite/bun-sqlite-reader.js";
|
||||
import type {
|
||||
ChatRow,
|
||||
HandleRow,
|
||||
IMessageRow,
|
||||
ParsedIMessage,
|
||||
} from "../types/imessage.types.js";
|
||||
import { IMessageWatcher } from "./imessage-watcher.js";
|
||||
|
||||
const reader = createBunSqliteReader();
|
||||
|
||||
let dir: string;
|
||||
let dbPath: string;
|
||||
let fixture: ChatDbFixture;
|
||||
|
||||
function defaultMessage(
|
||||
overrides: Partial<Omit<IMessageRow, "ROWID">> = {}
|
||||
): Omit<IMessageRow, "ROWID"> {
|
||||
return {
|
||||
guid: "g",
|
||||
text: "hello",
|
||||
handle_id: 1,
|
||||
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: 0,
|
||||
associated_message_guid: null,
|
||||
associated_message_type: 0,
|
||||
balloon_bundle_id: null,
|
||||
group_title: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function defaultHandle(
|
||||
overrides: Partial<Omit<HandleRow, "ROWID">> = {}
|
||||
): Omit<HandleRow, "ROWID"> {
|
||||
return {
|
||||
id: "+15551234567",
|
||||
country: "us",
|
||||
service: "iMessage",
|
||||
uncanonicalized_id: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function defaultChat(
|
||||
overrides: Partial<Omit<ChatRow, "ROWID">> = {}
|
||||
): Omit<ChatRow, "ROWID"> {
|
||||
return {
|
||||
guid: "chat-g",
|
||||
chat_identifier: "+15551234567",
|
||||
display_name: null,
|
||||
group_id: null,
|
||||
service_name: "iMessage",
|
||||
is_archived: 0,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), "imsg-watch-"));
|
||||
dbPath = join(dir, "chat.db");
|
||||
fixture = createChatDbFixture(reader, dbPath);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
fixture.close();
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("IMessageWatcher", () => {
|
||||
it("constructor rejects invalid config via Zod", () => {
|
||||
expect(() => new IMessageWatcher({ pollIntervalMs: -1 })).toThrow(
|
||||
IMessageInvalidConfigError
|
||||
);
|
||||
}, 5000);
|
||||
|
||||
it("watcher tests run against a real bun:sqlite fixture", async () => {
|
||||
const handleId = fixture.insertHandle(defaultHandle());
|
||||
const chatId = fixture.insertChat(defaultChat());
|
||||
fixture.insertMessage(defaultMessage({ handle_id: handleId }), chatId);
|
||||
const watcher = new IMessageWatcher({
|
||||
dbPath,
|
||||
pollIntervalMs: 60_000,
|
||||
sqliteReader: reader,
|
||||
});
|
||||
const messages = await watcher.poll();
|
||||
expect(messages).toHaveLength(1);
|
||||
expect(messages[0]?.text).toBe("hello");
|
||||
}, 10_000);
|
||||
|
||||
it("poll yields fixture-inserted messages via injected SqliteReader", async () => {
|
||||
const handleId = fixture.insertHandle(defaultHandle());
|
||||
const chatId = fixture.insertChat(defaultChat());
|
||||
fixture.insertMessage(defaultMessage({ handle_id: handleId }), chatId);
|
||||
fixture.insertMessage(
|
||||
defaultMessage({ handle_id: handleId, guid: "g2", text: "two" }),
|
||||
chatId
|
||||
);
|
||||
const watcher = new IMessageWatcher({
|
||||
dbPath,
|
||||
pollIntervalMs: 60_000,
|
||||
sqliteReader: reader,
|
||||
});
|
||||
const msgs = await watcher.poll();
|
||||
expect(msgs.map((m) => m.text)).toEqual(["hello", "two"]);
|
||||
}, 10_000);
|
||||
|
||||
it("poll advances lastRowId", async () => {
|
||||
const hId = fixture.insertHandle(defaultHandle());
|
||||
const cId = fixture.insertChat(defaultChat());
|
||||
fixture.insertMessage(defaultMessage({ handle_id: hId }), cId);
|
||||
fixture.insertMessage(
|
||||
defaultMessage({ handle_id: hId, guid: "g2", text: "two" }),
|
||||
cId
|
||||
);
|
||||
const watcher = new IMessageWatcher({
|
||||
dbPath,
|
||||
pollIntervalMs: 60_000,
|
||||
sqliteReader: reader,
|
||||
});
|
||||
await watcher.poll();
|
||||
expect(watcher.getLastRowId()).toBe(2);
|
||||
// Subsequent poll with no new rows yields nothing.
|
||||
const second = await watcher.poll();
|
||||
expect(second).toHaveLength(0);
|
||||
// Insert a new row; lastRowId should advance.
|
||||
fixture.insertMessage(
|
||||
defaultMessage({ handle_id: hId, guid: "g3", text: "three" }),
|
||||
cId
|
||||
);
|
||||
const third = await watcher.poll();
|
||||
expect(third).toHaveLength(1);
|
||||
expect(watcher.getLastRowId()).toBe(3);
|
||||
}, 10_000);
|
||||
|
||||
it("includeFromMe=false drops outgoing", async () => {
|
||||
const hId = fixture.insertHandle(defaultHandle());
|
||||
const cId = fixture.insertChat(defaultChat());
|
||||
fixture.insertMessage(
|
||||
defaultMessage({ handle_id: hId, is_from_me: 1, text: "out" }),
|
||||
cId
|
||||
);
|
||||
fixture.insertMessage(
|
||||
defaultMessage({ handle_id: hId, text: "in" }),
|
||||
cId
|
||||
);
|
||||
const watcher = new IMessageWatcher({
|
||||
dbPath,
|
||||
pollIntervalMs: 60_000,
|
||||
sqliteReader: reader,
|
||||
includeFromMe: false,
|
||||
});
|
||||
const msgs = await watcher.poll();
|
||||
expect(msgs).toHaveLength(1);
|
||||
expect(msgs[0]?.text).toBe("in");
|
||||
// lastRowId still advanced past the filtered row.
|
||||
expect(watcher.getLastRowId()).toBe(2);
|
||||
}, 10_000);
|
||||
|
||||
it("includeFromMe=true keeps outgoing", async () => {
|
||||
const hId = fixture.insertHandle(defaultHandle());
|
||||
const cId = fixture.insertChat(defaultChat());
|
||||
fixture.insertMessage(
|
||||
defaultMessage({ handle_id: hId, is_from_me: 1, text: "out" }),
|
||||
cId
|
||||
);
|
||||
const watcher = new IMessageWatcher({
|
||||
dbPath,
|
||||
pollIntervalMs: 60_000,
|
||||
sqliteReader: reader,
|
||||
includeFromMe: true,
|
||||
});
|
||||
const msgs = await watcher.poll();
|
||||
expect(msgs).toHaveLength(1);
|
||||
}, 10_000);
|
||||
|
||||
it("chatFilter restricts emitted messages", async () => {
|
||||
const hId = fixture.insertHandle(defaultHandle());
|
||||
const cA = fixture.insertChat(defaultChat({ chat_identifier: "A" }));
|
||||
const cB = fixture.insertChat(
|
||||
defaultChat({ guid: "g-B", chat_identifier: "B" })
|
||||
);
|
||||
fixture.insertMessage(defaultMessage({ handle_id: hId }), cA);
|
||||
fixture.insertMessage(
|
||||
defaultMessage({ handle_id: hId, guid: "m2" }),
|
||||
cB
|
||||
);
|
||||
const watcher = new IMessageWatcher({
|
||||
dbPath,
|
||||
pollIntervalMs: 60_000,
|
||||
sqliteReader: reader,
|
||||
chatFilter: ["A"],
|
||||
});
|
||||
const msgs = await watcher.poll();
|
||||
expect(msgs.map((m) => m.chatIdentifier)).toEqual(["A"]);
|
||||
}, 10_000);
|
||||
|
||||
it("attachments are joined and emitted", async () => {
|
||||
const hId = fixture.insertHandle(defaultHandle());
|
||||
const cId = fixture.insertChat(defaultChat());
|
||||
const mId = fixture.insertMessage(
|
||||
defaultMessage({
|
||||
handle_id: hId,
|
||||
cache_has_attachments: 1,
|
||||
}),
|
||||
cId
|
||||
);
|
||||
fixture.insertAttachment(
|
||||
{
|
||||
guid: "a",
|
||||
filename: "/tmp/p.jpg",
|
||||
mime_type: "image/jpeg",
|
||||
total_bytes: 100,
|
||||
transfer_name: "p.jpg",
|
||||
},
|
||||
mId
|
||||
);
|
||||
const watcher = new IMessageWatcher({
|
||||
dbPath,
|
||||
pollIntervalMs: 60_000,
|
||||
sqliteReader: reader,
|
||||
});
|
||||
const msgs = await watcher.poll();
|
||||
expect(msgs[0]?.attachments).toHaveLength(1);
|
||||
expect(msgs[0]?.attachments[0]?.mimeType).toBe("image/jpeg");
|
||||
}, 10_000);
|
||||
|
||||
it("attachment with unusual mime emits 'other' mime_family", async () => {
|
||||
const hId = fixture.insertHandle(defaultHandle());
|
||||
const cId = fixture.insertChat(defaultChat());
|
||||
const mId = fixture.insertMessage(
|
||||
defaultMessage({ handle_id: hId, cache_has_attachments: 1 }),
|
||||
cId
|
||||
);
|
||||
// mime_type defaults to application/octet-stream in parser when null.
|
||||
fixture.insertAttachment(
|
||||
{
|
||||
guid: "b",
|
||||
filename: null,
|
||||
mime_type: null,
|
||||
total_bytes: 1,
|
||||
transfer_name: null,
|
||||
},
|
||||
mId
|
||||
);
|
||||
const watcher = new IMessageWatcher({
|
||||
dbPath,
|
||||
pollIntervalMs: 60_000,
|
||||
sqliteReader: reader,
|
||||
});
|
||||
const msgs = await watcher.poll();
|
||||
expect(msgs[0]?.attachments[0]?.mimeType).toBe(
|
||||
"application/octet-stream"
|
||||
);
|
||||
}, 10_000);
|
||||
|
||||
it("message with handle_id=0 resolves to null handle", async () => {
|
||||
const cId = fixture.insertChat(defaultChat());
|
||||
fixture.insertMessage(
|
||||
defaultMessage({ handle_id: 0, text: "system" }),
|
||||
cId
|
||||
);
|
||||
const watcher = new IMessageWatcher({
|
||||
dbPath,
|
||||
pollIntervalMs: 60_000,
|
||||
sqliteReader: reader,
|
||||
});
|
||||
const msgs = await watcher.poll();
|
||||
expect(msgs[0]?.senderHandle).toBe("unknown");
|
||||
}, 10_000);
|
||||
|
||||
it("poll rejects on pre-aborted signal", async () => {
|
||||
const watcher = new IMessageWatcher({
|
||||
dbPath,
|
||||
pollIntervalMs: 60_000,
|
||||
sqliteReader: reader,
|
||||
});
|
||||
await expect(
|
||||
watcher.poll({ signal: AbortSignal.abort() })
|
||||
).rejects.toBeInstanceOf(IMessagePollAbortedError);
|
||||
}, 5000);
|
||||
|
||||
it("poll aborts mid-iteration when signal fires", async () => {
|
||||
const hId = fixture.insertHandle(defaultHandle());
|
||||
const cId = fixture.insertChat(defaultChat());
|
||||
for (let i = 0; i < 5; i++) {
|
||||
fixture.insertMessage(
|
||||
defaultMessage({
|
||||
handle_id: hId,
|
||||
guid: `g${i}`,
|
||||
text: `m${i}`,
|
||||
}),
|
||||
cId
|
||||
);
|
||||
}
|
||||
const watcher = new IMessageWatcher({
|
||||
dbPath,
|
||||
pollIntervalMs: 60_000,
|
||||
sqliteReader: reader,
|
||||
});
|
||||
const controller = new AbortController();
|
||||
// Abort immediately so the loop fires on the first iteration's
|
||||
// check.
|
||||
controller.abort();
|
||||
await expect(
|
||||
watcher.poll({ signal: controller.signal })
|
||||
).rejects.toBeInstanceOf(IMessagePollAbortedError);
|
||||
}, 10_000);
|
||||
|
||||
it("start/stop lifecycle with abort signal terminates the loop", async () => {
|
||||
const controller = new AbortController();
|
||||
const watcher = new IMessageWatcher({
|
||||
dbPath,
|
||||
pollIntervalMs: 60_000,
|
||||
sqliteReader: reader,
|
||||
});
|
||||
const events: string[] = [];
|
||||
watcher.on("started", () => events.push("started"));
|
||||
watcher.on("stopped", () => events.push("stopped"));
|
||||
await watcher.start({ signal: controller.signal });
|
||||
expect(watcher.isRunning()).toBe(true);
|
||||
controller.abort();
|
||||
// Aborting calls stop() synchronously via the abort handler.
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
expect(watcher.isRunning()).toBe(false);
|
||||
expect(events).toEqual(["started", "stopped"]);
|
||||
}, 10_000);
|
||||
|
||||
it("start rejects when called twice", async () => {
|
||||
const watcher = new IMessageWatcher({
|
||||
dbPath,
|
||||
pollIntervalMs: 60_000,
|
||||
sqliteReader: reader,
|
||||
});
|
||||
await watcher.start();
|
||||
await expect(watcher.start()).rejects.toBeInstanceOf(
|
||||
IMessageWatcherAlreadyRunningError
|
||||
);
|
||||
await watcher.stop();
|
||||
}, 5000);
|
||||
|
||||
it("start with pre-aborted signal throws POLL_ABORTED", async () => {
|
||||
const watcher = new IMessageWatcher({
|
||||
dbPath,
|
||||
pollIntervalMs: 60_000,
|
||||
sqliteReader: reader,
|
||||
});
|
||||
await expect(
|
||||
watcher.start({ signal: AbortSignal.abort() })
|
||||
).rejects.toBeInstanceOf(IMessagePollAbortedError);
|
||||
}, 5000);
|
||||
|
||||
it("stop rejects when not running", async () => {
|
||||
const watcher = new IMessageWatcher({
|
||||
dbPath,
|
||||
pollIntervalMs: 60_000,
|
||||
sqliteReader: reader,
|
||||
});
|
||||
await expect(watcher.stop()).rejects.toBeInstanceOf(
|
||||
IMessageWatcherNotRunningError
|
||||
);
|
||||
}, 5000);
|
||||
|
||||
it("scheduler emits error event when underlying poll fails", async () => {
|
||||
const failingReader = {
|
||||
open: () => {
|
||||
throw new Error("nope");
|
||||
},
|
||||
};
|
||||
const watcher = new IMessageWatcher({
|
||||
dbPath: "/x",
|
||||
pollIntervalMs: 5,
|
||||
sqliteReader: failingReader,
|
||||
});
|
||||
const errors: Error[] = [];
|
||||
watcher.on("error", (e) => errors.push(e));
|
||||
await watcher.start();
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
await watcher.stop();
|
||||
expect(errors.length).toBeGreaterThan(0);
|
||||
}, 10_000);
|
||||
|
||||
it("scheduler with signal catches poll rejection", async () => {
|
||||
const failingReader = {
|
||||
open: () => {
|
||||
throw new Error("scheduled-with-signal");
|
||||
},
|
||||
};
|
||||
const watcher = new IMessageWatcher({
|
||||
dbPath: "/x",
|
||||
pollIntervalMs: 5,
|
||||
sqliteReader: failingReader,
|
||||
});
|
||||
const errors: Error[] = [];
|
||||
watcher.on("error", (e) => errors.push(e));
|
||||
const controller = new AbortController();
|
||||
await watcher.start({ signal: controller.signal });
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
controller.abort();
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
expect(errors.length).toBeGreaterThan(0);
|
||||
}, 10_000);
|
||||
|
||||
it("scheduler catch path tolerates non-Error rejection values", async () => {
|
||||
const reader = {
|
||||
open: () => {
|
||||
const literal: unknown = "string-rejection";
|
||||
throw literal;
|
||||
},
|
||||
};
|
||||
const watcher = new IMessageWatcher({
|
||||
dbPath: "/x",
|
||||
pollIntervalMs: 5,
|
||||
sqliteReader: reader,
|
||||
});
|
||||
const errors: Error[] = [];
|
||||
watcher.on("error", (e) => errors.push(e));
|
||||
const controller = new AbortController();
|
||||
await watcher.start({ signal: controller.signal });
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
controller.abort();
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
expect(errors.length).toBeGreaterThan(0);
|
||||
}, 10_000);
|
||||
|
||||
it("explicit poll wraps reader errors in IMessageDbQueryFailedError", async () => {
|
||||
const failingReader = {
|
||||
open: () => {
|
||||
throw new Error("locked");
|
||||
},
|
||||
};
|
||||
const watcher = new IMessageWatcher({
|
||||
dbPath: "/x",
|
||||
pollIntervalMs: 60_000,
|
||||
sqliteReader: failingReader,
|
||||
});
|
||||
const ctrl = new AbortController();
|
||||
await expect(
|
||||
watcher.poll({ signal: ctrl.signal })
|
||||
).rejects.toBeInstanceOf(IMessageError);
|
||||
}, 5000);
|
||||
|
||||
it("explicit poll without a signal returns [] on reader error", async () => {
|
||||
const failingReader = {
|
||||
open: () => {
|
||||
throw new Error("transient");
|
||||
},
|
||||
};
|
||||
const watcher = new IMessageWatcher({
|
||||
dbPath: "/x",
|
||||
pollIntervalMs: 60_000,
|
||||
sqliteReader: failingReader,
|
||||
});
|
||||
const errors: Error[] = [];
|
||||
watcher.on("error", (e) => errors.push(e));
|
||||
const result = await watcher.poll();
|
||||
expect(result).toEqual([]);
|
||||
expect(errors).toHaveLength(1);
|
||||
}, 5000);
|
||||
|
||||
it("legacy on() delegates to the bus", async () => {
|
||||
const hId = fixture.insertHandle(defaultHandle());
|
||||
const cId = fixture.insertChat(defaultChat());
|
||||
fixture.insertMessage(defaultMessage({ handle_id: hId }), cId);
|
||||
const watcher = new IMessageWatcher({
|
||||
dbPath,
|
||||
pollIntervalMs: 60_000,
|
||||
sqliteReader: reader,
|
||||
});
|
||||
const seen: ParsedIMessage[] = [];
|
||||
const off = watcher.on("message", (m) => seen.push(m));
|
||||
await watcher.poll();
|
||||
expect(seen).toHaveLength(1);
|
||||
off();
|
||||
// After unregistering, the next poll for a new row should not
|
||||
// deliver.
|
||||
fixture.insertMessage(
|
||||
defaultMessage({ handle_id: hId, guid: "g2", text: "t2" }),
|
||||
cId
|
||||
);
|
||||
await watcher.poll();
|
||||
expect(seen).toHaveLength(1);
|
||||
}, 10_000);
|
||||
|
||||
it("off() removes a listener registered via on()", async () => {
|
||||
const watcher = new IMessageWatcher({
|
||||
dbPath,
|
||||
pollIntervalMs: 60_000,
|
||||
sqliteReader: reader,
|
||||
});
|
||||
const seen: ParsedIMessage[] = [];
|
||||
const handler = (m: ParsedIMessage): void => {
|
||||
seen.push(m);
|
||||
};
|
||||
watcher.on("message", handler);
|
||||
watcher.off("message", handler);
|
||||
// Emit directly via the bus.
|
||||
const hId = fixture.insertHandle(defaultHandle());
|
||||
const cId = fixture.insertChat(defaultChat());
|
||||
fixture.insertMessage(defaultMessage({ handle_id: hId }), cId);
|
||||
await watcher.poll();
|
||||
expect(seen).toHaveLength(0);
|
||||
}, 5000);
|
||||
|
||||
it("setLastRowId rejects invalid values", () => {
|
||||
const watcher = new IMessageWatcher({
|
||||
dbPath,
|
||||
pollIntervalMs: 60_000,
|
||||
sqliteReader: reader,
|
||||
});
|
||||
expect(() => watcher.setLastRowId(-1)).toThrow(
|
||||
IMessageDbQueryFailedError
|
||||
);
|
||||
expect(() => watcher.setLastRowId(Number.NaN)).toThrow(
|
||||
IMessageDbQueryFailedError
|
||||
);
|
||||
}, 5000);
|
||||
|
||||
it("setLastRowId accepts a positive value", () => {
|
||||
const watcher = new IMessageWatcher({
|
||||
dbPath,
|
||||
pollIntervalMs: 60_000,
|
||||
sqliteReader: reader,
|
||||
});
|
||||
watcher.setLastRowId(42);
|
||||
expect(watcher.getLastRowId()).toBe(42);
|
||||
}, 5000);
|
||||
|
||||
it("poll emits messages_emitted_total + poll_duration_ms via OTel", async () => {
|
||||
const hId = fixture.insertHandle(defaultHandle());
|
||||
const cId = fixture.insertChat(defaultChat());
|
||||
fixture.insertMessage(defaultMessage({ handle_id: hId }), cId);
|
||||
fixture.insertMessage(
|
||||
defaultMessage({ handle_id: hId, guid: "g2", text: "two" }),
|
||||
cId
|
||||
);
|
||||
const watcher = new IMessageWatcher({
|
||||
dbPath,
|
||||
pollIntervalMs: 60_000,
|
||||
sqliteReader: reader,
|
||||
});
|
||||
const messages = await watcher.poll();
|
||||
expect(messages).toHaveLength(2);
|
||||
// OTel emission is asserted in metrics.test.ts; here we only
|
||||
// confirm the path doesn't throw and yields the expected count.
|
||||
}, 10_000);
|
||||
|
||||
it("chat with null display_name and group_title falls back to null", async () => {
|
||||
const hId = fixture.insertHandle(defaultHandle());
|
||||
const cId = fixture.insertChat(defaultChat());
|
||||
fixture.insertMessage(defaultMessage({ handle_id: hId }), cId);
|
||||
const watcher = new IMessageWatcher({
|
||||
dbPath,
|
||||
pollIntervalMs: 60_000,
|
||||
sqliteReader: reader,
|
||||
});
|
||||
const msgs = await watcher.poll();
|
||||
expect(msgs[0]?.chatDisplayName).toBeNull();
|
||||
}, 10_000);
|
||||
|
||||
it("uses default config when no input provided", () => {
|
||||
// We cannot reach a real chat.db here, but the constructor with
|
||||
// the production reader must not throw on an empty config.
|
||||
const watcher = new IMessageWatcher();
|
||||
expect(watcher.isRunning()).toBe(false);
|
||||
expect(watcher.getLastRowId()).toBe(0);
|
||||
}, 5000);
|
||||
|
||||
it("message ROWID > lastRowId filter omits older rows", async () => {
|
||||
const hId = fixture.insertHandle(defaultHandle());
|
||||
const cId = fixture.insertChat(defaultChat());
|
||||
fixture.insertMessage(defaultMessage({ handle_id: hId }), cId);
|
||||
const watcher = new IMessageWatcher({
|
||||
dbPath,
|
||||
pollIntervalMs: 60_000,
|
||||
sqliteReader: reader,
|
||||
});
|
||||
watcher.setLastRowId(5);
|
||||
const msgs = await watcher.poll();
|
||||
expect(msgs).toHaveLength(0);
|
||||
}, 10_000);
|
||||
|
||||
it("explicit poll without signal aborted mid-iteration: covers the inner check", async () => {
|
||||
// Insert many rows; abort the controller AFTER opening the
|
||||
// database but BEFORE iteration completes by passing a controller
|
||||
// that aborts immediately.
|
||||
const hId = fixture.insertHandle(defaultHandle());
|
||||
const cId = fixture.insertChat(defaultChat());
|
||||
for (let i = 0; i < 3; i++) {
|
||||
fixture.insertMessage(
|
||||
defaultMessage({
|
||||
handle_id: hId,
|
||||
guid: `gx${i}`,
|
||||
text: `tx${i}`,
|
||||
}),
|
||||
cId
|
||||
);
|
||||
}
|
||||
const ctrl = new AbortController();
|
||||
ctrl.abort();
|
||||
const watcher = new IMessageWatcher({
|
||||
dbPath,
|
||||
pollIntervalMs: 60_000,
|
||||
sqliteReader: reader,
|
||||
});
|
||||
await expect(
|
||||
watcher.poll({ signal: ctrl.signal })
|
||||
).rejects.toBeInstanceOf(IMessagePollAbortedError);
|
||||
}, 10_000);
|
||||
});
|
||||
@@ -0,0 +1,271 @@
|
||||
import { EventBus } from "@suaveplan/events";
|
||||
import {
|
||||
IMessageDbQueryFailedError,
|
||||
IMessagePollAbortedError,
|
||||
IMessageWatcherAlreadyRunningError,
|
||||
IMessageWatcherNotRunningError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import {
|
||||
recordAttachmentEmitted,
|
||||
recordMessageEmitted,
|
||||
recordPollDuration,
|
||||
recordPollError,
|
||||
} from "../otel/metrics.js";
|
||||
import { IMessageParser } from "../parser/imessage-parser.js";
|
||||
import { parseWatcherConfig } from "../schemas/schemas.js";
|
||||
import { bunSqliteReader } from "../sqlite/bun-sqlite-reader.js";
|
||||
import type {
|
||||
AttachmentRow,
|
||||
ChatRow,
|
||||
HandleRow,
|
||||
IMessageRow,
|
||||
IMessageWatcherConfig,
|
||||
IMessageWatcherEvents,
|
||||
ParsedIMessage,
|
||||
} from "../types/imessage.types.js";
|
||||
import type { SqliteHandle, SqliteReader } from "../types/seams.types.js";
|
||||
|
||||
const DEFAULT_DB_PATH = "~/Library/Messages/chat.db";
|
||||
const DEFAULT_POLL_INTERVAL = 5000;
|
||||
|
||||
interface ResolvedConfig {
|
||||
readonly dbPath: string;
|
||||
readonly pollIntervalMs: number;
|
||||
readonly sinceDate: Date;
|
||||
readonly includeFromMe: boolean;
|
||||
readonly chatFilter: readonly string[];
|
||||
readonly sqliteReader: SqliteReader;
|
||||
}
|
||||
|
||||
export interface IMessageWatcherStartOptions {
|
||||
readonly signal?: AbortSignal;
|
||||
}
|
||||
|
||||
export interface IMessagePollOptions {
|
||||
readonly signal?: AbortSignal;
|
||||
}
|
||||
|
||||
/**
|
||||
* Polls Apple's iMessage chat.db SQLite database on a configurable
|
||||
* interval, projects new rows into `ParsedIMessage`, and emits them on
|
||||
* both a legacy `on/off` listener registry and the typed `EventBus`
|
||||
* exposed as `watcher.bus`.
|
||||
*/
|
||||
export class IMessageWatcher {
|
||||
public readonly bus: EventBus<IMessageWatcherEvents>;
|
||||
private readonly config: ResolvedConfig;
|
||||
private readonly parser: IMessageParser;
|
||||
private lastRowId = 0;
|
||||
private pollTimer: ReturnType<typeof setInterval> | null = null;
|
||||
private running = false;
|
||||
private abortListener: (() => void) | null = null;
|
||||
private currentSignal: AbortSignal | null = null;
|
||||
|
||||
public constructor(input: IMessageWatcherConfig = {}) {
|
||||
const parsed = parseWatcherConfig(input);
|
||||
this.config = {
|
||||
dbPath: parsed.dbPath ?? DEFAULT_DB_PATH,
|
||||
pollIntervalMs: parsed.pollIntervalMs ?? DEFAULT_POLL_INTERVAL,
|
||||
sinceDate: parsed.sinceDate ?? new Date(),
|
||||
includeFromMe: parsed.includeFromMe ?? false,
|
||||
chatFilter: parsed.chatFilter ?? [],
|
||||
sqliteReader: parsed.sqliteReader ?? bunSqliteReader,
|
||||
};
|
||||
this.parser = new IMessageParser();
|
||||
this.bus = new EventBus<IMessageWatcherEvents>();
|
||||
}
|
||||
|
||||
/** Register a listener (legacy API; delegates to the typed bus). */
|
||||
public on<K extends keyof IMessageWatcherEvents>(
|
||||
event: K,
|
||||
listener: (payload: IMessageWatcherEvents[K]) => void
|
||||
): () => void {
|
||||
return this.bus.on(event, listener);
|
||||
}
|
||||
|
||||
/** Remove a listener (legacy API; delegates to the typed bus). */
|
||||
public off<K extends keyof IMessageWatcherEvents>(
|
||||
event: K,
|
||||
listener: (payload: IMessageWatcherEvents[K]) => void
|
||||
): void {
|
||||
this.bus.off(event, listener);
|
||||
}
|
||||
|
||||
public async start(
|
||||
options: IMessageWatcherStartOptions = {}
|
||||
): Promise<void> {
|
||||
if (this.running) throw new IMessageWatcherAlreadyRunningError();
|
||||
const signal = options.signal;
|
||||
if (signal?.aborted) {
|
||||
throw new IMessagePollAbortedError();
|
||||
}
|
||||
this.running = true;
|
||||
this.currentSignal = signal ?? null;
|
||||
if (signal) {
|
||||
const handler = (): void => {
|
||||
void this.stop();
|
||||
};
|
||||
this.abortListener = handler;
|
||||
signal.addEventListener("abort", handler, { once: true });
|
||||
}
|
||||
this.bus.emit("started", undefined);
|
||||
this.pollTimer = setInterval(() => {
|
||||
this.poll(signal ? { signal } : {}).catch((err: unknown) => {
|
||||
const error =
|
||||
err instanceof Error ? err : new Error(String(err));
|
||||
this.bus.emit("error", error);
|
||||
});
|
||||
}, this.config.pollIntervalMs);
|
||||
}
|
||||
|
||||
public async stop(): Promise<void> {
|
||||
if (!this.running) throw new IMessageWatcherNotRunningError();
|
||||
this.running = false;
|
||||
if (this.pollTimer) {
|
||||
clearInterval(this.pollTimer);
|
||||
this.pollTimer = null;
|
||||
}
|
||||
if (this.currentSignal && this.abortListener) {
|
||||
this.currentSignal.removeEventListener("abort", this.abortListener);
|
||||
}
|
||||
this.abortListener = null;
|
||||
this.currentSignal = null;
|
||||
this.bus.emit("stopped", undefined);
|
||||
}
|
||||
|
||||
public async poll(
|
||||
options: IMessagePollOptions = {}
|
||||
): Promise<readonly ParsedIMessage[]> {
|
||||
const signal = options.signal;
|
||||
if (signal?.aborted) {
|
||||
recordPollDuration(0, "aborted");
|
||||
throw new IMessagePollAbortedError();
|
||||
}
|
||||
const started = Date.now();
|
||||
let handle: SqliteHandle | null = null;
|
||||
try {
|
||||
handle = this.config.sqliteReader.open(this.config.dbPath, {
|
||||
readonly: true,
|
||||
});
|
||||
const rows = this.queryNewMessages(handle);
|
||||
const parsed: ParsedIMessage[] = [];
|
||||
for (const row of rows) {
|
||||
if (signal?.aborted) {
|
||||
recordPollDuration(Date.now() - started, "aborted");
|
||||
throw new IMessagePollAbortedError();
|
||||
}
|
||||
const handleRow = this.queryHandle(handle, row.handle_id);
|
||||
const chatRow = this.queryChat(handle, row.ROWID);
|
||||
const attachments: readonly AttachmentRow[] =
|
||||
row.cache_has_attachments
|
||||
? this.queryAttachments(handle, row.ROWID)
|
||||
: [];
|
||||
const message = this.parser.parseMessage(
|
||||
row,
|
||||
handleRow,
|
||||
chatRow,
|
||||
attachments
|
||||
);
|
||||
if (row.ROWID > this.lastRowId) {
|
||||
this.lastRowId = row.ROWID;
|
||||
}
|
||||
if (!this.config.includeFromMe && message.isFromMe) continue;
|
||||
if (
|
||||
this.config.chatFilter.length > 0 &&
|
||||
!this.config.chatFilter.includes(message.chatIdentifier)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
parsed.push(message);
|
||||
this.bus.emit("message", message);
|
||||
recordMessageEmitted(
|
||||
message.isGroupChat ? "group" : "direct",
|
||||
message.isFromMe
|
||||
);
|
||||
for (const attachment of message.attachments) {
|
||||
const family = attachment.mimeType.split("/")[0] ?? "other";
|
||||
recordAttachmentEmitted(family);
|
||||
}
|
||||
}
|
||||
recordPollDuration(Date.now() - started, "ok");
|
||||
return parsed;
|
||||
} catch (cause) {
|
||||
recordPollDuration(Date.now() - started, "error");
|
||||
if (cause instanceof IMessagePollAbortedError) {
|
||||
throw cause;
|
||||
}
|
||||
const queryError = new IMessageDbQueryFailedError({ cause });
|
||||
recordPollError(queryError.code);
|
||||
const err =
|
||||
cause instanceof Error ? cause : new Error(String(cause));
|
||||
this.bus.emit("error", err);
|
||||
// Wrap-and-rethrow only when a caller explicitly polled (no
|
||||
// signal-driven internal scheduler); otherwise swallow to
|
||||
// mirror upstream behaviour where the loop keeps ticking.
|
||||
if (signal !== undefined) throw queryError;
|
||||
return [];
|
||||
} finally {
|
||||
if (handle) handle.close();
|
||||
}
|
||||
}
|
||||
|
||||
public getLastRowId(): number {
|
||||
return this.lastRowId;
|
||||
}
|
||||
|
||||
public setLastRowId(rowId: number): void {
|
||||
if (!Number.isFinite(rowId) || rowId < 0) {
|
||||
throw new IMessageDbQueryFailedError({
|
||||
message: "invalid lastRowId",
|
||||
context: { rowId },
|
||||
});
|
||||
}
|
||||
this.lastRowId = rowId;
|
||||
}
|
||||
|
||||
public isRunning(): boolean {
|
||||
return this.running;
|
||||
}
|
||||
|
||||
private queryNewMessages(handle: SqliteHandle): readonly IMessageRow[] {
|
||||
const stmt = handle.prepare(
|
||||
"SELECT * FROM message WHERE ROWID > ? ORDER BY ROWID ASC LIMIT 100"
|
||||
);
|
||||
return stmt.all(this.lastRowId) as readonly IMessageRow[];
|
||||
}
|
||||
|
||||
private queryHandle(
|
||||
handle: SqliteHandle,
|
||||
handleId: number
|
||||
): HandleRow | null {
|
||||
if (!handleId) return null;
|
||||
const stmt = handle.prepare("SELECT * FROM handle WHERE ROWID = ?");
|
||||
const row = stmt.get(handleId);
|
||||
return (row as HandleRow | undefined) ?? null;
|
||||
}
|
||||
|
||||
private queryChat(
|
||||
handle: SqliteHandle,
|
||||
messageRowId: number
|
||||
): ChatRow | null {
|
||||
const stmt = handle.prepare(
|
||||
`SELECT c.* FROM chat c
|
||||
JOIN chat_message_join cmj ON c.ROWID = cmj.chat_id
|
||||
WHERE cmj.message_id = ?`
|
||||
);
|
||||
const row = stmt.get(messageRowId);
|
||||
return (row as ChatRow | undefined) ?? null;
|
||||
}
|
||||
|
||||
private queryAttachments(
|
||||
handle: SqliteHandle,
|
||||
messageRowId: number
|
||||
): readonly AttachmentRow[] {
|
||||
const stmt = handle.prepare(
|
||||
`SELECT a.* FROM attachment a
|
||||
JOIN message_attachment_join maj ON a.ROWID = maj.attachment_id
|
||||
WHERE maj.message_id = ?`
|
||||
);
|
||||
return stmt.all(messageRowId) as readonly AttachmentRow[];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export type {
|
||||
IMessagePollOptions,
|
||||
IMessageWatcherStartOptions,
|
||||
} from "./imessage-watcher.js";
|
||||
export { IMessageWatcher } from "./imessage-watcher.js";
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Test-environment setup for @suaveplan/imessage-adapter.
|
||||
*
|
||||
* Wires a real in-memory OpenTelemetry `MeterProvider` BEFORE any source
|
||||
* module loads, so the meter resolved at module load is the real
|
||||
* provider's meter (and not the noop fallback). Tests inspect emitted
|
||||
* instruments by calling `flushTestMetrics()`.
|
||||
*
|
||||
* Server-tier (bun:sqlite required): no DOM, no browser globals.
|
||||
*/
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
import {
|
||||
AggregationTemporality,
|
||||
InMemoryMetricExporter,
|
||||
MeterProvider,
|
||||
PeriodicExportingMetricReader,
|
||||
} from "@opentelemetry/sdk-metrics";
|
||||
|
||||
export const testInMemoryMetricExporter = new InMemoryMetricExporter(
|
||||
AggregationTemporality.CUMULATIVE
|
||||
);
|
||||
|
||||
const reader = new PeriodicExportingMetricReader({
|
||||
exporter: testInMemoryMetricExporter,
|
||||
exportIntervalMillis: 60_000,
|
||||
});
|
||||
|
||||
export const testMeterProvider = new MeterProvider({ readers: [reader] });
|
||||
metrics.setGlobalMeterProvider(testMeterProvider);
|
||||
|
||||
export async function flushTestMetrics(): Promise<
|
||||
ReturnType<typeof testInMemoryMetricExporter.getMetrics>
|
||||
> {
|
||||
await testMeterProvider.forceFlush();
|
||||
const collected = testInMemoryMetricExporter.getMetrics();
|
||||
testInMemoryMetricExporter.reset();
|
||||
return collected;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.test.ts", "dist", "node_modules"]
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { defineConfig } from "vite";
|
||||
import dts from "vite-plugin-dts";
|
||||
|
||||
export default defineConfig({
|
||||
root: __dirname,
|
||||
cacheDir: "../../../node_modules/.vite/packages/imessage-adapter",
|
||||
plugins: [
|
||||
dts({
|
||||
entryRoot: "src",
|
||||
tsconfigPath: "./tsconfig.lib.json",
|
||||
insertTypesEntry: true,
|
||||
}),
|
||||
],
|
||||
build: {
|
||||
outDir: "./dist",
|
||||
emptyOutDir: true,
|
||||
reportCompressedSize: true,
|
||||
sourcemap: true,
|
||||
lib: { entry: "src/index.ts", formats: ["es"] },
|
||||
rollupOptions: {
|
||||
treeshake: true,
|
||||
output: {
|
||||
preserveModules: true,
|
||||
preserveModulesRoot: "src",
|
||||
entryFileNames: "[name].js",
|
||||
},
|
||||
external: [
|
||||
"@opentelemetry/api",
|
||||
"@suaveplan/error",
|
||||
"@suaveplan/events",
|
||||
"@suaveplan/types",
|
||||
"tslib",
|
||||
"zod",
|
||||
"bun:sqlite",
|
||||
/^node:/,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
117
packages/communication/messaging/notification-channel/README.md
Normal file
117
packages/communication/messaging/notification-channel/README.md
Normal 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
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
{
|
||||
"name": "@suaveplan/notification-channel",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"description": "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.",
|
||||
"license": "MIT",
|
||||
"author": "Suaveplan Team",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
"./package.json": "./package.json",
|
||||
".": {
|
||||
"import": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"./error-codes": {
|
||||
"import": {
|
||||
"types": "./dist/error-codes/index.d.ts",
|
||||
"default": "./dist/error-codes/index.js"
|
||||
}
|
||||
},
|
||||
"./types/notification": {
|
||||
"import": {
|
||||
"types": "./dist/types/notification.types.d.ts",
|
||||
"default": "./dist/types/notification.types.js"
|
||||
}
|
||||
},
|
||||
"./schemas/schemas": {
|
||||
"import": {
|
||||
"types": "./dist/schemas/schemas.d.ts",
|
||||
"default": "./dist/schemas/schemas.js"
|
||||
}
|
||||
},
|
||||
"./hooks/hooks": {
|
||||
"import": {
|
||||
"types": "./dist/hooks/hooks.d.ts",
|
||||
"default": "./dist/hooks/hooks.js"
|
||||
}
|
||||
},
|
||||
"./channels/delivery-channel": {
|
||||
"import": {
|
||||
"types": "./dist/channels/delivery-channel.d.ts",
|
||||
"default": "./dist/channels/delivery-channel.js"
|
||||
}
|
||||
},
|
||||
"./channels/email-channel": {
|
||||
"import": {
|
||||
"types": "./dist/channels/email-channel.d.ts",
|
||||
"default": "./dist/channels/email-channel.js"
|
||||
}
|
||||
},
|
||||
"./channels/sms-channel": {
|
||||
"import": {
|
||||
"types": "./dist/channels/sms-channel.d.ts",
|
||||
"default": "./dist/channels/sms-channel.js"
|
||||
}
|
||||
},
|
||||
"./channels/push-channel": {
|
||||
"import": {
|
||||
"types": "./dist/channels/push-channel.d.ts",
|
||||
"default": "./dist/channels/push-channel.js"
|
||||
}
|
||||
},
|
||||
"./channels/in-app-channel": {
|
||||
"import": {
|
||||
"types": "./dist/channels/in-app-channel.d.ts",
|
||||
"default": "./dist/channels/in-app-channel.js"
|
||||
}
|
||||
},
|
||||
"./channels/webhook-channel": {
|
||||
"import": {
|
||||
"types": "./dist/channels/webhook-channel.d.ts",
|
||||
"default": "./dist/channels/webhook-channel.js"
|
||||
}
|
||||
},
|
||||
"./digest/digest-channel": {
|
||||
"import": {
|
||||
"types": "./dist/digest/digest-channel.d.ts",
|
||||
"default": "./dist/digest/digest-channel.js"
|
||||
}
|
||||
},
|
||||
"./templates/template-engine": {
|
||||
"import": {
|
||||
"types": "./dist/templates/template-engine.d.ts",
|
||||
"default": "./dist/templates/template-engine.js"
|
||||
}
|
||||
},
|
||||
"./rate-limiter/channel-rate-limiter": {
|
||||
"import": {
|
||||
"types": "./dist/rate-limiter/channel-rate-limiter.d.ts",
|
||||
"default": "./dist/rate-limiter/channel-rate-limiter.js"
|
||||
}
|
||||
},
|
||||
"./retry/delivery-retry": {
|
||||
"import": {
|
||||
"types": "./dist/retry/delivery-retry.d.ts",
|
||||
"default": "./dist/retry/delivery-retry.js"
|
||||
}
|
||||
},
|
||||
"./scheduler/notification-scheduler": {
|
||||
"import": {
|
||||
"types": "./dist/scheduler/notification-scheduler.d.ts",
|
||||
"default": "./dist/scheduler/notification-scheduler.js"
|
||||
}
|
||||
},
|
||||
"./preferences/preference-manager": {
|
||||
"import": {
|
||||
"types": "./dist/preferences/preference-manager.d.ts",
|
||||
"default": "./dist/preferences/preference-manager.js"
|
||||
}
|
||||
},
|
||||
"./router/notification-router": {
|
||||
"import": {
|
||||
"types": "./dist/router/notification-router.d.ts",
|
||||
"default": "./dist/router/notification-router.js"
|
||||
}
|
||||
},
|
||||
"./otel/metrics": {
|
||||
"import": {
|
||||
"types": "./dist/otel/metrics.d.ts",
|
||||
"default": "./dist/otel/metrics.js"
|
||||
}
|
||||
},
|
||||
"./fakes/fake-fetch": {
|
||||
"import": {
|
||||
"types": "./dist/fakes/fake-fetch.d.ts",
|
||||
"default": "./dist/fakes/fake-fetch.js"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage --passWithNoTests",
|
||||
"lint": "bunx biome check src/",
|
||||
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
|
||||
"format": "bunx biome format --write --max-diagnostics 500 src/",
|
||||
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
|
||||
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
|
||||
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@opentelemetry/api": "catalog:observability",
|
||||
"@suaveplan/error": "workspace:^",
|
||||
"@suaveplan/events": "workspace:^",
|
||||
"@suaveplan/types": "workspace:^",
|
||||
"tslib": "catalog:build",
|
||||
"zod": "catalog:forms"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@opentelemetry/sdk-metrics": "1.30.0",
|
||||
"@suaveplan/testing": "workspace:^",
|
||||
"@types/node": "catalog:",
|
||||
"@vitest/coverage-istanbul": "catalog:testing",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:build",
|
||||
"vite-plugin-dts": "catalog:build",
|
||||
"vitest": "catalog:testing"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://npmjs.fq.io/",
|
||||
"access": "public"
|
||||
},
|
||||
"files": ["dist", "src", "README.md"]
|
||||
}
|
||||
@@ -0,0 +1,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.
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { Notification } from "../types/notification.types.js";
|
||||
|
||||
/** Result of delivering a notification through a channel. */
|
||||
export interface DeliveryResult {
|
||||
readonly success: boolean;
|
||||
readonly channel: string;
|
||||
readonly messageId?: string;
|
||||
readonly error?: string;
|
||||
readonly timestamp: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Contract every concrete delivery channel implements. Channels are
|
||||
* intentionally small — `send` MUST swallow transport-level exceptions
|
||||
* and return a `DeliveryResult` so the router can decide whether to
|
||||
* retry, dead-letter, or surface the failure to the bus.
|
||||
*/
|
||||
export interface DeliveryChannel {
|
||||
/** Unique name identifying this channel. */
|
||||
readonly name: string;
|
||||
/** Send a notification through this channel. */
|
||||
send(notification: Notification): Promise<DeliveryResult>;
|
||||
/** Check whether this channel can handle the given notification. */
|
||||
canSend(notification: Notification): boolean;
|
||||
}
|
||||
@@ -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.
|
||||
@@ -0,0 +1,138 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import type { Notification } from "../types/notification.types.js";
|
||||
import { EmailChannel } from "./email-channel.js";
|
||||
|
||||
function makeNotification(overrides: Partial<Notification> = {}): Notification {
|
||||
return {
|
||||
id: "notif_1",
|
||||
channel: "email",
|
||||
recipient: "user@example.com",
|
||||
subject: "Test Subject",
|
||||
body: "Test body",
|
||||
priority: "normal",
|
||||
status: "sending",
|
||||
createdAt: new Date().toISOString(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("EmailChannel", () => {
|
||||
it('has name "email"', { timeout: 5000 }, () => {
|
||||
const channel = new EmailChannel({
|
||||
from: "noreply@example.com",
|
||||
sendFn: async () => ({ messageId: "msg_1" }),
|
||||
});
|
||||
expect(channel.name).toBe("email");
|
||||
});
|
||||
|
||||
it("canSend returns true for email channel with @ recipient", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const channel = new EmailChannel({
|
||||
from: "noreply@example.com",
|
||||
sendFn: async () => ({ messageId: "msg_1" }),
|
||||
});
|
||||
expect(channel.canSend(makeNotification())).toBe(true);
|
||||
});
|
||||
|
||||
it("canSend returns false for non-email channel", { timeout: 5000 }, () => {
|
||||
const channel = new EmailChannel({
|
||||
from: "noreply@example.com",
|
||||
sendFn: async () => ({ messageId: "msg_1" }),
|
||||
});
|
||||
expect(channel.canSend(makeNotification({ channel: "sms" }))).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it("canSend returns false for recipient without @", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const channel = new EmailChannel({
|
||||
from: "noreply@example.com",
|
||||
sendFn: async () => ({ messageId: "msg_1" }),
|
||||
});
|
||||
expect(
|
||||
channel.canSend(makeNotification({ recipient: "no-at-sign" }))
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("sends successfully and returns result", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const channel = new EmailChannel({
|
||||
from: "noreply@example.com",
|
||||
sendFn: async () => ({ messageId: "msg_42" }),
|
||||
});
|
||||
const result = await channel.send(makeNotification());
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.channel).toBe("email");
|
||||
expect(result.messageId).toBe("msg_42");
|
||||
expect(result.timestamp).toBeDefined();
|
||||
});
|
||||
|
||||
it("returns failure when sendFn throws", { timeout: 30000 }, async () => {
|
||||
const channel = new EmailChannel({
|
||||
from: "noreply@example.com",
|
||||
sendFn: async () => {
|
||||
throw new Error("SMTP connection failed");
|
||||
},
|
||||
});
|
||||
const result = await channel.send(makeNotification());
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe("SMTP connection failed");
|
||||
});
|
||||
|
||||
it('uses "(No subject)" when subject is missing', {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
let capturedSubject = "";
|
||||
const channel = new EmailChannel({
|
||||
from: "noreply@example.com",
|
||||
sendFn: async (msg) => {
|
||||
capturedSubject = msg.subject;
|
||||
return { messageId: "msg_1" };
|
||||
},
|
||||
});
|
||||
const n = makeNotification();
|
||||
const { subject: _drop, ...rest } = n;
|
||||
await channel.send(rest as Notification);
|
||||
expect(capturedSubject).toBe("(No subject)");
|
||||
});
|
||||
|
||||
it("passes metadata to sendFn", { timeout: 30000 }, async () => {
|
||||
let capturedMeta: Record<string, unknown> | undefined;
|
||||
const channel = new EmailChannel({
|
||||
from: "noreply@example.com",
|
||||
sendFn: async (msg) => {
|
||||
capturedMeta = msg.metadata;
|
||||
return { messageId: "msg_1" };
|
||||
},
|
||||
});
|
||||
await channel.send(makeNotification({ metadata: { key: "val" } }));
|
||||
expect(capturedMeta).toEqual({ key: "val" });
|
||||
});
|
||||
|
||||
it("handles non-Error throw in sendFn", { timeout: 30000 }, async () => {
|
||||
const channel = new EmailChannel({
|
||||
from: "noreply@example.com",
|
||||
sendFn: async () => {
|
||||
const literal: unknown = "string error";
|
||||
throw literal;
|
||||
},
|
||||
});
|
||||
const result = await channel.send(makeNotification());
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe("string error");
|
||||
});
|
||||
|
||||
it("honours injected clock for timestamp", { timeout: 5000 }, async () => {
|
||||
const channel = new EmailChannel({
|
||||
from: "noreply@example.com",
|
||||
sendFn: async () => ({ messageId: "msg_1" }),
|
||||
clock: () => Date.UTC(2026, 0, 2, 3, 4, 5),
|
||||
});
|
||||
const result = await channel.send(makeNotification());
|
||||
expect(result.timestamp).toBe("2026-01-02T03:04:05.000Z");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import type { Clock } from "@suaveplan/types";
|
||||
import type { Notification } from "../types/notification.types.js";
|
||||
import type { DeliveryChannel, DeliveryResult } from "./delivery-channel.js";
|
||||
|
||||
/** Email message passed to the send function. */
|
||||
export interface EmailMessage {
|
||||
readonly to: string;
|
||||
readonly subject: string;
|
||||
readonly body: string;
|
||||
readonly metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** Configuration for the email channel. */
|
||||
export interface EmailChannelConfig {
|
||||
readonly from: string;
|
||||
readonly sendFn: (message: EmailMessage) => Promise<{ messageId: string }>;
|
||||
/** Optional clock for `timestamp` field (default: `Date.now`). */
|
||||
readonly clock?: Clock;
|
||||
}
|
||||
|
||||
const defaultClock: Clock = () => Date.now();
|
||||
|
||||
/**
|
||||
* Delivers notifications via email using a configurable send function.
|
||||
* The `sendFn` is the integration seam — wire a real provider (SES,
|
||||
* SendGrid, Postmark) by implementing the callback signature.
|
||||
*/
|
||||
export class EmailChannel implements DeliveryChannel {
|
||||
public readonly name = "email";
|
||||
private readonly config: EmailChannelConfig;
|
||||
private readonly clock: Clock;
|
||||
|
||||
public constructor(config: EmailChannelConfig) {
|
||||
this.config = config;
|
||||
this.clock = config.clock ?? defaultClock;
|
||||
}
|
||||
|
||||
public canSend(notification: Notification): boolean {
|
||||
return (
|
||||
notification.channel === "email" &&
|
||||
notification.recipient.includes("@")
|
||||
);
|
||||
}
|
||||
|
||||
public async send(notification: Notification): Promise<DeliveryResult> {
|
||||
const message: EmailMessage = {
|
||||
to: notification.recipient,
|
||||
subject: notification.subject ?? "(No subject)",
|
||||
body: notification.body,
|
||||
...(notification.metadata !== undefined
|
||||
? { metadata: notification.metadata }
|
||||
: {}),
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await this.config.sendFn(message);
|
||||
return {
|
||||
success: true,
|
||||
channel: this.name,
|
||||
messageId: result.messageId,
|
||||
timestamp: new Date(this.clock()).toISOString(),
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
success: false,
|
||||
channel: this.name,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
timestamp: new Date(this.clock()).toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -0,0 +1,173 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import type { Notification } from "../types/notification.types.js";
|
||||
import type { InAppNotification, InAppStore } from "./in-app-channel.js";
|
||||
import { InAppChannel } from "./in-app-channel.js";
|
||||
|
||||
function makeNotification(overrides: Partial<Notification> = {}): Notification {
|
||||
return {
|
||||
id: "notif_1",
|
||||
channel: "in_app",
|
||||
recipient: "user123",
|
||||
subject: "Test",
|
||||
body: "In-app message",
|
||||
priority: "normal",
|
||||
status: "sending",
|
||||
createdAt: new Date().toISOString(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("InAppChannel", () => {
|
||||
it('has name "in_app"', { timeout: 5000 }, () => {
|
||||
const channel = new InAppChannel();
|
||||
expect(channel.name).toBe("in_app");
|
||||
});
|
||||
|
||||
it("canSend returns true for in_app channel", { timeout: 5000 }, () => {
|
||||
const channel = new InAppChannel();
|
||||
expect(channel.canSend(makeNotification())).toBe(true);
|
||||
});
|
||||
|
||||
it("canSend returns false for non in_app channel", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const channel = new InAppChannel();
|
||||
expect(channel.canSend(makeNotification({ channel: "email" }))).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it("sends and stores notification in memory", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const channel = new InAppChannel();
|
||||
const result = await channel.send(makeNotification());
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.messageId).toBe("notif_1");
|
||||
const stored = await channel.getByRecipient("user123");
|
||||
expect(stored).toHaveLength(1);
|
||||
const first = stored[0];
|
||||
expect(first?.body).toBe("In-app message");
|
||||
});
|
||||
|
||||
it("stores multiple notifications per recipient", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const channel = new InAppChannel();
|
||||
await channel.send(makeNotification({ id: "n1" }));
|
||||
await channel.send(makeNotification({ id: "n2", body: "Second" }));
|
||||
const stored = await channel.getByRecipient("user123");
|
||||
expect(stored).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("returns empty array for unknown recipient", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const channel = new InAppChannel();
|
||||
const stored = await channel.getByRecipient("unknown");
|
||||
expect(stored).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("marks notification as read", { timeout: 30000 }, async () => {
|
||||
const channel = new InAppChannel();
|
||||
await channel.send(makeNotification());
|
||||
await channel.markRead("notif_1");
|
||||
const stored = await channel.getByRecipient("user123");
|
||||
expect(stored[0]?.read).toBe(true);
|
||||
});
|
||||
|
||||
it("markRead on unknown id is a no-op", { timeout: 5000 }, async () => {
|
||||
const channel = new InAppChannel();
|
||||
await channel.send(makeNotification());
|
||||
await channel.markRead("does-not-exist");
|
||||
const stored = await channel.getByRecipient("user123");
|
||||
expect(stored[0]?.read).toBe(false);
|
||||
});
|
||||
|
||||
it("uses custom store when provided", { timeout: 30000 }, async () => {
|
||||
const records: InAppNotification[] = [];
|
||||
const customStore: InAppStore = {
|
||||
async save(n) {
|
||||
records.push(n);
|
||||
},
|
||||
async getByRecipient() {
|
||||
return records;
|
||||
},
|
||||
async markRead() {
|
||||
/* noop */
|
||||
},
|
||||
};
|
||||
const channel = new InAppChannel(customStore);
|
||||
await channel.send(makeNotification());
|
||||
expect(records).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("handles store save failure gracefully", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const failingStore: InAppStore = {
|
||||
async save() {
|
||||
throw new Error("DB write failed");
|
||||
},
|
||||
async getByRecipient() {
|
||||
return [];
|
||||
},
|
||||
async markRead() {
|
||||
/* noop */
|
||||
},
|
||||
};
|
||||
const channel = new InAppChannel(failingStore);
|
||||
const result = await channel.send(makeNotification());
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe("DB write failed");
|
||||
});
|
||||
|
||||
it("returns string body when store throws non-Error", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const failingStore: InAppStore = {
|
||||
async save() {
|
||||
const literal: unknown = "boom";
|
||||
throw literal;
|
||||
},
|
||||
async getByRecipient() {
|
||||
return [];
|
||||
},
|
||||
async markRead() {
|
||||
/* noop */
|
||||
},
|
||||
};
|
||||
const channel = new InAppChannel(failingStore);
|
||||
const result = await channel.send(makeNotification());
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe("boom");
|
||||
});
|
||||
|
||||
it("notification stored as unread by default", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const channel = new InAppChannel();
|
||||
await channel.send(makeNotification());
|
||||
const stored = await channel.getByRecipient("user123");
|
||||
expect(stored[0]?.read).toBe(false);
|
||||
});
|
||||
|
||||
it("preserves metadata when notification carries it", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const channel = new InAppChannel();
|
||||
await channel.send(makeNotification({ metadata: { trace: "x-1" } }));
|
||||
const stored = await channel.getByRecipient("user123");
|
||||
expect(stored[0]?.metadata).toEqual({ trace: "x-1" });
|
||||
});
|
||||
|
||||
it("supports notifications without metadata and without subject", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const channel = new InAppChannel();
|
||||
const { subject: _drop, ...rest } = makeNotification();
|
||||
await channel.send(rest as Notification);
|
||||
const stored = await channel.getByRecipient("user123");
|
||||
expect(stored[0]?.subject).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
import type { Clock } from "@suaveplan/types";
|
||||
import type { Notification } from "../types/notification.types.js";
|
||||
import type { DeliveryChannel, DeliveryResult } from "./delivery-channel.js";
|
||||
|
||||
/** Stored in-app notification record. */
|
||||
export interface InAppNotification {
|
||||
readonly id: string;
|
||||
readonly recipientId: string;
|
||||
readonly subject?: string;
|
||||
readonly body: string;
|
||||
readonly read: boolean;
|
||||
readonly createdAt: string;
|
||||
readonly metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** Optional store interface for persistence. */
|
||||
export interface InAppStore {
|
||||
save(notification: InAppNotification): Promise<void>;
|
||||
getByRecipient(recipientId: string): Promise<InAppNotification[]>;
|
||||
markRead(id: string): Promise<void>;
|
||||
}
|
||||
|
||||
const defaultClock: Clock = () => Date.now();
|
||||
|
||||
/**
|
||||
* Delivers notifications in-app by storing them in memory (default) or
|
||||
* a caller-provided `InAppStore` implementation. Suitable for unread
|
||||
* badges, in-app inboxes, and per-user notification feeds.
|
||||
*/
|
||||
export class InAppChannel implements DeliveryChannel {
|
||||
public readonly name = "in_app";
|
||||
private readonly store: InAppStore;
|
||||
private readonly clock: Clock;
|
||||
private readonly memoryStore = new Map<string, InAppNotification[]>();
|
||||
|
||||
public constructor(store?: InAppStore, clock: Clock = defaultClock) {
|
||||
this.clock = clock;
|
||||
this.store = store ?? this.createMemoryStore();
|
||||
}
|
||||
|
||||
public canSend(notification: Notification): boolean {
|
||||
return notification.channel === "in_app";
|
||||
}
|
||||
|
||||
public async send(notification: Notification): Promise<DeliveryResult> {
|
||||
const record: InAppNotification = {
|
||||
id: notification.id,
|
||||
recipientId: notification.recipient,
|
||||
...(notification.subject !== undefined
|
||||
? { subject: notification.subject }
|
||||
: {}),
|
||||
body: notification.body,
|
||||
read: false,
|
||||
createdAt: new Date(this.clock()).toISOString(),
|
||||
...(notification.metadata !== undefined
|
||||
? { metadata: notification.metadata }
|
||||
: {}),
|
||||
};
|
||||
|
||||
try {
|
||||
await this.store.save(record);
|
||||
return {
|
||||
success: true,
|
||||
channel: this.name,
|
||||
messageId: notification.id,
|
||||
timestamp: new Date(this.clock()).toISOString(),
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
success: false,
|
||||
channel: this.name,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
timestamp: new Date(this.clock()).toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** Get notifications for a recipient (convenience). */
|
||||
public async getByRecipient(
|
||||
recipientId: string
|
||||
): Promise<InAppNotification[]> {
|
||||
return this.store.getByRecipient(recipientId);
|
||||
}
|
||||
|
||||
/** Mark a notification as read (convenience). */
|
||||
public async markRead(id: string): Promise<void> {
|
||||
return this.store.markRead(id);
|
||||
}
|
||||
|
||||
private createMemoryStore(): InAppStore {
|
||||
const store = this.memoryStore;
|
||||
return {
|
||||
async save(notification: InAppNotification): Promise<void> {
|
||||
const list = store.get(notification.recipientId) ?? [];
|
||||
list.push(notification);
|
||||
store.set(notification.recipientId, list);
|
||||
},
|
||||
async getByRecipient(
|
||||
recipientId: string
|
||||
): Promise<InAppNotification[]> {
|
||||
return store.get(recipientId) ?? [];
|
||||
},
|
||||
async markRead(id: string): Promise<void> {
|
||||
for (const list of store.values()) {
|
||||
const existing = list.find((n) => n.id === id);
|
||||
if (existing !== undefined) {
|
||||
const idx = list.indexOf(existing);
|
||||
list.splice(idx, 1, { ...existing, read: true });
|
||||
return;
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -0,0 +1,366 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { FakeFetch } from "../fakes/fake-fetch.js";
|
||||
import type { Notification } from "../types/notification.types.js";
|
||||
import { PushChannel } from "./push-channel.js";
|
||||
import type { PushSubscription, VapidKeys } from "./push-channel.types.js";
|
||||
|
||||
const vapid: VapidKeys = {
|
||||
publicKey: "BPubKey123",
|
||||
privateKey: "PrivKey456",
|
||||
subject: "mailto:admin@example.com",
|
||||
};
|
||||
|
||||
function makeSub(endpoint = "https://push.example.com/sub1"): PushSubscription {
|
||||
return {
|
||||
endpoint,
|
||||
keys: { p256dh: "p256dh-key", auth: "auth-key" },
|
||||
};
|
||||
}
|
||||
|
||||
function makeNotification(overrides: Partial<Notification> = {}): Notification {
|
||||
return {
|
||||
id: "notif-1",
|
||||
channel: "push",
|
||||
recipient: "user-1",
|
||||
subject: "Test Push",
|
||||
body: "Hello push",
|
||||
priority: "normal",
|
||||
status: "pending",
|
||||
createdAt: new Date().toISOString(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("PushChannel", () => {
|
||||
it("canSend returns true for push channel notifications", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const channel = new PushChannel({
|
||||
vapid,
|
||||
getSubscriptions: async () => [makeSub()],
|
||||
});
|
||||
expect(channel.canSend(makeNotification())).toBe(true);
|
||||
});
|
||||
|
||||
it("canSend returns false for non-push channel notifications", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const channel = new PushChannel({
|
||||
vapid,
|
||||
getSubscriptions: async () => [makeSub()],
|
||||
});
|
||||
expect(channel.canSend(makeNotification({ channel: "email" }))).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it("canSend returns false when recipient is empty", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const channel = new PushChannel({
|
||||
vapid,
|
||||
getSubscriptions: async () => [makeSub()],
|
||||
});
|
||||
expect(channel.canSend(makeNotification({ recipient: "" }))).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it("delivers to a single subscription successfully", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const sent: { sub: PushSubscription; payload: string }[] = [];
|
||||
const channel = new PushChannel({
|
||||
vapid,
|
||||
getSubscriptions: async () => [makeSub()],
|
||||
sendFn: async (sub, payload) => {
|
||||
sent.push({ sub, payload });
|
||||
return { success: true, statusCode: 201 };
|
||||
},
|
||||
});
|
||||
|
||||
const result = await channel.send(makeNotification());
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.channel).toBe("push");
|
||||
expect(result.messageId).toContain("push-notif-1");
|
||||
expect(sent).toHaveLength(1);
|
||||
|
||||
const parsed = JSON.parse(sent[0]?.payload ?? "{}");
|
||||
expect(parsed.title).toBe("Test Push");
|
||||
expect(parsed.body).toBe("Hello push");
|
||||
expect(parsed.tag).toBe("notif-1");
|
||||
});
|
||||
|
||||
it("delivers to multiple subscriptions", { timeout: 5000 }, async () => {
|
||||
let sendCount = 0;
|
||||
const channel = new PushChannel({
|
||||
vapid,
|
||||
getSubscriptions: async () => [
|
||||
makeSub("https://push.example.com/sub1"),
|
||||
makeSub("https://push.example.com/sub2"),
|
||||
makeSub("https://push.example.com/sub3"),
|
||||
],
|
||||
sendFn: async () => {
|
||||
sendCount++;
|
||||
return { success: true, statusCode: 201 };
|
||||
},
|
||||
});
|
||||
|
||||
const result = await channel.send(makeNotification());
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.messageId).toContain("3of3");
|
||||
expect(sendCount).toBe(3);
|
||||
});
|
||||
|
||||
it("reports partial success correctly", { timeout: 5000 }, async () => {
|
||||
let callIndex = 0;
|
||||
const channel = new PushChannel({
|
||||
vapid,
|
||||
getSubscriptions: async () => [
|
||||
makeSub("https://push.example.com/sub1"),
|
||||
makeSub("https://push.example.com/sub2"),
|
||||
],
|
||||
sendFn: async () => {
|
||||
callIndex++;
|
||||
if (callIndex === 1) return { success: true, statusCode: 201 };
|
||||
return { success: false, statusCode: 410 };
|
||||
},
|
||||
});
|
||||
|
||||
const result = await channel.send(makeNotification());
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.messageId).toContain("1of2");
|
||||
});
|
||||
|
||||
it("returns failure when all subscriptions fail with HTTP errors", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const channel = new PushChannel({
|
||||
vapid,
|
||||
getSubscriptions: async () => [
|
||||
makeSub(),
|
||||
makeSub("https://push.example.com/sub2"),
|
||||
],
|
||||
sendFn: async () => ({ success: false, statusCode: 500 }),
|
||||
});
|
||||
|
||||
const result = await channel.send(makeNotification());
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("All 2 subscriptions failed");
|
||||
expect(result.error).toContain("HTTP 500");
|
||||
});
|
||||
|
||||
it("returns failure when no subscriptions found", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const channel = new PushChannel({
|
||||
vapid,
|
||||
getSubscriptions: async () => [],
|
||||
});
|
||||
|
||||
const result = await channel.send(makeNotification());
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("No push subscriptions");
|
||||
});
|
||||
|
||||
it("handles rejected promises in subscriptions", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
let callIndex = 0;
|
||||
const channel = new PushChannel({
|
||||
vapid,
|
||||
getSubscriptions: async () => [
|
||||
makeSub(),
|
||||
makeSub("https://push.example.com/sub2"),
|
||||
],
|
||||
sendFn: async () => {
|
||||
callIndex++;
|
||||
if (callIndex === 1) throw new Error("Network timeout");
|
||||
return { success: true, statusCode: 201 };
|
||||
},
|
||||
});
|
||||
|
||||
const result = await channel.send(makeNotification());
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.messageId).toContain("1of2");
|
||||
});
|
||||
|
||||
it("handles all subscriptions throwing errors", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const channel = new PushChannel({
|
||||
vapid,
|
||||
getSubscriptions: async () => [makeSub()],
|
||||
sendFn: async () => {
|
||||
throw new Error("Connection refused");
|
||||
},
|
||||
});
|
||||
|
||||
const result = await channel.send(makeNotification());
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("Connection refused");
|
||||
});
|
||||
|
||||
it("handles non-Error rejection with default message", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const channel = new PushChannel({
|
||||
vapid,
|
||||
getSubscriptions: async () => [makeSub()],
|
||||
sendFn: async () => {
|
||||
const literal: unknown = { foo: "bar" };
|
||||
throw literal;
|
||||
},
|
||||
});
|
||||
const result = await channel.send(makeNotification());
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("Unknown error");
|
||||
});
|
||||
|
||||
it("handles getSubscriptions throwing", { timeout: 5000 }, async () => {
|
||||
const channel = new PushChannel({
|
||||
vapid,
|
||||
getSubscriptions: async () => {
|
||||
throw new Error("DB unavailable");
|
||||
},
|
||||
});
|
||||
|
||||
const result = await channel.send(makeNotification());
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("DB unavailable");
|
||||
});
|
||||
|
||||
it("handles getSubscriptions throwing non-Error", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const channel = new PushChannel({
|
||||
vapid,
|
||||
getSubscriptions: async () => {
|
||||
const literal: unknown = 7;
|
||||
throw literal;
|
||||
},
|
||||
});
|
||||
const result = await channel.send(makeNotification());
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe("7");
|
||||
});
|
||||
|
||||
it("uses default subject when none provided", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
let capturedPayload = "";
|
||||
const channel = new PushChannel({
|
||||
vapid,
|
||||
getSubscriptions: async () => [makeSub()],
|
||||
sendFn: async (_sub, payload) => {
|
||||
capturedPayload = payload;
|
||||
return { success: true, statusCode: 201 };
|
||||
},
|
||||
});
|
||||
|
||||
const { subject: _drop, ...rest } = makeNotification();
|
||||
await channel.send(rest as Notification);
|
||||
|
||||
const parsed = JSON.parse(capturedPayload);
|
||||
expect(parsed.title).toBe("Notification");
|
||||
});
|
||||
|
||||
it("includes metadata in payload data field", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
let capturedPayload = "";
|
||||
const channel = new PushChannel({
|
||||
vapid,
|
||||
getSubscriptions: async () => [makeSub()],
|
||||
sendFn: async (_sub, payload) => {
|
||||
capturedPayload = payload;
|
||||
return { success: true, statusCode: 201 };
|
||||
},
|
||||
});
|
||||
|
||||
await channel.send(
|
||||
makeNotification({
|
||||
metadata: { action: "open", url: "/dashboard" },
|
||||
})
|
||||
);
|
||||
|
||||
const parsed = JSON.parse(capturedPayload);
|
||||
expect(parsed.data).toEqual({ action: "open", url: "/dashboard" });
|
||||
});
|
||||
|
||||
it("respects custom TTL config", { timeout: 5000 }, () => {
|
||||
const channel = new PushChannel({
|
||||
vapid,
|
||||
getSubscriptions: async () => [],
|
||||
ttl: 3600,
|
||||
});
|
||||
expect(channel.name).toBe("push");
|
||||
});
|
||||
|
||||
it("uses default fetchFn against VAPID endpoint when sendFn omitted", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fake = new FakeFetch();
|
||||
fake.push({ status: 201 });
|
||||
const channel = new PushChannel({
|
||||
vapid,
|
||||
getSubscriptions: async () => [makeSub()],
|
||||
fetchFn: fake.fetch,
|
||||
});
|
||||
const r = await channel.send(makeNotification());
|
||||
expect(r.success).toBe(true);
|
||||
const first = fake.calls[0];
|
||||
expect(first?.url).toBe("https://push.example.com/sub1");
|
||||
const headers = (first?.init?.headers ?? {}) as Record<string, string>;
|
||||
expect(headers["Authorization"]).toContain("vapid");
|
||||
});
|
||||
|
||||
it("omits Authorization header when vapid.subject is empty", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fake = new FakeFetch();
|
||||
fake.push({ status: 201 });
|
||||
const channel = new PushChannel({
|
||||
vapid: { publicKey: "p", privateKey: "k", subject: "" },
|
||||
getSubscriptions: async () => [makeSub()],
|
||||
fetchFn: fake.fetch,
|
||||
});
|
||||
await channel.send(makeNotification());
|
||||
const headers = (fake.calls[0]?.init?.headers ?? {}) as Record<
|
||||
string,
|
||||
string
|
||||
>;
|
||||
expect(headers["Authorization"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("default fetchFn reports HTTP non-2xx as failure", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fake = new FakeFetch();
|
||||
fake.push({ status: 410 });
|
||||
const channel = new PushChannel({
|
||||
vapid,
|
||||
getSubscriptions: async () => [makeSub()],
|
||||
fetchFn: fake.fetch,
|
||||
});
|
||||
const r = await channel.send(makeNotification());
|
||||
expect(r.success).toBe(false);
|
||||
expect(r.error).toContain("HTTP 410");
|
||||
});
|
||||
|
||||
it('has name "push"', { timeout: 5000 }, () => {
|
||||
const channel = new PushChannel({
|
||||
vapid,
|
||||
getSubscriptions: async () => [],
|
||||
});
|
||||
expect(channel.name).toBe("push");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,163 @@
|
||||
import type { Clock, FetchLike } from "@suaveplan/types";
|
||||
import type { Notification } from "../types/notification.types.js";
|
||||
import type { DeliveryChannel, DeliveryResult } from "./delivery-channel.js";
|
||||
import type {
|
||||
PushChannelConfig,
|
||||
PushPayload,
|
||||
PushSendOutcome,
|
||||
PushSubscription,
|
||||
VapidKeys,
|
||||
} from "./push-channel.types.js";
|
||||
|
||||
const defaultClock: Clock = () => Date.now();
|
||||
|
||||
async function defaultSendPush(
|
||||
subscription: PushSubscription,
|
||||
payload: string,
|
||||
vapid: VapidKeys,
|
||||
ttl: number,
|
||||
fetchFn: FetchLike
|
||||
): Promise<PushSendOutcome> {
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
"Content-Encoding": "aes128gcm",
|
||||
TTL: String(ttl),
|
||||
Urgency: "normal",
|
||||
};
|
||||
|
||||
if (vapid.subject) {
|
||||
headers["Authorization"] =
|
||||
`vapid t=${vapid.publicKey}, k=${vapid.privateKey}`;
|
||||
}
|
||||
|
||||
const response = await fetchFn(subscription.endpoint, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: payload,
|
||||
});
|
||||
|
||||
return {
|
||||
success: response.status >= 200 && response.status < 300,
|
||||
statusCode: response.status,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Delivers notifications via the Web Push Protocol (RFC 8030). The
|
||||
* channel is intentionally provider-neutral: the `sendFn` callback OR
|
||||
* the injected `fetchFn` performs the VAPID-authenticated POST against
|
||||
* each subscription's endpoint, and the channel aggregates partial
|
||||
* success/failure into a single `DeliveryResult`.
|
||||
*/
|
||||
export class PushChannel implements DeliveryChannel {
|
||||
public readonly name = "push";
|
||||
private readonly config: PushChannelConfig;
|
||||
private readonly ttl: number;
|
||||
private readonly clock: Clock;
|
||||
private readonly fetchFn: FetchLike;
|
||||
|
||||
public constructor(config: PushChannelConfig) {
|
||||
this.config = config;
|
||||
this.ttl = config.ttl ?? 86_400;
|
||||
this.clock = config.clock ?? defaultClock;
|
||||
this.fetchFn = config.fetchFn ?? globalThis.fetch.bind(globalThis);
|
||||
}
|
||||
|
||||
public canSend(notification: Notification): boolean {
|
||||
return notification.channel === "push" && notification.recipient !== "";
|
||||
}
|
||||
|
||||
public async send(notification: Notification): Promise<DeliveryResult> {
|
||||
try {
|
||||
const subscriptions = await this.config.getSubscriptions(
|
||||
notification.recipient
|
||||
);
|
||||
|
||||
if (subscriptions.length === 0) {
|
||||
return this.fail(
|
||||
"No push subscriptions found for recipient",
|
||||
notification
|
||||
);
|
||||
}
|
||||
|
||||
const payload = this.buildPayload(notification);
|
||||
const payloadStr = JSON.stringify(payload);
|
||||
const results = await Promise.allSettled(
|
||||
subscriptions.map((sub) =>
|
||||
this.sendToSubscription(sub, payloadStr)
|
||||
)
|
||||
);
|
||||
|
||||
const successes = results.filter(
|
||||
(r) => r.status === "fulfilled" && r.value.success
|
||||
);
|
||||
|
||||
if (successes.length === 0) {
|
||||
const errors = results
|
||||
.map((r) => {
|
||||
if (r.status === "rejected") {
|
||||
const cause = r.reason as { message?: string };
|
||||
return cause.message ?? "Unknown error";
|
||||
}
|
||||
return `HTTP ${r.value.statusCode}`;
|
||||
})
|
||||
.filter(Boolean);
|
||||
return this.fail(
|
||||
`All ${subscriptions.length} subscriptions failed: ${errors.join(", ")}`,
|
||||
notification
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
channel: this.name,
|
||||
messageId: `push-${notification.id}-${successes.length}of${subscriptions.length}`,
|
||||
timestamp: new Date(this.clock()).toISOString(),
|
||||
};
|
||||
} catch (err) {
|
||||
return this.fail(
|
||||
err instanceof Error ? err.message : String(err),
|
||||
notification
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private fail(error: string, _notification: Notification): DeliveryResult {
|
||||
return {
|
||||
success: false,
|
||||
channel: this.name,
|
||||
error,
|
||||
timestamp: new Date(this.clock()).toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
private buildPayload(notification: Notification): PushPayload {
|
||||
const payload: {
|
||||
-readonly [K in keyof PushPayload]: PushPayload[K];
|
||||
} = {
|
||||
title: notification.subject ?? "Notification",
|
||||
body: notification.body,
|
||||
tag: notification.id,
|
||||
};
|
||||
if (notification.metadata !== undefined) {
|
||||
payload.data = notification.metadata;
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
private async sendToSubscription(
|
||||
subscription: PushSubscription,
|
||||
payload: string
|
||||
): Promise<PushSendOutcome> {
|
||||
if (this.config.sendFn !== undefined) {
|
||||
return this.config.sendFn(subscription, payload, this.config.vapid);
|
||||
}
|
||||
return defaultSendPush(
|
||||
subscription,
|
||||
payload,
|
||||
this.config.vapid,
|
||||
this.ttl,
|
||||
this.fetchFn
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { Clock, FetchLike } from "@suaveplan/types";
|
||||
|
||||
/** VAPID key pair for Web Push authentication. */
|
||||
export interface VapidKeys {
|
||||
readonly publicKey: string;
|
||||
readonly privateKey: string;
|
||||
readonly subject: string;
|
||||
}
|
||||
|
||||
/** A web push subscription (from the browser's PushManager). */
|
||||
export interface PushSubscription {
|
||||
readonly endpoint: string;
|
||||
readonly keys: {
|
||||
readonly p256dh: string;
|
||||
readonly auth: string;
|
||||
};
|
||||
readonly expirationTime?: number | null;
|
||||
}
|
||||
|
||||
/** Action button for push notifications. */
|
||||
export interface PushAction {
|
||||
readonly action: string;
|
||||
readonly title: string;
|
||||
readonly icon?: string;
|
||||
}
|
||||
|
||||
/** Push notification payload. */
|
||||
export interface PushPayload {
|
||||
readonly title: string;
|
||||
readonly body: string;
|
||||
readonly icon?: string;
|
||||
readonly badge?: string;
|
||||
readonly image?: string;
|
||||
readonly tag?: string;
|
||||
readonly data?: Record<string, unknown>;
|
||||
readonly actions?: readonly PushAction[];
|
||||
readonly requireInteraction?: boolean;
|
||||
readonly silent?: boolean;
|
||||
}
|
||||
|
||||
/** Outcome returned by the optional injected `sendFn`. */
|
||||
export interface PushSendOutcome {
|
||||
readonly success: boolean;
|
||||
readonly statusCode: number;
|
||||
}
|
||||
|
||||
/** Configuration for the push channel. */
|
||||
export interface PushChannelConfig {
|
||||
/** VAPID keys for authentication. */
|
||||
readonly vapid: VapidKeys;
|
||||
/** Function to look up push subscriptions for a recipient. */
|
||||
readonly getSubscriptions: (
|
||||
recipient: string
|
||||
) => Promise<readonly PushSubscription[]>;
|
||||
/** Optional function to send the actual push message. */
|
||||
readonly sendFn?: (
|
||||
subscription: PushSubscription,
|
||||
payload: string,
|
||||
vapid: VapidKeys
|
||||
) => Promise<PushSendOutcome>;
|
||||
/** Optional fetch implementation used when `sendFn` is omitted. */
|
||||
readonly fetchFn?: FetchLike;
|
||||
/** TTL in seconds for push messages (default: 86400). */
|
||||
readonly ttl?: number;
|
||||
/** Optional clock for the `timestamp` field. */
|
||||
readonly clock?: Clock;
|
||||
}
|
||||
@@ -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.
|
||||
@@ -0,0 +1,125 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import type { Notification } from "../types/notification.types.js";
|
||||
import { SmsChannel } from "./sms-channel.js";
|
||||
|
||||
function makeNotification(overrides: Partial<Notification> = {}): Notification {
|
||||
return {
|
||||
id: "notif_1",
|
||||
channel: "sms",
|
||||
recipient: "+15551234567",
|
||||
body: "Test SMS body",
|
||||
priority: "normal",
|
||||
status: "sending",
|
||||
createdAt: new Date().toISOString(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("SmsChannel", () => {
|
||||
it('has name "sms"', { timeout: 5000 }, () => {
|
||||
const channel = new SmsChannel({
|
||||
from: "+15559999999",
|
||||
sendFn: async () => ({ messageId: "sms_1" }),
|
||||
});
|
||||
expect(channel.name).toBe("sms");
|
||||
});
|
||||
|
||||
it("canSend returns true for sms channel with + recipient", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const channel = new SmsChannel({
|
||||
from: "+15559999999",
|
||||
sendFn: async () => ({ messageId: "sms_1" }),
|
||||
});
|
||||
expect(channel.canSend(makeNotification())).toBe(true);
|
||||
});
|
||||
|
||||
it("canSend returns false for non-sms channel", { timeout: 5000 }, () => {
|
||||
const channel = new SmsChannel({
|
||||
from: "+15559999999",
|
||||
sendFn: async () => ({ messageId: "sms_1" }),
|
||||
});
|
||||
expect(channel.canSend(makeNotification({ channel: "email" }))).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it("canSend returns false for recipient without +", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const channel = new SmsChannel({
|
||||
from: "+15559999999",
|
||||
sendFn: async () => ({ messageId: "sms_1" }),
|
||||
});
|
||||
expect(
|
||||
channel.canSend(makeNotification({ recipient: "5551234567" }))
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("sends successfully and returns result", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const channel = new SmsChannel({
|
||||
from: "+15559999999",
|
||||
sendFn: async () => ({ messageId: "sms_42" }),
|
||||
});
|
||||
const result = await channel.send(makeNotification());
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.channel).toBe("sms");
|
||||
expect(result.messageId).toBe("sms_42");
|
||||
expect(result.timestamp).toBeDefined();
|
||||
});
|
||||
|
||||
it("returns failure when sendFn throws", { timeout: 30000 }, async () => {
|
||||
const channel = new SmsChannel({
|
||||
from: "+15559999999",
|
||||
sendFn: async () => {
|
||||
throw new Error("SMS provider down");
|
||||
},
|
||||
});
|
||||
const result = await channel.send(makeNotification());
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe("SMS provider down");
|
||||
});
|
||||
|
||||
it("passes body and metadata to sendFn", { timeout: 30000 }, async () => {
|
||||
let capturedBody = "";
|
||||
let capturedMeta: Record<string, unknown> | undefined;
|
||||
const channel = new SmsChannel({
|
||||
from: "+15559999999",
|
||||
sendFn: async (msg) => {
|
||||
capturedBody = msg.body;
|
||||
capturedMeta = msg.metadata;
|
||||
return { messageId: "sms_1" };
|
||||
},
|
||||
});
|
||||
await channel.send(
|
||||
makeNotification({ body: "Hello SMS", metadata: { tag: "urgent" } })
|
||||
);
|
||||
expect(capturedBody).toBe("Hello SMS");
|
||||
expect(capturedMeta).toEqual({ tag: "urgent" });
|
||||
});
|
||||
|
||||
it("handles non-Error throw in sendFn", { timeout: 30000 }, async () => {
|
||||
const channel = new SmsChannel({
|
||||
from: "+15559999999",
|
||||
sendFn: async () => {
|
||||
const literal: unknown = 42;
|
||||
throw literal;
|
||||
},
|
||||
});
|
||||
const result = await channel.send(makeNotification());
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe("42");
|
||||
});
|
||||
|
||||
it("honours injected clock", { timeout: 5000 }, async () => {
|
||||
const channel = new SmsChannel({
|
||||
from: "+15559999999",
|
||||
sendFn: async () => ({ messageId: "sms_1" }),
|
||||
clock: () => Date.UTC(2026, 0, 1),
|
||||
});
|
||||
const r = await channel.send(makeNotification());
|
||||
expect(r.timestamp).toBe("2026-01-01T00:00:00.000Z");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import type { Clock } from "@suaveplan/types";
|
||||
import type { Notification } from "../types/notification.types.js";
|
||||
import type { DeliveryChannel, DeliveryResult } from "./delivery-channel.js";
|
||||
|
||||
/** SMS message passed to the send function. */
|
||||
export interface SmsMessage {
|
||||
readonly to: string;
|
||||
readonly body: string;
|
||||
readonly metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** Configuration for the SMS channel. */
|
||||
export interface SmsChannelConfig {
|
||||
readonly from: string;
|
||||
readonly sendFn: (message: SmsMessage) => Promise<{ messageId: string }>;
|
||||
/** Optional clock for `timestamp` field (default: `Date.now`). */
|
||||
readonly clock?: Clock;
|
||||
}
|
||||
|
||||
const defaultClock: Clock = () => Date.now();
|
||||
|
||||
/**
|
||||
* Delivers notifications via SMS using a configurable send function.
|
||||
* The `sendFn` is the integration seam — wire Twilio / MessageBird /
|
||||
* Vonage by implementing the callback signature.
|
||||
*/
|
||||
export class SmsChannel implements DeliveryChannel {
|
||||
public readonly name = "sms";
|
||||
private readonly config: SmsChannelConfig;
|
||||
private readonly clock: Clock;
|
||||
|
||||
public constructor(config: SmsChannelConfig) {
|
||||
this.config = config;
|
||||
this.clock = config.clock ?? defaultClock;
|
||||
}
|
||||
|
||||
public canSend(notification: Notification): boolean {
|
||||
return (
|
||||
notification.channel === "sms" &&
|
||||
notification.recipient.startsWith("+")
|
||||
);
|
||||
}
|
||||
|
||||
public async send(notification: Notification): Promise<DeliveryResult> {
|
||||
const message: SmsMessage = {
|
||||
to: notification.recipient,
|
||||
body: notification.body,
|
||||
...(notification.metadata !== undefined
|
||||
? { metadata: notification.metadata }
|
||||
: {}),
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await this.config.sendFn(message);
|
||||
return {
|
||||
success: true,
|
||||
channel: this.name,
|
||||
messageId: result.messageId,
|
||||
timestamp: new Date(this.clock()).toISOString(),
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
success: false,
|
||||
channel: this.name,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
timestamp: new Date(this.clock()).toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -0,0 +1,153 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { FakeFetch } from "../fakes/fake-fetch.js";
|
||||
import type { Notification } from "../types/notification.types.js";
|
||||
import { WebhookChannel } from "./webhook-channel.js";
|
||||
|
||||
function makeNotification(overrides: Partial<Notification> = {}): Notification {
|
||||
return {
|
||||
id: "notif_1",
|
||||
channel: "webhook",
|
||||
recipient: "https://hooks.example.com/callback",
|
||||
subject: "Test Hook",
|
||||
body: "Webhook payload",
|
||||
priority: "normal",
|
||||
status: "sending",
|
||||
createdAt: new Date().toISOString(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("WebhookChannel", () => {
|
||||
it('has name "webhook"', { timeout: 5000 }, () => {
|
||||
const channel = new WebhookChannel();
|
||||
expect(channel.name).toBe("webhook");
|
||||
});
|
||||
|
||||
it("canSend returns true for webhook channel with https URL", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const channel = new WebhookChannel();
|
||||
expect(channel.canSend(makeNotification())).toBe(true);
|
||||
});
|
||||
|
||||
it("canSend returns true for http URL", { timeout: 5000 }, () => {
|
||||
const channel = new WebhookChannel();
|
||||
expect(
|
||||
channel.canSend(
|
||||
makeNotification({ recipient: "http://hooks.example.com/cb" })
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("canSend returns false for non-webhook channel", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const channel = new WebhookChannel();
|
||||
expect(channel.canSend(makeNotification({ channel: "email" }))).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it("canSend returns false for non-URL recipient", { timeout: 5000 }, () => {
|
||||
const channel = new WebhookChannel();
|
||||
expect(
|
||||
channel.canSend(makeNotification({ recipient: "not-a-url" }))
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("sends successfully with 200 response", { timeout: 30000 }, async () => {
|
||||
const fake = new FakeFetch();
|
||||
fake.push({ status: 200 });
|
||||
const channel = new WebhookChannel({ fetchFn: fake.fetch });
|
||||
const result = await channel.send(makeNotification());
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.channel).toBe("webhook");
|
||||
expect(result.messageId).toBe("notif_1");
|
||||
const first = fake.calls[0];
|
||||
expect(first?.url).toBe("https://hooks.example.com/callback");
|
||||
});
|
||||
|
||||
it("returns failure for non-ok response", { timeout: 30000 }, async () => {
|
||||
const fake = new FakeFetch();
|
||||
fake.push({ status: 500 });
|
||||
const channel = new WebhookChannel({ fetchFn: fake.fetch });
|
||||
const result = await channel.send(makeNotification());
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("500");
|
||||
});
|
||||
|
||||
it("returns failure when fetch throws", { timeout: 30000 }, async () => {
|
||||
const channel = new WebhookChannel({
|
||||
fetchFn: async () => {
|
||||
throw new Error("Network error");
|
||||
},
|
||||
});
|
||||
const result = await channel.send(makeNotification());
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe("Network error");
|
||||
});
|
||||
|
||||
it("sends correct payload in POST body", { timeout: 30000 }, async () => {
|
||||
const fake = new FakeFetch();
|
||||
fake.push({ status: 200 });
|
||||
const channel = new WebhookChannel({ fetchFn: fake.fetch });
|
||||
await channel.send(makeNotification());
|
||||
const init = fake.calls[0]?.init;
|
||||
const body = init?.body;
|
||||
expect(typeof body).toBe("string");
|
||||
const parsed = JSON.parse(body as string);
|
||||
expect(parsed.id).toBe("notif_1");
|
||||
expect(parsed.body).toBe("Webhook payload");
|
||||
});
|
||||
|
||||
it("includes custom headers", { timeout: 30000 }, async () => {
|
||||
const fake = new FakeFetch();
|
||||
fake.push({ status: 200 });
|
||||
const channel = new WebhookChannel({
|
||||
headers: { Authorization: "Bearer token123" },
|
||||
fetchFn: fake.fetch,
|
||||
});
|
||||
await channel.send(makeNotification());
|
||||
const init = fake.calls[0]?.init;
|
||||
const headers = init?.headers as Record<string, string>;
|
||||
expect(headers["Authorization"]).toBe("Bearer token123");
|
||||
expect(headers["Content-Type"]).toBe("application/json");
|
||||
});
|
||||
|
||||
it("handles non-Error throw", { timeout: 30000 }, async () => {
|
||||
const channel = new WebhookChannel({
|
||||
fetchFn: async () => {
|
||||
const literal: unknown = "fetch failed";
|
||||
throw literal;
|
||||
},
|
||||
});
|
||||
const result = await channel.send(makeNotification());
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe("fetch failed");
|
||||
});
|
||||
|
||||
it("honours injected clock for timestamp", { timeout: 5000 }, async () => {
|
||||
const fake = new FakeFetch();
|
||||
fake.push({ status: 200 });
|
||||
const channel = new WebhookChannel({
|
||||
fetchFn: fake.fetch,
|
||||
clock: () => Date.UTC(2026, 0, 1),
|
||||
});
|
||||
const r = await channel.send(makeNotification());
|
||||
expect(r.timestamp).toBe("2026-01-01T00:00:00.000Z");
|
||||
});
|
||||
|
||||
it("respects custom timeout via AbortController", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fake = new FakeFetch();
|
||||
fake.push({ pending: true });
|
||||
const channel = new WebhookChannel({
|
||||
fetchFn: fake.fetch,
|
||||
timeoutMs: 5,
|
||||
});
|
||||
const result = await channel.send(makeNotification());
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe("aborted");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
import type { Clock, FetchLike } from "@suaveplan/types";
|
||||
import type { Notification } from "../types/notification.types.js";
|
||||
import type { DeliveryChannel, DeliveryResult } from "./delivery-channel.js";
|
||||
|
||||
/** Configuration for the webhook channel. */
|
||||
export interface WebhookChannelConfig {
|
||||
/** Default headers added to every request. */
|
||||
readonly headers?: Readonly<Record<string, string>>;
|
||||
/** Optional `FetchLike` implementation (default: `globalThis.fetch`). */
|
||||
readonly fetchFn?: FetchLike;
|
||||
/** Timeout in milliseconds (default: 10_000). */
|
||||
readonly timeoutMs?: number;
|
||||
/** Optional clock for the `timestamp` field (default: `Date.now`). */
|
||||
readonly clock?: Clock;
|
||||
}
|
||||
|
||||
const defaultClock: Clock = () => Date.now();
|
||||
|
||||
/**
|
||||
* Delivers notifications via HTTP webhook POST requests. The
|
||||
* notification's `recipient` field is the destination URL; the body
|
||||
* contains the serialised notification payload.
|
||||
*/
|
||||
export class WebhookChannel implements DeliveryChannel {
|
||||
public readonly name = "webhook";
|
||||
private readonly config: WebhookChannelConfig;
|
||||
private readonly clock: Clock;
|
||||
private readonly fetchFn: FetchLike;
|
||||
|
||||
public constructor(config?: WebhookChannelConfig) {
|
||||
this.config = config ?? {};
|
||||
this.clock = this.config.clock ?? defaultClock;
|
||||
this.fetchFn = this.config.fetchFn ?? globalThis.fetch.bind(globalThis);
|
||||
}
|
||||
|
||||
public canSend(notification: Notification): boolean {
|
||||
return (
|
||||
notification.channel === "webhook" &&
|
||||
(notification.recipient.startsWith("http://") ||
|
||||
notification.recipient.startsWith("https://"))
|
||||
);
|
||||
}
|
||||
|
||||
public async send(notification: Notification): Promise<DeliveryResult> {
|
||||
const timeoutMs = this.config.timeoutMs ?? 10_000;
|
||||
|
||||
const payload = {
|
||||
id: notification.id,
|
||||
subject: notification.subject,
|
||||
body: notification.body,
|
||||
priority: notification.priority,
|
||||
metadata: notification.metadata,
|
||||
createdAt: notification.createdAt,
|
||||
};
|
||||
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
|
||||
const response = await this.fetchFn(notification.recipient, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...this.config.headers,
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
clearTimeout(timer);
|
||||
|
||||
if (!response.ok) {
|
||||
return {
|
||||
success: false,
|
||||
channel: this.name,
|
||||
error: `HTTP ${response.status}: ${response.statusText}`,
|
||||
timestamp: new Date(this.clock()).toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
channel: this.name,
|
||||
messageId: notification.id,
|
||||
timestamp: new Date(this.clock()).toISOString(),
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
success: false,
|
||||
channel: this.name,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
timestamp: new Date(this.clock()).toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -0,0 +1,352 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import type {
|
||||
DeliveryChannel,
|
||||
DeliveryResult,
|
||||
} from "../channels/delivery-channel.js";
|
||||
import type { Notification } from "../types/notification.types.js";
|
||||
import { DigestChannel } from "./digest-channel.js";
|
||||
|
||||
let counter = 0;
|
||||
function makeNotification(overrides: Partial<Notification> = {}): Notification {
|
||||
counter++;
|
||||
return {
|
||||
id: `notif-${counter}`,
|
||||
channel: "email",
|
||||
recipient: "user@example.com",
|
||||
subject: "Test",
|
||||
body: "Hello",
|
||||
priority: "normal",
|
||||
status: "pending",
|
||||
createdAt: new Date().toISOString(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeMockDelegate(): DeliveryChannel & {
|
||||
sentNotifications: Notification[];
|
||||
} {
|
||||
const sentNotifications: Notification[] = [];
|
||||
return {
|
||||
name: "mock-email",
|
||||
sentNotifications,
|
||||
canSend: () => true,
|
||||
send: async (notification: Notification): Promise<DeliveryResult> => {
|
||||
sentNotifications.push(notification);
|
||||
return {
|
||||
success: true,
|
||||
channel: "mock-email",
|
||||
messageId: `msg-${notification.id}`,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("DigestChannel", () => {
|
||||
describe("enqueue", () => {
|
||||
it("queues notifications for a recipient", { timeout: 5000 }, () => {
|
||||
const delegate = makeMockDelegate();
|
||||
const digest = new DigestChannel({ delegate });
|
||||
|
||||
digest.enqueue(makeNotification({ recipient: "alice@test.com" }));
|
||||
digest.enqueue(makeNotification({ recipient: "alice@test.com" }));
|
||||
|
||||
expect(digest.queueSize("alice@test.com")).toBe(2);
|
||||
});
|
||||
|
||||
it("queues notifications for different recipients separately", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const delegate = makeMockDelegate();
|
||||
const digest = new DigestChannel({ delegate });
|
||||
|
||||
digest.enqueue(makeNotification({ recipient: "alice@test.com" }));
|
||||
digest.enqueue(makeNotification({ recipient: "bob@test.com" }));
|
||||
|
||||
expect(digest.queueSize("alice@test.com")).toBe(1);
|
||||
expect(digest.queueSize("bob@test.com")).toBe(1);
|
||||
});
|
||||
|
||||
it("respects maxPerDigest limit", { timeout: 5000 }, () => {
|
||||
const delegate = makeMockDelegate();
|
||||
const digest = new DigestChannel({ delegate, maxPerDigest: 3 });
|
||||
|
||||
for (let i = 0; i < 10; i++) {
|
||||
digest.enqueue(
|
||||
makeNotification({ recipient: "alice@test.com" })
|
||||
);
|
||||
}
|
||||
|
||||
expect(digest.queueSize("alice@test.com")).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("flush", () => {
|
||||
it("delivers a single digest via delegate", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const delegate = makeMockDelegate();
|
||||
const digest = new DigestChannel({ delegate });
|
||||
|
||||
digest.enqueue(
|
||||
makeNotification({ recipient: "alice@test.com", body: "First" })
|
||||
);
|
||||
digest.enqueue(
|
||||
makeNotification({
|
||||
recipient: "alice@test.com",
|
||||
body: "Second",
|
||||
})
|
||||
);
|
||||
|
||||
const result = await digest.flush("alice@test.com");
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(delegate.sentNotifications).toHaveLength(1);
|
||||
|
||||
const sent = delegate.sentNotifications[0];
|
||||
expect(sent?.subject).toContain("2 notifications");
|
||||
expect(sent?.body).toContain("First");
|
||||
expect(sent?.body).toContain("Second");
|
||||
expect(sent?.recipient).toBe("alice@test.com");
|
||||
});
|
||||
|
||||
it("clears the queue after flushing", { timeout: 5000 }, async () => {
|
||||
const delegate = makeMockDelegate();
|
||||
const digest = new DigestChannel({ delegate });
|
||||
|
||||
digest.enqueue(makeNotification({ recipient: "alice@test.com" }));
|
||||
await digest.flush("alice@test.com");
|
||||
|
||||
expect(digest.queueSize("alice@test.com")).toBe(0);
|
||||
});
|
||||
|
||||
it("returns empty result for empty queue", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const delegate = makeMockDelegate();
|
||||
const digest = new DigestChannel({ delegate });
|
||||
|
||||
const result = await digest.flush("nobody@test.com");
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.messageId).toBe("digest-empty");
|
||||
expect(delegate.sentNotifications).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("uses singular form for single notification", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const delegate = makeMockDelegate();
|
||||
const digest = new DigestChannel({ delegate });
|
||||
|
||||
digest.enqueue(makeNotification({ recipient: "alice@test.com" }));
|
||||
await digest.flush("alice@test.com");
|
||||
|
||||
const sent = delegate.sentNotifications[0];
|
||||
expect(sent?.subject).toBe("Digest: 1 notification");
|
||||
});
|
||||
|
||||
it("uses custom formatDigest function", { timeout: 5000 }, async () => {
|
||||
const delegate = makeMockDelegate();
|
||||
const digest = new DigestChannel({
|
||||
delegate,
|
||||
formatDigest: (notifs) =>
|
||||
notifs.map((n) => `* ${n.body}`).join("\n"),
|
||||
});
|
||||
|
||||
digest.enqueue(
|
||||
makeNotification({ recipient: "alice@test.com", body: "A" })
|
||||
);
|
||||
digest.enqueue(
|
||||
makeNotification({ recipient: "alice@test.com", body: "B" })
|
||||
);
|
||||
await digest.flush("alice@test.com");
|
||||
|
||||
const sent = delegate.sentNotifications[0];
|
||||
expect(sent?.body).toBe("* A\n* B");
|
||||
});
|
||||
|
||||
it("uses default format with numbered list", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const delegate = makeMockDelegate();
|
||||
const digest = new DigestChannel({ delegate });
|
||||
|
||||
digest.enqueue(
|
||||
makeNotification({
|
||||
recipient: "alice@test.com",
|
||||
subject: "Alert",
|
||||
body: "msg1",
|
||||
})
|
||||
);
|
||||
digest.enqueue(
|
||||
makeNotification({
|
||||
recipient: "alice@test.com",
|
||||
subject: "Alert",
|
||||
body: "msg2",
|
||||
})
|
||||
);
|
||||
await digest.flush("alice@test.com");
|
||||
|
||||
const sent = delegate.sentNotifications[0];
|
||||
expect(sent?.body).toContain("1. Alert: msg1");
|
||||
expect(sent?.body).toContain("2. Alert: msg2");
|
||||
});
|
||||
|
||||
it("default format uses 'Notification' label when subject missing", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const delegate = makeMockDelegate();
|
||||
const digest = new DigestChannel({ delegate });
|
||||
|
||||
const { subject: _drop, ...rest } = makeNotification({
|
||||
recipient: "alice@test.com",
|
||||
body: "body-only",
|
||||
});
|
||||
digest.enqueue(rest as Notification);
|
||||
await digest.flush("alice@test.com");
|
||||
|
||||
const sent = delegate.sentNotifications[0];
|
||||
expect(sent?.body).toContain("1. Notification: body-only");
|
||||
});
|
||||
});
|
||||
|
||||
describe("send", () => {
|
||||
it("enqueues and flushes when interval has elapsed", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const delegate = makeMockDelegate();
|
||||
const digest = new DigestChannel({ delegate, intervalMs: 0 });
|
||||
|
||||
const result = await digest.send(
|
||||
makeNotification({ recipient: "alice@test.com" })
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(delegate.sentNotifications).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("enqueues without flushing when interval has not passed", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const delegate = makeMockDelegate();
|
||||
// Use a clock so we can simulate the interval boundary.
|
||||
let now = 1_000_000;
|
||||
const digest = new DigestChannel({
|
||||
delegate,
|
||||
intervalMs: 3_600_000,
|
||||
clock: () => now,
|
||||
});
|
||||
|
||||
await digest.send(
|
||||
makeNotification({ recipient: "alice@test.com" })
|
||||
);
|
||||
// First send triggers a flush (no previous flush). Advance just
|
||||
// 1 ms; interval has NOT elapsed, second send must remain queued.
|
||||
now += 1;
|
||||
const result = await digest.send(
|
||||
makeNotification({ recipient: "alice@test.com" })
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.messageId).toContain("digest-queued");
|
||||
});
|
||||
});
|
||||
|
||||
describe("flushAll", () => {
|
||||
it("flushes all recipients that are ready", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const delegate = makeMockDelegate();
|
||||
const digest = new DigestChannel({ delegate, intervalMs: 0 });
|
||||
|
||||
digest.enqueue(makeNotification({ recipient: "alice@test.com" }));
|
||||
digest.enqueue(makeNotification({ recipient: "bob@test.com" }));
|
||||
|
||||
const results = await digest.flushAll();
|
||||
|
||||
expect(results).toHaveLength(2);
|
||||
expect(results.every((r) => r.success)).toBe(true);
|
||||
expect(delegate.sentNotifications).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("skips recipients not ready for flush", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const delegate = makeMockDelegate();
|
||||
let now = 1_000_000;
|
||||
const digest = new DigestChannel({
|
||||
delegate,
|
||||
intervalMs: 3_600_000,
|
||||
clock: () => now,
|
||||
});
|
||||
|
||||
digest.enqueue(makeNotification({ recipient: "alice@test.com" }));
|
||||
// Force a flush to set lastFlush time
|
||||
await digest.flush("alice@test.com");
|
||||
|
||||
// Enqueue more
|
||||
digest.enqueue(makeNotification({ recipient: "alice@test.com" }));
|
||||
now += 1; // not yet at interval
|
||||
|
||||
const results = await digest.flushAll();
|
||||
expect(results).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("pendingRecipients", () => {
|
||||
it("lists recipients with pending notifications", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const delegate = makeMockDelegate();
|
||||
const digest = new DigestChannel({ delegate });
|
||||
|
||||
digest.enqueue(makeNotification({ recipient: "alice@test.com" }));
|
||||
digest.enqueue(makeNotification({ recipient: "bob@test.com" }));
|
||||
|
||||
const pending = digest.pendingRecipients();
|
||||
|
||||
expect(pending).toContain("alice@test.com");
|
||||
expect(pending).toContain("bob@test.com");
|
||||
expect(pending).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("returns empty array when no pending", { timeout: 5000 }, () => {
|
||||
const delegate = makeMockDelegate();
|
||||
const digest = new DigestChannel({ delegate });
|
||||
|
||||
expect(digest.pendingRecipients()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("queueSize", () => {
|
||||
it("returns 0 for unknown recipient", { timeout: 5000 }, () => {
|
||||
const delegate = makeMockDelegate();
|
||||
const digest = new DigestChannel({ delegate });
|
||||
|
||||
expect(digest.queueSize("unknown@test.com")).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("canSend", () => {
|
||||
it("delegates canSend to underlying channel", { timeout: 5000 }, () => {
|
||||
const delegate: DeliveryChannel = {
|
||||
name: "selective",
|
||||
canSend: (n) => n.priority === "urgent",
|
||||
send: async () => ({
|
||||
success: true,
|
||||
channel: "selective",
|
||||
timestamp: new Date().toISOString(),
|
||||
}),
|
||||
};
|
||||
const digest = new DigestChannel({ delegate });
|
||||
|
||||
expect(
|
||||
digest.canSend(makeNotification({ priority: "urgent" }))
|
||||
).toBe(true);
|
||||
expect(
|
||||
digest.canSend(makeNotification({ priority: "normal" }))
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,149 @@
|
||||
import type { Clock } from "@suaveplan/types";
|
||||
import type {
|
||||
DeliveryChannel,
|
||||
DeliveryResult,
|
||||
} from "../channels/delivery-channel.js";
|
||||
import type { Notification } from "../types/notification.types.js";
|
||||
|
||||
/** Configuration for the digest channel. */
|
||||
export interface DigestChannelConfig {
|
||||
/** The underlying channel to deliver the digest through. */
|
||||
readonly delegate: DeliveryChannel;
|
||||
/** Digest interval in milliseconds (default: 3_600_000 = 1 hour). */
|
||||
readonly intervalMs?: number;
|
||||
/** Maximum notifications per digest (default: 50). */
|
||||
readonly maxPerDigest?: number;
|
||||
/** Optional custom formatter for the digest body. */
|
||||
readonly formatDigest?: (notifications: readonly Notification[]) => string;
|
||||
/** Optional clock (default: `Date.now`). */
|
||||
readonly clock?: Clock;
|
||||
}
|
||||
|
||||
const defaultClock: Clock = () => Date.now();
|
||||
|
||||
/**
|
||||
* Batches notifications and delivers periodic digests through a
|
||||
* delegate `DeliveryChannel`. Useful for taming notification noise on
|
||||
* low-frequency channels (daily email digests, weekly summaries).
|
||||
*/
|
||||
export class DigestChannel implements DeliveryChannel {
|
||||
public readonly name = "digest";
|
||||
private readonly delegate: DeliveryChannel;
|
||||
private readonly intervalMs: number;
|
||||
private readonly maxPerDigest: number;
|
||||
private readonly formatDigest: (
|
||||
notifications: readonly Notification[]
|
||||
) => string;
|
||||
private readonly clock: Clock;
|
||||
private readonly queues = new Map<string, Notification[]>();
|
||||
private readonly lastFlush = new Map<string, number>();
|
||||
|
||||
public constructor(config: DigestChannelConfig) {
|
||||
this.delegate = config.delegate;
|
||||
this.intervalMs = config.intervalMs ?? 3_600_000;
|
||||
this.maxPerDigest = config.maxPerDigest ?? 50;
|
||||
this.formatDigest = config.formatDigest ?? DigestChannel.defaultFormat;
|
||||
this.clock = config.clock ?? defaultClock;
|
||||
}
|
||||
|
||||
public canSend(notification: Notification): boolean {
|
||||
return this.delegate.canSend(notification);
|
||||
}
|
||||
|
||||
/** Enqueue a notification for the next digest. */
|
||||
public enqueue(notification: Notification): void {
|
||||
const queue = this.queues.get(notification.recipient) ?? [];
|
||||
if (queue.length < this.maxPerDigest) {
|
||||
queue.push(notification);
|
||||
}
|
||||
this.queues.set(notification.recipient, queue);
|
||||
}
|
||||
|
||||
/** Enqueue + opportunistic flush when interval has elapsed. */
|
||||
public async send(notification: Notification): Promise<DeliveryResult> {
|
||||
this.enqueue(notification);
|
||||
if (this.shouldFlush(notification.recipient)) {
|
||||
return this.flush(notification.recipient);
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
channel: this.name,
|
||||
messageId: `digest-queued-${notification.id}`,
|
||||
timestamp: new Date(this.clock()).toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/** Flush a single recipient's queue through the delegate. */
|
||||
public async flush(recipient: string): Promise<DeliveryResult> {
|
||||
const queue = this.queues.get(recipient);
|
||||
if (queue === undefined || queue.length === 0) {
|
||||
return {
|
||||
success: true,
|
||||
channel: this.name,
|
||||
messageId: "digest-empty",
|
||||
timestamp: new Date(this.clock()).toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
const digestBody = this.formatDigest(queue);
|
||||
// queue.length is guaranteed > 0 by the early-return above.
|
||||
const channel = (queue[0] as Notification).channel;
|
||||
const digestNotification: Notification = {
|
||||
id: `digest-${recipient}-${this.clock()}`,
|
||||
channel,
|
||||
recipient,
|
||||
subject: `Digest: ${queue.length} notification${queue.length > 1 ? "s" : ""}`,
|
||||
body: digestBody,
|
||||
priority: "normal",
|
||||
status: "pending",
|
||||
createdAt: new Date(this.clock()).toISOString(),
|
||||
};
|
||||
|
||||
this.queues.delete(recipient);
|
||||
this.lastFlush.set(recipient, this.clock());
|
||||
|
||||
return this.delegate.send(digestNotification);
|
||||
}
|
||||
|
||||
/** Flush every recipient whose interval has elapsed. */
|
||||
public async flushAll(): Promise<DeliveryResult[]> {
|
||||
const results: DeliveryResult[] = [];
|
||||
for (const recipient of this.queues.keys()) {
|
||||
if (this.shouldFlush(recipient)) {
|
||||
results.push(await this.flush(recipient));
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
/** Queue size for a given recipient (0 when unknown). */
|
||||
public queueSize(recipient: string): number {
|
||||
return this.queues.get(recipient)?.length ?? 0;
|
||||
}
|
||||
|
||||
/** Recipients with at least one pending notification. */
|
||||
public pendingRecipients(): string[] {
|
||||
// The iteration is over `this.queues.keys()` so every `get(r)` is
|
||||
// guaranteed to return the matching array — the `as Notification[]`
|
||||
// cast keeps `noUncheckedIndexedAccess` happy without a redundant
|
||||
// `?? 0` branch.
|
||||
return [...this.queues.keys()].filter(
|
||||
(r) => (this.queues.get(r) as Notification[]).length > 0
|
||||
);
|
||||
}
|
||||
|
||||
private shouldFlush(recipient: string): boolean {
|
||||
const last = this.lastFlush.get(recipient) ?? 0;
|
||||
return this.clock() - last >= this.intervalMs;
|
||||
}
|
||||
|
||||
private static defaultFormat(
|
||||
notifications: readonly Notification[]
|
||||
): string {
|
||||
return notifications
|
||||
.map(
|
||||
(n, i) => `${i + 1}. ${n.subject ?? "Notification"}: ${n.body}`
|
||||
)
|
||||
.join("\n");
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -0,0 +1,136 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
ChannelDisabledError,
|
||||
ChannelUnavailableError,
|
||||
DeliveryFailedError,
|
||||
DeliveryTimeoutError,
|
||||
InvalidRecipientError,
|
||||
NOTIFICATION_CHANNEL_CODES,
|
||||
type NotificationChannelErrorCode,
|
||||
NotificationNotFoundError,
|
||||
ProviderErrorError,
|
||||
RateLimitExceededError,
|
||||
RateLimitedError,
|
||||
RoutingFailedError,
|
||||
TemplateNotFoundError,
|
||||
} from "./error-codes.js";
|
||||
|
||||
describe("error-codes", () => {
|
||||
it("NOTIFICATION_CHANNEL_CODES exposes the eleven stable reasons", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(Object.keys(NOTIFICATION_CHANNEL_CODES).sort()).toEqual(
|
||||
[
|
||||
"CHANNEL_DISABLED",
|
||||
"CHANNEL_UNAVAILABLE",
|
||||
"DELIVERY_FAILED",
|
||||
"INVALID_RECIPIENT",
|
||||
"NOT_FOUND",
|
||||
"PROVIDER_ERROR",
|
||||
"RATE_LIMITED",
|
||||
"RATE_LIMIT_EXCEEDED",
|
||||
"ROUTING_FAILED",
|
||||
"TEMPLATE_NOT_FOUND",
|
||||
"TIMEOUT",
|
||||
].sort()
|
||||
);
|
||||
expect(NOTIFICATION_CHANNEL_CODES.NOT_FOUND).toBe(
|
||||
"NOTIFICATION_CHANNEL.NOT_FOUND"
|
||||
);
|
||||
expect(NOTIFICATION_CHANNEL_CODES.RATE_LIMITED).toBe(
|
||||
"NOTIFICATION_CHANNEL.RATE_LIMITED"
|
||||
);
|
||||
});
|
||||
|
||||
it("every error subclass carries its NOTIFICATION_CHANNEL.<REASON> code", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const pairs: ReadonlyArray<
|
||||
readonly [NotificationChannelErrorCode, Error]
|
||||
> = [
|
||||
[
|
||||
NOTIFICATION_CHANNEL_CODES.NOT_FOUND,
|
||||
new NotificationNotFoundError(),
|
||||
],
|
||||
[
|
||||
NOTIFICATION_CHANNEL_CODES.CHANNEL_DISABLED,
|
||||
new ChannelDisabledError(),
|
||||
],
|
||||
[
|
||||
NOTIFICATION_CHANNEL_CODES.TEMPLATE_NOT_FOUND,
|
||||
new TemplateNotFoundError(),
|
||||
],
|
||||
[
|
||||
NOTIFICATION_CHANNEL_CODES.RATE_LIMIT_EXCEEDED,
|
||||
new RateLimitExceededError(),
|
||||
],
|
||||
[
|
||||
NOTIFICATION_CHANNEL_CODES.ROUTING_FAILED,
|
||||
new RoutingFailedError(),
|
||||
],
|
||||
[
|
||||
NOTIFICATION_CHANNEL_CODES.DELIVERY_FAILED,
|
||||
new DeliveryFailedError(),
|
||||
],
|
||||
[
|
||||
NOTIFICATION_CHANNEL_CODES.PROVIDER_ERROR,
|
||||
new ProviderErrorError(),
|
||||
],
|
||||
[NOTIFICATION_CHANNEL_CODES.RATE_LIMITED, new RateLimitedError()],
|
||||
[
|
||||
NOTIFICATION_CHANNEL_CODES.INVALID_RECIPIENT,
|
||||
new InvalidRecipientError(),
|
||||
],
|
||||
[
|
||||
NOTIFICATION_CHANNEL_CODES.CHANNEL_UNAVAILABLE,
|
||||
new ChannelUnavailableError(),
|
||||
],
|
||||
[NOTIFICATION_CHANNEL_CODES.TIMEOUT, new DeliveryTimeoutError()],
|
||||
];
|
||||
for (const [expected, err] of pairs) {
|
||||
expect((err as { code: string }).code).toBe(expected);
|
||||
}
|
||||
});
|
||||
|
||||
it("RateLimitedError merges retryAfterMs into context", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const err = new RateLimitedError({ retryAfterMs: 30_000 });
|
||||
expect(err.context?.["retryAfterMs"]).toBe(30_000);
|
||||
});
|
||||
|
||||
it("RateLimitedError without retryAfterMs leaves context untouched", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const err = new RateLimitedError({ context: { extra: "x" } });
|
||||
expect(err.context?.["retryAfterMs"]).toBeUndefined();
|
||||
expect(err.context?.["extra"]).toBe("x");
|
||||
});
|
||||
|
||||
it("constructors preserve cause and traceId", { timeout: 5000 }, () => {
|
||||
const cause = new Error("nope");
|
||||
const err = new DeliveryFailedError({
|
||||
cause,
|
||||
traceId: "trace-123",
|
||||
context: { extra: 1 },
|
||||
});
|
||||
expect(err.cause).toBe(cause);
|
||||
expect(err.traceId).toBe("trace-123");
|
||||
expect(err.context?.["extra"]).toBe(1);
|
||||
});
|
||||
|
||||
it("constructors with empty options bag still work", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const err = new NotificationNotFoundError({});
|
||||
expect(err.code).toBe(NOTIFICATION_CHANNEL_CODES.NOT_FOUND);
|
||||
expect(err.context).toBeUndefined();
|
||||
});
|
||||
|
||||
it("constructors with custom message override the i18n key", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const err = new ChannelDisabledError({ message: "custom-msg" });
|
||||
expect(err.message).toBe("custom-msg");
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user