@ap3x/telegram
v0.1.0-rc.1
Published
Telegram Bot API client with pacing, retries and a swappable transport
Readme
@ap3x/telegram
Telegram Bot API client for AP3X agents: JSON and multipart calls, per-chat and group pacing, 429 retry, a swappable transport, update normalising, polling and webhook intake, a live-edited reply, and a set of agent tools. The only runtime dependency is @sinclair/typebox.
npm install @ap3x/telegramQuick start: polling
import { createBotApi, createMessenger, liveReply, startPolling, tokenTransport } from "@ap3x/telegram";
const api = createBotApi(tokenTransport(() => process.env.TELEGRAM_BOT_TOKEN ?? ""));
const messenger = createMessenger(api);
const stop = new AbortController();
await startPolling({
api,
signal: stop.signal,
async onUpdate(u) {
if (u.type !== "message") return;
const reply = liveReply(messenger, u.message.chatId, { replyTo: u.message.messageId });
reply.update("Thinking");
await reply.finish(`You said: ${u.message.text}`);
},
});startPolling deletes any webhook, long-polls getUpdates, and delivers each update at least once: the raw update is saved before onUpdate runs and the offset moves only after it settles. After a crash the unfinished update is delivered again; one older than 30 minutes is skipped. Pass a store to keep that state across restarts.
A runnable bot is in examples/echo-bot.mjs (live reply, a thumbs-up on "thanks", /start, and a two-button choice). It needs a bot token from @BotFather in TELEGRAM_BOT_TOKEN; running it is up to you.
Quick start: webhook
import { normalizeUpdate, setWebhook, verifyWebhookSecret, WEBHOOK_SECRET_HEADER } from "@ap3x/telegram";
await setWebhook(api, { url: "https://example.com/telegram", secret: SECRET });
// in any HTTP handler
if (!verifyWebhookSecret(req.headers[WEBHOOK_SECRET_HEADER] as string | undefined, SECRET)) {
res.statusCode = 401;
return res.end();
}
const incoming = normalizeUpdate(JSON.parse(body)); // message, edited, callback, reaction, membership or other
res.end("ok"); // answer fast, then handle `incoming`verifyWebhookSecret is a constant-time check. A webhook and polling cannot run together; startPolling removes the webhook when it starts.
The transport seam
createBotApi takes two functions and nothing else touches the network:
request(method, payload, signal)sends one Bot API call and returns Telegram's{ ok, result, ... }envelope.payloadis{ json }, or{ form }when a param is a file.fetchFile(filePath, signal)downloads a file by its Telegramfile_path.
tokenTransport is the default and talks to api.telegram.org. To route calls elsewhere, for example through a service that holds the token, supply your own. This one proxies JSON calls and keeps a token only for downloads:
import { createBotApi, type BotRequest, type FileFetch } from "@ap3x/telegram";
const request: BotRequest = async (method, payload, signal) => {
if ("form" in payload) throw new Error("uploads are not proxied here");
const res = await fetch(`https://gateway.internal/telegram/${method}`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(payload.json),
signal,
});
return await res.json(); // Telegram's envelope
};
const fetchFile: FileFetch = async (filePath, signal) => {
const res = await fetch(`https://api.telegram.org/file/bot${getToken()}/${filePath}`, { signal });
if (!res.ok) throw new Error(`telegram file download: ${res.status}`);
return new Uint8Array(await res.arrayBuffer());
};
const api = createBotApi({ request, fetchFile });A transport must not put the token in anything it throws. Catch the underlying error and throw a fresh one, as tokenTransport does. A TelegramError it throws passes through unchanged; any other error becomes the generic network error. api.file keeps a message of the form telegram file download: <status> and replaces anything else.
Stores
State goes through KeyValueStore (get, set, delete). memoryStore() is the default and loses everything on restart. Give startPolling and groupAdmin a durable implementation if you need replay across restarts or saved group permissions.
import { memoryStore, startPolling } from "@ap3x/telegram";
await startPolling({ api, store: memoryStore(), signal, onUpdate });Tools
telegramTools returns plain objects shaped like an agent-core AgentTool (no import of agent-core, so no peer dependency). Import it from the subpath:
import { telegramTools } from "@ap3x/telegram/tools";
const tools = telegramTools({
messenger,
api,
mayUse: (chatId, tool) => allowedChats.has(chatId),
mayFetch: (fileId) => knownFiles.has(fileId),
resolveFile: async (ref) => loadArtifact(ref), // InputFile | undefined
});mayUse(chatId, tool)is called on every tool use. Return false to refuse.mayFetch(fileId)gatestelegram_fetch_file. Without it any file id the model knows can be read.requester()returns the Telegram user id of whoever asked this turn. When set,telegram_moderaterefuses unless that user is a current admin of the chat, and makes no moderation call otherwise. Without it the host must gate moderation itself, for example inmayUse.- Each tool passes the
signalit is given to Telegram, so aborting a turn cancels a call that is waiting on the pace limiter or a 429 pause. resolveFile(ref)turns a name the model gives (an artifact path, a screenshot id) into bytes. URLs pass through unchanged.- The second argument,
only, restricts which tools are returned.
Tools: telegram_post, telegram_react, telegram_edit, telegram_delete, telegram_pin, telegram_moderate, telegram_fetch_file.
Link previews and cancelling
Link previews are off by default on every send and edit. Pass preview: { url } to show one.
Every send option object (and liveReply) takes a signal. Aborting it rejects the call with a TelegramError of kind unknown, including while it waits on the pace limiter or a 429 pause, and no further call is made.
Token hygiene
The bot token never appears in an error message, a log line, a thrown error's properties, or a returned value. That includes the /file/bot<token>/... download URL, which is never returned; api.file gives you bytes and Telegram's file_path only. A failed fetch is replaced with a generic telegram <method>: network error.
Retries
- A 429 is retried up to 3 times, waiting
min(retry_after, 300)seconds plus 250 ms. - An HTML parse rejection is resent once as plain text. Only the rejected chunk is resent.
- Network errors and timeouts are never retried. They surface as
TelegramErrorwithkind: "unknown". - Every send method takes
retry: falseto turn retries off.
Errors are TelegramError with a kind of rejected, rate_limited, migrated (a supergroup upgrade; migrateTo holds the new chat id), forbidden or unknown.
Pacing
One message per second per chat, 20 new messages per 60 seconds in a group, 35 ms between any two calls. Edits share the per-chat pace but not the group budget. sendChatAction is free.
Known limits
- With
html: true, chunking splits on lines and is not tag-aware, so a very long formatted line can be cut inside a tag. Telegram then rejects that chunk and it goes out as plain text. sendis not idempotent on a partial failure. If chunk 3 of 5 fails, chunks 1 and 2 were already delivered, so retrying the whole call repeats them. The same holds forliveReply.finish.- The group lock index is per-process. Saved permissions live in the store, but running two processes against one group can race.
