Feat: pushover notif
All checks were successful
release / build (push) Successful in 43s
release / verify-windows (push) Successful in 2m50s

This commit is contained in:
jeanotx32
2026-08-12 11:30:36 +02:00
parent d14369a914
commit 399f54f290
11 changed files with 410 additions and 2 deletions

View File

@@ -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 l'enregistrement quand le flux passe hors-ligne » désactive les deux temps, y compris la
pause. 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 ## Enregistrement automatique
Sur la fiche d'un streamer (onglet Streamers), assigne un agent puis coche Sur la fiche d'un streamer (onglet Streamers), assigne un agent puis coche

View File

@@ -6,6 +6,7 @@ import {
DEFAULT_OBS_SETTINGS, DEFAULT_OBS_SETTINGS,
isAgentAction, isAgentAction,
normalizeBrowserSettings, normalizeBrowserSettings,
normalizePushoverSettings,
normalizeRecordingSettings, normalizeRecordingSettings,
normalizeWatchSettings, normalizeWatchSettings,
parseStripchatUsername, parseStripchatUsername,
@@ -14,9 +15,12 @@ import { config } from './config.ts';
import { generateToken, hashToken, issueSession, requireSession, safeEqual } from './auth.ts'; import { generateToken, hashToken, issueSession, requireSession, safeEqual } from './auth.ts';
import { agentsRepo, logsRepo, targetsRepo } from './db.ts'; import { agentsRepo, logsRepo, targetsRepo } from './db.ts';
import { hub } from './hub.ts'; import { hub } from './hub.ts';
import { getPushoverSettings, savePushoverSettings, sendPushover } from './pushover.ts';
import { startTargetRecording } from './recorder.ts'; import { startTargetRecording } from './recorder.ts';
import { watchlist } from './watchlist.ts'; import { watchlist } from './watchlist.ts';
const PUSHOVER_MASK = '********';
export const api: Router = Router(); export const api: Router = Router();
// --- Authentification ------------------------------------------------------- // --- Authentification -------------------------------------------------------
@@ -386,6 +390,63 @@ api.get('/logs', (req, res) => {
res.json({ logs: logsRepo.recent(limit) }); 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. */ /** Infos nécessaires pour configurer un nouvel agent. */
api.get('/enrollment', (req, res) => { api.get('/enrollment', (req, res) => {
res.json({ res.json({

View File

@@ -80,6 +80,14 @@ db.exec(`
CREATE UNIQUE INDEX IF NOT EXISTS idx_targets_identity CREATE UNIQUE INDEX IF NOT EXISTS idx_targets_identity
ON watch_targets (provider, username); 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. */ /** Migrations additives : sûres à rejouer sur une base déjà peuplée. */
@@ -621,3 +629,24 @@ export const logsRepo = {
return rows.map(toLogEntry).reverse(); 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<T>(key: string, fallback: T): T {
const row = settingsStmts.get.get(key) as unknown as { value: string } | undefined;
if (!row) return fallback;
return safeJsonParse<T>(row.value) ?? fallback;
},
set<T>(key: string, value: T): void {
settingsStmts.set.run(key, JSON.stringify(value));
},
};

View File

@@ -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<void> {
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}`);
}
}

View File

@@ -3,6 +3,7 @@ import { DEFAULT_WATCH_SETTINGS, fetchStripchatStatus } from '@stream-control/sh
import { config } from './config.ts'; import { config } from './config.ts';
import { targetsRepo } from './db.ts'; import { targetsRepo } from './db.ts';
import { hub } from './hub.ts'; import { hub } from './hub.ts';
import { notifyTargetLive } from './pushover.ts';
import { startTargetRecording } from './recorder.ts'; import { startTargetRecording } from './recorder.ts';
/** /**
@@ -122,6 +123,7 @@ class Watchlist {
// Seul le passage effectif au flux public déclenche une notification. // Seul le passage effectif au flux public déclenche une notification.
if (next === 'public' && target.state !== 'public' && updated.notify) { if (next === 'public' && target.state !== 'public' && updated.notify) {
hub.publishTargetLive(updated); hub.publishTargetLive(updated);
void notifyTargetLive(updated.label ?? updated.username, updated.url);
} }
} }
} }

View File

@@ -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. // off / offline / deleted / notFound : le modèle n'est plus là du tout.
return 'offline'; 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<PushoverSettings>;
return {
enabled: input.enabled === true,
userKey: typeof input.userKey === 'string' ? input.userKey.trim() : '',
appToken: typeof input.appToken === 'string' ? input.appToken.trim() : '',
};
}

View File

@@ -7,6 +7,7 @@ import { AgentCard } from './components/AgentCard';
import { AgentSettings } from './components/AgentSettings'; import { AgentSettings } from './components/AgentSettings';
import { AgentHistory } from './components/AgentHistory'; import { AgentHistory } from './components/AgentHistory';
import { LogPanel } from './components/LogPanel'; import { LogPanel } from './components/LogPanel';
import { NotificationsPage } from './components/NotificationsPage';
import { StreamersPage } from './components/StreamersPage'; import { StreamersPage } from './components/StreamersPage';
import { useHashRoute } from './useHashRoute'; import { useHashRoute } from './useHashRoute';
@@ -185,6 +186,12 @@ export function App() {
> >
Journal Journal
</button> </button>
<button
className={route === 'notifications' ? 'tab active' : 'tab'}
onClick={() => setRoute('notifications')}
>
Notifications
</button>
</nav> </nav>
<div className="topbar-actions"> <div className="topbar-actions">
@@ -228,6 +235,8 @@ export function App() {
{route === 'logs' ? ( {route === 'logs' ? (
<LogPanel logs={logs} /> <LogPanel logs={logs} />
) : route === 'notifications' ? (
<NotificationsPage notify={notify} />
) : route === 'streamers' ? ( ) : route === 'streamers' ? (
<StreamersPage <StreamersPage
targets={targets} targets={targets}

View File

@@ -1,4 +1,10 @@
import type { AgentAction, AgentView, LogEntry, WatchTarget } from '@stream-control/shared'; import type {
AgentAction,
AgentView,
LogEntry,
PushoverSettings,
WatchTarget,
} from '@stream-control/shared';
const TOKEN_KEY = 'stream-control.session'; const TOKEN_KEY = 'stream-control.session';
@@ -130,6 +136,22 @@ export const api = {
request<{ ok: boolean }>(`/watchlist/${id}/record`, { method: 'POST' }), request<{ ok: boolean }>(`/watchlist/${id}/record`, { method: 'POST' }),
stopTarget: (id: string) => request<{ ok: boolean }>(`/watchlist/${id}/stop`, { 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<PushoverSettings, 'userKey' | 'appToken'>) =>
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). */ /** URL du flux temps réel, jeton en query (les WS ne portent pas d'en-tête). */

View File

@@ -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<PushoverSettings>(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<PushoverSettings>) {
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 <main className="page"><p className="muted">Chargement</p></main>;
return (
<main className="page notifications-page">
<h2>Notifications</h2>
<p className="muted">
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.
</p>
<fieldset className="group">
<legend>Pushover</legend>
<label className="checkbox">
<input
type="checkbox"
checked={settings.enabled}
onChange={(event) => patch({ enabled: event.target.checked })}
/>
<span>Activer les notifications Pushover</span>
</label>
<label className="field">
<span>Clé utilisateur (User Key)</span>
<input
value={settings.userKey}
onChange={(event) => patch({ userKey: event.target.value })}
onFocus={() => {
// Le champ masqué ne se ré-édite pas caractère par caractère :
// il se remplace en entier, comme le mot de passe OBS ailleurs.
if (settings.userKey === '********') patch({ userKey: '' });
}}
placeholder="visible sur la page d'accueil de ton compte pushover.net"
autoComplete="off"
/>
</label>
<label className="field">
<span>Jeton d'application (API Token)</span>
<input
value={settings.appToken}
onChange={(event) => patch({ appToken: event.target.value })}
onFocus={() => {
if (settings.appToken === '********') patch({ appToken: '' });
}}
placeholder="créé sur pushover.net/apps/build"
autoComplete="off"
/>
<span className="muted small">
Une seule application suffit pour tous les usages de Stream Control ; crée-la sur{' '}
<a href="https://pushover.net/apps/build" target="_blank" rel="noreferrer">
pushover.net/apps/build
</a>
.
</span>
</label>
<div className="row">
<button className="primary" disabled={saving} onClick={() => void save()}>
{saving ? 'Enregistrement' : 'Enregistrer'}
</button>
<button className="ghost" disabled={testing || !canTest} onClick={() => void test()}>
{testing ? 'Envoi' : 'Envoyer un test'}
</button>
</div>
</fieldset>
</main>
);
}

View File

@@ -578,6 +578,10 @@ fieldset.group > legend {
flex-wrap: wrap; flex-wrap: wrap;
} }
.notifications-page fieldset.group {
max-width: 480px;
}
.streamer-grid { .streamer-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));

View File

@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react'; 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]; export type Route = (typeof ROUTES)[number];
function currentRoute(): Route { function currentRoute(): Route {