first commit
This commit is contained in:
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user