Skip to main content
Smithers ships two Telegram surfaces:
  • @smithers-orchestrator/telegram for bots that run inside serverless functions, workflow tasks, or tests without owning a long-lived polling process (this page’s first half).
  • @smithers-orchestrator/integrations/telegram for workflows that wait on Telegram durably: listen for messages, and approve gated steps from the chat with inline buttons or a Mini App (Approve from the chat).
The package follows the integration shape proven by Eliza’s Telegram plugin: separate bot lifecycle/API access, normalized incoming messages, formatting helpers, and a fake test client. Smithers keeps the implementation raw fetch instead of Telegraf so Vercel Functions, Cloudflare Workers, and Sandbox tasks can call the Bot API without starting a local polling loop.

Install

pnpm add @smithers-orchestrator/telegram

Send messages

import {
  createTelegramClient,
  escapeTelegramMarkdownV2,
  sendTelegramTextChunks,
} from "@smithers-orchestrator/telegram";

const telegram = createTelegramClient({
  botToken: process.env.TELEGRAM_BOT_TOKEN!,
});

await sendTelegramTextChunks(telegram, {
  chatId: process.env.TELEGRAM_CHAT_ID!,
  text: escapeTelegramMarkdownV2("Build finished!"),
  parseMode: "MarkdownV2",
});
createTelegramClient retries Bot API 429 and 5xx responses plus transient network/read failures. 429 honors Telegram’s retry_after parameter; other retryable failures use exponential backoff. Permanent 4xx responses are not retried.

Verify webhooks

import {
  normalizeTelegramUpdate,
  isTelegramChatAllowed,
  verifyTelegramWebhookSecret,
} from "@smithers-orchestrator/telegram";

export async function POST(request: Request) {
  if (!verifyTelegramWebhookSecret(request, process.env.TELEGRAM_WEBHOOK_SECRET)) {
    return Response.json({ error: "unauthorized" }, { status: 401 });
  }

  const message = normalizeTelegramUpdate(await request.json());
  if (!message) return Response.json({ ok: true, ignored: true });
  if (!isTelegramChatAllowed(message.chatId, process.env.TELEGRAM_ALLOWED_CHATS)) {
    return Response.json({ ok: true, ignored: true });
  }

  // Persist message.updateId, message.chatId, message.messageId, message.text, ...
  return Response.json({ ok: true });
}
normalizeTelegramUpdate handles message, edited_message, channel_post, and edited_channel_post updates and ignores bot-authored messages by default. isTelegramChatAllowed follows Eliza’s fail-closed access-control shape: missing or blank TELEGRAM_ALLOWED_CHATS allows all chats, a JSON array of chat ids restricts processing to those ids, and malformed values block all chats until the configuration is fixed.

Test with a fake

import { FakeTelegramClient } from "@smithers-orchestrator/telegram";

const telegram = new FakeTelegramClient();
await telegram.sendMessage({ chatId: 123, text: "hello" });

console.log(telegram.messages[0].message_id);
Use the fake in local e2e tests when production uses real Telegram credentials.

Approve from the chat

<Telegram.Approval> turns a workflow approval into inline Approve and Reject buttons in a chat and resolves it from the button press, using the durable long-poll source. No web server, no gateway wiring. The decision has the same shape as the core <Approval> component: { approved, note, decidedBy, decidedAt }. Register the two internal node schemas with telegramApprovalSchemas, then drop the component in:
import { createSmithers } from "smithers-orchestrator";
import {
  TelegramApproval,
  telegramApprovalSchemas,
  telegramApprovalDecisionSchema,
} from "@smithers-orchestrator/integrations/telegram";

const { smithers, Workflow, outputs } = createSmithers({
  ...telegramApprovalSchemas,
  decision: telegramApprovalDecisionSchema,
});

export default smithers(() => (
  <Workflow name="deploy">
    <TelegramApproval
      id="ship"
      chatId={process.env.TELEGRAM_CHAT_ID}
      config={{ botToken: process.env.TELEGRAM_BOT_TOKEN }}
      request={{ title: "Deploy to prod?", summary: "Tests are green." }}
      output={outputs.decision}
    />
  </Workflow>
));
For a menu instead of Approve and Reject, pass mode="select" with options; the decision becomes { selected, notes }. For the run to receive the press, a Telegram source must be polling. Run one next to your workflow:
import { makeIntegrationRuntime, makeDbCursorStore } from "@smithers-orchestrator/integrations";
import { makeTelegramSource } from "@smithers-orchestrator/integrations/telegram";

const source = makeTelegramSource({
  botToken: process.env.TELEGRAM_BOT_TOKEN,
  cursorStore: makeDbCursorStore(adapter),
});
const runtime = makeIntegrationRuntime({ adapter, sources: [source] });
WaitForEvent wakes on the first callback query for the chat, so run one interactive approval per chat at a time, or pass threadId (a forum topic) to isolate concurrent approvals. Any chat member can press a button, so keep approvals in a private or allowlisted chat.

Mini App approvals

For a richer decision (read a diff, pick an option, add a note) inside Telegram, add a Mini App button. miniApp opens a web_app page in the chat alongside the plain buttons:
<TelegramApproval
  id="ship"
  chatId={chatId}
  request={{ title: "Deploy to prod?" }}
  miniApp={{ url: "https://telegram.smithers.sh/approve.html" }}
  output={outputs.decision}
/>
A Mini App exposes a signed initData string. Your backend must verify it before trusting the user. Never trust initDataUnsafe.
import { verifyTelegramWebAppInitData } from "@smithers-orchestrator/integrations/telegram";

// Resolves to the parsed fields, or throws on a bad signature, a stale
// auth_date, or the wrong token.
const data = await verifyTelegramWebAppInitData(initData, botToken, {
  maxAgeSeconds: 3600,
});
console.log(data.user?.id);
Verification is HMAC-SHA256 with a crossed key: secret = HMAC_SHA256(key="WebAppData", message=botToken), and the data is authentic when hex(HMAC_SHA256(key=secret, message=dataCheckString)) equals the hash field, where dataCheckString is every field except hash, formatted key=value, sorted, and newline-joined. It runs on Web Crypto, so the same call works in the Smithers runtime and in a Cloudflare Worker. verifyTelegramWebAppInitDataSignature verifies the newer Ed25519 signature from a third party with only the numeric bot id. A runnable reference (a static Mini App page plus a Worker endpoint that verifies initData for real) ships in apps/telegram-site: site/approve.html and src/handleApprove.ts.