Skip to content

feat(notifications): pluggable channels + Telegram (#146) and WhatsApp (#136)#148

Merged
v0l merged 12 commits into
masterfrom
notifications
Jun 25, 2026
Merged

feat(notifications): pluggable channels + Telegram (#146) and WhatsApp (#136)#148
v0l merged 12 commits into
masterfrom
notifications

Conversation

@v0l

@v0l v0l commented Jun 24, 2026

Copy link
Copy Markdown
Contributor

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_notification for two hardcoded channels (email/SMTP + NIP-17). This PR refactors that into a NotificationChannel trait and adds two new channels behind it — the worker dispatch loop just iterates every channel a user opted into.

Commits

  1. refactor — extract NotificationChannel abstraction (email + NIP-17 moved behind it, no behavior change)
  2. Telegram (Telegram notifications #146) — Bot API channel + deep-link account linking
  3. WhatsApp (Whatsapp notifications #136) — Cloud API channel + phone verification

Architecture

  • NotificationChannel trait: name() / wants(user) / send(user, notification) returning OpError (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 via wants()
  • Adding the two channels required zero changes to the dispatch loop

Telegram (#146)

  • TelegramClient (sendMessage / getUpdates), TelegramChannel (opt-in once a chat is linked)
  • TelegramBot long-poll loop resolves /start <token> deep links → persists chat id; spawned in API mode (single getUpdates consumer)
  • API: POST/DELETE /api/v1/account/telegram/link
  • Config: telegram { bot-token, bot-username }

WhatsApp (#136)

  • WhatsAppClient.send_template (Meta Cloud API, single body {{1}} param), WhatsAppChannel (opt-in once verified)
  • Code-based verification (WhatsApp has no inbound deep-link flow):
    • POST /api/v1/account/whatsapp/verify → stores number, sends 6-digit code via verify template
    • POST /api/v1/account/whatsapp/confirm → matches code, marks verified
    • DELETE /api/v1/account/whatsapp/verify → unlink
  • Config: whatsapp { access-token, phone-number-id, api-version, message/verify templates + langs }
  • Note: business-initiated WhatsApp messages require pre-approved templates with a single {{1}} body param.

DB

  • Two migrations adding per-user opt-in flags + linking/verification fields to users
  • User model, MySQL update_user, MockDb, and demo-data generator updated
  • New trait methods for Telegram token lookup + linking

Account API

AccountPatchRequest exposes contact_telegram / contact_whatsapp plus read-only link/verify status; PATCH guards prevent enabling a channel before linking/verifying.

Testing

  • New unit tests: Telegram/WhatsApp wants opt-in matrices + number normalisation
  • Full workspace builds; test suites green (lnvps_api 135, lnvps_db 129, lnvps_api_common 20); clean clippy on new files
  • Refactor verified regression-free (pre-existing router test flakiness under parallel runs confirmed identical on clean master)

@v0l v0l left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Comment thread lnvps_api/src/settings.rs Outdated
v0l added 5 commits June 25, 2026 10:38
…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.
@v0l v0l force-pushed the notifications branch from ce20b66 to 3f1bf36 Compare June 25, 2026 09:39
v0l added 7 commits June 25, 2026 10:52
…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.
@v0l v0l merged commit 5aa317c into master Jun 25, 2026
7 checks passed
@v0l v0l deleted the notifications branch June 25, 2026 13:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Telegram notifications Whatsapp notifications

1 participant