Skip to content

Notification settings and the Telegram bot — design

Builds on the header bell (in-app only, fixed kinds, no settings). Two additions: each member chooses what reaches them and where, and Telegram is a second channel that can also act (Approve / Reject from the chat). Ported in spirit from Korre PM’s Telegram claim approvals (its D1–D6), re-cut for this ERP’s workflow engine and River queue.

An approver only learns a claim waits for them if they open the web app and look at the bell. Approvers live in Telegram. And nobody can quiet a noisy kind, or be told only about what concerns them, because the bell has no settings at all.

As built (deviations from the design below, all deliberate)

Section titled “As built (deviations from the design below, all deliberate)”
  • Packages. platform/telegram (bot client, settings, link, outbox writer, i18n, prune) links no record package, so the HTTP edge (modules/identity) and the /me routes do not link every record package, the same reason notify is apart from notifications. platform/telegram/relay holds what reads and moves records: the send worker and the webhook handler. The edge depends on telegram.Bot and telegram.Hook only.
  • Webhook. POST /v1/telegram/webhook, authenticated by the X-Telegram-Bot-Api-Secret-Token header alone (no secret in the path, so it never reaches an access log). It sits under /v1 so the web proxy already forwards it, and is mounted by cmd/api only when TELEGRAM_PROVIDER=bot. Register it with go run ./cmd/telegram-webhook set.
  • One Telegram account is one member, platform-wide (telegram_link.telegram_user_id is unique across organizations), because the webhook receives only a Telegram user id and must find the organization from it. A person in two organizations connects a different Telegram account to each.
  • HTML, not MarkdownV2. Messages use parse_mode=HTML; only & < > are escaped, so record data can never become markup.
  • Queue. The outbox row is the truth and a River job (telegram_send, queue telegram) its trigger, with MaxAttempts 6; a 429 snoozes the job for exactly retry_after without using an attempt. There is no SECURITY DEFINER claim function: the job carries the organization and the outbox id.
  • Approve from chat covers expense claims only; advances, top-ups and petty-cash spends get Open in app.
  • Not built: telling the organization’s admins (the bell) when a group chat removes the bot (the binding is just forgotten); telegram_link.bot_id for sharding across bots; a separate cmd/telegram-fake (the tests use an in-process Fake, and TELEGRAM_PROVIDER=log writes messages to the log); the Telegram-originated decision is recorded in audit_log as transition_via_telegram, not as a via column on the decision row.
  • Definer functions take the instant (p_now) from the caller’s clock rather than now(), so tests with a fixed clock and the webhook agree on what “expired” means.
  • Not a second workflow, not a new permission model. A Telegram tap calls the same expenses.Transition (and pettycash / advances equivalents) a web click does, as the linked member, so authority, segregation of duties, amount bands, posting and audit are the engine’s answer. An unlinked, unauthorised or stale tap changes nothing. Telegram is an input device, never an authority.
  • Not a replacement. The bell stays and is always written. Telegram is delivery beside it.
  • Not push/email (still out). The channel table is generic so they can join later.
  • No prose in the notification row (bell decision 1 stands). The message text is rendered at send time from kind + record_kind + reference, in the recipient’s language.

D1 — Preferences: kind × channel, per member, default-on

Section titled “D1 — Preferences: kind × channel, per member, default-on”

Table notification_pref(org_id, member_id, kind, channel, enabled), PK (member_id, kind, channel); channel ∈ inbox, telegram. Absent row = the default: inbox on, Telegram on (once linked). Only changes are stored, so a new kind or new member needs no backfill.

  • inbox can be turned off for informational kinds (comment_added, request_approved, request_paid); awaiting_action and request_rejected/request_returned can never be turned off for inbox (an approver must be able to find what waits on them; an owner must be told their claim came back). The API refuses it (422), the UI disables the switch.
  • telegram can be turned off for any kind. A kind not in TELEGRAM_KINDS (v1: awaiting_action, request_approved, request_rejected, request_returned, request_paid, comment_added) has no Telegram column.
  • Quiet hours (per member, optional): quiet_from, quiet_to (local time) in the member’s timezone (notification_setting.timezone, default org timezone). Inside the window Telegram is held, not dropped: deliveries are scheduled for the window’s end and then collapsed into one digest (“5 things wait for you”) if more than 3. awaiting_action older than the window end that has since been retired (decision log moved on) is discarded, never sent stale. Inbox is unaffected.
  • Reads/writes: GET /me/notification-settings → {kinds:[{kind, inbox, telegram, locked}], quiet, timezone, language, telegram:{linked, username, bot_username}}; PUT /me/notification-settings (partial; same replay-safe shape as other /me writes). Own-member only, no permission (like the bell routes, api/public_routes.txt).
  • Org-level: Settings → Organisation gets one switch telegram.enabled and the list of kinds the org allows over Telegram (an org can say “no amounts in chat”: telegram.show_amount default on; when off the message omits amount and counterparty and shows reference + Open only). Permission org.notifications.manage.

