Skip to content

Telegram Bot Architecture

Telegram is the third messaging channel in yaya-engine, added to run the Ethiopia pilot where there is no SMS group, no WhatsApp acquisition, and no OTP — users arrive from a Meta ad, tap /start in Telegram, and open the Mini-App from inside the chat [@mini-app-design]. Rather than build a bespoke integration, the bot is implemented on nutgram/laravel, and the same ChannelInterface/ChannelManager/ChannelResolver abstractions described in Messaging Pipeline treat Telegram as a peer of WhatsApp and SMS: alerts and AI replies flow through the identical SendChannelMessage and ProcessIncomingMessage jobs [@mini-app-design]. What is Telegram-specific lives in routes/telegram.php's own dispatch table, a handful of slash-command handlers and one multi-step Conversation, a Mini-App auth flow with no phone/OTP requirement, and an automation layer that nags users for a phone number only when they actually need one (an airtime payout).

The command dispatch table

routes/telegram.php is not a Laravel route file — it is Nutgram's own registration table, loaded through config/nutgram.php, and it is order-sensitive: slash commands are matched before the catch-all handler ever sees the update [@telegram-routes]. In registration order: /start (with an optional payload capture) goes to StartHandler, /help goes to HelpHandler, and /payout instantiates PayoutConversation directly rather than using Nutgram's stepConversation helper — because stepConversation only persists a conversation for the next update, and the design wants the confirmation message sent and the next step registered on the same /payout request [@telegram-routes]. onContact catches the message.contact update Telegram sends when a user accepts a request_contact share (from the Mini-App or from PayoutConversation's reply keyboard), onLocation catches message.location updates, and a final onMessage catch-all routes any remaining freeform text to FreeformMessageHandler, which forwards it into the same ProcessIncomingMessage → Dify path used by every other channel [@telegram-routes].

config/nutgram.php reads the bot token directly from env() rather than config('services.telegram.bot_token'), because Laravel doesn't guarantee config file load order and the token could resolve to null when Nutgram's own config loads [@nutgram-config]. It also disables Nutgram's built-in safe_mode header check, since TelegramWebhookController independently validates the X-Telegram-Bot-Api-Secret-Token header itself, and having two competing checks on the same header was judged worse than one [@nutgram-config].

Onboarding: /start, location capture, and the mobile-only gotcha

StartHandler decodes the optional /start payload through StartPayloadParser, resolves or creates the user via TelegramUserResolver::findOrCreate(), and writes an audit row to telegram_start_payloads recording the raw payload, decoded JSON, resolved FSP, campaign code, and ad ID — this table is the attribution ledger the Generated Links system's Telegram flow also writes into [@start-handler]. It then sends a welcome photo with an inline web_app button that opens the Mini-App, and — as the very next message — prompts for location, specifically because the pilot wants that ask inside the first one or two bot messages (ENG-373) [@start-handler]. That prompt call is wrapped in a try/catch: a failed location-prompt send must not abort the rest of /start, because an uncaught throw would skip the Mixpanel tracking that follows and let Telegram retry the whole webhook, re-running user creation and re-sending the welcome photo [@start-handler].

The location prompt itself is built by TelegramLocationRequestSender, whose docblock states the underlying constraint directly: request_location is only honoured on a ReplyKeyboardMarkup, never an InlineKeyboardMarkup — which is why the location ask has to be its own separate message from the welcome, which uses an inline keyboard for the Mini-App button [@location-sender]. This same sender is reused by a broadcast job that backfills the location prompt to existing users (ENG-374), not just new ones from /start [@location-sender]. LocationShareHandler is the sole consumer of the resulting message.location update: it only acts if the sender maps to a known ChannelAccount — an unknown sender's location is silently dropped, since /start is what enrolls users, not a location share — then reverse-geocodes the coordinates via MunicipalityResolver, writes them to the user's primary location, and marks the user verified with an active alert subscription, mirroring every other location-confirming path in the codebase (UserController, CreateUser) so alerts actually start firing [@location-handler]. It finishes by dispatching a Supabase alert-subscription sync, since Supabase is still the store that drives alert delivery during the Rada v2 cutover [@location-handler].

The reason this matters operationally: Telegram's request_location and request_contact reply-keyboard buttons are a client feature, and Telegram Desktop and Telegram Web do not implement them — a tap on the button from a desktop or web session sends no update to the bot at all. Nothing in this codebase is broken when that happens; it is standard Telegram Bot API client behavior, not a bug. If a user reports "I tapped the button and nothing happened," the first thing to check is whether they were on a phone.

The payout phone-capture conversation

/payout is handled by PayoutConversation, a Nutgram Conversation subclass with two phases. start() sends a ReplyKeyboardMarkup with a single request_contact button and calls $this->next('handleContact') to advance state for the following update [@payout-conversation]. handleContact() re-loads the user's ChannelAccount (conversations span multiple webhook requests, each getting a fresh container, so nothing from start() survives except what Nutgram persists) and checks that the Telegram-signed contact.user_id on the incoming update matches the account's own identifier, refusing a contact the user copied out of someone else's chat [@payout-conversation]. On a unique-constraint violation when saving the phone number, it detects the Postgres/SQLite variants of the same error message and replies with a "phone already taken" message instead of surfacing a raw exception [@payout-conversation].

Phone-request automation outside the conversation flow

Not every user reaches /payout on their own, so SendTelegramPhoneRequest proactively nudges users for a phone number under four distinct triggers, tracked by PhoneRequestReasonEnum: PayoutCreated (an observer on requested-payout creation), RegistrationReminder (an hourly scheduled command), and PostAlertImmediate/PostAlertFollowUp (fired around alert dispatch) [@phone-reason-enum] [@phone-request-job]. The job is defensive about not double-prompting: the RegistrationReminder reason stamps telegram_phone_reminder_sent_at with a conditional whereNull() update and bails if the stamp didn't take, and the two alert-related reasons claim an atomic insertOrIgnore slot in alert_phone_prompts keyed on (alert_id, user_id, reason) before sending, releasing that slot again only if Telegram's API responds ok: false (a confirmed non-delivery) rather than on a transport-level timeout, which is left to bubble and keep the slot claimed [@phone-request-job]. Locale is resolved explicitly with trans($key, [], $locale) rather than the bare __() helper, because the queue worker's application locale is not the recipient's locale — an omission that would otherwise send every reminder in English regardless of who the user is [@phone-request-job].

Mini-App auth and the locale bug it exposed

The Mini-App reuses the existing channel-agnostic stack end to end: ChannelInterface, ChannelManager, ChannelResolver, ChannelTypeEnum, ChannelAccount, Conversation, and Message are all shared with WhatsApp and SMS, and Telegram authenticates through a signed initData payload verified by HMAC-SHA256 against the bot token, with no OTP or SMS step involved [@mini-app-design]. Because phone numbers are collected only at the payout step, phone_number on User had to become nullable, backed by a Postgres partial unique index so uniqueness is still enforced whenever a phone number is present [@mini-app-design].

That auth flow originally resolved a new user's locale purely from Telegram's language_code in TelegramUserResolver::resolveLocale(), mapping en/sw/es explicitly and defaulting anything else to Amharic. Telegram's native client has no Amharic language_code option, so an Ethiopian pilot user's language_code was always something else — usually en — which the resolver's 'en' => English arm then mapped straight to English, systematically registering Ethiopia-pilot users in the wrong language [@locale-override-design]. The fix, detailed in Telegram Locale Override, adds a nullable fsps.default_locale column that is authoritative at registration when set ($fsp->default_locale ?? $this->resolveLocale(...)), leaving resolveLocale()'s existing fallback untouched for every FSP that hasn't opted in — deliberately not deriving locale from country generally, since a blanket country = 'KE' backfill on pre-existing FSPs would have silently flipped unrelated partners to Swahili [@locale-override-design].