Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,5 @@
export {
createTelegramBotClient,
resetTelegramClientMetrics,
TelegramBotClient,
} from "./telegram-bot-client/telegram-bot-client.js";

View File

@@ -0,0 +1,5 @@
export {
createTelegramBotClient,
resetTelegramClientMetrics,
TelegramBotClient,
} from "./telegram-bot-client.js";

View File

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

View File

@@ -0,0 +1,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;
}
});
});

View File

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

View File

@@ -0,0 +1,5 @@
export {
TELEGRAM_CLIENT_CODES,
type TelegramClientCode,
TelegramClientError,
} from "./telegram-client-error.js";

View File

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

View File

@@ -0,0 +1,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",
])
);
});
});

View File

@@ -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";
}
}

View File

@@ -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";

View File

@@ -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";

View File

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

View File

@@ -0,0 +1,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);
});
});

View File

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

View File

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

View File

@@ -0,0 +1,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";

View File

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

View File

@@ -0,0 +1,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");
});
});

View File

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

View File

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

View File

@@ -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);
});
});

View File

@@ -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;
}

View File

@@ -0,0 +1,6 @@
export {
buildReplyMarkup,
buildSendMessageBody,
MEDIA_METHOD_MAP,
} from "./build-bodies.js";
export { classifyStatus, parseRetryAfterMs } from "./classify-error.js";