Move docs/ package mirror into document-navigator plugin

Relocate the ~6k-file docs tree from the repo root into
plugins/document-navigator/docs/ so the plugin is self-contained and
portable. docs/superpowers/ (specs and plans) stays at the repo root.

Moved via cp+rm rather than git mv because directory rename() fails with
EPERM on this WSL /mnt DrvFs mount; git rename-detection pairs the
identical-content files at commit time.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-30 01:10:00 +01:00
parent fe3fedf42e
commit 9bf56a806b
6022 changed files with 12437 additions and 0 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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