mirror of
https://github.com/Dictionarry-Hub/profilarr.git
synced 2026-04-19 05:18:42 -04:00
271 lines
10 KiB
Markdown
271 lines
10 KiB
Markdown
# Notification System
|
||
|
||
## Table of Contents
|
||
|
||
- [Overview](#overview)
|
||
- [Architecture](#architecture)
|
||
- [Notification Payload](#notification-payload)
|
||
- [Severity](#severity)
|
||
- [Definitions](#definitions)
|
||
- [NotificationManager](#notificationmanager)
|
||
- [Notifiers](#notifiers)
|
||
- [Discord (Detail Tier)](#discord-detail-tier)
|
||
- [Ntfy (Summary Tier)](#ntfy-summary-tier)
|
||
- [Webhook (Passthrough Tier)](#webhook-passthrough-tier)
|
||
- [Telegram (Summary Tier)](#telegram-summary-tier)
|
||
- [Service Tiers](#service-tiers)
|
||
- [Testing](#testing)
|
||
- [Adding a New Service](#adding-a-new-service)
|
||
|
||
## Overview
|
||
|
||
Profilarr's notification system sends alerts when jobs complete, databases sync,
|
||
or things go wrong. Two design goals:
|
||
|
||
1. **Extensibility without coupling.** Adding a new service or event is a
|
||
self-contained change that doesn't touch unrelated code.
|
||
2. **Definitions don't know about services.** The code that decides _what to
|
||
say_ produces a structured, service-agnostic payload. Each notifier decides
|
||
_how to render_ it.
|
||
|
||
Notifications are **fire-and-forget**. A failed webhook never blocks a job.
|
||
Errors are logged and recorded in history, never propagated.
|
||
|
||
## Architecture
|
||
|
||
Three layers:
|
||
|
||
| Layer | Responsibility | Knows about |
|
||
| -------------- | -------------------------------------------------------- | ----------------------------------- |
|
||
| **Definition** | Decides _what to say_: title, message, blocks, severity | Domain data (job logs, statuses) |
|
||
| **Manager** | Decides _who to tell_: queries services, filters by type | Service configs, type subscriptions |
|
||
| **Notifier** | Decides _how to render_: maps payload to platform format | Platform API (embeds, JSON, etc.) |
|
||
|
||
Adding a new **service** never touches definitions. Adding a new **event** never
|
||
touches notifiers.
|
||
|
||
```mermaid
|
||
flowchart TD
|
||
JOB[Job Handler] -->|"passes job log"| DEF[Definition]
|
||
DEF -->|"returns Notification"| NM[NotificationManager]
|
||
|
||
NM -->|get enabled services| DB[(notification_services)]
|
||
NM -->|filter by enabled_types| FILTER[Type Filter]
|
||
FILTER -->|create per service| FACTORY[createNotifier]
|
||
|
||
FACTORY -->|discord| DISCORD[DiscordNotifier]
|
||
FACTORY -->|ntfy| NTFY[NtfyNotifier]
|
||
FACTORY -->|webhook| WEBHOOK[WebhookNotifier]
|
||
FACTORY -->|telegram| TELEGRAM[TelegramNotifier]
|
||
FACTORY -->|future| OTHER[...]
|
||
|
||
DISCORD -->|renders to embeds| WEBHOOK_D[Discord Webhook]
|
||
NTFY -->|renders to ntfy JSON| WEBHOOK_N[Ntfy Server]
|
||
WEBHOOK -->|raw JSON| WEBHOOK_W[Webhook Endpoint]
|
||
TELEGRAM -->|renders to HTML| WEBHOOK_T[Telegram Bot API]
|
||
OTHER -->|renders| WEBHOOK_O[...]
|
||
|
||
NM -->|record result| HISTORY[(notification_history)]
|
||
```
|
||
|
||
## Notification Payload
|
||
|
||
```
|
||
src/lib/server/notifications/types.ts
|
||
```
|
||
|
||
```typescript
|
||
interface Notification {
|
||
type: string;
|
||
severity: 'success' | 'error' | 'warning' | 'info';
|
||
title: string;
|
||
message: string;
|
||
messageFormat?: 'plain' | 'code';
|
||
blocks?: NotificationBlock[];
|
||
}
|
||
|
||
type NotificationBlock = FieldBlock | SectionBlock;
|
||
|
||
interface FieldBlock {
|
||
kind: 'field';
|
||
label: string;
|
||
value: string;
|
||
inline?: boolean;
|
||
}
|
||
|
||
interface SectionBlock {
|
||
kind: 'section';
|
||
title: string;
|
||
content: string;
|
||
imageUrl?: string;
|
||
}
|
||
```
|
||
|
||
The payload is a structured document, not a rendering instruction. `blocks` is a
|
||
single ordered array (not separate `fields[]` and `sections[]`) because ordering
|
||
is meaningful: stats first, then content, then errors.
|
||
|
||
`messageFormat` is an optional rendering hint. When set to `'code'`, Detail-tier
|
||
notifiers (Discord) wrap the message in a code block. Summary-tier notifiers
|
||
(Ntfy, Telegram) and Passthrough (Webhook) ignore it and render the message as
|
||
plain text. Use for filename/path-style messages where monospace is desirable.
|
||
|
||
### Severity
|
||
|
||
Each notifier maps severity to its platform's concept:
|
||
|
||
| Notifier | success | error | warning | info |
|
||
| -------- | -------------------- | ------------------- | ----------------- | -------------------- |
|
||
| Discord | Green embed | Red embed | Yellow embed | Blue embed |
|
||
| Ntfy | Priority 3 (default) | Priority 5 (urgent) | Priority 4 (high) | Priority 3 (default) |
|
||
| Telegram | ✅ prefix | ❌ prefix | ⚠️ prefix | ℹ️ prefix |
|
||
| Webhook | No mapping | No mapping | No mapping | No mapping |
|
||
|
||
## Definitions
|
||
|
||
```
|
||
src/lib/server/notifications/definitions/
|
||
```
|
||
|
||
Pure functions that take domain data and return a `Notification`. They never
|
||
import anything from `notifiers/`. No `EmbedBuilder`, no `Colors`, no
|
||
service-specific types.
|
||
|
||
```typescript
|
||
export function rename({ log }: RenameNotificationParams): Notification {
|
||
return {
|
||
type: `rename.${log.status}`,
|
||
severity: log.status === 'failed' ? 'error' : 'success',
|
||
title: `Rename Complete - ${log.instanceName}`,
|
||
message: `Renamed ${log.results.filesRenamed} files`,
|
||
blocks: [{ kind: 'field', label: 'Files', value: '5/5', inline: true }, ...buildSections(log)]
|
||
};
|
||
}
|
||
```
|
||
|
||
## NotificationManager
|
||
|
||
```
|
||
src/lib/server/notifications/NotificationManager.ts
|
||
```
|
||
|
||
The only way notifications get sent. On `notify(notification)`:
|
||
|
||
1. Query `notification_services` for enabled services
|
||
2. Filter by `enabled_types` (JSON array includes notification type)
|
||
3. `createNotifier()` builds the notifier from `service_type` + `config` JSON
|
||
4. Send in parallel via `Promise.allSettled()`
|
||
5. Record success/failure in `notification_history`
|
||
|
||
`sendToService(serviceId, notification)` bypasses the type filter for test
|
||
notifications from the UI. Every send attempt is recorded in history regardless
|
||
of outcome.
|
||
|
||
## Notifiers
|
||
|
||
```
|
||
src/lib/server/notifications/notifiers/
|
||
```
|
||
|
||
Each notifier is a standalone class that uses `getWebhookClient()` for HTTP.
|
||
Two methods: `notify(notification)` and `getName()`.
|
||
|
||
`BaseHttpNotifier` exists as an abstract base with rate limiting, but current
|
||
notifiers (Discord, Ntfy) are standalone because they need custom HTTP behavior
|
||
(chunking, auth headers).
|
||
|
||
### Discord (Detail Tier)
|
||
|
||
Renders everything: embeds with color-coded severity, thumbnails, inline fields,
|
||
code blocks for sections. Handles Discord's embed limits via pagination. Supports
|
||
`@here` mentions.
|
||
|
||
### Ntfy (Summary Tier)
|
||
|
||
Quick ping: title, message, and field blocks only. Section blocks are omitted.
|
||
POSTs JSON to the server root with `topic` in the payload. Maps severity to
|
||
priority (3/4/5) and emoji tags. Optional `Authorization: Bearer` header for
|
||
authenticated topics.
|
||
|
||
### Webhook (Passthrough Tier)
|
||
|
||
POSTs the raw `Notification` object as JSON to a user-configured URL. No
|
||
rendering, no severity mapping, no block filtering — the payload is the
|
||
notification. Optional `Authorization` header for authenticated endpoints
|
||
(user provides the full header value, e.g. `Bearer token` or `Basic base64`).
|
||
|
||
### Telegram (Summary Tier)
|
||
|
||
Quick ping via the Telegram Bot API. Title, message, and field blocks only.
|
||
Section blocks are omitted. POSTs to
|
||
`https://api.telegram.org/bot{token}/sendMessage` with HTML parse mode. Severity
|
||
mapped to emoji prefix (✅/❌/⚠️/ℹ️). Messages truncated at 4096 chars.
|
||
Config: `bot_token` (secret), `chat_id`.
|
||
|
||
## Service Tiers
|
||
|
||
Not every service renders the same content. The tier determines what the notifier
|
||
includes from the structured payload.
|
||
|
||
| Tier | Renders | Drops | Example |
|
||
| ----------- | ------------------------------------ | ---------------------- | -------------- |
|
||
| Detail | Everything: fields, sections, images | Nothing | Discord |
|
||
| Summary | Title, message, field blocks | Section blocks, images | Ntfy, Telegram |
|
||
| Passthrough | Raw `Notification` JSON | Nothing (no rendering) | Webhook |
|
||
|
||
The tier is a design-time decision baked into the renderer, not a runtime config.
|
||
|
||
## Testing
|
||
|
||
```
|
||
tests/integration/notifications/
|
||
harness/
|
||
mock-server.ts - Deno.serve() that captures HTTP requests
|
||
specs/
|
||
test.test.ts - test notification (definition + all notifiers)
|
||
upgrade.test.ts - upgrade notification (definition + all notifiers)
|
||
rename.test.ts - rename notification (definition + all notifiers)
|
||
arrSync.test.ts - arr sync notification (definition + all notifiers)
|
||
pcdSync.test.ts - PCD sync notification (definition + all notifiers)
|
||
```
|
||
|
||
Tests are organized by event, not by notifier. Each spec covers definition
|
||
output, rendering for all notifiers (Discord, Ntfy, Webhook, Telegram) via
|
||
mock server, and optionally real sends.
|
||
|
||
```bash
|
||
deno task test integration notifications # all
|
||
deno task test integration notifications test # test event only
|
||
deno task test integration notifications upgrade # upgrade only
|
||
deno task test integration notifications telegram # (no standalone file — telegram tests are in each event spec)
|
||
```
|
||
|
||
Real send tests are skipped without `.env`. Copy `.env.example` and fill in
|
||
values for visual verification. Not part of CI. Telegram requires
|
||
`TEST_TELEGRAM_BOT_TOKEN` and `TEST_TELEGRAM_CHAT_ID`.
|
||
|
||
## Adding a New Service
|
||
|
||
Order: scope → tests → UI → backend.
|
||
|
||
**1. Define the scope.** What tier? What's the API? What config does the user
|
||
provide? Which fields are secrets? How does severity map? Document this in
|
||
`docs/todo/` or the PR description. Example (ntfy):
|
||
|
||
> **Tier:** Summary. **API:** POST JSON to server root with `topic` in body.
|
||
> **Config:** `server_url`, `topic`, `access_token` (secret).
|
||
> **Severity:** success/info → priority 3, warning → 4, error → 5.
|
||
|
||
**2. Write tests.** Add a section for the new notifier to each existing event
|
||
spec (`test.test.ts`, `rename.test.ts`, etc.) with mock tests (severity mapping,
|
||
payload structure, block rendering per tier) and real send tests (skipped without
|
||
`.env`). Tests are organized by event, not by notifier.
|
||
|
||
**3. Build the UI.** Config form component, add to type dropdown in
|
||
`NotificationServiceForm.svelte`, add config parsing in `new/` and `edit/` page
|
||
servers, strip secrets in load functions.
|
||
|
||
**4. Implement the backend.** Config interface in `types.ts`, notifier class in
|
||
`notifiers/{service}/`, add case in `NotificationManager.createNotifier()`.
|
||
Tests from step 2 should pass.
|