From fd08af36bacc2e92a7ff9c0aeb26cbcf23688caa Mon Sep 17 00:00:00 2001 From: martbost Date: Thu, 24 Sep 2026 14:05:55 -0500 Subject: [PATCH] Admin alerts arrive from Marty's own Hermes bot Operational alerts should come from @CoolifyHermes_Bot, where his other pings land, not from the member-facing InstantAdPay bot. The token is supplied as HERMES_BOT_TOKEN in the app environment and never appears in the repo. Still admin-only, and it falls back to the IAP bot then email rather than to any member-visible channel: these alerts name a member's campaign and their email. Also: the sweep swallowed a failed pause in an empty catch, which is precisely the failure mode the module exists to prevent. It reports now. Co-Authored-By: Claude Opus 5 --- server.js | 21 ++++++- videosweep.js | 155 ++++++++++++++++++++++++++------------------------ 2 files changed, 99 insertions(+), 77 deletions(-) diff --git a/server.js b/server.js index 0ef5aa0..c2e227b 100644 --- a/server.js +++ b/server.js @@ -485,8 +485,11 @@ async function boot() { videosweep.init({ dataDir: DATA_DIR, fs, path, db, videoCheck, alert: async text => { const sc = siteConfig(); - // Admin channels ONLY. This names a member's campaign and their email, so it must never - // fall back to the shared payments topic where the whole team would read it. + // Marty's own Hermes chat, where his other operational pings land (@CoolifyHermes_Bot). + // Admin channels ONLY, never a member-visible one: this names a member's campaign and + // their email address, so the shared payments topic would put it in front of the team. + const hermes = String(process.env.HERMES_BOT_TOKEN || '').trim(); + if (hermes && sc.telegramAdminChatId) return tgSendAs(hermes, sc.telegramAdminChatId, text); if (sc.telegramBotToken && sc.telegramAdminChatId) return telegramSend(sc.telegramAdminChatId, text); if (ADMIN_EMAIL && mailer.hasKey()) return mailer.send(ADMIN_EMAIL, 'InstantAdPay: broken video sources', text.replace(/<[^>]+>/g, '')); console.log('videosweep: no admin channel configured, alert not sent'); @@ -810,6 +813,20 @@ async function payPromo(email, day) { }); } // anti-fraud admin alert (Telegram admin chat, else email): who, which flags, and whether the sign-up was blocked +// Send as a specific bot, for operational alerts that should arrive from Marty's own Hermes bot +// rather than from the member-facing InstantAdPay one. +function tgSendAs(token, chatId, text, threadId) { + return new Promise(resolve => { + const body = JSON.stringify(Object.assign({ chat_id: String(chatId), text, parse_mode: 'HTML', disable_web_page_preview: true }, + threadId ? { message_thread_id: Number(threadId) } : {})); + const rq = https.request({ hostname: 'api.telegram.org', path: '/bot' + token + '/sendMessage', method: 'POST', timeout: 12000, + headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) } }, + r => { r.resume(); resolve(r.statusCode === 200); }); + rq.on('error', () => resolve(false)); + rq.on('timeout', () => { rq.destroy(); resolve(false); }); + rq.end(body); + }); +} function fraudAlert(email, fc, spAcct, blocked) { try { const sc = siteConfig(); diff --git a/videosweep.js b/videosweep.js index 444cdd4..c4b7a98 100644 --- a/videosweep.js +++ b/videosweep.js @@ -1,75 +1,80 @@ -// Re-check the video sources that are actually live (Marty, 2026-09-24). -// -// videoCheck runs when a campaign is SAVED, which leaves two holes it can never cover: -// -// 1. anything created before the check existed. Campaign #146 pointed at a placeholder, -// https://yourdomain.com/..., from 16 September. The check shipped on the 22nd. It sat -// live for another two days handing every viewer a black player and burning one of their -// daily video slots, until Marty hit it himself and asked why nothing played. -// 2. link rot. A source that was fine on the day can 404 a week later, and nothing notices. -// -// So the live ones get re-checked on a schedule. A source that fails is PAUSED, never deleted: -// the advertiser keeps every unspent credit (reservation is computed, not deducted) and can fix -// the link and start it again. Failing is reported, not silent, because a check that quietly -// does nothing is how the first one went unnoticed. -'use strict'; - -let X = null; -const STATE = () => X.path.join(X.dataDir, 'videosweep.json'); -function load() { try { return JSON.parse(X.fs.readFileSync(STATE(), 'utf8')); } catch (e) { return { checked: {}, at: 0 }; } } -function save(d) { try { X.fs.writeFileSync(STATE(), JSON.stringify(d)); } catch (e) {} } - -function init(deps) { X = deps; } - -// One pass. Returns what it found so the admin route and the scheduler can both report it. -async function run(opts) { - const dry = !!(opts && opts.dry); - if (!X || !X.db || !X.db.enabled()) return { error: 'no database' }; - const rows = await X.db.q( - "SELECT id, owner_email, name, image_url, status FROM campaigns WHERE type='video' AND status IN ('active','paused')"); - const out = { checked: 0, ok: 0, broken: [], paused: [], skipped: 0 }; - for (const c of rows) { - const url = String(c.image_url || '').trim(); - if (!url) { out.skipped++; continue; } - if (/^\/uploads\//.test(url)) { out.skipped++; continue; } // on our own disk - out.checked++; - let v; - try { v = await X.videoCheck(url); } catch (e) { v = { ok: false, reason: 'check threw: ' + e.message }; } - if (v.ok) { out.ok++; continue; } - out.broken.push({ id: c.id, name: c.name, owner: c.owner_email, url, reason: v.reason, status: c.status }); - // only an ACTIVE one needs stopping; a paused one is already out of rotation - if (c.status === 'active' && !dry) { - try { - await X.db.q("UPDATE campaigns SET status='paused' WHERE id=?", [c.id]); - out.paused.push(c.id); - } catch (e) { /* reported below either way */ } - } - } - if (!dry) { - const st = load(); - st.at = Date.now(); - st.lastBroken = out.broken.map(b => b.id); - save(st); - if (out.paused.length) { - const lines = out.broken.filter(b => out.paused.includes(b.id)) - .map(b => '#' + b.id + ' "' + b.name + '" (' + b.owner + ')\n ' + b.url); - console.log('videosweep paused ' + out.paused.length + ' broken video campaign(s): ' + out.paused.join(', ')); - if (X.alert) { - X.alert('\u{1F6D1} Video sources that no longer load\n\n' - + lines.join('\n') + '\n\nPaused so members stop losing a daily video slot on a black player. ' - + 'Every unspent credit stays with the advertiser and the campaign can be restarted once the link is fixed.').catch(() => {}); - } - } - } - return out; -} - -// Daily is plenty: a source that dies is a slow problem, and HEADing every live video more often -// than that is noise for the advertisers' servers. -function start() { - const tick = () => { run({}).catch(e => console.error('videosweep', e.message)); }; - setTimeout(tick, 90000); // once, shortly after boot - setInterval(tick, 24 * 60 * 60 * 1000); -} - -module.exports = { init, run, start }; +// Re-check the video sources that are actually live (Marty, 2026-09-24). +// +// videoCheck runs when a campaign is SAVED, which leaves two holes it can never cover: +// +// 1. anything created before the check existed. Campaign #146 pointed at a placeholder, +// https://yourdomain.com/..., from 16 September. The check shipped on the 22nd. It sat +// live for another two days handing every viewer a black player and burning one of their +// daily video slots, until Marty hit it himself and asked why nothing played. +// 2. link rot. A source that was fine on the day can 404 a week later, and nothing notices. +// +// So the live ones get re-checked on a schedule. A source that fails is PAUSED, never deleted: +// the advertiser keeps every unspent credit (reservation is computed, not deducted) and can fix +// the link and start it again. Failing is reported, not silent, because a check that quietly +// does nothing is how the first one went unnoticed. +'use strict'; + +let X = null; +const STATE = () => X.path.join(X.dataDir, 'videosweep.json'); +function load() { try { return JSON.parse(X.fs.readFileSync(STATE(), 'utf8')); } catch (e) { return { checked: {}, at: 0 }; } } +function save(d) { try { X.fs.writeFileSync(STATE(), JSON.stringify(d)); } catch (e) {} } + +function init(deps) { X = deps; } + +// One pass. Returns what it found so the admin route and the scheduler can both report it. +async function run(opts) { + const dry = !!(opts && opts.dry); + if (!X || !X.db || !X.db.enabled()) return { error: 'no database' }; + const rows = await X.db.q( + "SELECT id, owner_email, name, image_url, status FROM campaigns WHERE type='video' AND status IN ('active','paused')"); + const out = { checked: 0, ok: 0, broken: [], paused: [], skipped: 0 }; + for (const c of rows) { + const url = String(c.image_url || '').trim(); + if (!url) { out.skipped++; continue; } + if (/^\/uploads\//.test(url)) { out.skipped++; continue; } // on our own disk + out.checked++; + let v; + try { v = await X.videoCheck(url); } catch (e) { v = { ok: false, reason: 'check threw: ' + e.message }; } + if (v.ok) { out.ok++; continue; } + out.broken.push({ id: c.id, name: c.name, owner: c.owner_email, url, reason: v.reason, status: c.status }); + // only an ACTIVE one needs stopping; a paused one is already out of rotation + if (c.status === 'active' && !dry) { + try { + await X.db.q("UPDATE campaigns SET status='paused' WHERE id=?", [c.id]); + out.paused.push(c.id); + } catch (e) { + // never swallow this: a guard that fails quietly is the whole reason #146 survived + out.failed = out.failed || []; + out.failed.push({ id: c.id, error: e.message }); + console.error('videosweep could not pause #' + c.id + ': ' + e.message); + } + } + } + if (!dry) { + const st = load(); + st.at = Date.now(); + st.lastBroken = out.broken.map(b => b.id); + save(st); + if (out.paused.length) { + const lines = out.broken.filter(b => out.paused.includes(b.id)) + .map(b => '#' + b.id + ' "' + b.name + '" (' + b.owner + ')\n ' + b.url); + console.log('videosweep paused ' + out.paused.length + ' broken video campaign(s): ' + out.paused.join(', ')); + if (X.alert) { + X.alert('\u{1F6D1} Video sources that no longer load\n\n' + + lines.join('\n') + '\n\nPaused so members stop losing a daily video slot on a black player. ' + + 'Every unspent credit stays with the advertiser and the campaign can be restarted once the link is fixed.').catch(() => {}); + } + } + } + return out; +} + +// Daily is plenty: a source that dies is a slow problem, and HEADing every live video more often +// than that is noise for the advertisers' servers. +function start() { + const tick = () => { run({}).catch(e => console.error('videosweep', e.message)); }; + setTimeout(tick, 90000); // once, shortly after boot + setInterval(tick, 24 * 60 * 60 * 1000); +} + +module.exports = { init, run, start };