feat(notifications): pluggable channels + Telegram (#146) and WhatsApp (#136)#148
Merged
Conversation
v0l
commented
Jun 25, 2026
v0l
left a comment
Contributor
Author
There was a problem hiding this comment.
We need an api route which can list the configured notification channels so the UI can show/hide the inputs, no point showing inputs when they are not configured on the backend
…ction Move the inline email (SMTP) and NIP-17 notification dispatch out of Worker::send_notification into a new notifications module with a NotificationChannel trait. Each channel decides per-user opt-in via wants() and delivers via send(), returning OpError for transient/fatal handling. This is groundwork for adding Telegram (#146) and WhatsApp (#136) channels without touching the worker dispatch loop. - notifications::NotificationChannel trait + Notification payload - EmailChannel wraps SmtpConfig (send_email helper retained, reused by email verification) - Nip17Channel wraps the nostr Client - build_channels() assembles enabled channels from settings - Worker holds Vec<Arc<dyn NotificationChannel>>; send_notification just iterates channels No behavior change: email tried first then NIP-17, same opt-in flags, same transient/fatal error semantics.
Adds Telegram as a pluggable notification channel on top of the new
NotificationChannel abstraction, plus an account-linking flow.
DB:
- migration adding users.contact_telegram, telegram_chat_id and a
one-time telegram_link_token (+ index)
- User model fields, mysql update_user binding, and two new trait
methods: get_user_by_telegram_link_token + link_telegram_chat
- MockDb + demo data generator updated
Settings:
- optional TelegramConfig { bot-token, bot-username }; threaded through
WorkerSettings; documented (commented) in config.yaml
Channel + bot:
- TelegramClient (sendMessage / getUpdates) Bot API wrapper
- TelegramChannel: opt-in via contact_telegram once a chat is linked;
maps Telegram 4xx (non-429) to Fatal, everything else Transient
- TelegramBot long-poll loop resolves /start <token> deep links to an
account, persists the chat id and confirms; spawned in API mode
API:
- POST /api/v1/account/telegram/link returns a t.me deep link + token
- DELETE /api/v1/account/telegram/link unlinks and disables
- AccountPatchRequest exposes contact_telegram + read-only
telegram_linked; PATCH guards enabling without a linked chat
Tests: TelegramChannel.wants opt-in/linked matrix; full workspace
builds, lnvps_api/lnvps_db/lnvps_api_common test suites green.
Adds WhatsApp (Meta Cloud API) as a notification channel with phone
number verification, reusing the NotificationChannel abstraction.
DB:
- migration adding users.contact_whatsapp, whatsapp_number,
whatsapp_verified and a pending whatsapp_verify_code
- User model fields + mysql update_user binding; MockDb + demo data
Settings:
- optional WhatsAppConfig (access-token, phone-number-id, api-version,
message/verify templates + languages); threaded through WorkerSettings;
documented (commented) in config.yaml
Channel:
- WhatsAppClient.send_template (single body {{1}} param) against the
Cloud API; number normalised to digits-only international form
- WhatsAppChannel: opt-in via contact_whatsapp once the number is
verified; Cloud API 4xx (non-429) -> Fatal, else Transient
API (verification is code-based since WhatsApp has no inbound link flow):
- POST /api/v1/account/whatsapp/verify stores the number, generates a
6-digit code and sends it via the verify template
- POST /api/v1/account/whatsapp/confirm matches the code, marks verified
- DELETE /api/v1/account/whatsapp/verify removes the number
- AccountPatchRequest exposes contact_whatsapp + read-only
whatsapp_number/whatsapp_verified; PATCH guards enabling without a
verified number
Tests: number normalisation + WhatsAppChannel.wants matrix; full
workspace builds, lnvps_api/lnvps_db/lnvps_api_common suites green.
… config keys, support layered config files
…without nostr config
…ion required to order VMs)
…ries AdminUserInfo/User gained notification contact fields but the explicit-column admin user listing and active-customer queries still selected the old column set, causing 'no column found for name: contact_telegram' at runtime. Also add the missing email_hash column to get_active_customers_with_contact_prefs.
…Payment The subscription renew endpoint returned a payment record with no bolt11, so the invoice could not be paid (and the e2e lifecycle test's extract_bolt11 failed whenever renew returned 200). Add a 'data' field mirroring ApiVmPayment so subscription renewals expose the Lightning invoice.
…ively-expired subscriptions - AdminVmInfo.template_id is now Option<u64> so custom-template VMs report null instead of the 0 sentinel (lifecycle e2e: template_id should be null after standard->custom upgrade). - Worker CheckSubscriptions now fires expiry handling for any expired-but-in-grace subscription, not only ones that crossed expiry since the last check. Uses VM history (Expired entry >= sub.expires) as an idempotency marker so it acts once, and always logs the Expired entry even if the best-effort stop_vm fails.
Tests run serially against a shared per-run database and test_full_lifecycle is not the last test (rbac, user_api, test_unpaid_vm_cleanup follow it). It previously called drop_test_database() at the end, wiping the DB out from under those tests (Unknown database / table doesn't exist). This was never hit before because the test skipped (no SMTP) or failed early; now that it completes it must not tear down the shared DB. The harness already destroys the DB via 'docker compose down -v'.
…oon' The expiring-soon branch matched any subscription with expires within the notification window relative to last_check, but didn't require expires to be in the future. With a stale last_check (e.g. a fresh worker defaulting to the unix epoch), an already-expired subscription matched 'expiring soon' and never reached the expired/grace branches, so the VM was never stopped. Add an 'expires > now' guard so expired subscriptions fall through to expiry handling.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Adds Telegram and WhatsApp notification channels on top of a new pluggable channel abstraction.
Closes #146
Closes #136
Overview
Previously notifications were dispatched inline in
Worker::send_notificationfor two hardcoded channels (email/SMTP + NIP-17). This PR refactors that into aNotificationChanneltrait and adds two new channels behind it — the worker dispatch loop just iterates every channel a user opted into.Commits
NotificationChannelabstraction (email + NIP-17 moved behind it, no behavior change)Architecture
NotificationChanneltrait:name()/wants(user)/send(user, notification)returningOpError(Transient → retry job, Fatal → log+skip, other channels still run)build_channels()registers a channel only when its backend is configured; per-user opt-in checked viawants()Telegram (#146)
TelegramClient(sendMessage / getUpdates),TelegramChannel(opt-in once a chat is linked)TelegramBotlong-poll loop resolves/start <token>deep links → persists chat id; spawned in API mode (single getUpdates consumer)POST/DELETE /api/v1/account/telegram/linktelegram { bot-token, bot-username }WhatsApp (#136)
WhatsAppClient.send_template(Meta Cloud API, single body{{1}}param),WhatsAppChannel(opt-in once verified)POST /api/v1/account/whatsapp/verify→ stores number, sends 6-digit code via verify templatePOST /api/v1/account/whatsapp/confirm→ matches code, marks verifiedDELETE /api/v1/account/whatsapp/verify→ unlinkwhatsapp { access-token, phone-number-id, api-version, message/verify templates + langs }{{1}}body param.DB
usersUsermodel, MySQLupdate_user, MockDb, and demo-data generator updatedAccount API
AccountPatchRequestexposescontact_telegram/contact_whatsappplus read-only link/verify status; PATCH guards prevent enabling a channel before linking/verifying.Testing
wantsopt-in matrices + number normalisation