D2 — Delivery is a queue, never inline (the scalability point)

Section titled “D2 — Delivery is a queue, never inline (the scalability point)”

The event subscriber (notify) already writes notification rows in its own transaction. In that same transaction it also inserts telegram_outbox rows (one per recipient that is linked, enabled, and not muted) with a unique (notification_id), so a redelivered event writes nothing and a crash never loses or double-sends. A River worker (telegram_send, queue notifications, its own concurrency, so a Telegram outage cannot starve webhooks/einvoice) claims due rows FOR UPDATE SKIP LOCKED in batches and sends.

  • Rate limits obeyed, not hoped for: Telegram allows ~30 msgs/s globally and 1 msg/s per chat (20/min per group). A shared token bucket (30/s, in-process per worker; TELEGRAM_RATE_PER_SEC with the default 25 to leave headroom) plus a per-chat 1/s gate. 429 → honour retry_after exactly, pause the whole worker, requeue the row (no attempt burned).
  • Retries: transient 5xx/network → 6 attempts on the webhook backoff schedule, then failed with the last error; the bell notification is unaffected. 403 Forbidden: bot was blocked / chat not found → mark the link revoked (reason blocked), stop sending, show “Reconnect Telegram” in settings. Never retried.
  • Fan-out bounded: the existing caps (more than 20 eligible actors → nobody; 50 candidates) apply before the outbox, so one event costs at most 20 rows. Outbox rows are pruned after 14 days (sent/failed), by the existing maintenance periodic job pattern (notification_prune).
  • Many orgs, one bot: the bot is platform-wide (one token, TELEGRAM_BOT_TOKEN), a link is (member → telegram_user_id) and a member belongs to one org per link row, so 10 or 10,000 orgs cost nothing extra. Outbox rows are tenant-scoped (forced RLS); the worker claims across orgs through a SECURITY DEFINER claim function (the notification_prune pattern) and then sends each row under its org.
  • Digest/throttle per recipient: at most 1 comment_added message per record per 5 minutes per recipient (extra comments fold into the bell only).
Section titled “D3 — Linking: a one-time deep link, never a username”

POST /me/telegram/link (authenticated, 5/min/member) mints 32 random bytes, stores only the SHA-256 in telegram_link_token(token_hash, member_id, org_id, expires_at, used_at) (10-minute life, any earlier unused token of the member is invalidated), and returns https://t.me/<bot>?start=<token> + expires_at. The web opens it in a new tab and shows a countdown; staleTime: 0 on the status query so returning to the tab flips the card.

/start <token> (private chat only) consumes the token and writes telegram_link(org_id, member_id, telegram_user_id, chat_id, username, language, status). UNIQUE (telegram_user_id) per org and UNIQUE (member_id): one Telegram account ↔ one member per org; linking a second member in the same org from the same Telegram account is refused with a message (“this Telegram is already connected to X; disconnect there first”). A member’s Telegram is never inferred from a username. DELETE /me/telegram/link and /stop in the bot both unlink; a member who is deactivated or loses org access is unlinked by the existing member-lifecycle event so their phone stops receiving. Linking is audited (audit.Write, no token, no chat id in the payload).

D4 — The webhook is public, secret-verified, idempotent, fast

Section titled “D4 — The webhook is public, secret-verified, idempotent, fast”

POST /v1/telegram/webhook (no session auth; outside the authenticated group and the API-key/rate-limit chain, with its own body cap of 64 KiB).

  • Authenticated by X-Telegram-Bot-Api-Secret-Token (subtle.ConstantTimeCompare, ≥32 chars, TELEGRAM_WEBHOOK_SECRET); a mismatch is 401 and the supplied value is never logged.
  • update_id inserted into telegram_update(update_id PK, received_at) first; a unique violation → 200 and stop. This is what stops a retried Approve double-posting to the ledger.
  • Always 200 for handled-or-refused updates (a 500 makes Telegram retry a poison update forever); only a transient DB error is a 500.
  • Handling is fast and inline (an Approve is one transaction); anything slow (sending the reply) goes through the outbox like everything else, except answerCallbackQuery, which must be sent directly within a few seconds so the button stops spinning.
  • Registered with setWebhook by cmd/telegram-webhook set, with allowed_updates=["message","callback_query"] and drop_pending_updates off.

D5 — Buttons carry a token, not the record; Approve is conservative

Section titled “D5 — Buttons carry a token, not the record; Approve is conservative”

