Signals & Notifications — Architecture¶
Audience: Architects, tech leads, senior engineers.
Context and Purpose¶
The Signals system solves a single problem: delivering proactive notifications to users who are not currently in the Swisper chat interface. Background jobs decide what to notify about; Signals decides how to deliver it.
The architectural goals are:
- Channel abstraction — Adding a new delivery channel (e.g., email, SMS) requires only a new
NotificationChannelimplementation, with no changes to callers - Parallel delivery — When a user has multiple channels enabled, messages are sent concurrently
- Two-level preferences — A global signals toggle and per-channel enable flags give users fine-grained control
- Resilient delivery — Channel failures are isolated; one channel failing does not prevent delivery on others
Architecture Overview¶
graph TD
subgraph Jobs["Background Jobs"]
DB["DailyBriefingJob"]
EM["SendImportantEmailNotificationsJob"]
MP["PreMeetingPrepJob"]
CR["CommitmentReminderJob"]
AR["AwaitingResponseNotificationJob"]
end
subgraph Signals["SignalsService"]
SS["SignalsService"]
CM["ChannelManager"]
end
subgraph Channels["Notification Channels"]
TC["TelegramChannel"]
THC["ThreemaChannel"]
end
subgraph Prefs["Preferences"]
UP["user_preferences.signals_enabled"]
UI["integrations.notifications_enabled"]
end
subgraph External["External APIs"]
TAPI["Telegram Bot API"]
THAPI["Threema Gateway"]
end
Jobs --> SS
SS --> CM
CM --> UP & UI
SS --> TC & THC
TC --> TAPI
THC --> THAPI
Component Responsibilities¶
| Component | File | Responsibility |
|---|---|---|
| SignalsService | services/signals/signals_service.py |
Entry point for all notifications. Checks global signals_enabled, resolves available channels via ChannelManager, sends in parallel. Provides convenience methods like send_email_notification() |
| ChannelManager | services/signals/channel_manager.py |
Queries UserIntegration for active notification channels with notifications_enabled=True. Separates notification channels from data integrations |
| TelegramChannel | services/signals/notification_channels/telegram_channel.py |
Formats messages as Markdown v1 (bold titles, escaped special characters), sends via sendMessage endpoint using chat_id from integration config |
| ThreemaChannel | services/signals/notification_channels/threema_channel.py |
Formats as plain text with emoji prefixes, sends via Threema Gateway HTTPS API. Uses tenacity for retry (3 attempts, exponential backoff on timeout/5xx) |
| NotificationChannel ABC | services/signals/notification_channels/base_channel.py |
Abstract base class defining send(), get_user_id(), is_notifications_enabled() |
| Notification Preferences API | api/routes/user_notification_preferences.py |
GET/POST /users/notification-preferences, GET /users/notification-channels, PATCH /users/integrations/{integration_id}/notifications |
Data Model¶
NotificationMessage¶
The standard payload for all notifications:
| Field | Type | Required | Purpose |
|---|---|---|---|
title |
str | None |
No | When present, rendered as a bold header (Telegram) or emoji-prefixed header (Threema) |
content |
str |
Yes | Message body |
user_id |
str |
Yes | Target user ID, used to resolve channel addresses |
metadata |
dict | None |
No | Optional extra data (currently unused by channels) |
Preference Storage¶
| Preference | Location | Scope |
|---|---|---|
signals_enabled |
user_preferences.standard_rules.signals_enabled |
Global: all channels on/off |
notifications_enabled |
integrations.notifications_enabled |
Per-channel: individual toggle |
Message Formatting¶
| Channel | Title present | Title absent | Special handling |
|---|---|---|---|
| Telegram | 🔔 *{title}*\n\n{content} |
{content} |
Markdown v1: escapes _, *, `, [ in content |
| Threema | 📧 {title}\n\n{content} |
{content} |
Plain text; 3500-char limit is enforced via LLM prompts, not in the channel implementation |
Key Design Decisions¶
Decision: Channel abstraction via ABC
- Chosen:
NotificationChannelABC withsend(),get_user_id(),is_notifications_enabled()methods - Rejected: Hard-coded if/else per channel type in the service
- Rationale: Clean separation of concerns. Each channel owns its formatting, API interaction, and error handling. New channels are added by implementing the ABC and registering in
SignalsService.__init__().
Decision: Concurrent delivery to all enabled channels
- Chosen:
asyncio.create_task()per channel, then awaiting each task — channels run concurrently but results are collected individually for per-channel error isolation - Rejected: Sequential delivery, preferred-channel-only delivery
- Rationale: Users who enable multiple channels expect to receive notifications on all of them. Concurrent delivery minimizes latency. Failures on one channel do not block others, and per-task await enables per-channel success/failure tracking in the results dict.
Decision: Global + per-channel preference model
- Chosen: Two-level toggle:
signals_enabled(global) +notifications_enabled(per integration) - Rejected: Single global toggle, per-notification-type preferences
- Rationale: The global toggle is a quick "mute all" control. Per-channel enables let users keep Telegram on but Threema off (or vice versa). Per-notification-type preferences (e.g., disable meeting prep but keep email alerts) are not yet implemented but the model can be extended.
Interfaces and Contracts¶
| Interface | Direction | Consumer | Contract |
|---|---|---|---|
SignalsService.send_notification(msg) |
Inbound | Background jobs | Accepts NotificationMessage, delivers to all enabled channels |
SignalsService.send_email_notification(user_id, subject, sender_email, sender_name) |
Inbound | Email notification job | Convenience wrapper that builds a NotificationMessage with email details |
GET /users/notification-preferences |
Outbound | Frontend settings | Returns signals_enabled, preferred_channels, channel_by_type |
PATCH /users/integrations/{integration_id}/notifications |
Inbound | Frontend settings | Toggles notifications_enabled for a specific notification channel (via enabled query param) |
TelegramChannel.send(msg) |
Outbound | Telegram Bot API | POST /bot{token}/sendMessage with chat_id and parse_mode=Markdown |
ThreemaChannel.send(msg) |
Outbound | Threema Gateway | HTTPS POST with from, to, text fields |
Known Trade-offs and Debt¶
| Item | Impact | Remediation |
|---|---|---|
| No delivery receipts | The system logs API success/failure but has no confirmation that the user read the notification | Integrate delivery status callbacks where available (Telegram read receipts are not available via Bot API) |
| No per-notification-type preferences | Users cannot selectively disable specific notification types (e.g., keep email alerts, disable meeting prep) | Add a notification_type → enabled mapping in user preferences |
| Threema rate limiting is reactive | HTTP 429 from Threema Gateway is caught and logged but the message is not retried | Add the 429 response to the retry policy with appropriate backoff |
| Channel discovery at service init | Available channel types are determined at SignalsService.__init__() based on environment variables. If Telegram bot token is not configured, the channel is not created |
Consider lazy channel initialization or a registry pattern |