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 <noreply@anthropic.com>
This commit is contained in:
@@ -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();
|
||||
|
||||
+80
-75
@@ -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} <b>Video sources that no longer load</b>\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} <b>Video sources that no longer load</b>\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 };
|
||||
|
||||
Reference in New Issue
Block a user