Skip to content

Repository files navigation

handraise-telegram

Your agent's approval arrives in Telegram as a screenshot with two buttons — and the tap is the answer.

npm install handraise handraise-telegram

The adapter is not on npm yet. handraise is: channels, which this package is built on, landed in handraise 0.5.1. Until the adapter is published, install handraise from npm and take this one from the repo — see Version note.

import { raiseHand } from "handraise"
import { telegram } from "handraise-telegram"

const { TELEGRAM_BOT_TOKEN = "", TELEGRAM_CHAT_ID = "" } = process.env

// Build it once and reuse it: one channel owns one poll loop. See below.
const channel = telegram({ botToken: TELEGRAM_BOT_TOKEN, chatId: TELEGRAM_CHAT_ID })

await raiseHand(page, {
  mode: "approval",
  reason: "The agent may not move money without a human",
  action: "Submit $12,430 vendor payment to Acme GmbH",
  channels: [channel],
})
// outcome "approved" → somebody pressed Approve, in Telegram or on the phone

That is the whole integration. telegram() returns a HandoffChannel; handraise calls it when the handoff starts and hands it the screenshot the phone would show, plus a way to answer.

Zero dependencies. fetch, FormData and Blob are globals on Node 20+ and on bun. The one thing it imports is node:crypto, for the random nonce that authorizes a button press.

Setup, in two minutes

  1. Message @BotFather, send /newbot, copy the token.
  2. Send your new bot a message (a bot cannot write to you first), then open https://api.telegram.org/bot<TOKEN>/getUpdates and read chat.id out of the answer. For a group, add the bot to it and do the same.
  3. Put both in your environment.

No webhook, no public URL, no ngrok: this package long-polls getUpdates, so it only ever makes outbound requests. (That is why Telegram is the first adapter; Slack needs a public interactivity endpoint.)

What the human sees

An approval is one photo — the page as the agent left it — captioned with the reason and the exact step, with Deny and Approve under it. One tap settles the raiseHand call that is waiting. The buttons then disappear and the caption gains a line:

The agent may not move money without a human

Submit $12,430 vendor payment to Acme GmbH

Approved by Simon

If the phone got there first, the tap says so instead — "Already decided elsewhere — this tap changed nothing" — and nothing is overturned. handraise's first answer wins, whoever gives it.

A takeover is a message with the reason and the handoff link, because a takeover cannot be answered from a chat: the human has to drive the browser, and only the handoff page can do that.

Options

Option Type Default
botToken string required From @BotFather. A credential — it is in every request URL.
chatId string | number required Numeric chat id, or @channelname.
pollIntervalMs number 25 s How long one getUpdates call waits for a press before it is made again (Telegram's timeout). Must be positive and finite, at most 50 s — Telegram's own cap.
maxWaitMs number 6 min How long to keep an approval open. Must be positive and finite, at most 24 h. See the limitation below.
baseUrl string https://api.telegram.org The Bot API root.
onWarn (event, detail) => void Called for every failure this package swallows — a chat call Telegram refused while catching up with a decision already made. detail has the token redacted. Nothing is printed without it.

When an approval closes

It closes when the handoff does. handraise 0.5.1 hands every channel a settled promise, and this package waits on it alongside the button. So an approval answered on the phone, or one that times out, or one whose browser session dies, closes here immediately: the buttons come off and the caption says which of those happened — "Decided on the phone: approved.", "Timed out — nobody answered in time.", "The browser session ended before this was answered."

And nothing is left running. The getUpdates call in flight is dropped when the last approval closes, not waited out, so this package lets go of your event loop instead of holding it for the rest of pollIntervalMs — 25 seconds by default. scripts/exit-probe.ts measures exactly that, as a test: a child process that settles a handoff and then does nothing exits 1 ms after notify returns. Before the poll was aborted it exited 25.0 s after.

maxWaitMs is only a backstop. It bounds the wait for a handraise that somehow never settles, which should not happen. It matters in one case: if you raise timeoutMs on raiseHand past 6 minutes, raise maxWaitMs to match, or the buttons go away while the agent is still waiting.

One sharp edge remains, and it is small: a press that lands in the same instant the approval closes is consumed but not acted on, so that person's button spinner hangs until Telegram gives up on it. The handoff is unaffected.

One process per bot, and that one is fine. getUpdates is a bot-wide stream with a single cursor: an update is confirmed for the whole bot the moment any call uses a higher offset, and Telegram answers 409 to a second overlapping call. So:

  • Inside one process, any number of approvals can be open at once. One telegram({ … }) owns one long-poll loop and hands each press to the approval whose button it is. Approvals from separate telegram() calls in the same process are separate loops and will conflict — build the channel once and reuse it.
  • A second process on the same token is a conflict. Both get 409s and both lose updates. This package does not retry that: polling stops at once, the buttons come off, and onWarn gets the reason with what to do about it (stop the other poller, or deleteWebhook). Give each process its own bot.

Large screenshots arrive as a file. Telegram will not take a photo over 10 MB, over 10 000 px of width plus height, or past a 20:1 aspect ratio. A screenshot past any of those is sent with sendDocument instead — no inline preview, but the caption, the buttons and the answer all work the same. A 1280x800 viewport is nowhere near any of these limits.

Security

  • The bot token is a credential and it is in every request URL. This package never logs it, and every error it throws is redacted before it is built — String(error) on a failed fetch carries the URL. Do not paste a token into an issue; if you have, revoke it with /revoke in @BotFather.
  • A button press is authorized twice. Telegram says a client may submit any callback_data it likes, so the data is a claim, not a credential. Each approval carries a fresh 96-bit random nonce, and the press has to come from the exact chat and message this package sent — which Telegram fills in itself and a stranger cannot forge. Knowing a handoff id, or seeing an old button, is not enough to answer anything.
  • Anyone in the chat can decide. A group chat means every member can press Approve, and Telegram's own tools decide who is in that group. Use a private chat with the bot, or a group whose membership is your approver list.
  • The takeover link is a bearer credential. Whoever opens it drives a live browser session carrying the agent's cookies. It dies with the handoff, and the message says as much — but a chat is a durable log, so post takeover links only where you would post a password.
  • The screenshot is the page. It can contain an account number, an address, a half-filled form. Telegram stores it; that is the trade for deciding from a phone. Approval mode sends one frame and no live view, which bounds what can ever leave.

Running the tests

bun run test        # everything, against a local fake Bot API server
bun run dist:smoke  # loads the built artifact under node
bun run test:live   # one real message, waits for a real tap

scripts/exit-probe.ts runs inside bun run test as a child process: it settles a handoff, then does nothing, and the suite asserts that it exits. It is not a unit test of anything — it is the only way to check the claim above from outside the process making it.

bun run test starts an actual HTTP server that answers like the Bot API — the multipart upload, the long poll that really blocks, the 401 for a wrong token — because a mock of Telegram would only prove the mock. test:live needs TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID and skips with instructions when they are missing.

Version note

This package needs handraise ≥ 0.5.1, the release that added channels. handraise is a peerDependency (>=0.5.1): nothing of it is imported at runtime, and a second nested copy would put two HandoffChannel declarations in one program. So install it yourself, alongside this one:

npm install handraise           # on npm
npm install handraise-telegram  # not yet — from this repo for now

License

MIT © Simon Doba

About

Telegram channel for handraise: an agent's approval arrives as a screenshot with Approve/Deny buttons in a Telegram chat, answered by long polling — no public endpoint.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages