npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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/telegram

Quick 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. payload is { json }, or { form } when a param is a file.
  • fetchFile(filePath, signal) downloads a file by its Telegram file_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) gates telegram_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_moderate refuses 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 in mayUse.
  • Each tool passes the signal it 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 TelegramError with kind: "unknown".
  • Every send method takes retry: false to 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.
  • send is 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 for liveReply.finish.
  • The group lock index is per-process. Saved permissions live in the store, but running two processes against one group can race.