diff --git a/README.md b/README.md index 4c67a97..115e4b4 100644 --- a/README.md +++ b/README.md @@ -499,6 +499,29 @@ Régler le délai à `0` clôt dès la première lecture hors-ligne ; décocher l'enregistrement quand le flux passe hors-ligne » désactive les deux temps, y compris la pause. +## Notifications Pushover + +Onglet **Notifications** du dashboard. Complète — sans le remplacer — le mécanisme déjà en +place (notification navigateur sur l'onglet Streamers, cloche 🔔/🔕 par profil) : celui-ci +exige un onglet du dashboard ouvert et l'autorisation du navigateur ; Pushover atteint +l'opérateur même dashboard fermé, sur son téléphone. + +Réglage global, pas par agent : crée une application sur +[pushover.net/apps/build](https://pushover.net/apps/build) (une seule suffit pour tous les +usages de Stream Control), renseigne son jeton et ta clé utilisateur, coche « Activer », puis +« Envoyer un test » avant d'enregistrer — une notification de test part directement sur les +identifiants du formulaire, sans attendre un vrai passage en direct pour découvrir une faute +de frappe. + +Déclenché sur le même évènement que la notification navigateur : un profil dont `notify` est +coché (la cloche 🔔) passant à l'état public. Un échec d'envoi (identifiants invalides, +Pushover injoignable) finit dans le journal, jamais en erreur qui interromprait la veille — +c'est un canal secondaire, pas une condition de son fonctionnement. + +Les identifiants ne repartent jamais en clair vers le navigateur : `GET /api/settings/pushover` +les renvoie masqués (`********`), comme le mot de passe obs-websocket ailleurs dans +l'interface. Les renvoyer tels quels au `PUT` laisse la valeur enregistrée inchangée. + ## Enregistrement automatique Sur la fiche d'un streamer (onglet Streamers), assigne un agent puis coche diff --git a/packages/server/src/api.ts b/packages/server/src/api.ts index bc54020..142cdf7 100644 --- a/packages/server/src/api.ts +++ b/packages/server/src/api.ts @@ -6,6 +6,7 @@ import { DEFAULT_OBS_SETTINGS, isAgentAction, normalizeBrowserSettings, + normalizePushoverSettings, normalizeRecordingSettings, normalizeWatchSettings, parseStripchatUsername, @@ -14,9 +15,12 @@ import { config } from './config.ts'; import { generateToken, hashToken, issueSession, requireSession, safeEqual } from './auth.ts'; import { agentsRepo, logsRepo, targetsRepo } from './db.ts'; import { hub } from './hub.ts'; +import { getPushoverSettings, savePushoverSettings, sendPushover } from './pushover.ts'; import { startTargetRecording } from './recorder.ts'; import { watchlist } from './watchlist.ts'; +const PUSHOVER_MASK = '********'; + export const api: Router = Router(); // --- Authentification ------------------------------------------------------- @@ -386,6 +390,63 @@ api.get('/logs', (req, res) => { res.json({ logs: logsRepo.recent(limit) }); }); +// --- Notifications Pushover -------------------------------------------------- + +api.get('/settings/pushover', (_req, res) => { + const settings = getPushoverSettings(); + res.json({ + settings: { + enabled: settings.enabled, + // Jamais renvoyés en clair : un accès à l'écran du dashboard ne doit pas + // suffire à voler des identifiants qui permettent d'envoyer en son nom. + userKey: settings.userKey ? PUSHOVER_MASK : '', + appToken: settings.appToken ? PUSHOVER_MASK : '', + }, + }); +}); + +api.put('/settings/pushover', (req, res) => { + const current = getPushoverSettings(); + const input = normalizePushoverSettings({ + enabled: req.body?.enabled, + // Revenu masqué depuis le formulaire : la valeur enregistrée ne change pas. + userKey: req.body?.userKey === PUSHOVER_MASK ? current.userKey : req.body?.userKey, + appToken: req.body?.appToken === PUSHOVER_MASK ? current.appToken : req.body?.appToken, + }); + + savePushoverSettings(input); + hub.log(null, 'info', 'Réglages Pushover modifiés depuis le dashboard'); + res.json({ + settings: { + enabled: input.enabled, + userKey: input.userKey ? PUSHOVER_MASK : '', + appToken: input.appToken ? PUSHOVER_MASK : '', + }, + }); +}); + +/** + * Notification d'essai : accepte des identifiants fournis dans la requête pour + * pouvoir tester avant d'enregistrer, sans quoi une faute de frappe ne se + * découvrirait qu'au premier vrai passage en direct. + */ +api.post('/settings/pushover/test', async (req, res) => { + const current = getPushoverSettings(); + const settings = normalizePushoverSettings({ + enabled: true, + userKey: req.body?.userKey === PUSHOVER_MASK ? current.userKey : (req.body?.userKey ?? current.userKey), + appToken: + req.body?.appToken === PUSHOVER_MASK ? current.appToken : (req.body?.appToken ?? current.appToken), + }); + + const result = await sendPushover( + { title: 'Stream Control', message: 'Notification de test — tout fonctionne.' }, + settings, + ); + if (result.ok) res.json({ ok: true }); + else res.status(502).json({ ok: false, error: result.error }); +}); + /** Infos nécessaires pour configurer un nouvel agent. */ api.get('/enrollment', (req, res) => { res.json({ diff --git a/packages/server/src/db.ts b/packages/server/src/db.ts index 04b0bc7..96473bb 100644 --- a/packages/server/src/db.ts +++ b/packages/server/src/db.ts @@ -80,6 +80,14 @@ db.exec(` CREATE UNIQUE INDEX IF NOT EXISTS idx_targets_identity ON watch_targets (provider, username); + + -- Réglages globaux, hors agents et profils : une ligne par clé, en JSON. + -- Pensé pour grandir (un jour d'autres canaux que Pushover) sans nouvelle + -- table à chaque fois. + CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ); `); /** Migrations additives : sûres à rejouer sur une base déjà peuplée. */ @@ -621,3 +629,24 @@ export const logsRepo = { return rows.map(toLogEntry).reverse(); }, }; + +const settingsStmts = { + get: db.prepare('SELECT value FROM settings WHERE key = ?'), + set: db.prepare(` + INSERT INTO settings (key, value) VALUES (?, ?) + ON CONFLICT (key) DO UPDATE SET value = excluded.value + `), +}; + +/** Réglages globaux en JSON, une clé = un bloc de configuration. */ +export const settingsRepo = { + get(key: string, fallback: T): T { + const row = settingsStmts.get.get(key) as unknown as { value: string } | undefined; + if (!row) return fallback; + return safeJsonParse(row.value) ?? fallback; + }, + + set(key: string, value: T): void { + settingsStmts.set.run(key, JSON.stringify(value)); + }, +}; diff --git a/packages/server/src/pushover.ts b/packages/server/src/pushover.ts new file mode 100644 index 0000000..478dd74 --- /dev/null +++ b/packages/server/src/pushover.ts @@ -0,0 +1,90 @@ +import type { PushoverSettings } from '@stream-control/shared'; +import { settingsRepo } from './db.ts'; +import { hub } from './hub.ts'; + +const SETTINGS_KEY = 'pushover'; +const API_URL = 'https://api.pushover.net/1/messages.json'; + +export function getPushoverSettings(): PushoverSettings { + return settingsRepo.get(SETTINGS_KEY, { enabled: false, userKey: '', appToken: '' }); +} + +export function savePushoverSettings(settings: PushoverSettings): void { + settingsRepo.set(SETTINGS_KEY, settings); +} + +export interface PushoverMessage { + title: string; + message: string; + /** Lien ouvert depuis la notification (l'app Pushover l'affiche en bouton). */ + url?: string; + urlTitle?: string; +} + +/** + * Envoie une notification, avec les réglages fournis ou ceux enregistrés. + * + * L'API Pushover attend un corps `application/x-www-form-urlencoded`, pas du + * JSON — une erreur facile à faire en copiant le reste de ce fichier, où tout + * le reste parle JSON. + * + * N'échoue jamais bruyamment vers l'appelant : un envoi raté finit dans le + * journal, pas en exception qui remonterait jusqu'à casser l'évènement qui + * en est à l'origine (un streamer passé en direct, par exemple). + */ +export async function sendPushover( + content: PushoverMessage, + settings: PushoverSettings = getPushoverSettings(), +): Promise<{ ok: boolean; error?: string }> { + if (!settings.enabled) return { ok: false, error: 'Pushover désactivé' }; + if (!settings.userKey || !settings.appToken) { + return { ok: false, error: 'Clé utilisateur ou jeton d\'application manquant' }; + } + + const body = new URLSearchParams({ + token: settings.appToken, + user: settings.userKey, + title: content.title, + message: content.message, + }); + if (content.url) body.set('url', content.url); + if (content.urlTitle) body.set('url_title', content.urlTitle); + + try { + const response = await fetch(API_URL, { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body, + signal: AbortSignal.timeout(10_000), + }); + + if (response.ok) return { ok: true }; + + // Pushover répond en JSON même sur erreur, avec le détail dans `errors`. + const payload = (await response.json().catch(() => null)) as { errors?: string[] } | null; + return { ok: false, error: payload?.errors?.join(', ') ?? `HTTP ${response.status}` }; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : String(err) }; + } +} + +/** + * Notifie le passage en direct d'un profil surveillé. + * + * Complète — sans le remplacer — la notification navigateur déjà en place : + * celle-ci exige un onglet du dashboard ouvert, Pushover atteint l'opérateur + * même absent. Un échec est journalisé mais n'empêche rien d'autre : c'est un + * canal secondaire, pas une condition du fonctionnement de la veille. + */ +export async function notifyTargetLive(name: string, url: string): Promise { + const settings = getPushoverSettings(); + if (!settings.enabled) return; + + const result = await sendPushover( + { title: 'Stream Control', message: `${name} est en direct`, url, urlTitle: 'Ouvrir' }, + settings, + ); + if (!result.ok) { + hub.log(null, 'warn', `Notification Pushover (${name} en direct) non envoyée : ${result.error}`); + } +} diff --git a/packages/server/src/watchlist.ts b/packages/server/src/watchlist.ts index 1032876..b450a5b 100644 --- a/packages/server/src/watchlist.ts +++ b/packages/server/src/watchlist.ts @@ -3,6 +3,7 @@ import { DEFAULT_WATCH_SETTINGS, fetchStripchatStatus } from '@stream-control/sh import { config } from './config.ts'; import { targetsRepo } from './db.ts'; import { hub } from './hub.ts'; +import { notifyTargetLive } from './pushover.ts'; import { startTargetRecording } from './recorder.ts'; /** @@ -122,6 +123,7 @@ class Watchlist { // Seul le passage effectif au flux public déclenche une notification. if (next === 'public' && target.state !== 'public' && updated.notify) { hub.publishTargetLive(updated); + void notifyTargetLive(updated.label ?? updated.username, updated.url); } } } diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 43886fc..946741d 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1099,3 +1099,38 @@ export function mapStreamStatus(raw: string | undefined, privateStatuses: string // off / offline / deleted / notFound : le modèle n'est plus là du tout. return 'offline'; } + +// --------------------------------------------------------------------------- +// Notifications Pushover +// --------------------------------------------------------------------------- + +/** + * Identifiants Pushover, réglage serveur unique (pas par agent) : les + * notifications concernent l'opérateur, pas une VM en particulier. + * + * Complète — sans le remplacer — le mécanisme de notification déjà en place + * (notification navigateur, `WatchTarget.notify`) : ce canal-ci atteint + * l'opérateur même dashboard fermé, ce que le navigateur ne peut pas faire. + */ +export interface PushoverSettings { + enabled: boolean; + /** Clé utilisateur ou de groupe Pushover (`Your User Key` sur pushover.net). */ + userKey: string; + /** Jeton de l'application créée sur pushover.net/apps/build. */ + appToken: string; +} + +export const DEFAULT_PUSHOVER_SETTINGS: PushoverSettings = { + enabled: false, + userKey: '', + appToken: '', +}; + +export function normalizePushoverSettings(raw: unknown): PushoverSettings { + const input = (raw ?? {}) as Partial; + return { + enabled: input.enabled === true, + userKey: typeof input.userKey === 'string' ? input.userKey.trim() : '', + appToken: typeof input.appToken === 'string' ? input.appToken.trim() : '', + }; +} diff --git a/packages/web/src/App.tsx b/packages/web/src/App.tsx index 4823ca4..4c53070 100644 --- a/packages/web/src/App.tsx +++ b/packages/web/src/App.tsx @@ -7,6 +7,7 @@ import { AgentCard } from './components/AgentCard'; import { AgentSettings } from './components/AgentSettings'; import { AgentHistory } from './components/AgentHistory'; import { LogPanel } from './components/LogPanel'; +import { NotificationsPage } from './components/NotificationsPage'; import { StreamersPage } from './components/StreamersPage'; import { useHashRoute } from './useHashRoute'; @@ -185,6 +186,12 @@ export function App() { > Journal +
@@ -228,6 +235,8 @@ export function App() { {route === 'logs' ? ( + ) : route === 'notifications' ? ( + ) : route === 'streamers' ? ( (`/watchlist/${id}/record`, { method: 'POST' }), stopTarget: (id: string) => request<{ ok: boolean }>(`/watchlist/${id}/stop`, { method: 'POST' }), + + // --- Notifications ---------------------------------------------------------- + + pushoverSettings: () => request<{ settings: PushoverSettings }>('/settings/pushover'), + + updatePushoverSettings: (body: PushoverSettings) => + request<{ settings: PushoverSettings }>('/settings/pushover', { + method: 'PUT', + body: JSON.stringify(body), + }), + + testPushover: (body: Pick) => + request<{ ok: true }>('/settings/pushover/test', { + method: 'POST', + body: JSON.stringify(body), + }), }; /** URL du flux temps réel, jeton en query (les WS ne portent pas d'en-tête). */ diff --git a/packages/web/src/components/NotificationsPage.tsx b/packages/web/src/components/NotificationsPage.tsx new file mode 100644 index 0000000..27b7839 --- /dev/null +++ b/packages/web/src/components/NotificationsPage.tsx @@ -0,0 +1,133 @@ +import { useEffect, useState } from 'react'; +import type { PushoverSettings } from '@stream-control/shared'; +import { api } from '../api'; + +interface Props { + notify: (message: string, tone?: 'info' | 'error') => void; +} + +const EMPTY: PushoverSettings = { enabled: false, userKey: '', appToken: '' }; + +/** + * Réglages Pushover — un canal qui atteint l'opérateur même dashboard fermé, + * à la différence de la notification navigateur déjà en place sur l'onglet + * « Streamers » (qui exige un onglet ouvert et le focus accordé une fois). + */ +export function NotificationsPage({ notify }: Props) { + const [settings, setSettings] = useState(EMPTY); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [testing, setTesting] = useState(false); + + useEffect(() => { + api + .pushoverSettings() + .then(({ settings }) => setSettings(settings)) + .catch((err) => notify(err instanceof Error ? err.message : 'Lecture impossible', 'error')) + .finally(() => setLoading(false)); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + function patch(update: Partial) { + setSettings((current) => ({ ...current, ...update })); + } + + async function save() { + setSaving(true); + try { + const { settings: saved } = await api.updatePushoverSettings(settings); + setSettings(saved); + notify('Réglages Pushover enregistrés'); + } catch (err) { + notify(err instanceof Error ? err.message : 'Enregistrement impossible', 'error'); + } finally { + setSaving(false); + } + } + + async function test() { + setTesting(true); + try { + await api.testPushover({ userKey: settings.userKey, appToken: settings.appToken }); + notify('Notification de test envoyée — vérifie ton téléphone'); + } catch (err) { + notify(err instanceof Error ? err.message : 'Envoi impossible', 'error'); + } finally { + setTesting(false); + } + } + + // Le test exige des identifiants dans le champ, masqués ou non : un envoi + // sur des champs vides échouerait toujours côté Pushover, autant l'éviter ici. + const canTest = Boolean(settings.userKey) && Boolean(settings.appToken); + + if (loading) return

Chargement…

; + + return ( +
+

Notifications

+

+ Alerte l'opérateur — sur son téléphone, dashboard fermé compris — quand un streamer + surveillé passe en direct. Complète la notification navigateur déjà proposée sur + l'onglet « Streamers », qui exige un onglet ouvert. +

+ +
+ Pushover + + + + + + + +
+ + +
+
+
+ ); +} diff --git a/packages/web/src/styles.css b/packages/web/src/styles.css index e4fbc50..41c488d 100644 --- a/packages/web/src/styles.css +++ b/packages/web/src/styles.css @@ -578,6 +578,10 @@ fieldset.group > legend { flex-wrap: wrap; } +.notifications-page fieldset.group { + max-width: 480px; +} + .streamer-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); diff --git a/packages/web/src/useHashRoute.ts b/packages/web/src/useHashRoute.ts index 12b3e31..f8caa42 100644 --- a/packages/web/src/useHashRoute.ts +++ b/packages/web/src/useHashRoute.ts @@ -1,6 +1,6 @@ import { useEffect, useState } from 'react'; -export const ROUTES = ['agents', 'streamers', 'logs'] as const; +export const ROUTES = ['agents', 'streamers', 'logs', 'notifications'] as const; export type Route = (typeof ROUTES)[number]; function currentRoute(): Route {