One bot per company
Bronit runs one shared bot, and for almost every company that is the whole story: services, menu orders and menu orders from a TV all arrive through it. Nothing about that changes.
A business on the custom version gets its own bot instead — its own name,
its own picture, its own @username — which a superadmin attaches for them.
That is the exception this page describes.
Read this first, because it inverts the usual assumption: the absence of a custom bot is the normal, correct state. It is not “unconfigured”, it does not need fixing, and almost every company will stay that way forever.
Attaching one
Superadmin → Companies → (a company) → Telegram bot.
- Create the bot in @BotFather and copy its token.
- Paste it into the Bot token field and press Attach bot.
- Add the new bot to that company’s Telegram group as an administrator.
- Someone runs
/loginin the group again.
Step 4 is the one people miss. The group was connected to whichever bot was in
it before, and TelegramConfig records the group, not the bot — so until
/login is run again the company’s own bot is attached but its group still
expects the old one.
Pasting is checked against Telegram before anything is stored: a mistyped token
fails on the screen, showing you the bot’s @username when it works. That is
deliberate — the alternative is a setup that looks fine and goes quiet a week
later for reasons nobody connects back to this screen.
Testing locally
You can attach a bot locally; you cannot receive anything from it. Telegram
fetches the webhook URL itself, over the public internet, and refuses anything
that is not https — so localhost is not a configuration problem, it is
impossible.
What works on a laptop: the token is checked against Telegram, the @username
comes back, it is encrypted and stored, and the bot can send. What does not:
anything coming back. Buttons do nothing, /login is never seen. The screen
says Not receiving updates when this is the case.
Use your own bot from BotFather before you do any of this. Telegram allows exactly one webhook per bot, so pointing a shared token at your laptop takes that bot away from wherever it was working — and if the token is production’s, that is every hotel at once. It is silent, and it stays broken after you close the laptop. This is exactly how #386 happened.
For end-to-end locally, give Telegram a real address with a tunnel:
cloudflared tunnel --url http://localhost:3000 # or: ngrok http 3000
# then, in .env:
TELEGRAM_WEBHOOK_URL=https://<the-tunnel-host>
TELEGRAM_ALLOW_TUNNEL=1TELEGRAM_ALLOW_TUNNEL is the deliberate part. Without it a tunnel host is
refused, because the registration outlives the tunnel: the URL stays registered,
the tunnel closes, and Telegram spends the next several weeks getting a 404 from
a host nobody remembers setting. The superadmin tab shows that as Telegram is
delivering nowhere, with Telegram’s own error text.
Restart the app, press Register webhook, and the bot is fully live against
your machine. TELEGRAM_WEBHOOK_URL is checked ahead of NEXT_PUBLIC_APP_URL,
so this does not disturb any other setting.
When you are done, switch the bot off in the superadmin tab rather than leaving a dead tunnel registered.
In production the origin is required — a stored bot that receives nothing is a customer whose buttons do nothing, with no sign of why — so attaching there fails rather than half-succeeding.
Switching off vs removing
| What happens | |
|---|---|
| Switch off | Webhook removed, token kept. The company falls back to the shared bot immediately. One click to turn back on. |
| Remove | Webhook removed, token deleted. Attaching again means another trip to BotFather. |
Switch off is the one to reach for while diagnosing a bot that has been revoked or rate-limited: notifications keep arriving, through the shared bot, while you work out what happened.
How it works
The token never travels
Stored encrypted with secretBox (AES-256-GCM), the same as Exely PMS
credentials and for the same reason — Telegram is handed the original on every
call, so hashing is not an option. No API returns it, in any form. The
screen shows the @username and the last four characters, which is enough to
tell two bots apart and useless to anyone reading it off a screenshot.
Which bot is speaking
services/telegram/botContext.ts carries the answer for the current unit of work,
and client.ts reads it on every call. There is no ambient bot by default, so
everything falls back to the shared token — which is why adding this changed
nothing for existing companies.
Notification code establishes it with one call:
await withCompanyBot(companyId, async () => {
// everything in here, however deep, speaks as this company's bot
})It is ambient rather than a parameter because there are about sixty send sites across seventeen files, and every one of them already had the company in hand. Threading it through sixty signatures has one failure mode, and it is invisible: miss one, and that message goes out from the wrong company’s bot.
npm run check:company-bot is the regression test for that — it proves the
identity survives an await and does not leak between companies handled
concurrently. It is pure: no database, no network.
Why the webhook has the company in its URL
Telegram never says which bot an update came from. The payload carries the user and the chat and nothing else. A single shared endpoint receiving updates from several bots cannot tell them apart, so each company’s bot delivers to its own path:
/api/telegram/webhook ← the shared bot
/api/telegram/webhook/pos ← Bronit POS, the restaurant bot
/api/telegram/webhook/<companyId> ← one company's own botPOS is a platform bot rather than a company’s — one bot for every restaurant — so it gets a fixed path rather than one per tenant, and its handler establishes no company. Everything else about it is the shape above, for the reason above: register it against the shared path and its replies go out as the shared bot. See Bronit POS.
The path identifies; a per-company secret authenticates. Telegram echoes
it in X-Telegram-Bot-Api-Secret-Token on every update. It is generated per bot
because the shared TELEGRAM_WEBHOOK_SECRET can only prove that some Bronit
bot sent an update — never which one, which is worth nothing once several exist.
When something is wrong
| Symptom | Cause |
|---|---|
| Notifications arrive from the shared bot | No custom bot attached, or it is switched off. Both are normal states. |
| Bot posts, but buttons do nothing | Its webhook is not registered. Switching off removes it; Turn back on registers it again — flipping the flag in the database alone does not. |
| Nothing at all after attaching | The bot is not in the group, or /login has not been run again since. |
| Not receiving updates on the screen | No public https origin to register against — normal on a laptop. See Testing locally. |
| Telegram is delivering nowhere on the screen | Read from getWebhookInfo, so this is Telegram’s own account rather than ours. Switch off and turn back on to register again. |
| Everything looks right and the bot is still dead | Run npm run check:telegram-health — read-only, and it checks the three things that fail independently: the token, where Telegram is really delivering, and whether the bot is still in the group. |
| Log says “token could not be resolved” | INTEGRATION_ENC_KEY changed since the token was attached. The company falls back to the shared bot; re-attach the token. |
@x is already attached to another company | Telegram allows one webhook per bot, so two companies sharing a token would silently steal each other’s updates. Give the second company its own bot. |
A bot token revoked in BotFather fails with a 401, which callTelegram treats
as an answer rather than an outage — so it is not retried, and it will not
recover on its own. Nothing currently alerts on this; see #351.
See also
telegram.md— topics, notifications, the shared bottelegram-mini-app-setup.md— the Mini App, currently parkedsecurity.md— auth, roles, tenant scoping