RFC: Telegram as a PGR notification + interaction channel — feasibility, blockers & approach #995
ChakshuGautam
started this conversation in
Ideas
Replies: 0 comments
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Uh oh!
There was an error while loading. Please reload this page.
TL;DR
Can we add Telegram as a channel for PGR complaint notifications — and possibly as a two-way channel where citizens file/track complaints in chat? Short answer: yes, it's feasible, but not through our self-hosted Novu, and the real cost depends entirely on one decision — outbound-only vs bidirectional. This RFC lays out what we found (grounded in the live Bomet stack + the Telegram Bot API) and the decisions we need to make before building.
This is a sibling to the config-driven notification work (flattened
RAINMAKER-PGR.NotificationRouting/NotificationTemplate,novu-bridgepass-through, SMS→Twilio / Email→Gmail-SMTP via Novu, WhatsApp→Baileys direct). It also feeds the separate effort to refactor how we manage notification configuration (channels are currently a closed enum + per-tenant MDMS rows; adding channels like Telegram is the pressure test for that model).1. How notifications work today (context)
RAINMAKER-PGR.NotificationRoutingis flattened: one row per(businessService, action, toState, audience{CITIZEN|EMPLOYEE}, channel{SMS|WHATSAPP|EMAIL}), joining 1:1 withNotificationTemplate.pgr-servicesresolves the recipient (CITIZEN→citizen, EMPLOYEE→assignee), renders + localizes the body from MDMS, and publishes one pre-rendered per-recipient event to Kafkacomplaints.domain.events. The event'sContactcarries{userId, type, name, phone, email, locale}— nochatId.novu-bridgeis a thin pass-through: SMS/Email → identify Novu subscriber + trigger the per-channel Novu workflow (Twilio / Gmail-SMTP); WhatsApp → POST directly to a self-hostedbaileys-send-service(because Novu has no usable WhatsApp provider).So the channel set is a closed enum and adding one touches: the MDMS schema enum, routing+template rows, the recipient→contact resolver, and the
novu-bridgedispatch switch.2. Can Novu deliver Telegram? Not on our version.
Telegram is a first-class Novu chat provider upstream — but it was added on 2026-05-18, ~11 months after our pinned release. Our self-hosted Novu Community Edition v2.3.0 (Bomet) ships no Telegram provider (verified: the
novu-worker:2.3.0image bundlesslack, whatsapp-business, discord, msTeams, rocket-chat, mattermost, zulip, …and notelegram;ChatProviderIdEnumat the v2.3.0 tag has no Telegram member; the live org has onlyin_app/sms/emailintegrations).providerId— POSTing a faketelegramintegration returns HTTP 201 and creates a record, but no worker handler backs it, so nothing ever delivers. Don't trust the 201.Conclusion: Telegram on our stack = a direct bot service (a "Baileys-mirror" holding the @Botfather token), not Novu — unless/until we do a major Novu upgrade. Going direct means Telegram also gets none of Novu's delivery logs / retries / tracking for free.
3. The Telegram constraints that shape the design
/started it (Telegram platform rule; Novu can't bypass it). Stakeholder has accepted the opt-in: citizens will be asked to initiate the chat.chat_idis a routing identifier, not an auth credential. A/startgives you achat_id(+ first name, maybe username) — not the citizen's phone or DIGIT identity. Unlike a WhatsApp number, thechat_idproves nothing about who the person is.getUpdatesandsetWebhookare mutually exclusive, and a second consumer gets409 Conflict. This bites only the inbound (bidirectional) side — send-only is a plain HTTPS call that scales across replicas.403on blocked/never-started.sendMessagereturns403 Forbidden: bot was blocked by the user— needs achat_idstatus + cleanup path.4. The binding problem (and the good news)
The deepest issue with a citizen channel: after
/startyou must bind thechat_idto the righteg_user— both to address outbound and to authorize anything inbound. A wrong binding silently routes a citizen's notifications (and PII) to someone else.Good news (verified against the Telegram API): the
request_contactkeyboard button sends the sender's own phone number — there is no address-book picker, so a user cannot share someone else's number through it. Therefore:Bindings must be tenant-qualified and unique (mirroring
eg_user's(id, tenantid)PK), with rebind-reverify and explicit unbind. Thechat_idstore does not exist today (eg_userhas no Telegram column; theContactmodel carries only phone/email) — it's a prerequisite for both directions.5. The decision that sets the effort: outbound-only vs bidirectional
chat_id)chat_idstore +/startbindingPGR scopes a citizen to their own complaints purely from a validated Keycloak JWT (
accountId = userInfo.uuid, search bymobileNumber). Achat_idgives you no such principal — so the bidirectional "track/file my complaint" path needs an explicit binding + authorization model, and the existingmobileNumbersearch is an enumeration primitive that must never be exposed to a user-typed number (only the uuid/mobile bound to this chat).6. Gates, work-items, and governance
3 gates (must not skip):
chat_idas auth — bidirectional actions act only on the identity bound to that chat.novu-bridgedefaults unknown channels tocomplaints-sms/Twilio. ATELEGRAMrouting row with no code change sends a Twilio SMS and logs it aschannel=TELEGRAM, SENT. Add an explicit branch; make unknown channels throw, never fall back.Serious work-items: rebuild delivery logging/retry/idempotency off-Novu (reuse the bridge's
transactionId-keyed dedup from insideprocess());403-blockedchat_idcleanup (status field); Telegram rate limits (~1 msg/s per chat, 429retry_after); MarkdownV2/HTML escaping of pre-rendered bodies; per-tenant token + webhook route + MDMS rows + secret custody (OpenBao); webhook forgery protection (X-Telegram-Bot-Api-Secret-Token, IP allow-list); citizen lifecycle (phone change, multiple chats, cross-tenant).Governance calls: PII egress to Telegram cloud + Kenya DPA 2019 consent/residency (DPIA, minimize PII in messages → complaint id + status + link); coverage/cost-benefit (Telegram penetration in the counties is well below WhatsApp/SMS, so as reach it's weak — it pays off mainly if in-chat interaction is the actual product).
Verdict: with opt-in accepted, there is no true technical hard-blocker — it's solvable work-items + 3 must-not-skip gates + governance sign-offs. Governance could administratively stop it; nothing technical does.
7. Proposed approach (for discussion)
/starts →getUpdatesreadschat.id→sendMessageconfirms delivery. Proves the whole loop.TELEGRAMto the MDMS enum (EMPLOYEE rows only, keep 1:1), build the per-tenantchat_idstore, a/start+request_contactbinding flow for staff, aTelegramSendClientbranch inDispatchPipelineService(reusing existing idempotency/DLQ), and close the silent-SMS footgun. This is a contained Baileys-mirror build.request_contact→eg_usermatch), citizen routing rows, consent capture at bind time.8. Open questions for the team
request_contact→ mobile match acceptable, or do we mandate the OTP round-trip?Decide first: (a) the scope fork (outbound vs bidirectional), and (b) the
chat_id ↔ eg_userbinding ceremony. Everything else follows from those two.All reactions