callback_data is 64 bytes and client-controlled on the way back. Each button is a telegram_action(token, org_id, member_id, record_kind, record_id, from_state, to_state, kind, expires_at, used_at) row; only its 22-char token travels. Single-use, 48-hour life, and bound to the recipient’s member id: a different Telegram user (a bystander in a shared chat) tapping it is refused with a toast and nothing moves.

  • Approve is offered only when, for that member at send time, AvailableTransitions has exactly one allowed forward edge that needs no reason, no payout channel/proof and no sign-off shares — i.e. the same “plain Approve” the web shows as one button. Anything else (a payout step, a share sign-off, several ways forward) gets only Open in app (URL button). It is re-checked at tap time with the current state: from_state ≠ current → “already handled / moved on” and the message is edited to say so (the engine’s ErrStaleState is the truth, not a cache).
  • Reject never rejects directly: the bot sends a force_reply prompt; the reply’s text (1–500 chars) is the reason, then the same engine call runs with Reason. A reply is matched to its prompt by reply_to_message_id; the prompt expires in 15 minutes.
  • After any outcome the original message is edited in place (buttons removed, result line appended: “Approved by you · 14:02”) so a chat is a record, not a stack of stale buttons; the edit goes through the outbox rate gate.
  • Every Telegram-originated move runs in one db.InTx, as Actor = member, with a deterministic idempotency key tg:<update_id>, under ledger.MapError. The decision row’s via is recorded as telegram in the audit entry (audit.Write, Action: "approved_via_telegram") — last lock in the transaction, per the audit-lock rule.
  • Refusals (RefusedError, stale, not readable) are answered with a short localised alert; the message never states anything about a record the tapper cannot read (R21: unreadable = “no longer available”).

D6 — Shared chat per approval group (optional)

Section titled “D6 — Shared chat per approval group (optional)”

An org may bind an org unit (department) to one Telegram group chat, so that unit’s awaiting_action posts once to the room instead of DMing every approver. Reasons for unit, not role: a chat is a place; units are places; the engine’s pool for a step is not a chat’s membership and cannot be made to be.

  • Admin: Settings → Organisation → Notifications → “Telegram chats”: pick the unit, press Connect → the page shows a one-time code /connect <code> to send in the group (the bot must already be an admin-added member). The code binds chat_id (string everywhere, never a JSON number: supergroup ids are large and negative) to the unit and posts “Connected to ”.
  • Routing: a claim’s awaiting_action goes to the chat instead of DMs when the claim’s claimant sits in a bound unit (or a descendant: the same org_unit.path prefix rule as project departments) and the step’s actors are all inside that unit’s subtree; otherwise DMs. A chat post names no recipient. Buttons still work and are still authorised per tapping user (D5: token bound to the actor set, validated at tap against the tapper’s own link).
  • A group chat message never shows an amount when telegram.show_amount is off.
  • my_chat_member update with status kicked/left clears the binding and notifies the org admins (bell).
  • Out of scope for v1: forum topics, per-project chats, binding a role.

D7 — Provider indirection, safe in dev, loud in prod

Section titled “D7 — Provider indirection, safe in dev, loud in prod”

TELEGRAM_PROVIDER=bot|log|off (the einvoice/MyInvois-fake shape): off = the feature is absent (status reports bot_username: null, the Connect card renders nothing, POST .../link → 503); log writes what would be sent (refused when APP_ENV=production); bot needs TELEGRAM_BOT_TOKEN, TELEGRAM_BOT_USERNAME, TELEGRAM_WEBHOOK_SECRET, each missing key named individually at boot, no secret valued in a log. A cmd/telegram-fake mock Bot API (like cmd/myinvois-fake) backs the end-to-end tests.

Message text is rendered in the recipient’s language: notification_setting.language (en/ms/zh), set from the web’s active language when first linking/saving settings, editable in the same page, and used by /start’s replies too. Strings live in the same i18n catalogue as the web (the vocabgen drift tests cover the new keys). Messages are HTML-mode text in which every record string is escaped (& < >), because a claim title is user input.

Data model (migrations 01140–01149, new owner range)

Section titled “Data model (migrations 01140–01149, new owner range)”
Table Purpose Class
notification_setting(org_id, member_id PK, timezone, language, quiet_from, quiet_to, updated_at) per-member operational
notification_pref(org_id, member_id, kind, channel, enabled) PK (member_id, kind, channel) overrides only operational
org_notification_setting(org_id PK, telegram_enabled, show_amount, kinds jsonb) org switch operational
telegram_link(org_id, member_id UNIQUE, telegram_user_id, chat_id, username, language, status, linked_at) UNIQUE (org_id, telegram_user_id) the link operational
telegram_link_token(token_hash PK, org_id, member_id, expires_at, used_at) one-time deep link operational, pruned 1 day
telegram_chat(org_id, unit_id UNIQUE, chat_id, connected_by, connected_at) + telegram_chat_code shared chat operational
telegram_action(token PK, …) button tokens operational, pruned 7 days
telegram_update(update_id PK, received_at) webhook idempotency operational, pruned 3 days
telegram_outbox(id, org_id, notification_id UNIQUE, recipient link/chat, kind, due_at, status, attempts, last_error, sent_at) the queue operational, pruned 14 days

