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