This page sets up the Telegram bot Seed ships, and shows how a product adds its own commands to it.
A person links their Telegram account from the app with a one-time code. From then on, every
notification they get in the app also reaches the chat. The product's commands answer there,
acting as that person. The bot is off until TELEGRAM_BOT_TOKEN is set, and nothing else depends
on it.
In Telegram, send
/newbotto @BotFather. It gives you the bot's username and its token.Put the token only in your env file:
infra/envs/.envon your machine,infra/envs/.env.prodon the server. The token is a secret, so never put it in a commit, an issue or a log.Set the variables below in the same file.
For the server, make the webhook secret:
openssl rand -hex 32Deploy:
deno task deploy
At start, the API sets the bot's command menu. With the webhook, it also registers
https://<DOMAIN>/api/webhooks/telegram with the secret. A failing call is logged and never stops
the API.
| Variable | What it does | Default |
|---|---|---|
TELEGRAM_BOT_TOKEN |
The token from BotFather. Empty: the bot is off. | empty |
TELEGRAM_BOT_USERNAME |
The bot's username without @, for the t.me link the app opens. |
empty |
TELEGRAM_WEBHOOK_SECRET |
32 to 256 of A-Z a-z 0-9 _ -. Required with the webhook. |
empty |
TELEGRAM_POLLING |
true polls Telegram for updates, false takes the webhook. |
polling with ENV=dev |
TELEGRAM_API_URL |
A local Bot API server. Usually empty. | Telegram's own API |
Telegram sends the webhook secret with every update, and a request without it gets 401.
In development the API polls, because Telegram cannot reach localhost. Give your development bot
a token of its own: one token takes updates by polling or by webhook, never both.
- On the profile's Notifications section, the person presses "Link Telegram". The app gets a code that lasts 10 minutes and works once.
- "Open Telegram" opens
t.me/<bot>?start=<code>, so pressing Start sends the code. On another device, the person sends/link <code>instead. - The bot names the account the code belongs to, such as "Ada's account (a•••@example.com)".
- The person presses "Link". The bot links the accounts, writes an audit row, and the app closes its dialog.
The bot links only after a "Link" press by the same Telegram user. So a t.me link someone else
made cannot attach your Telegram to their account without you seeing whose it is. The masked
address is always shown, because anyone can copy a first name.
What else to know:
- A wrong, used or expired code is refused, and so is a Telegram account linked to someone else.
- A new code replaces the person's previous one. Linking a second Telegram account replaces the first, with an unlink audit row for it.
- Every press takes the "Link" button off its message. A stale button gets its own answer and links nothing.
- A code sent in a group chat is spent without linking, because others in the group could read it.
- Guests cannot link: they are asked to keep their account first (guests.md).
- The bot answers in the language Telegram reports for the person, when the app offers it, else in
English. Its words are the
TelegramBot.*keys of theappcatalog.
A link ends with /unlink in the chat, "Unlink" on the profile, or "Unlink Telegram" in the admin
panel. Each writes an audit row. A password reset or change ends it too, and deletes every waiting
code and "Link" press of the person.
A linked account cannot sign in: it only receives notifications and runs commands.
Every notification of a person with a linked chat goes to the chat too. The message has an "Open"
button when the app runs on https. A person who unlinked, was blocked or was deleted by then gets
nothing. When the person blocked the bot, the message is dropped; any other failure is retried
like every job.
A product edits one file, apps/api/telegram/commands.ts. Each command has a name, the description
the command menu shows, and a handler that gets the linked person:
{
name: `notes`,
description: `Your newest notes`,
handler: async (ctx, account) => {
const notes = await queryBus.execute(new NoteListQuery({ actor: account.actor, ... }))
await ctx.reply(...)
},
}
- Put
account.actoron every command and query. The buses check it as for any other client. - Commands answer in a private chat only. In a group, the bot says so and runs nothing, because the answer would show the person's data to everyone there.
- A person who has not linked gets the steps to link instead.
- Button presses go in
callbacks, bycallback_dataprefix. Multi-step talks go inconversations, kept in Valkey. Both get the linked person the same way. - Seed's own commands are
/start,/link,/unlinkand/help. A product command with one of those names is ignored.
apps/api/telegram/fake-telegram.ts is a fake Telegram API for tests. The router, the client and
the webhook handling come from @spy4x/integrations/telegram.
- The webhook takes no session. The secret header is its only proof, compared in constant time before the body is read.
- A link code is
<user id>_<binding>_<secret>: a random binding of 96 bits that finds it, and a random secret of 144 bits. It works once, lasts 10 minutes and counts failed tries. - The code is found by the user id and the binding together. So knowing a person's user id is not enough to use up their tries.
- Making a code needs a full session, second factor included, and spends the stricter per-user rate limit.
- A password reset or change deletes waiting codes and presses. So one prepared with a stolen session cannot link after the owner has recovered the account.
- A linked chat acts as its person, with the second factor counted as passed. Unlike an API token, it has no group and no read-only scope.
- So a product must not offer in the bot any command the app guards with a fresh password or code, such as changing the address or deleting the account.
Seed keeps the Telegram user id only: no Telegram name and no messages. The person's data export (data-export.md) lists it with the time it was linked.
A product that turns the bot on adds it to its privacy page, for example: "Your Telegram user ID (a number, not your phone number), only if you link Telegram. Unlinking removes it." Seed's own demo runs without a bot, so its page does not list it.
- Background jobs (on GitHub): how notifications reach the chat and are retried.
- Data export: where the linked account shows up.