Telegram integration
A Telegram group mirrors what happens in Bronit, in Russian. Everything is
best‑effort — a Telegram failure never blocks a booking or a web request. If
TELEGRAM_BOT_TOKEN is unset, all of it no‑ops.
Two rules shape the rest of this document:
-
One chat serves one product. A group belongs to Bronit Service, Bronit Menu or Bronit Custom — never two.
TelegramConfig.productrecords which, and/api/telegram/connectrefuses a chat another product already holds.Bronit TV is not on that list and has no group of its own. A room request is express checkout, cleaning or Do Not Disturb: front desk and housekeeping, who already watch the bookings chat. So
TvRequestTopicrows live in Bronit Service’s group,request-telegram.tsresolvesdefaultGroupFor(company, 'service'), and TV’s Advanced Settings has no Telegram tab — there is nothing for it to connect.PRODUCT_MODULESincore/lib/telegram.tsrecords that by listingtvunderservice, which is what makes TV’s rows get retired when Service’s default group moves. -
How a group is split is the owner’s choice, made before the group is added and editable afterwards.
TelegramConfig.topicModeis one ofgroup,hotelorservice.
Registering the webhook
Nothing reaches the bot until Telegram has been told where to deliver updates.
That used to be a hand-rolled curl, which is why it kept coming undone; it is
now a script:
npm run telegram:webhook -- status # what Telegram thinks
npm run telegram:webhook -- set https://hotel.bronit.uz # register (origin only)
npm run telegram:webhook -- deleteset sends TELEGRAM_WEBHOOK_SECRET along with the URL, so rotating that
variable in .env means re-running set — otherwise the route rejects every
update with 403 and the bot goes silent with no error anywhere. status prints
Telegram’s own last_error_message, which is the fastest way to tell “the app is
broken” apart from “Telegram cannot reach the app”.
allowed_updates is set explicitly and always includes callback_query. Without
it every inline button — booking, menu order, and the connect flow — silently
stops working.
Connecting a group: the /id flow
Run in the group itself, by a group administrator. The bot must already be an
administrator of the group; that is the only blocking check, and /id names it
rather than failing halfway through.
The bot replies with one message: the group’s id, in a <code> block that
Telegram copies to the clipboard on a tap. Nothing else. The owner pastes it into
Settings → Advanced → Telegram group on the product they want it for, and
that screen — where a session already exists — connects it.
/start does the same thing, because Telegram sends it when a bot is added.
/login also still works, undocumented, because that is what already-onboarded
hotels were told to use; it appears in no message and in no command menu.
What this replaced. The flow before it asked for the owner’s email and password, typed into a group chat, then offered a product picker. Deleting the password removed it from the chat but not from the clients of everyone watching, nor from another bot in the group running without privacy mode. Nothing is typed into a group now except a command.
The three modes
Chosen on the connect form, before the id is submitted, and changeable later from the group’s row on the same screen.
| Mode | What is created | Message shape |
|---|---|---|
group | Nothing | Every message opens with 🏨 **Hotel · Service**, because nothing else distinguishes one stream from another |
hotel | One topic per property (SAF-Сервис), plus 📥 Заявки | No header — the topic is the frame |
service | One topic per (hotel, service) (SAF-Бассейн), plus 📥 Заявки | No header |
service mode is the most topics by a distance: three hotels with five services
each is sixteen. The picker states the arithmetic before the choice is made.
A chat that is not a forum can only be group, whatever the mode says —
effectiveTopicMode() is what every caller reads, and the connect endpoint
refuses a topic-requiring mode on a plain chat rather than degrading silently.
Changing the mode creates the new topics and leaves the old ones alone. No Telegram API merges or splits a thread, and the threads a finer mode makes redundant are full of history, so they stay in the group and go quiet. The card says so before the change is applied.
📥 Заявки lives in Bronit Service’s group
Even though a guest request arrives through Bronit Menu. The request is a service booking — Menu is only the surface it came in through — so it belongs beside the bookings the front desk already watches, not in the kitchen’s order chat. It is created only for a company that holds Menu; an inbox that cannot receive is worse than a missing one.
How a module contributes its topics
Core does not know what a module’s topics are. A module adds connectable to its
ModuleContribution (src/services/core/lib/contributions.ts) with a label and
a provision(companyId) that creates its topics and returns their names — see
menuContribution. syncProductTopics(companyId, product) asks the one module
that owns that product, so a module nobody bought is never asked and a module
added next year needs no change in core.
provision must be safe to run twice — a sync will run again.
Setup / topics
TelegramConfig— one row per (company, chat, product). Carriesproduct,topicMode,topicsEnabled(whether the chat is a forum),isDefault(one per product), and thereportsThreadId/requestsThreadIdthat Bronit Service’s own threads live on.defaultGroupFor(companyId, product)— the group anything unassigned goes to. The product argument is required and deliberately not defaulted: a default would have let every existing call site compile untouched and keep routing Menu’s orders into the bookings group.ensureServiceTopic(companyId, hotelId, serviceId?)— returns the chat and, in the two topic modes, themessageThreadId, creating it if needed. Returns no thread id ingroupmode, which is the caller’s signal to add the header.syncAllTopics(companyId)— core’s own sweep, walking hotels or services depending on the mode, plus the requests inbox.syncProductTopics(companyId, product)— the sweep one product’s Sync button and one product’s connect call run./topicstyped in a chat syncs the products that chat is connected for, which is normally exactly one. It used to sync every product the company had, so the command typed in the kitchen’s group went and rebuilt the bookings one.TelegramTopic— core’s topic rows, unique on(groupId, hotelId, serviceId).serviceIdis null inhotelmode, not absent: Mongo indexes the two differently in a unique key.
Posting a new booking
On POST /api/bookings, after the record is created, notifyNewBooking(...)
posts wherever the group’s mode says — the hotel’s topic, the service’s topic, or
the main chat — and returns the message coordinates
({ chatId, messageThreadId, messageId }), which are saved back onto the booking
(tgChatId/tgMessageId/tgThreadId). This is what lets a later update edit the
same message instead of posting a duplicate.
In group mode the message is prefixed with 🏨 **Hotel · Service**. An edit
reads ref.messageThreadId rather than the mode to decide whether to include it,
so a message sent before a mode change keeps the header it was sent with —
stripping it on an edit would remove the only line saying which hotel it was
about.
Message shape (Russian, emoji intentional) — a spacious, sectioned layout that
mirrors the itemized order message (buildOrderMessage):
🆕 <b>Новое бронирование</b> (🚫 <b>Отменено</b> when cancelled)
🏢 Заведение: {hotel name}
🛎️ Услуга: {service name}
🛏️ Номер: {room} (only when set)
🕒 {date} {start}-{end}
👤 Гость: {customer}
👥 {persons} чел. (only when persons > 1)
🧑💼 Забронировал: {admin name}
💵 Сумма: {price} UZS
💰 Оплачено ✅ | Не оплачено ❌
💵 Оплату принял: {name} (who tapped 💰 Оплачено in Telegram)
✅ Завершено: {name} (when finished; name added if tapped in Telegram)
⏰ Время: {render time}When only a deposit has been collected, the money line becomes
💰 Частично: {collected} / {total} UZS ⏳.
Cancelled bookings render with a 🚫 <b>Отменено</b> header. Built by
buildBookingMessage().
Booking inline buttons: mark paid / finish
Every booking message carries an inline keyboard driven purely by its state
(buildBookingKeyboard() in src/lib/telegram.ts), attached both on the initial
notifyNewBooking and on every notifyBookingUpdated edit:
- unpaid and
totalPrice > 0→💰 Оплачено - not finished →
🏁 Завершить - both shown together while both apply; paid + finished → no keyboard;
cancelled→ no keyboard (explicitly cleared)
Taps arrive as callback_query updates on the same webhook. callback_data is
booking:<paid|finish>:<bookingId> (parsed by parseBookingCallback).
applyBookingCallbackAction():
- Validates against the booking’s current state —
paidis rejected if the booking is free, already paid, or cancelled;finishif already finished or cancelled. A stale tap gets a rejection alert viaanswerCallbackQuery. paidsetsamountPaid = totalPrice,paid = true,paidAt, andpaymentMethod = 'cash'(a tap from the group means cash was collected on site), pushes apaidhistory event, and stampstgPaidBy.finishsetsfinished/finishedAt, pushes afinishedevent, and stampstgFinishedBy. Both actor stamps are{ telegramUserId, name }snapshots fromcallback_query.from(no Admin ↔ Telegram link), shown as💵 Оплату принял: {name}/✅ Завершено: {name}.- Saves the booking, then edits the message in place (new text + keyboard for the new state).
The dashboard’s PUT /api/bookings/[id] passes tgPaidBy/tgFinishedBy through
notifyBookingUpdated, so advancing a booking from the UI keeps the buttons and
“who acted” lines in sync (same as the menu-order flow).
Editing on update / lifecycle
notifyBookingUpdated(...) rebuilds the text and calls editMessageText so the
message stays in sync in place (never a duplicate). Triggered by:
PUT /api/bookings/[id]when paid/deposit, finished, status, a reschedule (date/time), or an edited name/room/persons changes.DELETE /api/bookings/[id]— the message is edited to the cancelled header before the record is removed.
All runs via after() (post‑response), best‑effort.
Helpers
sendMessage, editMessageText, deleteMessage, createForumTopic,
deleteForumTopic — thin wrappers over callTelegram(method, payload) which
guards on TELEGRAM_BOT_TOKEN.
Gaps (backlog)
- Moving a booking to a different service isn’t supported (its message lives in the original service’s topic). Date/time reschedules within the same service edit the message in place.
Menu orders: inline accept / reject / complete buttons
A guest order posted to the hotel’s HUB topic (notifyNewMenuOrder, MenuOrder
model) carries an inline keyboard driven purely by order.status
(buildMenuOrderKeyboard() in src/lib/telegram.ts):
pending→✅ Принять/❌ Отклонитьpreparingorready→🏁 Завершитьdelivered/cancelled→ no keyboard (explicitly cleared)
Button taps arrive as callback_query updates on the same webhook
(src/app/api/telegram/webhook/route.ts). callback_data is
morder:<accept|reject|complete>:<orderId> (parsed by parseMenuOrderCallback).
applyMenuOrderCallbackAction():
- Validates the transition against the order’s current status (
pendingfor accept/reject,preparing/readyfor complete) — a stale/duplicate tap gets a rejection alert viaanswerCallbackQuery, not a state change. - Stamps who acted —
acceptedBy/rejectedBy/completedBy, each{ telegramUserId, name }snapshotted fromcallback_query.from(there’s no Admin ↔ Telegram identity link in the app, so this is a free-text name, not a linked account). - Saves the order, then edits the Telegram message in place (new status text +
the
acceptedBy/rejectedBy/completedBylines + the keyboard for the new status).
Authorization: same model as the existing /orders//income report
commands — anyone in the connected group can tap. There’s no per-user
permission check.
The dashboard’s PATCH /api/menu/orders/[id] (manual status change) shares the
same buildMenuOrderKeyboard/message builder via notifyMenuOrderUpdated, so a
staff member advancing status from the UI keeps the Telegram message’s buttons
in sync too — and passes through any already-recorded acceptedBy/etc. so that
info isn’t dropped on a dashboard-driven edit.
Webhook registration note: if setWebhook was ever called with an explicit
allowed_updates that didn’t include "callback_query", button presses won’t
reach the webhook — re-run setWebhook without restricting allowed_updates (or
include callback_query explicitly) if buttons appear unresponsive.
Service fee label: MenuOrder.serviceFeeLabel snapshots how the fee was
computed at order time (e.g. "10%" for a percent-based HotelMenuSettings
fee; unset for a flat/fixed fee or no fee) — shown as 🔧 Сервисный сбор (10%): … in the message when present. Snapshotted rather than looked up live
so the message stays correct even if the hotel’s fee settings change later.
Orders dashboard link: every menu-order message ends with 🔗 Просмотр всех заказов: https://hotel.bronit.uz/ru/orders (MENU_ORDERS_DASHBOARD_URL in
src/lib/telegram.ts) — a generic link, not order-specific; whoever opens it
sees their own company’s orders once logged in, same as the /orders//income
report commands.
Statistics: VIP-only report section + /statistics command
One shared aggregation function, getStatisticsData() in src/lib/statistics.ts,
powers both the dashboard’s /statistics page and everything below — so the
numbers can never disagree between Telegram and the dashboard.
buildDailyReportMessage()/buildMonthlyReportMessage()(both insrc/lib/telegramReports.ts) append an extra section — income by service and by payment method — whencompanyHasFeature(companyId, 'analytics')is true (the same VIP-tier plan feature that gates the dashboard page and the main dashboard’s revenue-analytics widgets). Non-VIP companies see no change to their existing daily/monthly report./statistics [today|week|month](defaulttoday) is a new on-demand command in the webhook, parsed like/orders//income. It checks the VIP gate itself and replies with a plan-upgrade message instead of data when the company lacksanalytics— everyone else gets the same breakdown immediately, built bybuildStatisticsMessage().