Skip to Content
Bronit HotelTelegram integration

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.product records which, and /api/telegram/connect refuses 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 TvRequestTopic rows live in Bronit Service’s group, request-telegram.ts resolves defaultGroupFor(company, 'service'), and TV’s Advanced Settings has no Telegram tab — there is nothing for it to connect. PRODUCT_MODULES in core/lib/telegram.ts records that by listing tv under service, 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.topicMode is one of group, hotel or service.

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 -- delete

set 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.

ModeWhat is createdMessage shape
groupNothingEvery message opens with 🏨 **Hotel · Service**, because nothing else distinguishes one stream from another
hotelOne topic per property (SAF-Сервис), plus 📥 ЗаявкиNo header — the topic is the frame
serviceOne 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). Carries product, topicMode, topicsEnabled (whether the chat is a forum), isDefault (one per product), and the reportsThreadId / requestsThreadId that 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, the messageThreadId, creating it if needed. Returns no thread id in group mode, 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.
  • /topics typed 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). serviceId is null in hotel mode, 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():

  1. Validates against the booking’s current state — paid is rejected if the booking is free, already paid, or cancelled; finish if already finished or cancelled. A stale tap gets a rejection alert via answerCallbackQuery.
  2. paid sets amountPaid = totalPrice, paid = true, paidAt, and paymentMethod = 'cash' (a tap from the group means cash was collected on site), pushes a paid history event, and stamps tgPaidBy. finish sets finished/finishedAt, pushes a finished event, and stamps tgFinishedBy. Both actor stamps are { telegramUserId, name } snapshots from callback_query.from (no Admin ↔ Telegram link), shown as 💵 Оплату принял: {name} / ✅ Завершено: {name}.
  3. 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.

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 → ✅ Принять / ❌ Отклонить
  • preparing or ready → 🏁 Завершить
  • 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():

  1. Validates the transition against the order’s current status (pending for accept/reject, preparing/ready for complete) — a stale/duplicate tap gets a rejection alert via answerCallbackQuery, not a state change.
  2. Stamps who acted — acceptedBy / rejectedBy / completedBy, each { telegramUserId, name } snapshotted from callback_query.from (there’s no Admin ↔ Telegram identity link in the app, so this is a free-text name, not a linked account).
  3. Saves the order, then edits the Telegram message in place (new status text + the acceptedBy/rejectedBy/completedBy lines + 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 in src/lib/telegramReports.ts) append an extra section — income by service and by payment method — when companyHasFeature(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] (default today) 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 lacks analytics — everyone else gets the same breakdown immediately, built by buildStatisticsMessage().