d90f08d54a
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
132 lines
10 KiB
JavaScript
132 lines
10 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;
|
|
}
|
|
|
|
function compose(s, cta) {
|
|
const trend = (cur, prev, what) => cur > prev ? ' (up from ' + n(prev) + ' ' + what + ')' : cur < prev ? ' (' + n(prev) + ' ' + what + ')' : '';
|
|
const L = [];
|
|
L.push('\u{1F4C8} <b>InstantAdPay</b> · 24-hour snapshot');
|
|
L.push('\u{1F465} Sign-ups: <b>' + n(s.signups) + '</b>' + trend(s.signups, s.signupsPrior, 'the day before') + ' · ' + n(s.activations) + ' switched on payouts');
|
|
L.push('\u{1F9FE} Purchases: <b>' + n(s.buys) + '</b> for $' + n(Math.round(s.usd)) + trend(s.buys, s.buysPrior, 'the day before') + ' · <b>' + pol(s.paidWei) + ' POL</b> paid to members in the same transactions');
|
|
L.push('\u{1F4E3} Campaigns: <b>' + n(s.campaigns) + '</b> posted by ' + n(s.advertisers) + ' advertiser' + (s.advertisers === 1 ? '' : 's') + ' · ' + n(s.active) + ' running now · ' + n(s.imps) + ' ad impressions served');
|
|
L.push('\u{1F440} Earning: <b>' + n(s.viewers) + '</b> members viewed ads today · ' + n(s.seen24h) + ' signed in');
|
|
L.push('\u{1F4B3} Credits: <b>' + n(s.creditsHandedOut) + '</b> handed out · <b>' + n(s.creditsEarned) + '</b> earned by viewing · ' + n(s.creditsSpent) + ' spent on ads');
|
|
L.push('\u{1F3C1} So far: <b>' + n(s.members) + '</b> members · ' + n(s.wallets) + ' wallets linked · ' + n(s.payoutsOn) + ' with payouts on · ' + n(s.lifeBuys) + ' purchases · <b>' + pol(s.lifePaidWei) + ' POL</b> paid out in ' + n(s.lifePayouts) + ' payouts, every one on the public ledger');
|
|
L.push('<a href="https://instantadpay.com/ledger">Live ledger</a>' + (cta ? ' · <a href="' + cta + '">Join free</a>' : ''));
|
|
return L.join('\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 (Marty, 2026-09-19): one celebratory post per mark, the moment it is crossed
|
|
const MILESTONES = [500, 1000, 2500, 5000, 10000];
|
|
function composeMilestone(mark, s, cta) {
|
|
const L = [];
|
|
L.push('\u{1F389} <b>' + n(mark) + ' members on InstantAdPay.</b>');
|
|
L.push(n(mark) + ' people have joined, and here is what that looks like right now: <b>' + n(s.wallets) + '</b> wallets linked, <b>' + n(s.payoutsOn) + '</b> with payouts switched on, <b>' + n(s.lifeBuys) + '</b> purchases, and <b>' + pol(s.lifePaidWei) + ' POL</b> paid to members in the same transactions the purchases came from. Every one of those payments is on the public ledger.');
|
|
L.push('Thank you to every member who brought someone in. That is the whole engine, and it is working.');
|
|
L.push('The next ' + n(mark) + ' start today.');
|
|
L.push('<a href="https://instantadpay.com/ledger">Live ledger</a>' + (cta ? ' \u00b7 <a href="' + cta + '">Join free</a>' : ''));
|
|
return L.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();
|
|
const due = MILESTONES.filter(m => count >= m && !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());
|
|
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++; }
|
|
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, memberCount, MILESTONES };
|