// Where `storebound watch` sends events: OS notification, ntfy, Telegram, a webhook. All optional, configured from the // environment or ~/.storebound/watch.json (never printed; logs name the sink, not its URL, topic or token): // STOREBOUND_NTFY_URL (default https://ntfy.sh) + STOREBOUND_NTFY_TOPIC [+ STOREBOUND_NTFY_TOKEN] // STOREBOUND_TELEGRAM_TOKEN + STOREBOUND_TELEGRAM_CHAT // STOREBOUND_WEBHOOK_URL // watch.json: { "notify": ["os"], "ntfy": { "url", "topic", "token" }, "telegram": { "token", "chat" }, "webhook": { "url" } } // Builders are pure (tests check them); `send` takes the fetch and the OS runner as parameters. import { spawnSync } from 'node:child_process'; import { readFileSync } from 'node:fs'; import { type EventKind, watchConfigFile, type WatchEvent } from './watch'; export type SinkName = 'os' | 'ntfy' | 'telegram' | 'webhook'; export const SINK_NAMES: readonly SinkName[] = ['os', 'ntfy', 'telegram', 'webhook']; export interface SinkConfig { os?: true; ntfy?: { url: string; topic: string; token?: string }; telegram?: { token: string; chat: string }; webhook?: { url: string }; } type Env = Record; /** The sinks that are configured (os only when the file lists it). `only` limits and, for os, enables. */ export function sinkConfig(env: Env = process.env, file: string | null = watchConfigFile(env), only?: SinkName[]): SinkConfig { let f: { notify?: string[]; ntfy?: Record; telegram?: Record; webhook?: Record } = {}; if (file) { try { f = JSON.parse(readFileSync(file, 'utf8')); } catch { /* no file */ } } const out: SinkConfig = {}; const topic = env.STOREBOUND_NTFY_TOPIC || f.ntfy?.topic; if (topic) out.ntfy = { url: (env.STOREBOUND_NTFY_URL || f.ntfy?.url || 'https://ntfy.sh').replace(/\/+$/, ''), topic, ...(env.STOREBOUND_NTFY_TOKEN || f.ntfy?.token ? { token: (env.STOREBOUND_NTFY_TOKEN || f.ntfy?.token)! } : {}) }; const tg = { token: env.STOREBOUND_TELEGRAM_TOKEN || f.telegram?.token, chat: env.STOREBOUND_TELEGRAM_CHAT || f.telegram?.chat }; if (tg.token && tg.chat) out.telegram = { token: tg.token, chat: tg.chat }; const hook = env.STOREBOUND_WEBHOOK_URL || f.webhook?.url; if (hook) out.webhook = { url: hook }; if (Array.isArray(f.notify) && f.notify.includes('os')) out.os = true; if (only) { const picked: SinkConfig = {}; for (const n of only) if (n === 'os') picked.os = true; else if (out[n]) (picked as Record)[n] = out[n]; return picked; } return out; } export const configuredSinks = (c: SinkConfig): SinkName[] => SINK_NAMES.filter(n => !!c[n]); /** `--notify os,ntfy` -> names; throws on an unknown one. */ export function parseNotify(list: string[]): SinkName[] { for (const n of list) if (!(SINK_NAMES as readonly string[]).includes(n)) throw new Error(`--notify: ${n} is not a sink (${SINK_NAMES.join(', ')})`); return list as SinkName[]; } const LABEL: Record = { rejected: 'Rejected', approved: 'Approved', live: 'Live', rollout_halted: 'Rollout halted', crash_threshold: 'Crash threshold', review_low: 'Low review', review_needs_reply: 'Review needs a reply', }; const TAGS: Record = { rejected: 'x', approved: 'white_check_mark', live: 'rocket', rollout_halted: 'octagonal_sign', crash_threshold: 'warning', review_low: 'star', review_needs_reply: 'speech_balloon', }; const URGENT: ReadonlySet = new Set(['rejected', 'rollout_halted']); /** HTTP header values are Latin-1 text on one line. */ const header = (s: string): string => s.replace(/[\r\n]+/g, ' ').replace(/[^\x20-\x7e]/g, '?').slice(0, 250); export const titleOf = (e: WatchEvent): string => `${e.app}${e.version ? ` ${e.version}` : ''}: ${LABEL[e.kind]}`; export const textOf = (e: WatchEvent): string => `${e.detail}\n${e.url}`; export interface HttpRequest { url: string; headers: Record; body: string } export function ntfyRequest(e: WatchEvent, c: NonNullable): HttpRequest { return { url: `${c.url}/${encodeURIComponent(c.topic)}`, body: e.detail, headers: { Title: header(titleOf(e)), Tags: `${TAGS[e.kind]},${e.store}`, Click: e.url, Priority: URGENT.has(e.kind) ? 'high' : 'default', ...(c.token ? { Authorization: `Bearer ${c.token}` } : {}) } }; } export function telegramRequest(e: WatchEvent, c: NonNullable): HttpRequest { return { url: `https://api.telegram.org/bot${c.token}/sendMessage`, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ chat_id: c.chat, text: `${titleOf(e)}\n${textOf(e)}`.slice(0, 4000), disable_web_page_preview: true }) }; } export function webhookRequest(e: WatchEvent, c: NonNullable): HttpRequest { return { url: c.url, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(e) }; } /** The command of an OS notification (title and message travel in the environment, never inside a script string). */ export function osCommand(platform: string, title: string, message: string): { cmd: string; args: string[]; env: Record } | null { const env = { SB_TITLE: title.slice(0, 120), SB_MSG: message.slice(0, 400) }; if (platform === 'win32') { const script = [ '[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] > $null', '$x = [Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent([Windows.UI.Notifications.ToastTemplateType]::ToastText02)', '$t = $x.GetElementsByTagName(\'text\')', '$t.Item(0).AppendChild($x.CreateTextNode($env:SB_TITLE)) > $null', '$t.Item(1).AppendChild($x.CreateTextNode($env:SB_MSG)) > $null', '[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier(\'storebound\').Show([Windows.UI.Notifications.ToastNotification]::new($x))', ].join('; '); return { cmd: 'powershell', args: ['-NoProfile', '-NonInteractive', '-Command', script], env }; } if (platform === 'darwin') return { cmd: 'osascript', args: ['-e', 'display notification (system attribute "SB_MSG") with title (system attribute "SB_TITLE")'], env }; if (platform === 'linux') return { cmd: 'notify-send', args: [env.SB_TITLE, env.SB_MSG], env }; return null; } export type FetchLike = (url: string, init: { method: string; headers: Record; body: string; signal?: AbortSignal }) => Promise<{ ok: boolean; status: number }>; export type OsRunner = (c: { cmd: string; args: string[]; env: Record }) => boolean; export const runOs: OsRunner = c => { try { return spawnSync(c.cmd, c.args, { env: { ...process.env, ...c.env }, timeout: 10_000, stdio: 'ignore', windowsHide: true }).status === 0; } catch { return false; } }; /** POST with a timeout and one retry (network error, 429 or 5xx). Resolves to an error text, or null when sent. */ export async function post(fetchFn: FetchLike, r: HttpRequest, o: { timeoutMs?: number; retryMs?: number } = {}): Promise { let last = ''; for (let attempt = 0; attempt < 2; attempt++) { if (attempt) await new Promise(res => setTimeout(res, o.retryMs ?? 1500)); try { const res = await fetchFn(r.url, { method: 'POST', headers: r.headers, body: r.body, signal: AbortSignal.timeout(o.timeoutMs ?? 10_000) }); if (res.ok) return null; last = `HTTP ${res.status}`; if (res.status < 500 && res.status !== 429) return last; } catch (e) { last = (e as Error).name === 'TimeoutError' ? 'timed out' : 'network error'; } } return last; } export interface SendReport { sink: SinkName; ok: boolean; error?: string } /** Sends one event to every sink in the config. A failing sink is a report row, never an exception. */ export async function sendEvent(e: WatchEvent, c: SinkConfig, d: { fetch?: FetchLike; os?: OsRunner; platform?: string; timeoutMs?: number; retryMs?: number } = {}): Promise { const fetchFn = d.fetch ?? (fetch as unknown as FetchLike); const out: SendReport[] = []; const http = async (sink: SinkName, r: HttpRequest): Promise => { const err = await post(fetchFn, r, d); out.push(err ? { sink, ok: false, error: err } : { sink, ok: true }); }; if (c.os) { const cmd = osCommand(d.platform ?? process.platform, titleOf(e), e.detail); // unavailable (no notifier on this machine): skipped quietly, not a failure if (cmd) { const ok = (d.os ?? runOs)(cmd); if (ok) out.push({ sink: 'os', ok: true }); } } if (c.ntfy) await http('ntfy', ntfyRequest(e, c.ntfy)); if (c.telegram) await http('telegram', telegramRequest(e, c.telegram)); if (c.webhook) await http('webhook', webhookRequest(e, c.webhook)); return out; }