Milestones post a card, and a dead target stops being silent
The 600-member milestone went out as plain text into the shared payments topic and Marty scrolled straight past it. Every hundred now posts a picture. The cards are pre-rendered for the whole ladder (600 through 10,000) and carry no live figures on purpose: a number baked into an image rendered weeks early is wrong by the time the mark is reached, so the numbers stay in the caption. The send falls back to plain text if the card for a mark is missing or the caption runs past Telegram's 1024. Separately: a target listed in snapshotTargets with no chat id was skipped without a word, so the "feed" half of every milestone and every daily snapshot has been going nowhere for weeks while the log said it sent. It now warns. telegramChatId is still unset, so feed is still dark until Marty says where it should point, but at least it says so. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
After Width: | Height: | Size: 108 KiB |
|
After Width: | Height: | Size: 112 KiB |
|
After Width: | Height: | Size: 105 KiB |
|
After Width: | Height: | Size: 109 KiB |
|
After Width: | Height: | Size: 106 KiB |
|
After Width: | Height: | Size: 109 KiB |
|
After Width: | Height: | Size: 108 KiB |
|
After Width: | Height: | Size: 111 KiB |
|
After Width: | Height: | Size: 111 KiB |
|
After Width: | Height: | Size: 112 KiB |
|
After Width: | Height: | Size: 110 KiB |
|
After Width: | Height: | Size: 105 KiB |
|
After Width: | Height: | Size: 106 KiB |
|
After Width: | Height: | Size: 105 KiB |
|
After Width: | Height: | Size: 107 KiB |
@@ -447,7 +447,9 @@ async function boot() {
|
|||||||
coach.init({ dataDir: DATA_DIR, chain, accounts, mailer, ads, tank, lb: () => leaderboard, siteConfig });
|
coach.init({ dataDir: DATA_DIR, chain, accounts, mailer, ads, tank, lb: () => leaderboard, siteConfig });
|
||||||
pipeline.init({ dataDir: DATA_DIR, accounts, coach, chain });
|
pipeline.init({ dataDir: DATA_DIR, accounts, coach, chain });
|
||||||
badge.init({ publicDir: PUBLIC_DIR });
|
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,
|
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} <b>InstantAdPay</b> \u00b7 ' + text, sc.telegramEchoTopicId); },
|
telegram: async text => { const sc = siteConfig(); if (!sc.telegramBotToken || !sc.telegramEchoChatId) return false; return telegramSend(sc.telegramEchoChatId, '\u{1F7E0} <b>InstantAdPay</b> \u00b7 ' + text, sc.telegramEchoTopicId); },
|
||||||
notify: async (memberId, subject, text, kind) => { const a = await accounts.byMemberId(memberId); if (!a || !a.email) return; const html = '<p>' + String(text).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/(https:\/\/[^\s]+)/g, '<a href="$1" target="_blank" rel="noopener">$1</a>').split('\n\n').join('</p><p>').replace(/\n/g, '<br>') + '</p>'; await messages.deliver(1, ADMIN_EMAIL || 'house@instantadpay.com', [a.email], subject, html, kind || 'notice'); } });
|
notify: async (memberId, subject, text, kind) => { const a = await accounts.byMemberId(memberId); if (!a || !a.email) return; const html = '<p>' + String(text).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/(https:\/\/[^\s]+)/g, '<a href="$1" target="_blank" rel="noopener">$1</a>').split('\n\n').join('</p><p>').replace(/\n/g, '<br>') + '</p>'; 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
|
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 (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 (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);
|
return serveJoinPage(res, tok, ang ? angle : '', ang, set);
|
||||||
}
|
}
|
||||||
// -- legacy bridge: /from/faucetwave | /from/tieroneads [?seg=advertiser]. Same squeeze page
|
// -- 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
|
// 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/<ref>.
|
// PolHunter guessed at. sponsorRef is the upline's username or code, matching /join/<ref>.
|
||||||
let sponsorRef = (acct && acct.sponsorRef) || '';
|
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());
|
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
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,173 +1,190 @@
|
|||||||
// Growth snapshot (Marty, 2026-09-15): once a day, a short "how the site is doing" post in the Telegram
|
// 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
|
// 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
|
// 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),
|
// 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.
|
// snapshotHourUtc (default 14 = 9 AM Central), snapshotTargets (feed,echo). State: snapshot-state.json.
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
|
||||||
let X = {};
|
let X = {};
|
||||||
const DAY = 86400000;
|
const DAY = 86400000;
|
||||||
function init(opts) { X = opts || {}; }
|
function init(opts) { X = opts || {}; }
|
||||||
const STATE = () => path.join(X.dataDir, 'snapshot-state.json');
|
const STATE = () => path.join(X.dataDir, 'snapshot-state.json');
|
||||||
function state() { try { return JSON.parse(fs.readFileSync(STATE(), 'utf8')); } catch (e) { return {}; } }
|
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) {} }
|
function setState(s) { try { fs.writeFileSync(STATE(), JSON.stringify(s)); } catch (e) {} }
|
||||||
const n = v => Number(v || 0).toLocaleString('en-US');
|
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'; } };
|
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()) {
|
async function gather(now = Date.now()) {
|
||||||
const since = now - DAY, prior = now - 2 * DAY;
|
const since = now - DAY, prior = now - 2 * DAY;
|
||||||
const s = { signups: 0, signupsPrior: 0, members: 0, wallets: 0, payoutsOn: 0, seen24h: 0,
|
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,
|
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,
|
buys: 0, buysPrior: 0, usd: 0, activations: 0, paidWei: 0n, lifeBuys: 0, lifePaidWei: 0n, lifePayouts: 0,
|
||||||
creditsHandedOut: 0, creditsEarned: 0, creditsSpent: 0 };
|
creditsHandedOut: 0, creditsEarned: 0, creditsSpent: 0 };
|
||||||
if (X.db && X.db.enabled()) {
|
if (X.db && X.db.enabled()) {
|
||||||
const q = (a, b) => X.db.q(a, b || []);
|
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] || {};
|
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);
|
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] || {};
|
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);
|
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] || {};
|
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);
|
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
|
// 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 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] || {};
|
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);
|
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] || {};
|
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);
|
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
|
// 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
|
// 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] || {};
|
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);
|
s.creditsHandedOut = Number(cl.handed || 0); s.creditsEarned = Number(cl.earned || 0); s.creditsSpent = Number(cl.spent || 0);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
for (const ev of X.chain.recentEvents(1e9)) {
|
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++; }
|
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 === '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); }
|
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
|
// 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;
|
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); }
|
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) {}
|
} catch (e) {}
|
||||||
return s;
|
return s;
|
||||||
}
|
}
|
||||||
|
|
||||||
// One block per subject, a blank line between them, and no line long enough for a phone to wrap it
|
// 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).
|
// into a wall of text (Marty, 2026-09-22 — the single-paragraph version was unreadable on mobile).
|
||||||
function compose(s, cta) {
|
function compose(s, cta) {
|
||||||
const up = (cur, prev) => cur > prev ? ', up from ' + n(prev) + ' yesterday'
|
const up = (cur, prev) => cur > prev ? ', up from ' + n(prev) + ' yesterday'
|
||||||
: cur < prev ? ', down from ' + n(prev) + ' yesterday' : '';
|
: cur < prev ? ', down from ' + n(prev) + ' yesterday' : '';
|
||||||
const plural = (c, word) => n(c) + ' ' + word + (Number(c) === 1 ? '' : 's');
|
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 day = new Date().toLocaleDateString('en-US', { timeZone: 'America/Chicago', weekday: 'long', month: 'long', day: 'numeric' });
|
||||||
const B = [];
|
const B = [];
|
||||||
B.push('\u{1F4C8} <b>InstantAdPay · 24-hour snapshot</b>\n<i>' + day + '</i>');
|
B.push('\u{1F4C8} <b>InstantAdPay · 24-hour snapshot</b>\n<i>' + day + '</i>');
|
||||||
B.push('\u{1F465} <b>Sign-ups</b>\n'
|
B.push('\u{1F465} <b>Sign-ups</b>\n'
|
||||||
+ '<b>' + n(s.signups) + '</b> new' + up(s.signups, s.signupsPrior) + '\n'
|
+ '<b>' + n(s.signups) + '</b> new' + up(s.signups, s.signupsPrior) + '\n'
|
||||||
+ n(s.activations) + ' switched on payouts');
|
+ n(s.activations) + ' switched on payouts');
|
||||||
B.push('\u{1F9FE} <b>Purchases</b>\n'
|
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>' + 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>' + pol(s.paidWei) + ' POL</b> paid to members\nin the same transactions');
|
||||||
B.push('\u{1F4E3} <b>Campaigns</b>\n'
|
B.push('\u{1F4E3} <b>Campaigns</b>\n'
|
||||||
+ '<b>' + n(s.campaigns) + '</b> posted by ' + plural(s.advertisers, 'advertiser') + '\n'
|
+ '<b>' + n(s.campaigns) + '</b> posted by ' + plural(s.advertisers, 'advertiser') + '\n'
|
||||||
+ n(s.active) + ' running · ' + n(s.imps) + ' impressions');
|
+ n(s.active) + ' running · ' + n(s.imps) + ' impressions');
|
||||||
B.push('\u{1F440} <b>Earning</b>\n'
|
B.push('\u{1F440} <b>Earning</b>\n'
|
||||||
+ '<b>' + n(s.viewers) + '</b> members viewed ads today\n'
|
+ '<b>' + n(s.viewers) + '</b> members viewed ads today\n'
|
||||||
+ n(s.seen24h) + ' signed in');
|
+ n(s.seen24h) + ' signed in');
|
||||||
B.push('\u{1F4B3} <b>Credits</b>\n'
|
B.push('\u{1F4B3} <b>Credits</b>\n'
|
||||||
+ '<b>' + n(s.creditsHandedOut) + '</b> handed out · <b>' + n(s.creditsEarned) + '</b> earned\n'
|
+ '<b>' + n(s.creditsHandedOut) + '</b> handed out · <b>' + n(s.creditsEarned) + '</b> earned\n'
|
||||||
+ n(s.creditsSpent) + ' spent on ads');
|
+ n(s.creditsSpent) + ' spent on ads');
|
||||||
B.push('\u{1F3C1} <b>So far</b>\n'
|
B.push('\u{1F3C1} <b>So far</b>\n'
|
||||||
+ '<b>' + n(s.members) + '</b> members · ' + n(s.wallets) + ' wallets linked\n'
|
+ '<b>' + n(s.members) + '</b> members · ' + n(s.wallets) + ' wallets linked\n'
|
||||||
+ n(s.payoutsOn) + ' with payouts on · ' + n(s.lifeBuys) + ' buys\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'
|
+ '<b>' + pol(s.lifePaidWei) + ' POL</b> paid in ' + n(s.lifePayouts) + ' payouts\n'
|
||||||
+ 'All of it on the public ledger.');
|
+ '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>' : ''));
|
B.push('<a href="https://instantadpay.com/ledger">Live ledger</a>' + (cta ? ' · <a href="' + cta + '">Join free</a>' : ''));
|
||||||
return B.join('\n\n');
|
return B.join('\n\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function post() {
|
async function post() {
|
||||||
const sc = X.siteConfig();
|
const sc = X.siteConfig();
|
||||||
if (!sc.telegramBotToken) return { error: 'No Telegram bot token set.' };
|
if (!sc.telegramBotToken) return { error: 'No Telegram bot token set.' };
|
||||||
const s = await gather(); const text = compose(s, sc.telegramCtaUrl);
|
const s = await gather(); const text = compose(s, sc.telegramCtaUrl);
|
||||||
const targets = String(sc.snapshotTargets || 'feed,echo').split(',').map(x => x.trim());
|
const targets = String(sc.snapshotTargets || 'feed,echo').split(',').map(x => x.trim());
|
||||||
let sent = 0;
|
let sent = 0;
|
||||||
if (targets.includes('feed') && sc.telegramChatId) { if (await X.send(sc.telegramChatId, text, sc.telegramTopicId)) sent++; }
|
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++; }
|
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);
|
const st = state(); st.lastPost = Date.now(); st.lastDay = new Date().toISOString().slice(0, 10); setState(st);
|
||||||
return { ok: true, sent, text };
|
return { ok: true, sent, text };
|
||||||
}
|
}
|
||||||
|
|
||||||
// every 10 minutes: post once a day at or after snapshotHourUtc (a restart never double-posts)
|
// every 10 minutes: post once a day at or after snapshotHourUtc (a restart never double-posts)
|
||||||
async function tick() {
|
async function tick() {
|
||||||
try { await milestoneTick(); } catch (e) { console.error('milestone', e.message); }
|
try { await milestoneTick(); } catch (e) { console.error('milestone', e.message); }
|
||||||
const sc = X.siteConfig();
|
const sc = X.siteConfig();
|
||||||
if (String(sc.snapshotEnabled || '1') !== '1' || !sc.telegramBotToken) return;
|
if (String(sc.snapshotEnabled || '1') !== '1' || !sc.telegramBotToken) return;
|
||||||
const hour = Number(sc.snapshotHourUtc == null || sc.snapshotHourUtc === '' ? 14 : sc.snapshotHourUtc);
|
const hour = Number(sc.snapshotHourUtc == null || sc.snapshotHourUtc === '' ? 14 : sc.snapshotHourUtc);
|
||||||
const now = new Date(); const day = now.toISOString().slice(0, 10);
|
const now = new Date(); const day = now.toISOString().slice(0, 10);
|
||||||
if (now.getUTCHours() < hour) return;
|
if (now.getUTCHours() < hour) return;
|
||||||
if (state().lastDay === day) return;
|
if (state().lastDay === day) return;
|
||||||
await post();
|
await post();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function preview() { const sc = X.siteConfig(); return compose(await gather(), sc.telegramCtaUrl); }
|
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.
|
// ---- 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
|
// 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.
|
// thousands and the half-thousands so the small ones stay a quick cheer, not a repeated speech.
|
||||||
const STEP = 100;
|
const STEP = 100;
|
||||||
const isBig = m => m % 500 === 0;
|
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 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
|
const MILESTONES = marksUpTo(10000); // kept for callers that want the ladder
|
||||||
|
|
||||||
function composeMilestone(mark, s, cta) {
|
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 ledger = '<a href="https://instantadpay.com/ledger">Live ledger</a>' + (cta ? ' \u00b7 <a href="' + cta + '">Join free</a>' : '');
|
||||||
const B = [];
|
const B = [];
|
||||||
B.push('\u{1F389} <b>' + n(mark) + ' members on InstantAdPay</b>');
|
B.push('\u{1F389} <b>' + n(mark) + ' members on InstantAdPay</b>');
|
||||||
if (isBig(mark)) {
|
if (isBig(mark)) {
|
||||||
B.push(n(mark) + ' people have joined. Here is what that looks like right now:');
|
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.push('<b>' + n(s.wallets) + '</b> wallets linked\n'
|
||||||
+ '<b>' + n(s.payoutsOn) + '</b> with payouts switched on\n'
|
+ '<b>' + n(s.payoutsOn) + '</b> with payouts switched on\n'
|
||||||
+ '<b>' + n(s.lifeBuys) + '</b> purchases\n'
|
+ '<b>' + n(s.lifeBuys) + '</b> purchases\n'
|
||||||
+ '<b>' + pol(s.lifePaidWei) + ' POL</b> paid to members\nin the same transactions');
|
+ '<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('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('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.');
|
B.push('The next ' + n(mark) + ' start today.');
|
||||||
} else {
|
} else {
|
||||||
B.push('Another hundred through the door.');
|
B.push('Another hundred through the door.');
|
||||||
B.push('<b>' + n(s.wallets) + '</b> wallets linked\n'
|
B.push('<b>' + n(s.wallets) + '</b> wallets linked\n'
|
||||||
+ '<b>' + n(s.payoutsOn) + '</b> with payouts on\n'
|
+ '<b>' + n(s.payoutsOn) + '</b> with payouts on\n'
|
||||||
+ '<b>' + pol(s.lifePaidWei) + ' POL</b> paid to members');
|
+ '<b>' + pol(s.lifePaidWei) + ' POL</b> paid to members');
|
||||||
B.push('Every payment on the public ledger.');
|
B.push('Every payment on the public ledger.');
|
||||||
B.push('Next stop: <b>' + n(mark + STEP) + '</b>.');
|
B.push('Next stop: <b>' + n(mark + STEP) + '</b>.');
|
||||||
}
|
}
|
||||||
B.push(ledger);
|
B.push(ledger);
|
||||||
return B.join('\n\n');
|
return B.join('\n\n');
|
||||||
}
|
}
|
||||||
async function memberCount() {
|
async function memberCount() {
|
||||||
if (!(X.db && X.db.enabled())) return 0;
|
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);
|
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
|
// runs from tick(): announce the highest newly crossed mark, once, whatever snapshotEnabled says
|
||||||
async function milestoneTick() {
|
async function milestoneTick() {
|
||||||
const sc = X.siteConfig(); if (!sc.telegramBotToken) return null;
|
const sc = X.siteConfig(); if (!sc.telegramBotToken) return null;
|
||||||
const st = state(); const done = st.milestones || {};
|
const st = state(); const done = st.milestones || {};
|
||||||
const count = await memberCount();
|
const count = await memberCount();
|
||||||
// presence of the key is what counts, never its value: a mark recorded as 0 (or any other
|
// 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
|
// 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));
|
const due = marksUpTo(count).filter(m => !Object.prototype.hasOwnProperty.call(done, m));
|
||||||
if (!due.length) return null;
|
if (!due.length) return null;
|
||||||
const mark = due[due.length - 1];
|
const mark = due[due.length - 1];
|
||||||
const text = composeMilestone(mark, await gather(), sc.telegramCtaUrl);
|
const text = composeMilestone(mark, await gather(), sc.telegramCtaUrl);
|
||||||
const targets = String(sc.snapshotTargets || 'feed,echo').split(',').map(x => x.trim());
|
const targets = String(sc.snapshotTargets || 'feed,echo').split(',').map(x => x.trim());
|
||||||
let sent = 0;
|
// A milestone read as plain text in a busy topic and got scrolled straight past (Marty,
|
||||||
if (targets.includes('feed') && sc.telegramChatId) { if (await X.send(sc.telegramChatId, text, sc.telegramTopicId)) sent++; }
|
// 2026-09-24), so it goes out as a card now. The cards are pre-rendered for the whole ladder
|
||||||
if (targets.includes('echo') && sc.telegramEchoChatId) { if (await X.send(sc.telegramEchoChatId, text, sc.telegramEchoTopicId)) sent++; }
|
// and deliberately carry NO live figures: the numbers stay in the caption, where they cannot
|
||||||
for (const m of due) done[m] = Date.now(); // lower marks crossed in the same jump are marked too, so they never post late
|
// go stale between rendering and the mark actually being reached.
|
||||||
st.milestones = done; setState(st);
|
let jpeg = null;
|
||||||
console.log('milestone posted', mark, 'members', count, 'sent', sent);
|
try { jpeg = fs.readFileSync(path.join(X.publicDir || '', 'milestones', mark + '.jpg')); } catch (e) {}
|
||||||
return { ok: true, mark, count, sent, text };
|
const caption = text.length <= 1024 ? text : null; // Telegram caps a photo caption at 1024
|
||||||
}
|
let sent = 0;
|
||||||
async function previewMilestone(mark) { const sc = X.siteConfig(); return composeMilestone(Number(mark) || 500, await gather(), sc.telegramCtaUrl); }
|
const post = async (chat, thread) => {
|
||||||
|
if (jpeg && caption && X.sendPhoto && await X.sendPhoto(chat, jpeg, caption, thread)) return true;
|
||||||
module.exports = { init, gather, compose, post, tick, preview, milestoneTick, previewMilestone, composeMilestone, memberCount, MILESTONES, STEP };
|
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 };
|
||||||
|
|||||||