Initial Commit
This commit is contained in:
92
packages/communication/messaging/telegram-client/README.md
Normal file
92
packages/communication/messaging/telegram-client/README.md
Normal file
@@ -0,0 +1,92 @@
|
||||
# @suaveplan/telegram-client - Telegram Bot API client — send/edit/delete messages, media, inline + custom keyboards, typed errors, injectable fetch + clock, OTel instrumented
|
||||
|
||||
Telegram Bot API client: send/edit/delete messages, six media variants,
|
||||
inline + custom keyboards, typed errors, injectable `fetch` + `clock`, and
|
||||
OpenTelemetry counters/histograms on every code path. Runtime-agnostic
|
||||
(works in Bun, Node, Deno, browsers).
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [telegram-bot-client](./src/core/telegram-bot-client/telegram-bot-client.md) — `TelegramBotClient` is the public, runtime-agnostic Telegram Bot API client. It wraps the handful of Bot API endpoints…
|
||||
- [telegram-bot-client](./src/core/telegram-bot-client/telegram-bot-client.md) — `TelegramBotClient` is the public, runtime-agnostic Telegram Bot API client. It wraps the handful of Bot API endpoints…
|
||||
- [errors](./src/errors/telegram-client-error.md) — Defines the namespaced, frozen error-code registry and the typed `TelegramClientError` class used by every runtime fail…
|
||||
- [telegram-client-error](./src/errors/telegram-client-error.md) — Defines the namespaced, frozen error-code registry and the typed `TelegramClientError` class used by every runtime fail…
|
||||
- [types](./src/types/telegram-bot-config.md) — Defines the Zod schema that validates `TelegramBotConfig` at the public construction boundary of `TelegramBotClient`. T…
|
||||
- [telegram-bot-config](./src/types/telegram-bot-config.md) — Defines the Zod schema that validates `TelegramBotConfig` at the public construction boundary of `TelegramBotClient`. T…
|
||||
- [build-bodies](./src/utils/build-bodies.md) — Pure, side-effect-free helpers that translate the camelCase public message / reply-markup shape used by callers into th…
|
||||
- [classify-error](./src/utils/classify-error.md) — Pure helpers that turn an outgoing-protocol failure (HTTP status + Telegram `description` + parsed body) into a stable…
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
bun add @suaveplan/telegram-client
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
```ts
|
||||
import { createTelegramBotClient } from "@suaveplan/telegram-client";
|
||||
|
||||
const bot = createTelegramBotClient({ token: process.env.TELEGRAM_TOKEN! });
|
||||
|
||||
const sent = await bot.sendMessage({
|
||||
chatId: 12345,
|
||||
text: "Hello",
|
||||
parseMode: "HTML",
|
||||
});
|
||||
|
||||
console.log(sent.messageId);
|
||||
```
|
||||
|
||||
## Modules
|
||||
|
||||
| Module | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| Client | [src/core/telegram-bot-client/telegram-bot-client.md](src/core/telegram-bot-client/telegram-bot-client.md) | `TelegramBotClient` class, factory, OTel metrics |
|
||||
| Body Builders | [src/utils/build-bodies.md](src/utils/build-bodies.md) | Pure camelCase → snake_case translators |
|
||||
| Error Classifier | [src/utils/classify-error.md](src/utils/classify-error.md) | Pure status + retry-after classifier |
|
||||
| Errors | [src/errors/telegram-client-error.md](src/errors/telegram-client-error.md) | Code registry + typed error class |
|
||||
| Config Schema | [src/types/telegram-bot-config.md](src/types/telegram-bot-config.md) | Zod validation at the boundary |
|
||||
| Types | [src/types/telegram-client.types.md](src/types/telegram-client.types.md) | Public type surface |
|
||||
|
||||
## Subpath Exports
|
||||
|
||||
```ts
|
||||
import { createTelegramBotClient } from "@suaveplan/telegram-client";
|
||||
import {
|
||||
TELEGRAM_CLIENT_CODES,
|
||||
TelegramClientError,
|
||||
} from "@suaveplan/telegram-client/errors";
|
||||
import type { TelegramMessage } from "@suaveplan/telegram-client/types";
|
||||
```
|
||||
|
||||
## Error Codes
|
||||
|
||||
| Code | When |
|
||||
|---|---|
|
||||
| `TELEGRAM_CLIENT.CONFIG_MISSING` | Config rejected by Zod schema (missing token, invalid URL, etc.) |
|
||||
| `TELEGRAM_CLIENT.TIMEOUT` | Request aborted before completion |
|
||||
| `TELEGRAM_CLIENT.AUTH_ERROR` | Telegram returned HTTP 401 |
|
||||
| `TELEGRAM_CLIENT.RATE_LIMITED` | Telegram returned HTTP 429 (`context.retryAfterMs` set if parseable) |
|
||||
| `TELEGRAM_CLIENT.CHAT_NOT_FOUND` | HTTP 404, or HTTP 400 with `chat not found` / `channel not found` |
|
||||
| `TELEGRAM_CLIENT.BOT_BLOCKED` | HTTP 403 with `bot was blocked` / `user is deactivated` |
|
||||
| `TELEGRAM_CLIENT.FORBIDDEN` | Other HTTP 403, or HTTP 400 with `not enough rights` / `forbidden` |
|
||||
| `TELEGRAM_CLIENT.API_ERROR` | Catch-all for unclassified Telegram failures |
|
||||
|
||||
## OTel Instruments
|
||||
|
||||
- Counter `telegram_client.request.total` — keyed by `{ method, outcome }`
|
||||
- Histogram `telegram_client.request.duration_ms` — keyed by `{ method, outcome }`
|
||||
- `outcome ∈ { "success", "error", "timeout" }`
|
||||
|
||||
Call `resetTelegramClientMetrics()` from tests after replacing the global
|
||||
`MeterProvider` so each test sees a fresh meter.
|
||||
|
||||
## Verification
|
||||
|
||||
- Tests use injected `fetch` / `clock` — no mocks, no network.
|
||||
- 100% istanbul coverage on touched modules.
|
||||
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
{
|
||||
"name": "@suaveplan/telegram-client",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"description": "Telegram Bot API client — send/edit/delete messages, media, inline + custom keyboards, typed errors, injectable fetch + clock, OTel instrumented",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"./errors": {
|
||||
"import": {
|
||||
"types": "./dist/errors/telegram-client-error.d.ts",
|
||||
"default": "./dist/errors/telegram-client-error.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/errors/telegram-client-error.d.ts",
|
||||
"default": "./dist/errors/telegram-client-error.js"
|
||||
}
|
||||
},
|
||||
"./types": {
|
||||
"import": {
|
||||
"types": "./dist/types/telegram-client.types.d.ts",
|
||||
"default": "./dist/types/telegram-client.types.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/types/telegram-client.types.d.ts",
|
||||
"default": "./dist/types/telegram-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,5 @@
|
||||
export {
|
||||
createTelegramBotClient,
|
||||
resetTelegramClientMetrics,
|
||||
TelegramBotClient,
|
||||
} from "./telegram-bot-client/telegram-bot-client.js";
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
createTelegramBotClient,
|
||||
resetTelegramClientMetrics,
|
||||
TelegramBotClient,
|
||||
} from "./telegram-bot-client.js";
|
||||
@@ -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,974 @@
|
||||
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 {
|
||||
TELEGRAM_CLIENT_CODES,
|
||||
TelegramClientError,
|
||||
} from "../../errors/telegram-client-error.js";
|
||||
import type {
|
||||
FetchLike,
|
||||
TelegramChat,
|
||||
TelegramFetchResponse,
|
||||
TelegramResponse,
|
||||
TelegramSentMessage,
|
||||
TelegramUser,
|
||||
} from "../../types/telegram-client.types.js";
|
||||
import {
|
||||
createTelegramBotClient,
|
||||
resetTelegramClientMetrics,
|
||||
TelegramBotClient,
|
||||
} from "./telegram-bot-client.js";
|
||||
|
||||
// ── In-memory MeterProvider recording every counter/histogram observation ──
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Fixture builders ──
|
||||
|
||||
const TEST_TOKEN = "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11";
|
||||
|
||||
const sentMessage: TelegramSentMessage = {
|
||||
messageId: 42,
|
||||
chat: { id: 100, type: "private" },
|
||||
date: 1700000000,
|
||||
text: "Hello",
|
||||
};
|
||||
|
||||
const botUser: TelegramUser = {
|
||||
id: 1,
|
||||
isBot: true,
|
||||
firstName: "TestBot",
|
||||
username: "test_bot",
|
||||
};
|
||||
|
||||
const chatInfo: TelegramChat = {
|
||||
id: 100,
|
||||
type: "group",
|
||||
title: "Test Group",
|
||||
};
|
||||
|
||||
function jsonResponse(
|
||||
body: unknown,
|
||||
status = 200,
|
||||
headers: Record<string, string> = {}
|
||||
): TelegramFetchResponse {
|
||||
const lower: Record<string, string> = {};
|
||||
for (const k of Object.keys(headers)) lower[k.toLowerCase()] = headers[k];
|
||||
return {
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
statusText: status === 200 ? "OK" : "ERR",
|
||||
headers: {
|
||||
get(name: string): string | null {
|
||||
const v = lower[name.toLowerCase()];
|
||||
return v === undefined ? null : v;
|
||||
},
|
||||
},
|
||||
text: async () => JSON.stringify(body),
|
||||
json: async () => body,
|
||||
};
|
||||
}
|
||||
|
||||
function okWrap<T>(result: T): TelegramResponse<T> {
|
||||
return { ok: true, result };
|
||||
}
|
||||
|
||||
interface CallRecord {
|
||||
url: string;
|
||||
init: {
|
||||
method: string;
|
||||
headers: Record<string, string>;
|
||||
body?: string;
|
||||
signal?: AbortSignal;
|
||||
};
|
||||
}
|
||||
|
||||
function makeQueueFetch(queue: TelegramFetchResponse[]): {
|
||||
fetch: FetchLike;
|
||||
calls: CallRecord[];
|
||||
} {
|
||||
const calls: CallRecord[] = [];
|
||||
const fetchImpl: FetchLike = async (url, init) => {
|
||||
calls.push({ url, init });
|
||||
const r = queue.shift();
|
||||
if (!r) throw new Error("response queue empty");
|
||||
return r;
|
||||
};
|
||||
return { fetch: fetchImpl, calls };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetTelegramClientMetrics();
|
||||
});
|
||||
|
||||
describe("createTelegramBotClient", () => {
|
||||
it("returns a TelegramBotClient instance", { timeout: 5000 }, () => {
|
||||
const client = createTelegramBotClient({ token: TEST_TOKEN });
|
||||
expect(client).toBeInstanceOf(TelegramBotClient);
|
||||
});
|
||||
});
|
||||
|
||||
describe("TelegramBotClient.sendMessage", () => {
|
||||
it("sends a minimal text message", { timeout: 30000 }, async () => {
|
||||
const { fetch, calls } = makeQueueFetch([
|
||||
jsonResponse(okWrap(sentMessage)),
|
||||
]);
|
||||
const client = createTelegramBotClient(
|
||||
{ token: TEST_TOKEN },
|
||||
{ fetch }
|
||||
);
|
||||
|
||||
const result = await client.sendMessage({ chatId: 100, text: "Hello" });
|
||||
|
||||
expect(result.messageId).toBe(42);
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0].url).toContain(`/bot${TEST_TOKEN}/sendMessage`);
|
||||
const body = JSON.parse(calls[0].init.body as string);
|
||||
expect(body.chat_id).toBe(100);
|
||||
expect(body.text).toBe("Hello");
|
||||
});
|
||||
|
||||
it("sends a message with parse mode and inline keyboard", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const { fetch, calls } = makeQueueFetch([
|
||||
jsonResponse(okWrap(sentMessage)),
|
||||
]);
|
||||
const client = createTelegramBotClient(
|
||||
{ token: TEST_TOKEN },
|
||||
{ fetch }
|
||||
);
|
||||
|
||||
await client.sendMessage({
|
||||
chatId: "100",
|
||||
text: "<b>Bold</b>",
|
||||
parseMode: "HTML",
|
||||
disableWebPagePreview: true,
|
||||
disableNotification: true,
|
||||
replyToMessageId: 10,
|
||||
replyMarkup: {
|
||||
inlineKeyboard: [
|
||||
[{ text: "Click", url: "https://example.com" }],
|
||||
[{ text: "Data", callbackData: "cb_1" }],
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const body = JSON.parse(calls[0].init.body as string);
|
||||
expect(body.parse_mode).toBe("HTML");
|
||||
expect(body.disable_web_page_preview).toBe(true);
|
||||
expect(body.disable_notification).toBe(true);
|
||||
expect(body.reply_to_message_id).toBe(10);
|
||||
expect(body.reply_markup.inline_keyboard).toHaveLength(2);
|
||||
expect(body.reply_markup.inline_keyboard[0][0].url).toBe(
|
||||
"https://example.com"
|
||||
);
|
||||
expect(body.reply_markup.inline_keyboard[1][0].callback_data).toBe(
|
||||
"cb_1"
|
||||
);
|
||||
});
|
||||
|
||||
it("sends a message with custom keyboard", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const { fetch, calls } = makeQueueFetch([
|
||||
jsonResponse(okWrap(sentMessage)),
|
||||
]);
|
||||
const client = createTelegramBotClient(
|
||||
{ token: TEST_TOKEN },
|
||||
{ fetch }
|
||||
);
|
||||
|
||||
await client.sendMessage({
|
||||
chatId: 100,
|
||||
text: "Choose",
|
||||
replyMarkup: {
|
||||
keyboard: [
|
||||
[
|
||||
{ text: "Contact", requestContact: true },
|
||||
{ text: "Location", requestLocation: true },
|
||||
],
|
||||
],
|
||||
oneTimeKeyboard: true,
|
||||
},
|
||||
});
|
||||
|
||||
const body = JSON.parse(calls[0].init.body as string);
|
||||
expect(body.reply_markup.keyboard[0][0].request_contact).toBe(true);
|
||||
expect(body.reply_markup.keyboard[0][1].request_location).toBe(true);
|
||||
expect(body.reply_markup.one_time_keyboard).toBe(true);
|
||||
});
|
||||
|
||||
it("sends a message with removeKeyboard", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const { fetch, calls } = makeQueueFetch([
|
||||
jsonResponse(okWrap(sentMessage)),
|
||||
]);
|
||||
const client = createTelegramBotClient(
|
||||
{ token: TEST_TOKEN },
|
||||
{ fetch }
|
||||
);
|
||||
|
||||
await client.sendMessage({
|
||||
chatId: 100,
|
||||
text: "Done",
|
||||
replyMarkup: { removeKeyboard: true },
|
||||
});
|
||||
const body = JSON.parse(calls[0].init.body as string);
|
||||
expect(body.reply_markup.remove_keyboard).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("TelegramBotClient.editMessage / deleteMessage", () => {
|
||||
it("edits a message", { timeout: 30000 }, async () => {
|
||||
const { fetch, calls } = makeQueueFetch([
|
||||
jsonResponse(okWrap(sentMessage)),
|
||||
]);
|
||||
const client = createTelegramBotClient(
|
||||
{ token: TEST_TOKEN },
|
||||
{ fetch }
|
||||
);
|
||||
|
||||
const result = await client.editMessage(100, 42, "Updated", "HTML");
|
||||
|
||||
expect(result.messageId).toBe(42);
|
||||
expect(calls[0].url).toContain("/editMessageText");
|
||||
const body = JSON.parse(calls[0].init.body as string);
|
||||
expect(body.text).toBe("Updated");
|
||||
expect(body.parse_mode).toBe("HTML");
|
||||
});
|
||||
|
||||
it("edits a message without parseMode", { timeout: 30000 }, async () => {
|
||||
const { fetch, calls } = makeQueueFetch([
|
||||
jsonResponse(okWrap(sentMessage)),
|
||||
]);
|
||||
const client = createTelegramBotClient(
|
||||
{ token: TEST_TOKEN },
|
||||
{ fetch }
|
||||
);
|
||||
|
||||
await client.editMessage(100, 42, "Updated");
|
||||
const body = JSON.parse(calls[0].init.body as string);
|
||||
expect(body.parse_mode).toBeUndefined();
|
||||
});
|
||||
|
||||
it("deletes a message", { timeout: 30000 }, async () => {
|
||||
const { fetch, calls } = makeQueueFetch([jsonResponse(okWrap(true))]);
|
||||
const client = createTelegramBotClient(
|
||||
{ token: TEST_TOKEN },
|
||||
{ fetch }
|
||||
);
|
||||
|
||||
const result = await client.deleteMessage(100, 42);
|
||||
expect(result).toBe(true);
|
||||
expect(calls[0].url).toContain("/deleteMessage");
|
||||
});
|
||||
});
|
||||
|
||||
describe("TelegramBotClient.sendMedia", () => {
|
||||
it("sends media with caption", { timeout: 30000 }, async () => {
|
||||
const { fetch, calls } = makeQueueFetch([
|
||||
jsonResponse(okWrap(sentMessage)),
|
||||
]);
|
||||
const client = createTelegramBotClient(
|
||||
{ token: TEST_TOKEN },
|
||||
{ fetch }
|
||||
);
|
||||
|
||||
await client.sendMedia(
|
||||
100,
|
||||
"photo",
|
||||
"https://example.com/img.jpg",
|
||||
"A photo"
|
||||
);
|
||||
expect(calls[0].url).toContain("/sendPhoto");
|
||||
const body = JSON.parse(calls[0].init.body as string);
|
||||
expect(body.photo).toBe("https://example.com/img.jpg");
|
||||
expect(body.caption).toBe("A photo");
|
||||
});
|
||||
|
||||
it("sends media without caption", { timeout: 30000 }, async () => {
|
||||
const { fetch, calls } = makeQueueFetch([
|
||||
jsonResponse(okWrap(sentMessage)),
|
||||
]);
|
||||
const client = createTelegramBotClient(
|
||||
{ token: TEST_TOKEN },
|
||||
{ fetch }
|
||||
);
|
||||
|
||||
await client.sendMedia(100, "document", "https://example.com/f.pdf");
|
||||
expect(calls[0].url).toContain("/sendDocument");
|
||||
const body = JSON.parse(calls[0].init.body as string);
|
||||
expect(body.document).toBe("https://example.com/f.pdf");
|
||||
expect(body.caption).toBeUndefined();
|
||||
});
|
||||
|
||||
it("maps every media type to its endpoint", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const expected = [
|
||||
["video", "sendVideo"],
|
||||
["audio", "sendAudio"],
|
||||
["voice", "sendVoice"],
|
||||
["animation", "sendAnimation"],
|
||||
] as const;
|
||||
|
||||
for (const [type, endpoint] of expected) {
|
||||
const { fetch, calls } = makeQueueFetch([
|
||||
jsonResponse(okWrap(sentMessage)),
|
||||
]);
|
||||
const client = createTelegramBotClient(
|
||||
{ token: TEST_TOKEN },
|
||||
{ fetch }
|
||||
);
|
||||
await client.sendMedia(100, type, "https://example.com/file");
|
||||
expect(calls[0].url).toContain(`/${endpoint}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("TelegramBotClient.getChat / getMe", () => {
|
||||
it("retrieves chat info", { timeout: 30000 }, async () => {
|
||||
const { fetch } = makeQueueFetch([jsonResponse(okWrap(chatInfo))]);
|
||||
const client = createTelegramBotClient(
|
||||
{ token: TEST_TOKEN },
|
||||
{ fetch }
|
||||
);
|
||||
|
||||
const result = await client.getChat(100);
|
||||
expect(result.id).toBe(100);
|
||||
expect(result.type).toBe("group");
|
||||
expect(result.title).toBe("Test Group");
|
||||
});
|
||||
|
||||
it("retrieves bot info", { timeout: 30000 }, async () => {
|
||||
const { fetch } = makeQueueFetch([jsonResponse(okWrap(botUser))]);
|
||||
const client = createTelegramBotClient(
|
||||
{ token: TEST_TOKEN },
|
||||
{ fetch }
|
||||
);
|
||||
|
||||
const result = await client.getMe();
|
||||
expect(result.id).toBe(1);
|
||||
expect(result.isBot).toBe(true);
|
||||
expect(result.firstName).toBe("TestBot");
|
||||
});
|
||||
});
|
||||
|
||||
describe("TelegramBotClient error classification", () => {
|
||||
it("throws API_ERROR for ok:false body", { timeout: 30000 }, async () => {
|
||||
const { fetch } = makeQueueFetch([
|
||||
jsonResponse(
|
||||
{
|
||||
ok: false,
|
||||
description: "Bad Request: chat not found",
|
||||
error_code: 400,
|
||||
},
|
||||
200
|
||||
),
|
||||
]);
|
||||
const client = createTelegramBotClient(
|
||||
{ token: TEST_TOKEN },
|
||||
{ fetch }
|
||||
);
|
||||
|
||||
await expect(
|
||||
client.sendMessage({ chatId: 999, text: "Hi" })
|
||||
).rejects.toSatisfy(
|
||||
(err: unknown) =>
|
||||
err instanceof TelegramClientError &&
|
||||
err.code === "TELEGRAM_CLIENT.API_ERROR"
|
||||
);
|
||||
});
|
||||
|
||||
it("throws AUTH_ERROR for HTTP 401", { timeout: 30000 }, async () => {
|
||||
const { fetch } = makeQueueFetch([
|
||||
jsonResponse({ ok: false, description: "Unauthorized" }, 401),
|
||||
]);
|
||||
const client = createTelegramBotClient(
|
||||
{ token: TEST_TOKEN },
|
||||
{ fetch }
|
||||
);
|
||||
|
||||
await expect(
|
||||
client.sendMessage({ chatId: 100, text: "Hi" })
|
||||
).rejects.toSatisfy(
|
||||
(err: unknown) =>
|
||||
err instanceof TelegramClientError &&
|
||||
err.code === "TELEGRAM_CLIENT.AUTH_ERROR"
|
||||
);
|
||||
});
|
||||
|
||||
it("throws CHAT_NOT_FOUND for HTTP 404", { timeout: 30000 }, async () => {
|
||||
const { fetch } = makeQueueFetch([
|
||||
jsonResponse(
|
||||
{ ok: false, description: "Bad Request: chat not found" },
|
||||
404
|
||||
),
|
||||
]);
|
||||
const client = createTelegramBotClient(
|
||||
{ token: TEST_TOKEN },
|
||||
{ fetch }
|
||||
);
|
||||
|
||||
await expect(
|
||||
client.sendMessage({ chatId: 999, text: "Hi" })
|
||||
).rejects.toSatisfy(
|
||||
(err: unknown) =>
|
||||
err instanceof TelegramClientError &&
|
||||
err.code === "TELEGRAM_CLIENT.CHAT_NOT_FOUND"
|
||||
);
|
||||
});
|
||||
|
||||
it("throws CHAT_NOT_FOUND for HTTP 400 with chat not found description", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const { fetch } = makeQueueFetch([
|
||||
jsonResponse(
|
||||
{ ok: false, description: "Bad Request: chat not found" },
|
||||
400
|
||||
),
|
||||
]);
|
||||
const client = createTelegramBotClient(
|
||||
{ token: TEST_TOKEN },
|
||||
{ fetch }
|
||||
);
|
||||
|
||||
await expect(
|
||||
client.sendMessage({ chatId: 999, text: "Hi" })
|
||||
).rejects.toSatisfy(
|
||||
(err: unknown) =>
|
||||
err instanceof TelegramClientError &&
|
||||
err.code === "TELEGRAM_CLIENT.CHAT_NOT_FOUND"
|
||||
);
|
||||
});
|
||||
|
||||
it("throws BOT_BLOCKED for HTTP 403 with blocked description", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const { fetch } = makeQueueFetch([
|
||||
jsonResponse(
|
||||
{
|
||||
ok: false,
|
||||
description: "Forbidden: bot was blocked by the user",
|
||||
},
|
||||
403
|
||||
),
|
||||
]);
|
||||
const client = createTelegramBotClient(
|
||||
{ token: TEST_TOKEN },
|
||||
{ fetch }
|
||||
);
|
||||
|
||||
await expect(
|
||||
client.sendMessage({ chatId: 100, text: "Hi" })
|
||||
).rejects.toSatisfy(
|
||||
(err: unknown) =>
|
||||
err instanceof TelegramClientError &&
|
||||
err.code === "TELEGRAM_CLIENT.BOT_BLOCKED"
|
||||
);
|
||||
});
|
||||
|
||||
it("throws FORBIDDEN for HTTP 403 generic", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const { fetch } = makeQueueFetch([
|
||||
jsonResponse(
|
||||
{ ok: false, description: "Forbidden: not enough rights" },
|
||||
403
|
||||
),
|
||||
]);
|
||||
const client = createTelegramBotClient(
|
||||
{ token: TEST_TOKEN },
|
||||
{ fetch }
|
||||
);
|
||||
|
||||
await expect(
|
||||
client.sendMessage({ chatId: 100, text: "Hi" })
|
||||
).rejects.toSatisfy(
|
||||
(err: unknown) =>
|
||||
err instanceof TelegramClientError &&
|
||||
err.code === "TELEGRAM_CLIENT.FORBIDDEN"
|
||||
);
|
||||
});
|
||||
|
||||
it("throws FORBIDDEN for HTTP 400 with not enough rights", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const { fetch } = makeQueueFetch([
|
||||
jsonResponse(
|
||||
{
|
||||
ok: false,
|
||||
description:
|
||||
"Bad Request: not enough rights to send messages",
|
||||
},
|
||||
400
|
||||
),
|
||||
]);
|
||||
const client = createTelegramBotClient(
|
||||
{ token: TEST_TOKEN },
|
||||
{ fetch }
|
||||
);
|
||||
|
||||
await expect(
|
||||
client.sendMessage({ chatId: 100, text: "Hi" })
|
||||
).rejects.toSatisfy(
|
||||
(err: unknown) =>
|
||||
err instanceof TelegramClientError &&
|
||||
err.code === "TELEGRAM_CLIENT.FORBIDDEN"
|
||||
);
|
||||
});
|
||||
|
||||
it("throws RATE_LIMITED with retryAfterMs from parameters.retry_after", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const { fetch } = makeQueueFetch([
|
||||
jsonResponse(
|
||||
{
|
||||
ok: false,
|
||||
description: "Too Many Requests: retry after 30",
|
||||
parameters: { retry_after: 30 },
|
||||
},
|
||||
429
|
||||
),
|
||||
]);
|
||||
const client = createTelegramBotClient(
|
||||
{ token: TEST_TOKEN },
|
||||
{ fetch }
|
||||
);
|
||||
|
||||
let caught: TelegramClientError | undefined;
|
||||
try {
|
||||
await client.sendMessage({ chatId: 100, text: "Hi" });
|
||||
} catch (err) {
|
||||
caught = err as TelegramClientError;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(TelegramClientError);
|
||||
expect(caught?.code).toBe("TELEGRAM_CLIENT.RATE_LIMITED");
|
||||
expect(
|
||||
(caught?.context as { retryAfterMs?: number } | undefined)
|
||||
?.retryAfterMs
|
||||
).toBe(30_000);
|
||||
});
|
||||
|
||||
it("throws API_ERROR on unparseable JSON body", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const r: TelegramFetchResponse = {
|
||||
ok: false,
|
||||
status: 500,
|
||||
statusText: "Internal",
|
||||
headers: { get: () => null },
|
||||
text: async () => "<<not-json>>",
|
||||
json: async () => ({}),
|
||||
};
|
||||
const { fetch } = makeQueueFetch([r]);
|
||||
const client = createTelegramBotClient(
|
||||
{ token: TEST_TOKEN },
|
||||
{ fetch }
|
||||
);
|
||||
|
||||
await expect(
|
||||
client.sendMessage({ chatId: 1, text: "x" })
|
||||
).rejects.toSatisfy(
|
||||
(err: unknown) =>
|
||||
err instanceof TelegramClientError &&
|
||||
err.code === "TELEGRAM_CLIENT.API_ERROR"
|
||||
);
|
||||
});
|
||||
|
||||
it("throws API_ERROR when text() rejects", { timeout: 30000 }, async () => {
|
||||
const r: TelegramFetchResponse = {
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
headers: { get: () => null },
|
||||
text: async () => {
|
||||
throw new Error("read-failed");
|
||||
},
|
||||
json: async () => ({}),
|
||||
};
|
||||
const { fetch } = makeQueueFetch([r]);
|
||||
const client = createTelegramBotClient(
|
||||
{ token: TEST_TOKEN },
|
||||
{ fetch }
|
||||
);
|
||||
|
||||
await expect(
|
||||
client.sendMessage({ chatId: 1, text: "x" })
|
||||
).rejects.toSatisfy(
|
||||
(err: unknown) =>
|
||||
err instanceof TelegramClientError &&
|
||||
err.code === "TELEGRAM_CLIENT.API_ERROR"
|
||||
);
|
||||
});
|
||||
|
||||
it("throws API_ERROR when text() rejects with a non-Error value", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const nonError: unknown = "read-failed-string";
|
||||
const r: TelegramFetchResponse = {
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
headers: { get: () => null },
|
||||
text: () => Promise.reject(nonError),
|
||||
json: async () => ({}),
|
||||
};
|
||||
const { fetch } = makeQueueFetch([r]);
|
||||
const client = createTelegramBotClient(
|
||||
{ token: TEST_TOKEN },
|
||||
{ fetch }
|
||||
);
|
||||
|
||||
await expect(
|
||||
client.sendMessage({ chatId: 1, text: "x" })
|
||||
).rejects.toSatisfy(
|
||||
(err: unknown) =>
|
||||
err instanceof TelegramClientError &&
|
||||
err.code === "TELEGRAM_CLIENT.API_ERROR"
|
||||
);
|
||||
});
|
||||
|
||||
it("treats empty response body as API_ERROR", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const r: TelegramFetchResponse = {
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
headers: { get: () => null },
|
||||
text: async () => "",
|
||||
json: async () => ({}),
|
||||
};
|
||||
const { fetch } = makeQueueFetch([r]);
|
||||
const client = createTelegramBotClient(
|
||||
{ token: TEST_TOKEN },
|
||||
{ fetch }
|
||||
);
|
||||
|
||||
await expect(
|
||||
client.sendMessage({ chatId: 1, text: "x" })
|
||||
).rejects.toSatisfy(
|
||||
(err: unknown) =>
|
||||
err instanceof TelegramClientError &&
|
||||
err.code === "TELEGRAM_CLIENT.API_ERROR"
|
||||
);
|
||||
});
|
||||
|
||||
it("throws API_ERROR with NETWORK cause on generic fetch failure", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const fetchImpl: FetchLike = async () => {
|
||||
throw new TypeError("fetch failed");
|
||||
};
|
||||
const client = createTelegramBotClient(
|
||||
{ token: TEST_TOKEN },
|
||||
{ fetch: fetchImpl }
|
||||
);
|
||||
|
||||
await expect(
|
||||
client.sendMessage({ chatId: 100, text: "Hi" })
|
||||
).rejects.toSatisfy(
|
||||
(err: unknown) =>
|
||||
err instanceof TelegramClientError &&
|
||||
err.code === "TELEGRAM_CLIENT.API_ERROR"
|
||||
);
|
||||
});
|
||||
|
||||
it("throws API_ERROR when fetch rejects with non-Error value", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const nonError: unknown = "boom";
|
||||
const fetchImpl: FetchLike = () => Promise.reject(nonError);
|
||||
const client = createTelegramBotClient(
|
||||
{ token: TEST_TOKEN },
|
||||
{ fetch: fetchImpl }
|
||||
);
|
||||
|
||||
await expect(
|
||||
client.sendMessage({ chatId: 100, text: "Hi" })
|
||||
).rejects.toSatisfy(
|
||||
(err: unknown) =>
|
||||
err instanceof TelegramClientError &&
|
||||
err.code === "TELEGRAM_CLIENT.API_ERROR"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("TelegramBotClient timeout / config / base URL", () => {
|
||||
it("throws TIMEOUT when the request is aborted", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const fetchImpl: FetchLike = (_, init) =>
|
||||
new Promise<TelegramFetchResponse>((_resolve, reject) => {
|
||||
init.signal?.addEventListener("abort", () => {
|
||||
const e = new Error("aborted");
|
||||
e.name = "AbortError";
|
||||
reject(e);
|
||||
});
|
||||
});
|
||||
|
||||
const client = createTelegramBotClient(
|
||||
{ token: TEST_TOKEN, timeoutMs: 5 },
|
||||
{ fetch: fetchImpl }
|
||||
);
|
||||
|
||||
let caught: TelegramClientError | undefined;
|
||||
try {
|
||||
await client.sendMessage({ chatId: 1, text: "x" });
|
||||
} catch (err) {
|
||||
caught = err as TelegramClientError;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(TelegramClientError);
|
||||
expect(caught?.code).toBe(TELEGRAM_CLIENT_CODES.TIMEOUT);
|
||||
});
|
||||
|
||||
it("throws CONFIG_MISSING when constructed with empty token", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() => new TelegramBotClient({ token: "" })).toThrowError(
|
||||
TelegramClientError
|
||||
);
|
||||
});
|
||||
|
||||
it("uses a custom base URL", { timeout: 30000 }, async () => {
|
||||
const { fetch, calls } = makeQueueFetch([
|
||||
jsonResponse(okWrap(botUser)),
|
||||
]);
|
||||
const client = createTelegramBotClient(
|
||||
{
|
||||
token: TEST_TOKEN,
|
||||
baseUrl: "https://custom-api.example.com",
|
||||
},
|
||||
{ fetch }
|
||||
);
|
||||
|
||||
await client.getMe();
|
||||
expect(calls[0].url).toContain("https://custom-api.example.com/bot");
|
||||
});
|
||||
|
||||
it("uses the injected fetch for every call", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const { fetch, calls } = makeQueueFetch([
|
||||
jsonResponse(okWrap(botUser)),
|
||||
jsonResponse(okWrap(chatInfo)),
|
||||
]);
|
||||
const client = createTelegramBotClient(
|
||||
{ token: TEST_TOKEN },
|
||||
{ fetch }
|
||||
);
|
||||
|
||||
await client.getMe();
|
||||
await client.getChat(100);
|
||||
expect(calls).toHaveLength(2);
|
||||
expect(calls[0].url).toContain("/getMe");
|
||||
expect(calls[1].url).toContain("/getChat");
|
||||
});
|
||||
|
||||
it("uses the injected clock for duration measurement", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const provider = makeRecordingProvider();
|
||||
metrics.setGlobalMeterProvider(provider.provider);
|
||||
resetTelegramClientMetrics();
|
||||
|
||||
let tick = 0;
|
||||
const ticks = [1000, 1300]; // start, end
|
||||
const fakeClock = (): number => {
|
||||
const v = ticks[Math.min(tick, ticks.length - 1)] ?? 1300;
|
||||
tick++;
|
||||
return v;
|
||||
};
|
||||
|
||||
const { fetch } = makeQueueFetch([jsonResponse(okWrap(botUser))]);
|
||||
const client = createTelegramBotClient(
|
||||
{ token: TEST_TOKEN },
|
||||
{ fetch, clock: fakeClock }
|
||||
);
|
||||
|
||||
await client.getMe();
|
||||
|
||||
const hist = provider.histograms.find(
|
||||
(h) => h.name === "telegram_client.request.duration_ms"
|
||||
);
|
||||
expect(hist?.value).toBe(300);
|
||||
|
||||
metrics.disable();
|
||||
});
|
||||
});
|
||||
|
||||
describe("TelegramBotClient OTel metrics", () => {
|
||||
it("emits OTel success metrics against a real MeterProvider", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const provider = makeRecordingProvider();
|
||||
metrics.setGlobalMeterProvider(provider.provider);
|
||||
resetTelegramClientMetrics();
|
||||
|
||||
const { fetch } = makeQueueFetch([jsonResponse(okWrap(botUser))]);
|
||||
const client = createTelegramBotClient(
|
||||
{ token: TEST_TOKEN },
|
||||
{ fetch, clock: () => 1000 }
|
||||
);
|
||||
|
||||
await client.getMe();
|
||||
|
||||
expect(provider.counterNames).toContain(
|
||||
"telegram_client.request.total"
|
||||
);
|
||||
expect(provider.histogramNames).toContain(
|
||||
"telegram_client.request.duration_ms"
|
||||
);
|
||||
const counter = provider.counters.find(
|
||||
(c) => c.attrs?.outcome === "success"
|
||||
);
|
||||
expect(counter?.attrs?.method).toBe("getMe");
|
||||
expect(counter?.value).toBe(1);
|
||||
|
||||
metrics.disable();
|
||||
});
|
||||
|
||||
it("emits OTel error outcome on classified failure", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const provider = makeRecordingProvider();
|
||||
metrics.setGlobalMeterProvider(provider.provider);
|
||||
resetTelegramClientMetrics();
|
||||
|
||||
const { fetch } = makeQueueFetch([
|
||||
jsonResponse({ ok: false, description: "Unauthorized" }, 401),
|
||||
]);
|
||||
const client = createTelegramBotClient(
|
||||
{ token: TEST_TOKEN },
|
||||
{ fetch, clock: () => 1000 }
|
||||
);
|
||||
|
||||
await expect(
|
||||
client.sendMessage({ chatId: 100, text: "x" })
|
||||
).rejects.toBeInstanceOf(TelegramClientError);
|
||||
|
||||
const counter = provider.counters.find(
|
||||
(c) => c.attrs?.outcome === "error"
|
||||
);
|
||||
expect(counter).toBeDefined();
|
||||
expect(counter?.attrs?.method).toBe("sendMessage");
|
||||
|
||||
metrics.disable();
|
||||
});
|
||||
|
||||
it("emits OTel timeout outcome on abort", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const provider = makeRecordingProvider();
|
||||
metrics.setGlobalMeterProvider(provider.provider);
|
||||
resetTelegramClientMetrics();
|
||||
|
||||
const fetchImpl: FetchLike = (_, init) =>
|
||||
new Promise<TelegramFetchResponse>((_resolve, reject) => {
|
||||
init.signal?.addEventListener("abort", () => {
|
||||
const e = new Error("aborted");
|
||||
e.name = "AbortError";
|
||||
reject(e);
|
||||
});
|
||||
});
|
||||
|
||||
const client = createTelegramBotClient(
|
||||
{ token: TEST_TOKEN, timeoutMs: 5 },
|
||||
{ fetch: fetchImpl, clock: () => 1000 }
|
||||
);
|
||||
await expect(
|
||||
client.sendMessage({ chatId: 1, text: "x" })
|
||||
).rejects.toBeInstanceOf(TelegramClientError);
|
||||
|
||||
const counter = provider.counters.find(
|
||||
(c) => c.attrs?.outcome === "timeout"
|
||||
);
|
||||
expect(counter).toBeDefined();
|
||||
|
||||
metrics.disable();
|
||||
});
|
||||
});
|
||||
|
||||
describe("TelegramBotClient default fetch adapter", () => {
|
||||
it("falls back to globalThis.fetch when none injected", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
let called = 0;
|
||||
globalThis.fetch = (async () => {
|
||||
called++;
|
||||
return new Response(JSON.stringify(okWrap(botUser)), {
|
||||
status: 200,
|
||||
});
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
try {
|
||||
const client = createTelegramBotClient({ token: TEST_TOKEN });
|
||||
const result = await client.getMe();
|
||||
expect(result.firstName).toBe("TestBot");
|
||||
expect(called).toBe(1);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,314 @@
|
||||
import type { Counter, Histogram, Meter } from "@opentelemetry/api";
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
import type { Clock } from "@suaveplan/types";
|
||||
|
||||
import {
|
||||
TELEGRAM_CLIENT_CODES,
|
||||
TelegramClientError,
|
||||
} from "../../errors/telegram-client-error.js";
|
||||
import { TelegramBotConfigSchema } from "../../types/telegram-bot-config.js";
|
||||
import type {
|
||||
FetchLike,
|
||||
TelegramBotConfig,
|
||||
TelegramBotInjectables,
|
||||
TelegramChat,
|
||||
TelegramFetchResponse,
|
||||
TelegramMediaType,
|
||||
TelegramMessage,
|
||||
TelegramResponse,
|
||||
TelegramSentMessage,
|
||||
TelegramUser,
|
||||
} from "../../types/telegram-client.types.js";
|
||||
import {
|
||||
buildSendMessageBody,
|
||||
MEDIA_METHOD_MAP,
|
||||
} from "../../utils/build-bodies.js";
|
||||
import {
|
||||
classifyStatus,
|
||||
parseRetryAfterMs,
|
||||
} from "../../utils/classify-error.js";
|
||||
|
||||
const DEFAULT_BASE_URL = "https://api.telegram.org";
|
||||
const DEFAULT_TIMEOUT_MS = 10_000;
|
||||
|
||||
/** 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/telegram-client");
|
||||
}
|
||||
if (!requestCounter) {
|
||||
requestCounter = cachedMeter.createCounter(
|
||||
"telegram_client.request.total",
|
||||
{ description: "Total Telegram Bot API requests by outcome" }
|
||||
);
|
||||
}
|
||||
if (!requestDuration) {
|
||||
requestDuration = cachedMeter.createHistogram(
|
||||
"telegram_client.request.duration_ms",
|
||||
{
|
||||
description:
|
||||
"Round-trip duration of every Telegram request in ms",
|
||||
unit: "ms",
|
||||
}
|
||||
);
|
||||
}
|
||||
return { counter: requestCounter, histogram: requestDuration };
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the cached OTel meter handles. Tests use this after pointing
|
||||
* `@opentelemetry/api`'s global meter provider at an in-memory recorder.
|
||||
*/
|
||||
export function resetTelegramClientMetrics(): void {
|
||||
requestCounter = undefined;
|
||||
requestDuration = undefined;
|
||||
cachedMeter = undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Telegram Bot API client.
|
||||
*
|
||||
* Wraps the Bot API endpoints used in production bot workflows
|
||||
* (`sendMessage`, `editMessageText`, `deleteMessage`, the six media
|
||||
* variants, `getChat`, `getMe`) behind a typed, runtime-agnostic surface.
|
||||
*
|
||||
* Construction validates `TelegramBotConfig` via Zod; failures raise
|
||||
* `TelegramClientError` with code `CONFIG_MISSING`. All I/O goes through
|
||||
* an injectable `fetch` and durations are measured via an injectable
|
||||
* `clock` for deterministic tests.
|
||||
*/
|
||||
export class TelegramBotClient {
|
||||
protected readonly token: string;
|
||||
protected readonly baseUrl: string;
|
||||
protected readonly timeoutMs: number;
|
||||
protected readonly fetchImpl: FetchLike;
|
||||
protected readonly clock: Clock;
|
||||
|
||||
constructor(
|
||||
config: TelegramBotConfig,
|
||||
injectables?: TelegramBotInjectables
|
||||
) {
|
||||
const parsed = TelegramBotConfigSchema.safeParse(config);
|
||||
if (!parsed.success) {
|
||||
throw new TelegramClientError(
|
||||
TELEGRAM_CLIENT_CODES.CONFIG_MISSING,
|
||||
"Telegram bot configuration is invalid",
|
||||
{ context: { issues: parsed.error.issues } }
|
||||
);
|
||||
}
|
||||
this.token = parsed.data.token;
|
||||
this.baseUrl = parsed.data.baseUrl ?? DEFAULT_BASE_URL;
|
||||
this.timeoutMs = parsed.data.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
||||
this.fetchImpl =
|
||||
injectables?.fetch ?? (defaultFetchAdapter as unknown as FetchLike);
|
||||
this.clock = injectables?.clock ?? (() => Date.now());
|
||||
}
|
||||
|
||||
/** Send a text message to a chat. */
|
||||
async sendMessage(message: TelegramMessage): Promise<TelegramSentMessage> {
|
||||
const body = buildSendMessageBody(message);
|
||||
return this.request<TelegramSentMessage>("sendMessage", body);
|
||||
}
|
||||
|
||||
/** Edit the text of a previously sent message. */
|
||||
async editMessage(
|
||||
chatId: string | number,
|
||||
messageId: number,
|
||||
text: string,
|
||||
parseMode?: string
|
||||
): Promise<TelegramSentMessage> {
|
||||
const body: Record<string, unknown> = {
|
||||
chat_id: chatId,
|
||||
message_id: messageId,
|
||||
text,
|
||||
};
|
||||
if (parseMode) body["parse_mode"] = parseMode;
|
||||
return this.request<TelegramSentMessage>("editMessageText", body);
|
||||
}
|
||||
|
||||
/** Delete a message from a chat. */
|
||||
async deleteMessage(
|
||||
chatId: string | number,
|
||||
messageId: number
|
||||
): Promise<boolean> {
|
||||
return this.request<boolean>("deleteMessage", {
|
||||
chat_id: chatId,
|
||||
message_id: messageId,
|
||||
});
|
||||
}
|
||||
|
||||
/** Send a media file (photo, document, video, etc.) by URL. */
|
||||
async sendMedia(
|
||||
chatId: string | number,
|
||||
type: TelegramMediaType,
|
||||
url: string,
|
||||
caption?: string
|
||||
): Promise<TelegramSentMessage> {
|
||||
const method = MEDIA_METHOD_MAP[type];
|
||||
const body: Record<string, unknown> = {
|
||||
chat_id: chatId,
|
||||
[type]: url,
|
||||
};
|
||||
if (caption) body["caption"] = caption;
|
||||
return this.request<TelegramSentMessage>(method, body);
|
||||
}
|
||||
|
||||
/** Get information about a chat. */
|
||||
async getChat(chatId: string | number): Promise<TelegramChat> {
|
||||
return this.request<TelegramChat>("getChat", { chat_id: chatId });
|
||||
}
|
||||
|
||||
/** Get information about the bot itself. */
|
||||
async getMe(): Promise<TelegramUser> {
|
||||
return this.request<TelegramUser>("getMe", {});
|
||||
}
|
||||
|
||||
/**
|
||||
* Make an authenticated POST request to the Telegram Bot API, applying
|
||||
* the per-client timeout, parsing the response envelope, mapping
|
||||
* failures onto `TelegramClientError`, and emitting OTel metrics.
|
||||
*/
|
||||
protected async request<T>(
|
||||
method: string,
|
||||
body: Record<string, unknown>
|
||||
): Promise<T> {
|
||||
const url = `${this.baseUrl}/bot${this.token}/${method}`;
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), this.timeoutMs);
|
||||
const started = this.clock();
|
||||
const { counter, histogram } = getMetrics();
|
||||
|
||||
const recordOutcome = (outcome: string): void => {
|
||||
const duration = this.clock() - started;
|
||||
const labels = { method, outcome };
|
||||
counter.add(1, labels);
|
||||
histogram.record(duration, labels);
|
||||
};
|
||||
|
||||
let response: TelegramFetchResponse;
|
||||
try {
|
||||
response = await this.fetchImpl(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
signal: controller.signal,
|
||||
});
|
||||
} catch (err) {
|
||||
clearTimeout(timer);
|
||||
const cause = err instanceof Error ? err : new Error(String(err));
|
||||
if (cause.name === "AbortError") {
|
||||
recordOutcome("timeout");
|
||||
throw new TelegramClientError(
|
||||
TELEGRAM_CLIENT_CODES.TIMEOUT,
|
||||
`Request to Telegram method '${method}' timed out after ${this.timeoutMs}ms`,
|
||||
{
|
||||
context: { method, timeoutMs: this.timeoutMs },
|
||||
cause,
|
||||
}
|
||||
);
|
||||
}
|
||||
recordOutcome("error");
|
||||
throw new TelegramClientError(
|
||||
TELEGRAM_CLIENT_CODES.API_ERROR,
|
||||
`Network error calling Telegram method '${method}': ${cause.message}`,
|
||||
{ context: { url, method }, cause }
|
||||
);
|
||||
}
|
||||
clearTimeout(timer);
|
||||
|
||||
let data: TelegramResponse<T>;
|
||||
try {
|
||||
const text = await response.text();
|
||||
data = text
|
||||
? (safeParse<TelegramResponse<T>>(text) as TelegramResponse<T>)
|
||||
: ({ ok: false } as TelegramResponse<T>);
|
||||
} catch (err) {
|
||||
recordOutcome("error");
|
||||
const cause = err instanceof Error ? err : new Error(String(err));
|
||||
throw new TelegramClientError(
|
||||
TELEGRAM_CLIENT_CODES.API_ERROR,
|
||||
`Failed to read Telegram '${method}' response body: ${cause.message}`,
|
||||
{ context: { url, method }, cause }
|
||||
);
|
||||
}
|
||||
|
||||
if (!response.ok || !data.ok) {
|
||||
recordOutcome("error");
|
||||
throw toTypedError(response, data, method, url, this.clock);
|
||||
}
|
||||
|
||||
recordOutcome("success");
|
||||
return data.result as T;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory for {@link TelegramBotClient}. Preferred over `new` for cleaner
|
||||
* call sites and forward compatibility with future option bundles.
|
||||
*/
|
||||
export function createTelegramBotClient(
|
||||
config: TelegramBotConfig,
|
||||
injectables?: TelegramBotInjectables
|
||||
): TelegramBotClient {
|
||||
return new TelegramBotClient(config, injectables);
|
||||
}
|
||||
|
||||
/** Parse JSON without throwing; returns `{ ok: false }` on failure. */
|
||||
function safeParse<T>(text: string): T | { ok: false } {
|
||||
try {
|
||||
return JSON.parse(text) as T;
|
||||
} catch {
|
||||
return { ok: false };
|
||||
}
|
||||
}
|
||||
|
||||
/** Classify a non-OK Telegram response into a `TelegramClientError`. */
|
||||
function toTypedError(
|
||||
response: TelegramFetchResponse,
|
||||
data: TelegramResponse<unknown>,
|
||||
method: string,
|
||||
url: string,
|
||||
clock: Clock
|
||||
): TelegramClientError {
|
||||
const status = response.status;
|
||||
const description =
|
||||
data.description ?? `Telegram API error (HTTP ${status})`;
|
||||
|
||||
if (status === 429) {
|
||||
const retryAfterMs = parseRetryAfterMs(response, data, clock);
|
||||
return new TelegramClientError(
|
||||
TELEGRAM_CLIENT_CODES.RATE_LIMITED,
|
||||
description,
|
||||
{
|
||||
context: {
|
||||
method,
|
||||
url,
|
||||
retryAfterMs,
|
||||
telegramErrorCode: data.errorCode,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const code = classifyStatus(status, description, data);
|
||||
return new TelegramClientError(code, description, {
|
||||
context: {
|
||||
url,
|
||||
method,
|
||||
statusCode: status,
|
||||
telegramErrorCode: data.errorCode,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Adapter that lets the standard `globalThis.fetch` satisfy the tighter
|
||||
* `FetchLike` contract (it returns a `Response`, which is a superset of
|
||||
* `TelegramFetchResponse`).
|
||||
*/
|
||||
const defaultFetchAdapter: FetchLike = (url, init) =>
|
||||
globalThis.fetch(url, init) as unknown as Promise<TelegramFetchResponse>;
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
TELEGRAM_CLIENT_CODES,
|
||||
type TelegramClientCode,
|
||||
TelegramClientError,
|
||||
} from "./telegram-client-error.js";
|
||||
@@ -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,186 @@
|
||||
import { SuaveplanError } from "@suaveplan/error";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
|
||||
import { createTelegramBotClient } from "../core/telegram-bot-client/telegram-bot-client.js";
|
||||
import type {
|
||||
FetchLike,
|
||||
TelegramFetchResponse,
|
||||
} from "../types/telegram-client.types.js";
|
||||
import {
|
||||
TELEGRAM_CLIENT_CODES,
|
||||
TelegramClientError,
|
||||
} from "./telegram-client-error.js";
|
||||
|
||||
const TEST_TOKEN = "123:abc";
|
||||
|
||||
function jsonResponse(body: unknown, status = 200): TelegramFetchResponse {
|
||||
const headers: Record<string, string> = {};
|
||||
return {
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
statusText: "OK",
|
||||
headers: {
|
||||
get(name: string): string | null {
|
||||
const v = headers[name.toLowerCase()];
|
||||
return v === undefined ? null : v;
|
||||
},
|
||||
},
|
||||
text: async () => JSON.stringify(body),
|
||||
json: async () => body,
|
||||
};
|
||||
}
|
||||
|
||||
describe("TELEGRAM_CLIENT_CODES", () => {
|
||||
it("code constants are namespaced literals", { timeout: 5000 }, () => {
|
||||
expect(TELEGRAM_CLIENT_CODES.API_ERROR).toBe(
|
||||
"TELEGRAM_CLIENT.API_ERROR"
|
||||
);
|
||||
expect(TELEGRAM_CLIENT_CODES.AUTH_ERROR).toBe(
|
||||
"TELEGRAM_CLIENT.AUTH_ERROR"
|
||||
);
|
||||
expect(TELEGRAM_CLIENT_CODES.RATE_LIMITED).toBe(
|
||||
"TELEGRAM_CLIENT.RATE_LIMITED"
|
||||
);
|
||||
expect(TELEGRAM_CLIENT_CODES.CHAT_NOT_FOUND).toBe(
|
||||
"TELEGRAM_CLIENT.CHAT_NOT_FOUND"
|
||||
);
|
||||
expect(TELEGRAM_CLIENT_CODES.BOT_BLOCKED).toBe(
|
||||
"TELEGRAM_CLIENT.BOT_BLOCKED"
|
||||
);
|
||||
expect(TELEGRAM_CLIENT_CODES.CONFIG_MISSING).toBe(
|
||||
"TELEGRAM_CLIENT.CONFIG_MISSING"
|
||||
);
|
||||
expect(TELEGRAM_CLIENT_CODES.TIMEOUT).toBe("TELEGRAM_CLIENT.TIMEOUT");
|
||||
expect(TELEGRAM_CLIENT_CODES.FORBIDDEN).toBe(
|
||||
"TELEGRAM_CLIENT.FORBIDDEN"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("TelegramClientError", () => {
|
||||
it("TelegramClientError extends SuaveplanError and exposes the code", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const err = new TelegramClientError(
|
||||
TELEGRAM_CLIENT_CODES.CONFIG_MISSING,
|
||||
"missing config"
|
||||
);
|
||||
expect(err).toBeInstanceOf(SuaveplanError);
|
||||
expect(err).toBeInstanceOf(TelegramClientError);
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
expect(err.code).toBe("TELEGRAM_CLIENT.CONFIG_MISSING");
|
||||
expect(err.name).toBe("TelegramClientError");
|
||||
});
|
||||
|
||||
it("carries context and cause through to the base class", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const cause = new Error("underlying");
|
||||
const err = new TelegramClientError(
|
||||
TELEGRAM_CLIENT_CODES.API_ERROR,
|
||||
"wrap",
|
||||
{ context: { method: "sendMessage" }, cause }
|
||||
);
|
||||
expect(err.context).toEqual({ method: "sendMessage" });
|
||||
expect(err.cause).toBe(cause);
|
||||
});
|
||||
|
||||
it("every declared code is thrown by a production path", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
// Build a fake-fetch driven client and exercise every classifier
|
||||
// branch + config + timeout path so every code is reached.
|
||||
const queue: TelegramFetchResponse[] = [
|
||||
// for sendMessage → 200 ok:false → API_ERROR
|
||||
jsonResponse({ ok: false, description: "wat" }, 200),
|
||||
// 401 → AUTH_ERROR
|
||||
jsonResponse({ ok: false, description: "Unauthorized" }, 401),
|
||||
// 429 → RATE_LIMITED
|
||||
jsonResponse(
|
||||
{
|
||||
ok: false,
|
||||
description: "Too Many",
|
||||
parameters: { retry_after: 1 },
|
||||
},
|
||||
429
|
||||
),
|
||||
// 404 → CHAT_NOT_FOUND
|
||||
jsonResponse(
|
||||
{ ok: false, description: "Bad Request: chat not found" },
|
||||
404
|
||||
),
|
||||
// 403 bot blocked → BOT_BLOCKED
|
||||
jsonResponse(
|
||||
{
|
||||
ok: false,
|
||||
description: "Forbidden: bot was blocked by the user",
|
||||
},
|
||||
403
|
||||
),
|
||||
// 403 forbidden → FORBIDDEN
|
||||
jsonResponse(
|
||||
{ ok: false, description: "Forbidden: not enough rights" },
|
||||
403
|
||||
),
|
||||
];
|
||||
|
||||
const fetchImpl: FetchLike = async () => {
|
||||
const r = queue.shift();
|
||||
if (!r) throw new Error("queue empty");
|
||||
return r;
|
||||
};
|
||||
|
||||
const client = createTelegramBotClient(
|
||||
{ token: TEST_TOKEN },
|
||||
{ fetch: fetchImpl, clock: () => 1_700_000_000_000 }
|
||||
);
|
||||
|
||||
const seen = new Set<string>();
|
||||
for (let i = 0; i < 6; i++) {
|
||||
try {
|
||||
await client.sendMessage({ chatId: 1, text: "x" });
|
||||
} catch (err) {
|
||||
seen.add((err as TelegramClientError).code);
|
||||
}
|
||||
}
|
||||
|
||||
// CONFIG_MISSING via construction
|
||||
try {
|
||||
createTelegramBotClient({ token: "" });
|
||||
} catch (err) {
|
||||
seen.add((err as TelegramClientError).code);
|
||||
}
|
||||
|
||||
// TIMEOUT via never-resolving fetch
|
||||
const neverFetch: FetchLike = (_, init) =>
|
||||
new Promise<TelegramFetchResponse>((_resolve, reject) => {
|
||||
init.signal?.addEventListener("abort", () => {
|
||||
const e = new Error("aborted");
|
||||
e.name = "AbortError";
|
||||
reject(e);
|
||||
});
|
||||
});
|
||||
const timeoutClient = createTelegramBotClient(
|
||||
{ token: TEST_TOKEN, timeoutMs: 5 },
|
||||
{ fetch: neverFetch, clock: () => 1_700_000_000_000 }
|
||||
);
|
||||
try {
|
||||
await timeoutClient.sendMessage({ chatId: 1, text: "x" });
|
||||
} catch (err) {
|
||||
seen.add((err as TelegramClientError).code);
|
||||
}
|
||||
|
||||
expect(seen).toEqual(
|
||||
new Set([
|
||||
"TELEGRAM_CLIENT.API_ERROR",
|
||||
"TELEGRAM_CLIENT.AUTH_ERROR",
|
||||
"TELEGRAM_CLIENT.RATE_LIMITED",
|
||||
"TELEGRAM_CLIENT.CHAT_NOT_FOUND",
|
||||
"TELEGRAM_CLIENT.BOT_BLOCKED",
|
||||
"TELEGRAM_CLIENT.FORBIDDEN",
|
||||
"TELEGRAM_CLIENT.CONFIG_MISSING",
|
||||
"TELEGRAM_CLIENT.TIMEOUT",
|
||||
])
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import {
|
||||
defineCodes,
|
||||
SuaveplanError,
|
||||
type SuaveplanErrorOptions,
|
||||
} from "@suaveplan/error";
|
||||
|
||||
/**
|
||||
* Stable error-code registry for `@suaveplan/telegram-client`.
|
||||
*
|
||||
* All codes are namespaced under `TELEGRAM_CLIENT` and registered via
|
||||
* `defineCodes`, producing deterministic literal-typed string identifiers
|
||||
* that survive tree-shaking and minification.
|
||||
*
|
||||
* Every code MUST be thrown by at least one production code path; see the
|
||||
* `error code registry` requirement in
|
||||
* `openspec/specs/telegram-client/spec.md`.
|
||||
*/
|
||||
export const TELEGRAM_CLIENT_CODES = defineCodes("TELEGRAM_CLIENT", [
|
||||
"API_ERROR",
|
||||
"AUTH_ERROR",
|
||||
"RATE_LIMITED",
|
||||
"CHAT_NOT_FOUND",
|
||||
"BOT_BLOCKED",
|
||||
"CONFIG_MISSING",
|
||||
"TIMEOUT",
|
||||
"FORBIDDEN",
|
||||
] as const);
|
||||
|
||||
/** Literal union of every error code emitted by this package. */
|
||||
export type TelegramClientCode =
|
||||
(typeof TELEGRAM_CLIENT_CODES)[keyof typeof TELEGRAM_CLIENT_CODES];
|
||||
|
||||
/**
|
||||
* Typed error class for all `@suaveplan/telegram-client` failures.
|
||||
*
|
||||
* Extends `SuaveplanError` so callers can use `instanceof TelegramClientError`
|
||||
* for narrow discrimination while the full error chain, context, redaction,
|
||||
* and OTel span-event adapter remain available through the base class.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* import {
|
||||
* TelegramClientError,
|
||||
* TELEGRAM_CLIENT_CODES,
|
||||
* } from "@suaveplan/telegram-client/errors";
|
||||
*
|
||||
* throw new TelegramClientError(
|
||||
* TELEGRAM_CLIENT_CODES.RATE_LIMITED,
|
||||
* "Telegram rate limit exceeded",
|
||||
* { context: { retryAfterMs: 30000 } },
|
||||
* );
|
||||
* ```
|
||||
*/
|
||||
export class TelegramClientError extends SuaveplanError<TelegramClientCode> {
|
||||
public constructor(
|
||||
code: TelegramClientCode,
|
||||
messageKey: string,
|
||||
options?: SuaveplanErrorOptions
|
||||
) {
|
||||
super(code, messageKey, options);
|
||||
this.name = "TelegramClientError";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
// Client
|
||||
export {
|
||||
createTelegramBotClient,
|
||||
resetTelegramClientMetrics,
|
||||
TelegramBotClient,
|
||||
} from "./core/telegram-bot-client/telegram-bot-client.js";
|
||||
// Errors
|
||||
export {
|
||||
TELEGRAM_CLIENT_CODES,
|
||||
type TelegramClientCode,
|
||||
TelegramClientError,
|
||||
} from "./errors/telegram-client-error.js";
|
||||
// Types
|
||||
export {
|
||||
type TelegramBotConfigParsed,
|
||||
TelegramBotConfigSchema,
|
||||
} from "./types/telegram-bot-config.js";
|
||||
export type {
|
||||
FetchLike,
|
||||
TelegramBotConfig,
|
||||
TelegramBotInjectables,
|
||||
TelegramChat,
|
||||
TelegramDeliveryReport,
|
||||
TelegramDeliveryStatus,
|
||||
TelegramFetchResponse,
|
||||
TelegramInlineButton,
|
||||
TelegramKeyboardButton,
|
||||
TelegramMediaType,
|
||||
TelegramMessage,
|
||||
TelegramReplyMarkup,
|
||||
TelegramResponse,
|
||||
TelegramSentMessage,
|
||||
TelegramUser,
|
||||
} from "./types/telegram-client.types.js";
|
||||
// Utils (pure helpers — exported for testability and downstream composition)
|
||||
export {
|
||||
buildReplyMarkup,
|
||||
buildSendMessageBody,
|
||||
MEDIA_METHOD_MAP,
|
||||
} from "./utils/build-bodies.js";
|
||||
export { classifyStatus, parseRetryAfterMs } from "./utils/classify-error.js";
|
||||
@@ -0,0 +1,21 @@
|
||||
export {
|
||||
type TelegramBotConfigParsed,
|
||||
TelegramBotConfigSchema,
|
||||
} from "./telegram-bot-config.js";
|
||||
export type {
|
||||
FetchLike,
|
||||
TelegramBotConfig,
|
||||
TelegramBotInjectables,
|
||||
TelegramChat,
|
||||
TelegramDeliveryReport,
|
||||
TelegramDeliveryStatus,
|
||||
TelegramFetchResponse,
|
||||
TelegramInlineButton,
|
||||
TelegramKeyboardButton,
|
||||
TelegramMediaType,
|
||||
TelegramMessage,
|
||||
TelegramReplyMarkup,
|
||||
TelegramResponse,
|
||||
TelegramSentMessage,
|
||||
TelegramUser,
|
||||
} from "./telegram-client.types.js";
|
||||
@@ -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,77 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
|
||||
import { TelegramBotClient } from "../core/telegram-bot-client/telegram-bot-client.js";
|
||||
import { TelegramClientError } from "../errors/telegram-client-error.js";
|
||||
import { TelegramBotConfigSchema } from "./telegram-bot-config.js";
|
||||
|
||||
describe("TelegramBotConfigSchema", () => {
|
||||
it("rejects empty token", { timeout: 5000 }, () => {
|
||||
const result = TelegramBotConfigSchema.safeParse({ token: "" });
|
||||
expect(result.success).toBe(false);
|
||||
expect(() => new TelegramBotClient({ token: "" })).toThrowError(
|
||||
TelegramClientError
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects missing token", { timeout: 5000 }, () => {
|
||||
const result = TelegramBotConfigSchema.safeParse({});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects invalid baseUrl", { timeout: 5000 }, () => {
|
||||
const result = TelegramBotConfigSchema.safeParse({
|
||||
token: "x",
|
||||
baseUrl: "not-a-url",
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects non-positive timeoutMs", { timeout: 5000 }, () => {
|
||||
const result = TelegramBotConfigSchema.safeParse({
|
||||
token: "x",
|
||||
timeoutMs: -1,
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
|
||||
const zero = TelegramBotConfigSchema.safeParse({
|
||||
token: "x",
|
||||
timeoutMs: 0,
|
||||
});
|
||||
expect(zero.success).toBe(false);
|
||||
|
||||
const fractional = TelegramBotConfigSchema.safeParse({
|
||||
token: "x",
|
||||
timeoutMs: 1.5,
|
||||
});
|
||||
expect(fractional.success).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts a valid config", { timeout: 5000 }, () => {
|
||||
const result = TelegramBotConfigSchema.safeParse({
|
||||
token: "valid",
|
||||
baseUrl: "https://api.telegram.org",
|
||||
timeoutMs: 1000,
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.token).toBe("valid");
|
||||
expect(result.data.baseUrl).toBe("https://api.telegram.org");
|
||||
expect(result.data.timeoutMs).toBe(1000);
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts a config with only token (defaults applied at runtime)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const result = TelegramBotConfigSchema.safeParse({ token: "valid" });
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects unknown fields under strict mode", { timeout: 5000 }, () => {
|
||||
const result = TelegramBotConfigSchema.safeParse({
|
||||
token: "valid",
|
||||
extra: "no",
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import z from "zod";
|
||||
|
||||
/**
|
||||
* Zod schema for `TelegramBotConfig`. Used by `TelegramBotClient`'s
|
||||
* constructor to validate caller-supplied options at the public boundary.
|
||||
*
|
||||
* - `token`: non-empty string (raw Bot API token from @BotFather).
|
||||
* - `baseUrl`: optional valid URL; defaults to `https://api.telegram.org`.
|
||||
* - `timeoutMs`: optional positive integer; defaults to 10000.
|
||||
*/
|
||||
export const TelegramBotConfigSchema = z
|
||||
.object({
|
||||
token: z.string().min(1, "token must be a non-empty string"),
|
||||
baseUrl: z.string().url().optional(),
|
||||
timeoutMs: z.number().int().positive().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
/** Type inferred from {@link TelegramBotConfigSchema}. */
|
||||
export type TelegramBotConfigParsed = z.infer<typeof TelegramBotConfigSchema>;
|
||||
@@ -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,138 @@
|
||||
import type { Clock } from "@suaveplan/types";
|
||||
|
||||
/**
|
||||
* 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. Keeping the contract tight (no `typeof
|
||||
* fetch`) makes it portable across Bun, Node, Deno, and browser hosts.
|
||||
*/
|
||||
export type FetchLike = (
|
||||
url: string,
|
||||
init: {
|
||||
method: string;
|
||||
headers: Record<string, string>;
|
||||
body?: string;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
) => Promise<TelegramFetchResponse>;
|
||||
|
||||
/** Subset of the global `Response` shape consumed by the Telegram client. */
|
||||
export interface TelegramFetchResponse {
|
||||
readonly ok: boolean;
|
||||
readonly status: number;
|
||||
readonly statusText: string;
|
||||
readonly headers: {
|
||||
get(name: string): string | null;
|
||||
};
|
||||
text(): Promise<string>;
|
||||
json(): Promise<unknown>;
|
||||
}
|
||||
|
||||
/** Configuration for the Telegram Bot API client. */
|
||||
export interface TelegramBotConfig {
|
||||
/** Bot token from @BotFather. */
|
||||
token: string;
|
||||
/** Override the base API URL (default: `https://api.telegram.org`). */
|
||||
baseUrl?: string;
|
||||
/** Request timeout in milliseconds (default: 10000). */
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
/** Optional injectable dependencies. */
|
||||
export interface TelegramBotInjectables {
|
||||
/** Injectable fetch implementation. Defaults to `globalThis.fetch`. */
|
||||
fetch?: FetchLike;
|
||||
/** Wall-clock used for OTel duration measurement. Defaults to `Date.now`. */
|
||||
clock?: Clock;
|
||||
}
|
||||
|
||||
/** Outgoing message payload. */
|
||||
export interface TelegramMessage {
|
||||
chatId: string | number;
|
||||
text: string;
|
||||
parseMode?: "HTML" | "Markdown" | "MarkdownV2";
|
||||
disableWebPagePreview?: boolean;
|
||||
disableNotification?: boolean;
|
||||
replyToMessageId?: number;
|
||||
replyMarkup?: TelegramReplyMarkup;
|
||||
}
|
||||
|
||||
/** Reply markup options for inline keyboards and custom keyboards. */
|
||||
export interface TelegramReplyMarkup {
|
||||
inlineKeyboard?: TelegramInlineButton[][];
|
||||
keyboard?: TelegramKeyboardButton[][];
|
||||
removeKeyboard?: boolean;
|
||||
oneTimeKeyboard?: boolean;
|
||||
}
|
||||
|
||||
/** A button in an inline keyboard. */
|
||||
export interface TelegramInlineButton {
|
||||
text: string;
|
||||
url?: string;
|
||||
callbackData?: string;
|
||||
}
|
||||
|
||||
/** A button in a custom reply keyboard. */
|
||||
export interface TelegramKeyboardButton {
|
||||
text: string;
|
||||
requestContact?: boolean;
|
||||
requestLocation?: boolean;
|
||||
}
|
||||
|
||||
/** Generic API response wrapper from Telegram. */
|
||||
export interface TelegramResponse<T> {
|
||||
ok: boolean;
|
||||
result?: T;
|
||||
errorCode?: number;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/** Telegram chat object. */
|
||||
export interface TelegramChat {
|
||||
id: number;
|
||||
type: "private" | "group" | "supergroup" | "channel";
|
||||
title?: string;
|
||||
username?: string;
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
}
|
||||
|
||||
/** Telegram user object. */
|
||||
export interface TelegramUser {
|
||||
id: number;
|
||||
isBot: boolean;
|
||||
firstName: string;
|
||||
lastName?: string;
|
||||
username?: string;
|
||||
}
|
||||
|
||||
/** A message returned after sending. */
|
||||
export interface TelegramSentMessage {
|
||||
messageId: number;
|
||||
chat: TelegramChat;
|
||||
date: number;
|
||||
text?: string;
|
||||
from?: TelegramUser;
|
||||
}
|
||||
|
||||
/** Delivery status for tracked messages. */
|
||||
export type TelegramDeliveryStatus = "queued" | "sent" | "delivered" | "failed";
|
||||
|
||||
/** Delivery report for a sent message. */
|
||||
export interface TelegramDeliveryReport {
|
||||
messageId: string;
|
||||
chatId: string;
|
||||
status: TelegramDeliveryStatus;
|
||||
timestamp: Date;
|
||||
errorCode?: string;
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
/** Supported media types for `sendMedia`. */
|
||||
export type TelegramMediaType =
|
||||
| "photo"
|
||||
| "document"
|
||||
| "video"
|
||||
| "audio"
|
||||
| "voice"
|
||||
| "animation";
|
||||
@@ -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,130 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
|
||||
import {
|
||||
buildReplyMarkup,
|
||||
buildSendMessageBody,
|
||||
MEDIA_METHOD_MAP,
|
||||
} from "./build-bodies.js";
|
||||
|
||||
describe("buildSendMessageBody", () => {
|
||||
it("buildSendMessageBody includes every supplied option", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const body = buildSendMessageBody({
|
||||
chatId: 100,
|
||||
text: "Hello",
|
||||
parseMode: "HTML",
|
||||
disableWebPagePreview: true,
|
||||
disableNotification: true,
|
||||
replyToMessageId: 10,
|
||||
replyMarkup: {
|
||||
inlineKeyboard: [
|
||||
[{ text: "Click", url: "https://example.com" }],
|
||||
[{ text: "Data", callbackData: "cb_1" }],
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(body.chat_id).toBe(100);
|
||||
expect(body.text).toBe("Hello");
|
||||
expect(body.parse_mode).toBe("HTML");
|
||||
expect(body.disable_web_page_preview).toBe(true);
|
||||
expect(body.disable_notification).toBe(true);
|
||||
expect(body.reply_to_message_id).toBe(10);
|
||||
expect(
|
||||
(body.reply_markup as { inline_keyboard: unknown[][] })
|
||||
.inline_keyboard
|
||||
).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("omits optional fields when falsy/absent", { timeout: 5000 }, () => {
|
||||
const body = buildSendMessageBody({ chatId: "42", text: "x" });
|
||||
expect(body.parse_mode).toBeUndefined();
|
||||
expect(body.disable_web_page_preview).toBeUndefined();
|
||||
expect(body.disable_notification).toBeUndefined();
|
||||
expect(body.reply_to_message_id).toBeUndefined();
|
||||
expect(body.reply_markup).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildReplyMarkup", () => {
|
||||
it("buildReplyMarkup snake-cases every button field", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const markup = buildReplyMarkup({
|
||||
inlineKeyboard: [
|
||||
[{ text: "u", url: "https://e.com" }],
|
||||
[{ text: "c", callbackData: "cb" }],
|
||||
],
|
||||
keyboard: [
|
||||
[
|
||||
{ text: "contact", requestContact: true },
|
||||
{ text: "loc", requestLocation: true },
|
||||
],
|
||||
],
|
||||
oneTimeKeyboard: true,
|
||||
});
|
||||
|
||||
const inline = (
|
||||
markup as { inline_keyboard: Array<Array<Record<string, unknown>>> }
|
||||
).inline_keyboard;
|
||||
expect(inline[0][0]).toEqual({ text: "u", url: "https://e.com" });
|
||||
expect(inline[1][0]).toEqual({ text: "c", callback_data: "cb" });
|
||||
|
||||
const kb = (
|
||||
markup as { keyboard: Array<Array<Record<string, unknown>>> }
|
||||
).keyboard;
|
||||
expect(kb[0][0]).toEqual({ text: "contact", request_contact: true });
|
||||
expect(kb[0][1]).toEqual({ text: "loc", request_location: true });
|
||||
expect(markup.one_time_keyboard).toBe(true);
|
||||
});
|
||||
|
||||
it("emits remove_keyboard when requested", { timeout: 5000 }, () => {
|
||||
const markup = buildReplyMarkup({ removeKeyboard: true });
|
||||
expect(markup.remove_keyboard).toBe(true);
|
||||
});
|
||||
|
||||
it("returns an empty record when no fields supplied", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const markup = buildReplyMarkup({});
|
||||
expect(markup).toEqual({});
|
||||
});
|
||||
|
||||
it("inline keyboard buttons without url/callbackData carry only text", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const markup = buildReplyMarkup({
|
||||
inlineKeyboard: [[{ text: "plain" }]],
|
||||
});
|
||||
const inline = (
|
||||
markup as { inline_keyboard: Array<Array<Record<string, unknown>>> }
|
||||
).inline_keyboard;
|
||||
expect(inline[0][0]).toEqual({ text: "plain" });
|
||||
});
|
||||
|
||||
it("custom keyboard buttons without contact/location carry only text", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const markup = buildReplyMarkup({
|
||||
keyboard: [[{ text: "plain" }]],
|
||||
});
|
||||
const kb = (
|
||||
markup as { keyboard: Array<Array<Record<string, unknown>>> }
|
||||
).keyboard;
|
||||
expect(kb[0][0]).toEqual({ text: "plain" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("MEDIA_METHOD_MAP", () => {
|
||||
it("maps every Telegram media type to the correct method", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(MEDIA_METHOD_MAP.photo).toBe("sendPhoto");
|
||||
expect(MEDIA_METHOD_MAP.document).toBe("sendDocument");
|
||||
expect(MEDIA_METHOD_MAP.video).toBe("sendVideo");
|
||||
expect(MEDIA_METHOD_MAP.audio).toBe("sendAudio");
|
||||
expect(MEDIA_METHOD_MAP.voice).toBe("sendVoice");
|
||||
expect(MEDIA_METHOD_MAP.animation).toBe("sendAnimation");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import type {
|
||||
TelegramMessage,
|
||||
TelegramReplyMarkup,
|
||||
} from "../types/telegram-client.types.js";
|
||||
|
||||
/**
|
||||
* Build the `sendMessage` Bot API JSON body from a {@link TelegramMessage}.
|
||||
*
|
||||
* Translates the camelCase public shape to Telegram's snake_case wire format,
|
||||
* including reply markup (delegated to {@link buildReplyMarkup}). Optional
|
||||
* fields are emitted only when truthy so the wire payload stays minimal.
|
||||
*
|
||||
* @param message - The outgoing message payload from the caller.
|
||||
* @returns A `Record<string, unknown>` ready to `JSON.stringify`.
|
||||
*/
|
||||
export function buildSendMessageBody(
|
||||
message: TelegramMessage
|
||||
): Record<string, unknown> {
|
||||
const body: Record<string, unknown> = {
|
||||
chat_id: message.chatId,
|
||||
text: message.text,
|
||||
};
|
||||
if (message.parseMode) body["parse_mode"] = message.parseMode;
|
||||
if (message.disableWebPagePreview) {
|
||||
body["disable_web_page_preview"] = message.disableWebPagePreview;
|
||||
}
|
||||
if (message.disableNotification) {
|
||||
body["disable_notification"] = message.disableNotification;
|
||||
}
|
||||
if (message.replyToMessageId) {
|
||||
body["reply_to_message_id"] = message.replyToMessageId;
|
||||
}
|
||||
if (message.replyMarkup) {
|
||||
body["reply_markup"] = buildReplyMarkup(message.replyMarkup);
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the snake_case `reply_markup` block from the camelCase
|
||||
* {@link TelegramReplyMarkup} shape. Handles inline keyboards, custom
|
||||
* keyboards, removeKeyboard, and one-time keyboards.
|
||||
*
|
||||
* @param markup - Reply markup options from the caller.
|
||||
* @returns A `Record<string, unknown>` matching the Telegram wire format.
|
||||
*/
|
||||
export function buildReplyMarkup(
|
||||
markup: TelegramReplyMarkup
|
||||
): Record<string, unknown> {
|
||||
const result: Record<string, unknown> = {};
|
||||
if (markup.inlineKeyboard) {
|
||||
result["inline_keyboard"] = markup.inlineKeyboard.map((row) =>
|
||||
row.map((btn) => ({
|
||||
text: btn.text,
|
||||
...(btn.url ? { url: btn.url } : {}),
|
||||
...(btn.callbackData
|
||||
? { callback_data: btn.callbackData }
|
||||
: {}),
|
||||
}))
|
||||
);
|
||||
}
|
||||
if (markup.keyboard) {
|
||||
result["keyboard"] = markup.keyboard.map((row) =>
|
||||
row.map((btn) => ({
|
||||
text: btn.text,
|
||||
...(btn.requestContact
|
||||
? { request_contact: btn.requestContact }
|
||||
: {}),
|
||||
...(btn.requestLocation
|
||||
? { request_location: btn.requestLocation }
|
||||
: {}),
|
||||
}))
|
||||
);
|
||||
}
|
||||
if (markup.removeKeyboard) result["remove_keyboard"] = true;
|
||||
if (markup.oneTimeKeyboard) result["one_time_keyboard"] = true;
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Bot API method names per supported media type. */
|
||||
export const MEDIA_METHOD_MAP = {
|
||||
photo: "sendPhoto",
|
||||
document: "sendDocument",
|
||||
video: "sendVideo",
|
||||
audio: "sendAudio",
|
||||
voice: "sendVoice",
|
||||
animation: "sendAnimation",
|
||||
} as const;
|
||||
@@ -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.
|
||||
@@ -0,0 +1,294 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
|
||||
import type {
|
||||
TelegramFetchResponse,
|
||||
TelegramResponse,
|
||||
} from "../types/telegram-client.types.js";
|
||||
import { classifyStatus, parseRetryAfterMs } from "./classify-error.js";
|
||||
|
||||
function makeResponse(
|
||||
headers: Record<string, string> = {}
|
||||
): TelegramFetchResponse {
|
||||
const lower: Record<string, string> = {};
|
||||
for (const k of Object.keys(headers)) lower[k.toLowerCase()] = headers[k];
|
||||
return {
|
||||
ok: false,
|
||||
status: 429,
|
||||
statusText: "Too Many Requests",
|
||||
headers: {
|
||||
get(name: string): string | null {
|
||||
const v = lower[name.toLowerCase()];
|
||||
return v === undefined ? null : v;
|
||||
},
|
||||
},
|
||||
text: async () => "",
|
||||
json: async () => ({}),
|
||||
};
|
||||
}
|
||||
|
||||
const FIXED_NOW = 1_700_000_000_000;
|
||||
const fixedClock = (): number => FIXED_NOW;
|
||||
|
||||
describe("classifyStatus", () => {
|
||||
it("maps 401 to AUTH_ERROR", { timeout: 5000 }, () => {
|
||||
expect(classifyStatus(401, "Unauthorized", { ok: false })).toBe(
|
||||
"TELEGRAM_CLIENT.AUTH_ERROR"
|
||||
);
|
||||
});
|
||||
|
||||
it("maps 403 with bot blocked description to BOT_BLOCKED", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(
|
||||
classifyStatus(403, "Forbidden: bot was blocked by the user", {
|
||||
ok: false,
|
||||
})
|
||||
).toBe("TELEGRAM_CLIENT.BOT_BLOCKED");
|
||||
});
|
||||
|
||||
it("maps 403 with user deactivated description to BOT_BLOCKED", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(
|
||||
classifyStatus(403, "Forbidden: user is deactivated", {
|
||||
ok: false,
|
||||
})
|
||||
).toBe("TELEGRAM_CLIENT.BOT_BLOCKED");
|
||||
});
|
||||
|
||||
it("maps generic 403 to FORBIDDEN", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
classifyStatus(403, "Forbidden: not enough rights", { ok: false })
|
||||
).toBe("TELEGRAM_CLIENT.FORBIDDEN");
|
||||
});
|
||||
|
||||
it("maps 404 to CHAT_NOT_FOUND", { timeout: 5000 }, () => {
|
||||
expect(classifyStatus(404, "Not Found", { ok: false })).toBe(
|
||||
"TELEGRAM_CLIENT.CHAT_NOT_FOUND"
|
||||
);
|
||||
});
|
||||
|
||||
it("maps 400 with chat not found description to CHAT_NOT_FOUND", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(
|
||||
classifyStatus(400, "Bad Request: chat not found", { ok: false })
|
||||
).toBe("TELEGRAM_CLIENT.CHAT_NOT_FOUND");
|
||||
});
|
||||
|
||||
it("maps 400 with channel not found description to CHAT_NOT_FOUND", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(
|
||||
classifyStatus(400, "Bad Request: channel not found", {
|
||||
ok: false,
|
||||
})
|
||||
).toBe("TELEGRAM_CLIENT.CHAT_NOT_FOUND");
|
||||
});
|
||||
|
||||
it("maps 400 with not enough rights to FORBIDDEN", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(
|
||||
classifyStatus(
|
||||
400,
|
||||
"Bad Request: not enough rights to send messages",
|
||||
{ ok: false }
|
||||
)
|
||||
).toBe("TELEGRAM_CLIENT.FORBIDDEN");
|
||||
});
|
||||
|
||||
it("maps 400 with forbidden keyword to FORBIDDEN", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(
|
||||
classifyStatus(400, "Bad Request: forbidden", { ok: false })
|
||||
).toBe("TELEGRAM_CLIENT.FORBIDDEN");
|
||||
});
|
||||
|
||||
it("falls back to API_ERROR on 200 ok:false", { timeout: 5000 }, () => {
|
||||
expect(classifyStatus(200, "weird response", { ok: false })).toBe(
|
||||
"TELEGRAM_CLIENT.API_ERROR"
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to API_ERROR on unknown 5xx", { timeout: 5000 }, () => {
|
||||
expect(classifyStatus(503, "Service Unavailable", { ok: false })).toBe(
|
||||
"TELEGRAM_CLIENT.API_ERROR"
|
||||
);
|
||||
});
|
||||
|
||||
it("returns API_ERROR even when data.ok is true (defensive)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(classifyStatus(500, "Internal", { ok: true })).toBe(
|
||||
"TELEGRAM_CLIENT.API_ERROR"
|
||||
);
|
||||
});
|
||||
|
||||
it("maps unknown 400 (no keyword match) to API_ERROR", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(
|
||||
classifyStatus(400, "Bad Request: weird thing", { ok: false })
|
||||
).toBe("TELEGRAM_CLIENT.API_ERROR");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseRetryAfterMs", () => {
|
||||
const baseBody: TelegramResponse<unknown> = {
|
||||
ok: false,
|
||||
description: "rate limited",
|
||||
};
|
||||
|
||||
it("parseRetryAfterMs prefers top-level body retry_after", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const ms = parseRetryAfterMs(
|
||||
makeResponse(),
|
||||
{
|
||||
...baseBody,
|
||||
retry_after: 12,
|
||||
} as unknown as TelegramResponse<unknown>,
|
||||
fixedClock
|
||||
);
|
||||
expect(ms).toBe(12_000);
|
||||
});
|
||||
|
||||
it("falls back to parameters.retry_after", { timeout: 5000 }, () => {
|
||||
const ms = parseRetryAfterMs(
|
||||
makeResponse(),
|
||||
{
|
||||
...baseBody,
|
||||
parameters: { retry_after: 30 },
|
||||
} as unknown as TelegramResponse<unknown>,
|
||||
fixedClock
|
||||
);
|
||||
expect(ms).toBe(30_000);
|
||||
});
|
||||
|
||||
it("ignores body retry_after when <= 0", { timeout: 5000 }, () => {
|
||||
const ms = parseRetryAfterMs(
|
||||
makeResponse({ "retry-after": "5" }),
|
||||
{
|
||||
...baseBody,
|
||||
retry_after: 0,
|
||||
} as unknown as TelegramResponse<unknown>,
|
||||
fixedClock
|
||||
);
|
||||
expect(ms).toBe(5_000);
|
||||
});
|
||||
|
||||
it("parseRetryAfterMs honours retry-after seconds header", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const ms = parseRetryAfterMs(
|
||||
makeResponse({ "retry-after": "7" }),
|
||||
baseBody,
|
||||
fixedClock
|
||||
);
|
||||
expect(ms).toBe(7_000);
|
||||
});
|
||||
|
||||
it("parseRetryAfterMs honours retry-after HTTP-date header", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const future = new Date(FIXED_NOW + 9_000).toUTCString();
|
||||
const ms = parseRetryAfterMs(
|
||||
makeResponse({ "retry-after": future }),
|
||||
baseBody,
|
||||
fixedClock
|
||||
);
|
||||
expect(ms).toBeGreaterThanOrEqual(0);
|
||||
expect(Number.isFinite(ms as number)).toBe(true);
|
||||
});
|
||||
|
||||
it("parseRetryAfterMs honours x-ratelimit-reset epoch seconds", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const resetSeconds = Math.floor(FIXED_NOW / 1000) + 10;
|
||||
const ms = parseRetryAfterMs(
|
||||
makeResponse({ "x-ratelimit-reset": String(resetSeconds) }),
|
||||
baseBody,
|
||||
fixedClock
|
||||
);
|
||||
expect(ms).toBe(10_000);
|
||||
});
|
||||
|
||||
it("parseRetryAfterMs honours x-ratelimit-reset epoch millis", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const resetMs = FIXED_NOW + 4_000;
|
||||
const ms = parseRetryAfterMs(
|
||||
makeResponse({ "x-ratelimit-reset": String(resetMs) }),
|
||||
baseBody,
|
||||
fixedClock
|
||||
);
|
||||
expect(ms).toBe(4_000);
|
||||
});
|
||||
|
||||
it("parseRetryAfterMs honours x-ratelimit-reset ISO date", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const iso = new Date(FIXED_NOW + 6_000).toISOString();
|
||||
const ms = parseRetryAfterMs(
|
||||
makeResponse({ "x-ratelimit-reset": iso }),
|
||||
baseBody,
|
||||
fixedClock
|
||||
);
|
||||
expect(ms).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it("parseRetryAfterMs returns undefined when no hint is parseable", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const ms = parseRetryAfterMs(makeResponse(), baseBody, fixedClock);
|
||||
expect(ms).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined when retry-after header is garbage", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const ms = parseRetryAfterMs(
|
||||
makeResponse({ "retry-after": "not-a-thing" }),
|
||||
baseBody,
|
||||
fixedClock
|
||||
);
|
||||
expect(ms).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined when x-ratelimit-reset is garbage", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const ms = parseRetryAfterMs(
|
||||
makeResponse({ "x-ratelimit-reset": "not-a-thing" }),
|
||||
baseBody,
|
||||
fixedClock
|
||||
);
|
||||
expect(ms).toBeUndefined();
|
||||
});
|
||||
|
||||
it("clamps negative retry-after (past HTTP date) to 0", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const past = new Date(FIXED_NOW - 10_000).toUTCString();
|
||||
const ms = parseRetryAfterMs(
|
||||
makeResponse({ "retry-after": past }),
|
||||
baseBody,
|
||||
fixedClock
|
||||
);
|
||||
expect(ms).toBe(0);
|
||||
});
|
||||
|
||||
it("clamps negative x-ratelimit-reset (past) to 0", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const pastSeconds = Math.floor(FIXED_NOW / 1000) - 100;
|
||||
const ms = parseRetryAfterMs(
|
||||
makeResponse({ "x-ratelimit-reset": String(pastSeconds) }),
|
||||
baseBody,
|
||||
fixedClock
|
||||
);
|
||||
expect(ms).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
import type { Clock } from "@suaveplan/types";
|
||||
|
||||
import type { TelegramClientCode } from "../errors/telegram-client-error.js";
|
||||
import type {
|
||||
TelegramFetchResponse,
|
||||
TelegramResponse,
|
||||
} from "../types/telegram-client.types.js";
|
||||
|
||||
/**
|
||||
* Map an HTTP status + Telegram-provided description onto a stable
|
||||
* `TelegramClientCode`. The description is matched case-insensitively against
|
||||
* the well-known Telegram failure phrases (`chat not found`, `bot blocked`,
|
||||
* etc) so the mapping survives minor punctuation drift in the Bot API.
|
||||
*
|
||||
* @param status - HTTP response status.
|
||||
* @param description - The `description` field of the JSON body, or a synth.
|
||||
* @param data - The parsed response body, used to detect 200/ok:false drift.
|
||||
* @returns The classified `TelegramClientCode`.
|
||||
*/
|
||||
export function classifyStatus(
|
||||
status: number,
|
||||
description: string,
|
||||
data: TelegramResponse<unknown>
|
||||
): TelegramClientCode {
|
||||
if (status === 401) return "TELEGRAM_CLIENT.AUTH_ERROR";
|
||||
|
||||
if (status === 403) {
|
||||
if (/bot.{0,20}blocked|user.{0,20}deactivated/i.test(description)) {
|
||||
return "TELEGRAM_CLIENT.BOT_BLOCKED";
|
||||
}
|
||||
return "TELEGRAM_CLIENT.FORBIDDEN";
|
||||
}
|
||||
|
||||
if (status === 404) return "TELEGRAM_CLIENT.CHAT_NOT_FOUND";
|
||||
|
||||
// Telegram often returns 400 with rich descriptions that should narrow
|
||||
// to the more specific code than the generic API_ERROR.
|
||||
if (status === 400) {
|
||||
if (
|
||||
/chat.{0,20}not.{0,20}found|channel.{0,20}not.{0,20}found/i.test(
|
||||
description
|
||||
)
|
||||
) {
|
||||
return "TELEGRAM_CLIENT.CHAT_NOT_FOUND";
|
||||
}
|
||||
if (/not enough rights|forbidden/i.test(description)) {
|
||||
return "TELEGRAM_CLIENT.FORBIDDEN";
|
||||
}
|
||||
}
|
||||
|
||||
// A 200 OK body with `ok: false` is treated as a generic API error so
|
||||
// downstream handlers can rely on a single code for the catch-all case.
|
||||
if (!data.ok) return "TELEGRAM_CLIENT.API_ERROR";
|
||||
|
||||
return "TELEGRAM_CLIENT.API_ERROR";
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a Telegram-style retry hint from a 429 response. Order:
|
||||
*
|
||||
* 1. Body `retry_after` (top-level)
|
||||
* 2. Body `parameters.retry_after`
|
||||
* 3. Header `retry-after` (seconds, or HTTP-date)
|
||||
* 4. Header `x-ratelimit-reset` (epoch seconds, epoch millis, or ISO date)
|
||||
*
|
||||
* @param response - The fetch response object.
|
||||
* @param data - The parsed response body.
|
||||
* @param clock - Injectable wall-clock for computing relative dates.
|
||||
* @returns A non-negative duration in ms, or `undefined` if nothing parseable.
|
||||
*/
|
||||
export function parseRetryAfterMs(
|
||||
response: TelegramFetchResponse,
|
||||
data: TelegramResponse<unknown>,
|
||||
clock: Clock
|
||||
): number | undefined {
|
||||
const raw = data as unknown as Record<string, unknown>;
|
||||
const params = raw["parameters"] as Record<string, unknown> | undefined;
|
||||
const bodySeconds = raw["retry_after"] ?? params?.["retry_after"];
|
||||
if (typeof bodySeconds === "number" && bodySeconds > 0) {
|
||||
return Math.round(bodySeconds * 1000);
|
||||
}
|
||||
|
||||
const retryAfter = response.headers.get("retry-after");
|
||||
if (retryAfter) {
|
||||
const seconds = Number(retryAfter);
|
||||
if (Number.isFinite(seconds) && seconds >= 0) {
|
||||
return Math.round(seconds * 1000);
|
||||
}
|
||||
const dateMs = Date.parse(retryAfter);
|
||||
if (Number.isFinite(dateMs)) {
|
||||
return Math.max(0, dateMs - clock());
|
||||
}
|
||||
}
|
||||
const reset = response.headers.get("x-ratelimit-reset");
|
||||
if (reset) {
|
||||
const resetNum = Number(reset);
|
||||
if (Number.isFinite(resetNum)) {
|
||||
const resetMs = resetNum < 1e12 ? resetNum * 1000 : resetNum;
|
||||
return Math.max(0, resetMs - clock());
|
||||
}
|
||||
const isoMs = Date.parse(reset);
|
||||
if (Number.isFinite(isoMs)) {
|
||||
return Math.max(0, isoMs - clock());
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
buildReplyMarkup,
|
||||
buildSendMessageBody,
|
||||
MEDIA_METHOD_MAP,
|
||||
} from "./build-bodies.js";
|
||||
export { classifyStatus, parseRetryAfterMs } from "./classify-error.js";
|
||||
@@ -0,0 +1,52 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
|
||||
describe("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("sideEffects is false and version is 0.1.0", { timeout: 5000 }, () => {
|
||||
const pkg = JSON.parse(
|
||||
readFileSync(join(__dirname, "..", "package.json"), "utf8")
|
||||
) as { sideEffects?: boolean; version?: string };
|
||||
expect(pkg.sideEffects).toBe(false);
|
||||
expect(pkg.version).toBe("0.1.0");
|
||||
});
|
||||
});
|
||||
@@ -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"],
|
||||
"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/telegram-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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user