feat: add telegram notification channel with outbox service and per-user preferences

Extend the notification system with a third channel (telegram) alongside existing in_app and push channels. Add `telegram_enabled` column to `notification_preferences` table, update `NOTIFICATION_CHANNELS` and `_NOTIFICATION_CHANNEL_COLUMNS` mappings, and set telegram default to off (`_NOTIFICATION_CHANNEL_DEFAULTS`). Create `telegram_outbox` table with columns for uid, user_uid, chat_id, text, status, attempts, created_at, and sent_at, plus an index on status/id for efficient polling. Register `TelegramOutboxService` in the service manager lifecycle. Update API documentation strings to describe the new channel and its pairing requirement. Extend admin notification defaults view to include telegram column. Pass `notif_telegram_paired` flag to profile template based on `telegram_store.is_paired()` check. Update `NotificationPrefForm` and `NotificationDefaultForm` model literals to accept "telegram" as a valid channel value.
This commit is contained in:
2026-06-22 20:52:02 +00:00
parent a9d0814c47
commit e0e64c8d9e
36 changed files with 720 additions and 78 deletions
+1 -1
View File
@@ -219,7 +219,7 @@ Devii's project filesystem tools include surgical **line-range editing** (`proje
Users and guests inject their own **CSS** (look) and **JS** (behaviour), scoped to a **page type** or **globally**, configured **only through Devii** (no HTTP routes - persistence is owner-scoped from the trusted Devii session, like `LessonStore`). It is **per-owner only**: the code runs solely in that owner's own browser sessions (self-XSS, like a userscript), never in anyone else's view - never key injection off URL-supplied data. **Page type** = the matched route template (`request.scope["route"].path`, e.g. `/posts/{slug}`) via `page_type_for(request)`; one value covers all instances of a type (all posts), never one post; also rendered as `data-page-type` on `<main>` and surfaced to Devii via `DeviiClient._context().pageType`. **Owner** = `owner_for(request)`: user uid, else the `DEVII_GUEST_COOKIE` (`constants.py`, shared with `routers/devii.py`), else none. **Storage** (`database.py`): table `user_customizations` (per `owner_kind`/`owner_id`/`scope`/`lang`) with helpers `get_custom_overrides` (merges global-then-page-type, cached under the `"customizations"` cache-version name via `sync_local_cache`/`bump_cache_version`), `set_custom_override`, `delete_custom_override`. **Injection**: template globals `custom_css_tag`/`custom_js_tag` (registered in `templating.py`) emit a `<style id="user-custom-css">` after `extra_head` (CSS overrides the design system; `</`->`<\/` neutralizes breakout) and a JSON-island `<script>` after `Application.js` (`<`/`>` escaped, run via `new Function(JSON.parse(...))` so `app` exists). Both fail closed to empty Markup - a customization issue must never break page render. Kill-switches: `customization_enabled`, `customization_js_enabled`. **Devii** (`services/devii/customization/`, `handler="customization"`): `customize_list`/`get`/`set_css`/`set_js`/`reset`, all `requires_auth=False`; `CustomizationController(owner_kind, owner_id)` is built in `Dispatcher.__init__` (owner threaded from `DeviiSession`); set/reset are in `CONFIRM_REQUIRED` so `confirmation_error` forces Devii to ask **page-type vs global** before `confirm=true`. Devii previews live with `run_js` (ids `devii-preview-css`/`devii-preview-js`) and `reload_page` after saving.
### Notification preferences (`database.py`)
Every notification type (`NOTIFICATION_TYPES`) is independently toggleable per user on two channels (in-app, push). Resolution `notification_enabled(user_uid, type, channel)` = live `notification_preferences` override -> admin global default (`notif_default_{type}_{channel}` setting, default `1`) -> on. Enforcement is the single funnel `utils.create_notification` (in-app insert gated by the `in_app` channel, `_schedule_push` by `push`); the `messages.py` DM path was routed through `create_notification` so it is gated too. **`create_notification` defers its work to the background task queue** (`background.submit(_deliver_notification, ...)`), so the preference reads + in-app insert + push schedule + audit all run off the request path (inline in tests). See `### Background task queue`. UI: profile **Notifications** tab (`/profile/{username}?tab=notifications`, owner-or-admin, `POST /profile/{username}/notifications` + `/reset`) and admin global defaults at `/admin/notifications`. Devii tools `notification_list`/`notification_set`/`notification_reset` (`handler="notification"`, owner-scoped, `set`/`reset` in `CONFIRM_REQUIRED`). Storage is the soft-deletable `notification_preferences` table, cached under the `"notif_prefs"` cache-version name. Reuse this canonical-list + resolver + override-table + single-funnel pattern for future per-user per-channel preferences. **Live toasts ride the `in_app` channel:** `services/notification_relay.py` `NotificationRelayService` (lock-owner `BaseService`, like `MessageRelay`) polls `notifications.id > watermark` and publishes each new row to the pub/sub topic `user.{uid}.notifications`; because it runs on the lock owner where every `/pubsub/ws` subscriber converges, the in-process `services.pubsub.publish` reaches the recipient, and because the row exists only when `in_app` is enabled the toast fires exactly for enabled notifications (no new channel). Frontend `static/js/LiveNotifications.js` (`app.liveNotifications`) reads `<body data-user-uid>`, subscribes via `app.pubsub`, and raises a click-through `app.toast`. See AGENTS.md -> "Notification preferences". **The same relay tick also publishes fresh unread counts** `{notifications, messages}` to `user.{uid}.counts` (one publish point covers new notifications AND new DMs, since a DM inserts a `message`-type notification row); `static/js/CounterManager.js` (`app.counters`, constructed with `this.pubsub`) subscribes for instant badge bumps and keeps a 60s `/notifications/counts` poll as a decrement/missed-frame fallback.
Every notification type (`NOTIFICATION_TYPES`) is independently toggleable per user on three channels (in-app, push, telegram). Channels are data-driven via `NOTIFICATION_CHANNELS` / `_NOTIFICATION_CHANNEL_COLUMNS` / `_NOTIFICATION_CHANNEL_DEFAULTS` (in-app/push default on, **telegram defaults off for every type**); the pref helpers iterate them, so adding a channel is a constant entry + a `notification_preferences` column in `init_db`. Resolution `notification_enabled(user_uid, type, channel)` = live `notification_preferences` override -> admin global default (`notif_default_{type}_{channel}` setting) -> the channel fallback. Enforcement is the single funnel `utils.create_notification` (in-app insert gated by the `in_app` channel, `_schedule_push` by `push`, `_schedule_telegram` by `telegram`); the `messages.py` DM path was routed through `create_notification` so it is gated too. **Telegram delivery is cross-worker:** `_schedule_telegram` enqueues a `telegram_outbox` row (no-op if the user is not paired); `services/telegram/outbox_service.py` `TelegramOutboxService` (lock-owner `BaseService`, default-enabled, ~2s) drains it via `TelegramService.send_markdown` on the worker that owns the bot subprocess (rows wait if the bot is down). The profile Telegram column is disabled until the user pairs Telegram. **`create_notification` defers its work to the background task queue** (`background.submit(_deliver_notification, ...)`), so the preference reads + in-app insert + push schedule + audit all run off the request path (inline in tests). See `### Background task queue`. UI: profile **Notifications** tab (`/profile/{username}?tab=notifications`, owner-or-admin, `POST /profile/{username}/notifications` + `/reset`) and admin global defaults at `/admin/notifications`. Devii tools `notification_list`/`notification_set`/`notification_reset` (`handler="notification"`, owner-scoped, `set`/`reset` in `CONFIRM_REQUIRED`). Storage is the soft-deletable `notification_preferences` table, cached under the `"notif_prefs"` cache-version name. Reuse this canonical-list + resolver + override-table + single-funnel pattern for future per-user per-channel preferences. **Live toasts ride the `in_app` channel:** `services/notification_relay.py` `NotificationRelayService` (lock-owner `BaseService`, like `MessageRelay`) polls `notifications.id > watermark` and publishes each new row to the pub/sub topic `user.{uid}.notifications`; because it runs on the lock owner where every `/pubsub/ws` subscriber converges, the in-process `services.pubsub.publish` reaches the recipient, and because the row exists only when `in_app` is enabled the toast fires exactly for enabled notifications (no new channel). Frontend `static/js/LiveNotifications.js` (`app.liveNotifications`) reads `<body data-user-uid>`, subscribes via `app.pubsub`, and raises a click-through `app.toast`. See AGENTS.md -> "Notification preferences". **The same relay tick also publishes fresh unread counts** `{notifications, messages}` to `user.{uid}.counts` (one publish point covers new notifications AND new DMs, since a DM inserts a `message`-type notification row); `static/js/CounterManager.js` (`app.counters`, constructed with `this.pubsub`) subscribes for instant badge bumps and keeps a 60s `/notifications/counts` poll as a decrement/missed-frame fallback.
### Live view relay (`devplacepy/services/live_view_relay.py`)
`LiveViewRelayService` is a second lock-owner pub/sub bridge (default-enabled, 1s interval, registered in `main.py`) that **replaces per-client HTTP polling on the admin live views with server push, computing a snapshot only for topics that currently have subscribers**. Each tick it reads `pubsub.topics()`, matches concrete subscribed topics against the `VIEWS` registry `(regex, async compute, min_interval)`, throttles per topic, and publishes the **same payload shape the matching HTTP endpoint already returns** (so frontend render code is unchanged). Topics + cadence: `container.list` (4s), `project.{slug}.containers` (3s), `container.{uid}.detail` (4s), `container.{uid}.logs` (3s), `fleet.bots` (2s), `admin.services` (5s), `admin.services.{name}` (5s), `admin.ai-usage.{hours}` (15s), `admin.backups` (8s) - all admin-only by pub/sub policy. The frontend monitors (`ContainerInstance`/`ContainerList`/`ContainerManager`/`BotMonitor`/`ServiceMonitor`/`AiUsageMonitor`/`BackupMonitor`) subscribe via `window.app.pubsub` and keep a lengthened (15-30s) HTTP poll as initial-load + fallback. **The `admin.backups` view is published with `can_download=False`** so the primary-admin-only backup `download_url` never traverses the bus (the same withholding every backups endpoint applies); `BackupMonitor` establishes download capability from its authoritative HTTP poll and builds the `/admin/backups/{uid}/download` URL client-side. To add an admin live view: add one `VIEWS` row returning the endpoint payload and subscribe on the frontend. Keep durable/ordered/stateful/raw-I/O channels (messages, Devii, SEO/DeepSearch progress, container PTY) OFF pub/sub. See `pubreport.md` and AGENTS.md -> "Live view relay".