Skip to Content
Bronit HotelOne bot per company

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.

  1. Create the bot in @BotFather  and copy its token.
  2. Paste it into the Bot token field and press Attach bot.
  3. Add the new bot to that company’s Telegram group as an administrator.
  4. Someone runs /login in 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=1

TELEGRAM_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 offWebhook removed, token kept. The company falls back to the shared bot immediately. One click to turn back on.
RemoveWebhook 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 bot

POS 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

SymptomCause
Notifications arrive from the shared botNo custom bot attached, or it is switched off. Both are normal states.
Bot posts, but buttons do nothingIts 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 attachingThe bot is not in the group, or /login has not been run again since.
Not receiving updates on the screenNo public https origin to register against — normal on a laptop. See Testing locally.
Telegram is delivering nowhere on the screenRead 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 deadRun 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 companyTelegram 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