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