All carry org_id with forced RLS org_isolation and composite FKs to organization_member (except telegram_update, which has no tenant and is touched only by the webhook’s definer function); the definer functions (telegram_outbox_claim, telegram_prune) follow notification_prune (refuse to run unless the owner bypasses RLS). telegram_link and tokens hold no secret beyond the hash; the bot token lives only in env. Retention test: all operational; REVOKE DELETE not applied (they are pruned). Audit on link/unlink, chat connect/disconnect, org switch change, and Telegram-originated decisions.

GET/PUT /me/notification-settings; POST/DELETE /me/telegram/link; GET/PUT /org/notification-settings (org.notifications.manage); GET/POST/DELETE /org/telegram/chats (same permission); POST /telegram/webhook/{secret} (public, not in the generated client; documented in api/public_routes.txt and a short docs/ note). make generate regenerates the client, the routes_test permission map and vocab.

  • Settings → Notifications (new tab in SettingsLayout): a Telegram card (Connect / countdown / Connected as @handle · Disconnect · Reconnect when revoked; renders nothing when bot_username is null), a kind × channel matrix (locked cells disabled with a lock and tooltip), a quiet-hours row (from/to + timezone), a language select, and a “Send test message” button (rate-limited 3/hour) so a member can prove it works.
  • Settings → Organisation → Notifications (permission-gated): Telegram on/off, show-amount switch, and the Telegram chats table (unit, chat title, Connect / Remove).
  • The bell is unchanged except it hides kinds a member turned off for inbox (the row is still written for locked kinds and for audit-neutral reasons: rows are written for every kind, the preference only filters the read/unread badge and list, so turning a kind back on shows its history within the 90 days).
  • Go unit/property: preference resolution (default, override, locked refused), quiet-window maths across midnight and DST, digest collapse, MarkdownV2 escaping fuzz (no input yields an unparseable message), token bucket and per-chat gate (injected clock), deep-link token single-use/expiry/replacement, callback-token binding.
  • Go integration (testcontainers, Telegram fake): event → exactly one outbox row per eligible linked recipient and none for a muted one; redelivery writes nothing; outbox claim with two concurrent workers never sends twice; 429 pauses and requeues without burning an attempt; blocked-bot revokes the link; webhook: bad secret 401, duplicate update_id is a no-op, Approve moves the claim and a replay of the same update does not move it twice, a stale Approve is refused and the message edited, a non-approver tapper is refused, Reject asks for a reason and the reply completes it, an Approve for a payout step is never offered; cross-org: a link in org A can never act on org B’s claim (RLS + token org binding); shared chat routing and kick handling.
  • Web (vitest): matrix locks, Connect countdown and focus refetch, quiet-hours form validation, the card hidden when bot_username is null, org page gating, ms/zh keys present.
  • Live proof (as the claim-export work did): demo users on the dev stack with the fake bot — submit a claim, see the Approve message, tap Approve, claim moves; mute a kind, see no message.
  • A Telegram outage or a throttle never slows or fails a business request: every send is post-commit, via the outbox, on its own River queue.
  • Worst-case burst (an org’s month-end approvals): outbox grows, worker drains at ≤25/s; a recipient’s own quiet-hours digest means a 60-claim backlog becomes one message.
  • The webhook does one indexed insert + at most one engine transaction per update; Telegram retries are absorbed by update_id. It is stateless, so more API replicas need no coordination.
  • One bot token, one webhook URL for all orgs. If the volume ever outgrows a single bot’s global 30 msg/s, shard by org to additional bots (telegram_link.bot_id is reserved in the schema for this, unused in v1) — noted, not built.

Open questions / assumptions (defaulted, say if wrong)

Section titled “Open questions / assumptions (defaulted, say if wrong)”
  1. Telegram DMs are not end-to-end encrypted, so claim amounts leave the system for a third party: hence the org switch and show_amount (default on, matching Korre PM).
  2. v1 covers the four approvals kinds the bell already knows (expense_claim, petty_cash_spend, float_topup, advance); Approve-from-chat is wired for expense claims and petty cash spends only, advances and top-ups get Open-in-app (their finance steps often need extra input).
  3. No QR code (the link is on screen and copyable), as in Korre PM.
  4. The ERP’s web has no stored per-member language today; this spec adds it (notification_setting.language) rather than guessing from the browser at send time.