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

@@ -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({

View File

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

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.
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 { 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
</button>
<button
className={route === 'notifications' ? 'tab active' : 'tab'}
onClick={() => setRoute('notifications')}
>
Notifications
</button>
</nav>
<div className="topbar-actions">
@@ -228,6 +235,8 @@ export function App() {
{route === 'logs' ? (
<LogPanel logs={logs} />
) : route === 'notifications' ? (
<NotificationsPage notify={notify} />
) : route === 'streamers' ? (
<StreamersPage
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';
@@ -130,6 +136,22 @@ export const api = {
request<{ ok: boolean }>(`/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<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). */

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;
}
.notifications-page fieldset.group {
max-width: 480px;
}
.streamer-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));

View File

@@ -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 {