Files
instantadpay/snapshot.js
T
martbost 3d4b07a8ab Real cover art, and a third milestone destination
The cover was a drawn arc and a hoop I built out of rectangles. It looked like
it. Replaced with a kie.ai generated photograph, a basketball sitting untouched
on a wet floodlit court at night with the hoop in shadow behind it, which is the
whole argument of the post in one frame. The type is still set here rather than
prompted, because image models cannot letter.

Milestones and the daily snapshot now post to three places instead of two: the
main Crypto Team Build group, the InstantAdPay support topic (634443) and the
shared payments topic. The RM Circle proof channel is deliberately excluded,
that channel is RM Circle only.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-24 08:07:09 -05:00

195 lines
13 KiB
JavaScript

// Growth snapshot (Marty, 2026-09-15): once a day, a short "how the site is doing" post in the Telegram
// payments feed (and the shared payments topic), so members watching the payout lines also see the
// whole picture: sign-ups, purchases, POL paid out, campaigns posted, ads viewed. Every number comes
// from the same tables and chain index the dashboards read. Settings: snapshotEnabled (1/0),
// snapshotHourUtc (default 14 = 9 AM Central), snapshotTargets (feed,echo). State: snapshot-state.json.
const fs = require('fs');
const path = require('path');
let X = {};
const DAY = 86400000;
function init(opts) { X = opts || {}; }
const STATE = () => path.join(X.dataDir, 'snapshot-state.json');
function state() { try { return JSON.parse(fs.readFileSync(STATE(), 'utf8')); } catch (e) { return {}; } }
function setState(s) { try { fs.writeFileSync(STATE(), JSON.stringify(s)); } catch (e) {} }
const n = v => Number(v || 0).toLocaleString('en-US');
const pol = w => { try { return (Number(BigInt(w) / (10n ** 16n)) / 100).toLocaleString('en-US', { maximumFractionDigits: 0 }); } catch (e) { return '0'; } };
async function gather(now = Date.now()) {
const since = now - DAY, prior = now - 2 * DAY;
const s = { signups: 0, signupsPrior: 0, members: 0, wallets: 0, payoutsOn: 0, seen24h: 0,
campaigns: 0, advertisers: 0, active: 0, activeAdvertisers: 0, imps: 0, viewers: 0, views: 0,
buys: 0, buysPrior: 0, usd: 0, activations: 0, paidWei: 0n, lifeBuys: 0, lifePaidWei: 0n, lifePayouts: 0,
creditsHandedOut: 0, creditsEarned: 0, creditsSpent: 0 };
if (X.db && X.db.enabled()) {
const q = (a, b) => X.db.q(a, b || []);
const a = (await q('SELECT COUNT(*) total, SUM(created>=?) s24, SUM(created>=? AND created<?) sPrior, SUM(address IS NOT NULL) wallet, SUM(member_id>0) payouts, SUM(last_seen>=?) seen FROM accounts', [since, prior, since, since]))[0] || {};
s.members = Number(a.total || 0); s.signups = Number(a.s24 || 0); s.signupsPrior = Number(a.sPrior || 0); s.wallets = Number(a.wallet || 0); s.payoutsOn = Number(a.payouts || 0); s.seen24h = Number(a.seen || 0);
const c = (await q('SELECT COUNT(*) n, COUNT(DISTINCT owner_email) owners FROM campaigns WHERE house=0 AND created>=?', [since]))[0] || {};
s.campaigns = Number(c.n || 0); s.advertisers = Number(c.owners || 0);
const ac = (await q("SELECT COUNT(*) n, COUNT(DISTINCT owner_email) owners FROM campaigns WHERE house=0 AND status='active'"))[0] || {};
s.active = Number(ac.n || 0); s.activeAdvertisers = Number(ac.owners || 0);
// impressions in the last 24 hours: camp_hours is keyed by UTC day + hour
const d0 = new Date(since).toISOString().slice(0, 10), h0 = new Date(since).getUTCHours(), d1 = new Date(now).toISOString().slice(0, 10);
const im = (await q('SELECT SUM(n) imps FROM camp_hours WHERE (day=? AND hour>=?) OR (day>? AND day<=?)', [d0, h0, d0, d1]))[0] || {};
s.imps = Number(im.imps || 0);
const v = (await q('SELECT COUNT(*) viewers, SUM(views) views FROM daily_views WHERE day=? AND views>0', [d1]))[0] || {};
s.viewers = Number(v.viewers || 0); s.views = Number(v.views || 0);
// where the credits came from (Marty, 2026-09-19): handed out = grants, partner codes and
// bonuses; earned = viewing; spent = everything that left a balance for ad delivery or a buy
const cl = (await q("SELECT SUM(CASE WHEN delta>0 AND kind IN ('grant','promo','bonus') THEN delta ELSE 0 END) handed, SUM(CASE WHEN delta>0 AND kind='earn' THEN delta ELSE 0 END) earned, SUM(CASE WHEN delta<0 THEN -delta ELSE 0 END) spent FROM credit_log WHERE ts>=?", [since]))[0] || {};
s.creditsHandedOut = Number(cl.handed || 0); s.creditsEarned = Number(cl.earned || 0); s.creditsSpent = Number(cl.spent || 0);
}
try {
for (const ev of X.chain.recentEvents(1e9)) {
if (ev.type === 'Purchase') { s.lifeBuys++; if (ev.ts >= since) { s.buys++; s.usd += ev.priceCents / 100; } else if (ev.ts >= prior) s.buysPrior++; }
else if (ev.type === 'MemberActivated' && ev.ts >= since) s.activations++;
else if (ev.type === 'TierPaid' || ev.type === 'AwardPaid') { s.lifePayouts++; s.lifePaidWei += BigInt(ev.amountWei); if (ev.ts >= since) s.paidWei += BigInt(ev.amountWei); }
}
// the index keeps a bounded event list; lifetime totals come from its running tally when present
const t = X.chain.totals ? X.chain.totals() : null;
if (t) { if (t.purchases) s.lifeBuys = Number(t.purchases); if (t.payoutWei) s.lifePaidWei = BigInt(t.payoutWei); if (t.payouts) s.lifePayouts = Number(t.payouts); }
} catch (e) {}
return s;
}
// One block per subject, a blank line between them, and no line long enough for a phone to wrap it
// into a wall of text (Marty, 2026-09-22 — the single-paragraph version was unreadable on mobile).
function compose(s, cta) {
const up = (cur, prev) => cur > prev ? ', up from ' + n(prev) + ' yesterday'
: cur < prev ? ', down from ' + n(prev) + ' yesterday' : '';
const plural = (c, word) => n(c) + ' ' + word + (Number(c) === 1 ? '' : 's');
const day = new Date().toLocaleDateString('en-US', { timeZone: 'America/Chicago', weekday: 'long', month: 'long', day: 'numeric' });
const B = [];
B.push('\u{1F4C8} <b>InstantAdPay · 24-hour snapshot</b>\n<i>' + day + '</i>');
B.push('\u{1F465} <b>Sign-ups</b>\n'
+ '<b>' + n(s.signups) + '</b> new' + up(s.signups, s.signupsPrior) + '\n'
+ n(s.activations) + ' switched on payouts');
B.push('\u{1F9FE} <b>Purchases</b>\n'
+ '<b>' + n(s.buys) + '</b> for <b>$' + n(Math.round(s.usd)) + '</b>' + up(s.buys, s.buysPrior) + '\n'
+ '<b>' + pol(s.paidWei) + ' POL</b> paid to members\nin the same transactions');
B.push('\u{1F4E3} <b>Campaigns</b>\n'
+ '<b>' + n(s.campaigns) + '</b> posted by ' + plural(s.advertisers, 'advertiser') + '\n'
+ n(s.active) + ' running · ' + n(s.imps) + ' impressions');
B.push('\u{1F440} <b>Earning</b>\n'
+ '<b>' + n(s.viewers) + '</b> members viewed ads today\n'
+ n(s.seen24h) + ' signed in');
B.push('\u{1F4B3} <b>Credits</b>\n'
+ '<b>' + n(s.creditsHandedOut) + '</b> handed out · <b>' + n(s.creditsEarned) + '</b> earned\n'
+ n(s.creditsSpent) + ' spent on ads');
B.push('\u{1F3C1} <b>So far</b>\n'
+ '<b>' + n(s.members) + '</b> members · ' + n(s.wallets) + ' wallets linked\n'
+ n(s.payoutsOn) + ' with payouts on · ' + n(s.lifeBuys) + ' buys\n'
+ '<b>' + pol(s.lifePaidWei) + ' POL</b> paid in ' + n(s.lifePayouts) + ' payouts\n'
+ 'All of it on the public ledger.');
B.push('<a href="https://instantadpay.com/ledger">Live ledger</a>' + (cta ? ' · <a href="' + cta + '">Join free</a>' : ''));
return B.join('\n\n');
}
async function post() {
const sc = X.siteConfig();
if (!sc.telegramBotToken) return { error: 'No Telegram bot token set.' };
const s = await gather(); const text = compose(s, sc.telegramCtaUrl);
const targets = String(sc.snapshotTargets || 'feed,echo').split(',').map(x => x.trim());
let sent = 0;
if (targets.includes('feed') && sc.telegramChatId) { if (await X.send(sc.telegramChatId, text, sc.telegramTopicId)) sent++; }
if (targets.includes('echo') && sc.telegramEchoChatId) { if (await X.send(sc.telegramEchoChatId, text, sc.telegramEchoTopicId)) sent++; }
const st = state(); st.lastPost = Date.now(); st.lastDay = new Date().toISOString().slice(0, 10); setState(st);
return { ok: true, sent, text };
}
// every 10 minutes: post once a day at or after snapshotHourUtc (a restart never double-posts)
async function tick() {
try { await milestoneTick(); } catch (e) { console.error('milestone', e.message); }
const sc = X.siteConfig();
if (String(sc.snapshotEnabled || '1') !== '1' || !sc.telegramBotToken) return;
const hour = Number(sc.snapshotHourUtc == null || sc.snapshotHourUtc === '' ? 14 : sc.snapshotHourUtc);
const now = new Date(); const day = now.toISOString().slice(0, 10);
if (now.getUTCHours() < hour) return;
if (state().lastDay === day) return;
await post();
}
async function preview() { const sc = X.siteConfig(); return compose(await gather(), sc.telegramCtaUrl); }
// ---- membership milestones: one celebratory post per mark, the moment it is crossed.
// Every 100 members since 2026-09-22 (Marty), with the fuller write-up saved for the round
// thousands and the half-thousands so the small ones stay a quick cheer, not a repeated speech.
const STEP = 100;
const isBig = m => m % 500 === 0;
const marksUpTo = count => { const out = []; for (let m = STEP; m <= count; m += STEP) out.push(m); return out; };
const MILESTONES = marksUpTo(10000); // kept for callers that want the ladder
function composeMilestone(mark, s, cta) {
const ledger = '<a href="https://instantadpay.com/ledger">Live ledger</a>' + (cta ? ' \u00b7 <a href="' + cta + '">Join free</a>' : '');
const B = [];
B.push('\u{1F389} <b>' + n(mark) + ' members on InstantAdPay</b>');
if (isBig(mark)) {
B.push(n(mark) + ' people have joined. Here is what that looks like right now:');
B.push('<b>' + n(s.wallets) + '</b> wallets linked\n'
+ '<b>' + n(s.payoutsOn) + '</b> with payouts switched on\n'
+ '<b>' + n(s.lifeBuys) + '</b> purchases\n'
+ '<b>' + pol(s.lifePaidWei) + ' POL</b> paid to members\nin the same transactions');
B.push('Every one of those payments is on the public ledger.');
B.push('Thank you to every member who brought someone in. That is the whole engine, and it is working.');
B.push('The next ' + n(mark) + ' start today.');
} else {
B.push('Another hundred through the door.');
B.push('<b>' + n(s.wallets) + '</b> wallets linked\n'
+ '<b>' + n(s.payoutsOn) + '</b> with payouts on\n'
+ '<b>' + pol(s.lifePaidWei) + ' POL</b> paid to members');
B.push('Every payment on the public ledger.');
B.push('Next stop: <b>' + n(mark + STEP) + '</b>.');
}
B.push(ledger);
return B.join('\n\n');
}
async function memberCount() {
if (!(X.db && X.db.enabled())) return 0;
const r = await X.db.q('SELECT COUNT(*) n FROM accounts'); return Number((r[0] || {}).n || 0);
}
// runs from tick(): announce the highest newly crossed mark, once, whatever snapshotEnabled says
async function milestoneTick() {
const sc = X.siteConfig(); if (!sc.telegramBotToken) return null;
const st = state(); const done = st.milestones || {};
const count = await memberCount();
// presence of the key is what counts, never its value: a mark recorded as 0 (or any other
// falsy stamp) must stay done, or the ladder back-posts a number the site passed long ago
const due = marksUpTo(count).filter(m => !Object.prototype.hasOwnProperty.call(done, m));
if (!due.length) return null;
const mark = due[due.length - 1];
const text = composeMilestone(mark, await gather(), sc.telegramCtaUrl);
const targets = String(sc.snapshotTargets || 'feed,echo').split(',').map(x => x.trim());
// A milestone read as plain text in a busy topic and got scrolled straight past (Marty,
// 2026-09-24), so it goes out as a card now. The cards are pre-rendered for the whole ladder
// and deliberately carry NO live figures: the numbers stay in the caption, where they cannot
// go stale between rendering and the mark actually being reached.
let jpeg = null;
try { jpeg = fs.readFileSync(path.join(X.publicDir || '', 'milestones', mark + '.jpg')); } catch (e) {}
const caption = text.length <= 1024 ? text : null; // Telegram caps a photo caption at 1024
let sent = 0;
const post = async (chat, thread) => {
if (jpeg && caption && X.sendPhoto && await X.sendPhoto(chat, jpeg, caption, thread)) return true;
return X.send(chat, text, thread);
};
// A target that is listed but has no chat id used to fail silently, so the feed half of every
// milestone and every daily snapshot went nowhere for weeks without a line in the log.
// Three destinations, all in the same forum on different topics (Marty, 2026-09-24): the main
// group, the InstantAdPay support topic, and the shared payments topic. The RM Circle proof
// channel is deliberately NOT one of them: that channel is RM Circle only.
for (const [name, chat, thread] of [['feed', sc.telegramChatId, sc.telegramTopicId],
['support', sc.telegramSupportChatId, sc.telegramSupportTopicId],
['echo', sc.telegramEchoChatId, sc.telegramEchoTopicId]]) {
if (!targets.includes(name)) continue;
if (!chat) { console.warn('snapshot: target "' + name + '" is listed but has no chat id, nothing sent'); continue; }
if (await post(chat, thread)) sent++;
}
for (const m of due) done[m] = Date.now(); // lower marks crossed in the same jump are marked too, so they never post late
st.milestones = done; setState(st);
console.log('milestone posted', mark, 'members', count, 'sent', sent);
return { ok: true, mark, count, sent, text };
}
async function previewMilestone(mark) { const sc = X.siteConfig(); return composeMilestone(Number(mark) || 500, await gather(), sc.telegramCtaUrl); }
module.exports = { init, gather, compose, post, tick, preview, milestoneTick, previewMilestone, composeMilestone, memberCount, MILESTONES, STEP };