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