Feat: pushover notif
This commit is contained in:
@@ -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({
|
||||
|
||||
@@ -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));
|
||||
},
|
||||
};
|
||||
|
||||
90
packages/server/src/pushover.ts
Normal file
90
packages/server/src/pushover.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user