diff --git a/public/milestones/1000.jpg b/public/milestones/1000.jpg
new file mode 100644
index 0000000..4e5ff6c
Binary files /dev/null and b/public/milestones/1000.jpg differ
diff --git a/public/milestones/10000.jpg b/public/milestones/10000.jpg
new file mode 100644
index 0000000..1c0a332
Binary files /dev/null and b/public/milestones/10000.jpg differ
diff --git a/public/milestones/1100.jpg b/public/milestones/1100.jpg
new file mode 100644
index 0000000..7945e73
Binary files /dev/null and b/public/milestones/1100.jpg differ
diff --git a/public/milestones/1200.jpg b/public/milestones/1200.jpg
new file mode 100644
index 0000000..e12ec4f
Binary files /dev/null and b/public/milestones/1200.jpg differ
diff --git a/public/milestones/1300.jpg b/public/milestones/1300.jpg
new file mode 100644
index 0000000..7b81535
Binary files /dev/null and b/public/milestones/1300.jpg differ
diff --git a/public/milestones/1400.jpg b/public/milestones/1400.jpg
new file mode 100644
index 0000000..8026024
Binary files /dev/null and b/public/milestones/1400.jpg differ
diff --git a/public/milestones/1500.jpg b/public/milestones/1500.jpg
new file mode 100644
index 0000000..f63d267
Binary files /dev/null and b/public/milestones/1500.jpg differ
diff --git a/public/milestones/2000.jpg b/public/milestones/2000.jpg
new file mode 100644
index 0000000..899c11c
Binary files /dev/null and b/public/milestones/2000.jpg differ
diff --git a/public/milestones/2500.jpg b/public/milestones/2500.jpg
new file mode 100644
index 0000000..48ad440
Binary files /dev/null and b/public/milestones/2500.jpg differ
diff --git a/public/milestones/3000.jpg b/public/milestones/3000.jpg
new file mode 100644
index 0000000..08856d1
Binary files /dev/null and b/public/milestones/3000.jpg differ
diff --git a/public/milestones/5000.jpg b/public/milestones/5000.jpg
new file mode 100644
index 0000000..d7893c3
Binary files /dev/null and b/public/milestones/5000.jpg differ
diff --git a/public/milestones/600.jpg b/public/milestones/600.jpg
new file mode 100644
index 0000000..d65a177
Binary files /dev/null and b/public/milestones/600.jpg differ
diff --git a/public/milestones/700.jpg b/public/milestones/700.jpg
new file mode 100644
index 0000000..664c0cf
Binary files /dev/null and b/public/milestones/700.jpg differ
diff --git a/public/milestones/800.jpg b/public/milestones/800.jpg
new file mode 100644
index 0000000..311c176
Binary files /dev/null and b/public/milestones/800.jpg differ
diff --git a/public/milestones/900.jpg b/public/milestones/900.jpg
new file mode 100644
index 0000000..a16faf5
Binary files /dev/null and b/public/milestones/900.jpg differ
diff --git a/server.js b/server.js
index a071a64..c8e55c7 100644
--- a/server.js
+++ b/server.js
@@ -447,7 +447,9 @@ async function boot() {
coach.init({ dataDir: DATA_DIR, chain, accounts, mailer, ads, tank, lb: () => leaderboard, siteConfig });
pipeline.init({ dataDir: DATA_DIR, accounts, coach, chain });
badge.init({ publicDir: PUBLIC_DIR });
- snapshot.init({ dataDir: DATA_DIR, db, chain, siteConfig, send: async (c, t, th) => { await telegramSend(c, t, th); return true; } });
+ snapshot.init({ dataDir: DATA_DIR, db, chain, siteConfig, publicDir: PUBLIC_DIR,
+ send: async (c, t, th) => { await telegramSend(c, t, th); return true; },
+ sendPhoto: (c, jpeg, cap, th) => telegramSendPhoto(c, jpeg, cap, th) });
friday.init({ dataDir: DATA_DIR, siteConfig, chain, ads, accounts, mailer, drip,
telegram: async text => { const sc = siteConfig(); if (!sc.telegramBotToken || !sc.telegramEchoChatId) return false; return telegramSend(sc.telegramEchoChatId, '\u{1F7E0} InstantAdPay \u00b7 ' + text, sc.telegramEchoTopicId); },
notify: async (memberId, subject, text, kind) => { const a = await accounts.byMemberId(memberId); if (!a || !a.email) return; const html = '
' + String(text).replace(/&/g, '&').replace(//g, '>').replace(/(https:\/\/[^\s]+)/g, '$1').split('\n\n').join('
').replace(/\n/g, '
') + '
'; await messages.deliver(1, ADMIN_EMAIL || 'house@instantadpay.com', [a.email], subject, html, kind || 'notice'); } });
@@ -1189,7 +1191,15 @@ const server = http.createServer(async (req, res) => {
const promo = promos.norm(u.searchParams.get('promo')); if (promo) set.push('iap.promo=' + promo + cookieTail); // partner code, redeemed at signup
if (promo) { const pref = String(req.headers.referer || '').replace(/^https?:\/\//, '').split('/')[0].toLowerCase().slice(0, 80); if (pref) set.push('iap.promoref=' + encodeURIComponent(pref) + cookieTail); } // which partner page it came from
if (ang) set.push('iap.angle=' + angle + cookieTail);
- if (!cookies['iap.ref']) set.push('iap.ref=' + encodeURIComponent(coach.refHost(req.headers.referer)) + cookieTail); // first-touch source
+ // First-touch source. A PolHunter share link carries ?from=polhunter in the URL itself, which
+ // survives the referrer being stripped (in-app browsers, a link pasted into chat). PolHunter
+ // pays a referral bounty only for people it actually sent, so that proof has to be reliable
+ // rather than dependent on a header (Marty, 2026-09-24: "not just members that come on board
+ // IAP and work their way over to POL Hunter").
+ if (!cookies['iap.ref']) {
+ const host = u.searchParams.get('from') === 'polhunter' ? 'polhunter.com' : coach.refHost(req.headers.referer);
+ set.push('iap.ref=' + encodeURIComponent(host) + cookieTail);
+ }
return serveJoinPage(res, tok, ang ? angle : '', ang, set);
}
// -- legacy bridge: /from/faucetwave | /from/tieroneads [?seg=advertiser]. Same squeeze page
@@ -2246,7 +2256,9 @@ const server = http.createServer(async (req, res) => {
// person who gets it has to be the one this member actually joined under here, not a cookie
// PolHunter guessed at. sponsorRef is the upline's username or code, matching /join/[.
let sponsorRef = (acct && acct.sponsorRef) || '';
- const payload = JSON.stringify({ iat: Date.now(), exp: Date.now() + 5 * 60000, nonce: crypto.randomBytes(12).toString('hex'), memberId: Number(memberId), email: s.email, wallet: (acct && acct.address) || s.address || null, username: (acct && acct.username) || null, sponsorRef: sponsorRef || null, joinedAt: (acct && acct.created) || null });
+ const payload = JSON.stringify({ iat: Date.now(), exp: Date.now() + 5 * 60000, nonce: crypto.randomBytes(12).toString('hex'), memberId: Number(memberId), email: s.email, wallet: (acct && acct.address) || s.address || null, username: (acct && acct.username) || null, sponsorRef: sponsorRef || null, joinedAt: (acct && acct.created) || null,
+ // where they first arrived from, so PolHunter can pay a bounty only for people it sent
+ joinedRef: (acct && acct.joinedRef) || null });
const tok = b64u(payload) + '.' + b64u(crypto.createHmac('sha256', secret).update(payload).digest());
res.writeHead(302, { Location: huntUrl + '/auth?t=' + tok, 'Cache-Control': 'no-store', 'Set-Cookie': 'iap.return=; Path=/; SameSite=Lax; Max-Age=0' + (IS_PROD ? '; Secure' : '') }); return res.end(); // the loop is closed
}
diff --git a/snapshot.js b/snapshot.js
index 54a9784..5641978 100644
--- a/snapshot.js
+++ b/snapshot.js
@@ -1,173 +1,190 @@
-// 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} InstantAdPay · 24-hour snapshot\n' + day + '');
- B.push('\u{1F465} Sign-ups\n'
- + '' + n(s.signups) + ' new' + up(s.signups, s.signupsPrior) + '\n'
- + n(s.activations) + ' switched on payouts');
- B.push('\u{1F9FE} Purchases\n'
- + '' + n(s.buys) + ' for $' + n(Math.round(s.usd)) + '' + up(s.buys, s.buysPrior) + '\n'
- + '' + pol(s.paidWei) + ' POL paid to members\nin the same transactions');
- B.push('\u{1F4E3} Campaigns\n'
- + '' + n(s.campaigns) + ' posted by ' + plural(s.advertisers, 'advertiser') + '\n'
- + n(s.active) + ' running · ' + n(s.imps) + ' impressions');
- B.push('\u{1F440} Earning\n'
- + '' + n(s.viewers) + ' members viewed ads today\n'
- + n(s.seen24h) + ' signed in');
- B.push('\u{1F4B3} Credits\n'
- + '' + n(s.creditsHandedOut) + ' handed out · ' + n(s.creditsEarned) + ' earned\n'
- + n(s.creditsSpent) + ' spent on ads');
- B.push('\u{1F3C1} So far\n'
- + '' + n(s.members) + ' members · ' + n(s.wallets) + ' wallets linked\n'
- + n(s.payoutsOn) + ' with payouts on · ' + n(s.lifeBuys) + ' buys\n'
- + '' + pol(s.lifePaidWei) + ' POL paid in ' + n(s.lifePayouts) + ' payouts\n'
- + 'All of it on the public ledger.');
- B.push('Live ledger' + (cta ? ' · Join free' : ''));
- 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 = 'Live ledger' + (cta ? ' \u00b7 Join free' : '');
- const B = [];
- B.push('\u{1F389} ' + n(mark) + ' members on InstantAdPay');
- if (isBig(mark)) {
- B.push(n(mark) + ' people have joined. Here is what that looks like right now:');
- B.push('' + n(s.wallets) + ' wallets linked\n'
- + '' + n(s.payoutsOn) + ' with payouts switched on\n'
- + '' + n(s.lifeBuys) + ' purchases\n'
- + '' + pol(s.lifePaidWei) + ' POL 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('' + n(s.wallets) + ' wallets linked\n'
- + '' + n(s.payoutsOn) + ' with payouts on\n'
- + '' + pol(s.lifePaidWei) + ' POL paid to members');
- B.push('Every payment on the public ledger.');
- B.push('Next stop: ' + n(mark + STEP) + '.');
- }
- 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());
- 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, composeMilestone, memberCount, MILESTONES, STEP };
+// 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} InstantAdPay · 24-hour snapshot\n' + day + '');
+ B.push('\u{1F465} Sign-ups\n'
+ + '' + n(s.signups) + ' new' + up(s.signups, s.signupsPrior) + '\n'
+ + n(s.activations) + ' switched on payouts');
+ B.push('\u{1F9FE} Purchases\n'
+ + '' + n(s.buys) + ' for $' + n(Math.round(s.usd)) + '' + up(s.buys, s.buysPrior) + '\n'
+ + '' + pol(s.paidWei) + ' POL paid to members\nin the same transactions');
+ B.push('\u{1F4E3} Campaigns\n'
+ + '' + n(s.campaigns) + ' posted by ' + plural(s.advertisers, 'advertiser') + '\n'
+ + n(s.active) + ' running · ' + n(s.imps) + ' impressions');
+ B.push('\u{1F440} Earning\n'
+ + '' + n(s.viewers) + ' members viewed ads today\n'
+ + n(s.seen24h) + ' signed in');
+ B.push('\u{1F4B3} Credits\n'
+ + '' + n(s.creditsHandedOut) + ' handed out · ' + n(s.creditsEarned) + ' earned\n'
+ + n(s.creditsSpent) + ' spent on ads');
+ B.push('\u{1F3C1} So far\n'
+ + '' + n(s.members) + ' members · ' + n(s.wallets) + ' wallets linked\n'
+ + n(s.payoutsOn) + ' with payouts on · ' + n(s.lifeBuys) + ' buys\n'
+ + '' + pol(s.lifePaidWei) + ' POL paid in ' + n(s.lifePayouts) + ' payouts\n'
+ + 'All of it on the public ledger.');
+ B.push('Live ledger' + (cta ? ' · Join free' : ''));
+ 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 = 'Live ledger' + (cta ? ' \u00b7 Join free' : '');
+ const B = [];
+ B.push('\u{1F389} ' + n(mark) + ' members on InstantAdPay');
+ if (isBig(mark)) {
+ B.push(n(mark) + ' people have joined. Here is what that looks like right now:');
+ B.push('' + n(s.wallets) + ' wallets linked\n'
+ + '' + n(s.payoutsOn) + ' with payouts switched on\n'
+ + '' + n(s.lifeBuys) + ' purchases\n'
+ + '' + pol(s.lifePaidWei) + ' POL 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('' + n(s.wallets) + ' wallets linked\n'
+ + '' + n(s.payoutsOn) + ' with payouts on\n'
+ + '' + pol(s.lifePaidWei) + ' POL paid to members');
+ B.push('Every payment on the public ledger.');
+ B.push('Next stop: ' + n(mark + STEP) + '.');
+ }
+ 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.
+ for (const [name, chat, thread] of [['feed', sc.telegramChatId, sc.telegramTopicId],
+ ['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 };
]