diff --git a/feedgroup.js b/feedgroup.js new file mode 100644 index 0000000..2413e7a --- /dev/null +++ b/feedgroup.js @@ -0,0 +1,39 @@ +// One proof-feed message per purchase (Marty, 2026-09-25). +// +// The feed used to post each tier payout on its own line and, in payouts mode, never said who +// bought what. Marty: "I see the level 1 payout, I see a level 2 payout, but I don't see who +// purchased something and what they bought." So: the buyer and the package first, then the +// level payouts under it, then any pass-ups, as ONE message. It composes from every event of +// the transaction, which is why the caller triggers it on AdminPaid, the last event the contract +// emits for a purchase: by then Purchase, TierPaid and PassedUp of that tx are all indexed. +// +// Pure: events in, HTML string out. Tested against real index rows before it shipped. +'use strict'; + +const PCT = { 1: 50, 2: 20, 3: 10 }; + +function usd(cents) { return ('$' + (cents / 100).toFixed(2)).replace(/\.00$/, ''); } + +// txEvents: every indexed event with this tx. who(id) -> "#12 @name". weiToPol(wei) -> "1.23". +// Returns null when there is no Purchase in the tx (not a package purchase) or when +// `requirePayout` is set and no member was paid (a root or unsponsored buyer: nothing to prove). +function compose(txEvents, who, weiToPol, opts) { + opts = opts || {}; + const pur = txEvents.find(e => e.type === 'Purchase'); + if (!pur) return null; + const tiers = txEvents.filter(e => e.type === 'TierPaid').sort((a, b) => (a.tier || 0) - (b.tier || 0) || (a.li || 0) - (b.li || 0)); + if (opts.requirePayout && !tiers.length) return null; + const lines = ['\u{1F9FE} ' + who(pur.buyerId) + ' bought a ' + usd(pur.priceCents) + ' package']; + for (const t of tiers) { + const pct = PCT[t.tier]; + const about = pct ? ' (' + usd(Math.round(pur.priceCents * pct / 100)) + ')' : ''; + lines.push('\u{1F4B8} Level ' + t.tier + ': ' + weiToPol(t.amountWei) + ' POL' + about + ' → ' + who(t.recipientId) + + (t.hops > 0 ? ' (passed up ' + t.hops + ')' : '')); + } + if (!tiers.length) lines.push('No member payout on this one: the buyer has nobody above them to pay.'); + const passed = txEvents.filter(e => e.type === 'PassedUp'); + for (const p of passed) if (p.skippedId) lines.push('⏭ Level ' + (p.tier || '?') + ' skipped ' + who(p.skippedId) + ' (not yet qualified)'); + return lines.join('\n'); +} + +module.exports = { compose }; diff --git a/server.js b/server.js index b345b53..754424a 100644 --- a/server.js +++ b/server.js @@ -37,7 +37,8 @@ const releases = require('./releases'); const ledger = require('./ledger'); const videosweep = require('./videosweep'); const sbcheck = require('./sbcheck'); -const marks = require('./marks'); // one-time claims shared across running instances (deploy overlap) // Google Safe Browsing screen on advertiser destinations +const marks = require('./marks'); +const feedgroup = require('./feedgroup'); // one proof-feed message per purchase: buyer, package, then the tier payouts // one-time claims shared across running instances (deploy overlap) // Google Safe Browsing screen on advertiser destinations const updates = require('./updates'); const audit = require('./audit'); // counter audit: views vs delivery logs, charges vs shows (Marty, 2026-09-15) // member update emails from Admin > Releases (Marty, 2026-09-14) const leaderboard = require('./leaderboard'); @@ -1072,11 +1073,19 @@ async function telegramOnEvent(ev) { const who = id => '#' + id + (names[id] ? ' @' + names[id] : ''); const cc = chain.getConfig(); const tx = (cc.explorer ? cc.explorer.replace(/\/+$/, '') : 'https://polygonscan.com') + '/tx/' + ev.tx; - const build = (mode) => { + const build = async (mode) => { let line = null; - if (ev.type === 'TierPaid') line = '\u{1F4B8} Level ' + ev.tier + ' payout: ' + weiToPol(ev.amountWei) + ' POL \u2192 ' + who(ev.recipientId); + // Tier payouts and the purchase itself are no longer posted one by one: they are composed + // into ONE message on AdminPaid, the last event of every purchase tx, so the feed reads + // "who bought what, then who was paid" (Marty, 2026-09-25). In payouts mode a purchase that + // paid no member (root or unsponsored buyer) stays silent, as before. + if (ev.type === 'AdminPaid') { + const txEvents = chain.recentEvents(1e9).filter(x => x.tx === ev.tx); + const ids = new Set(); txEvents.forEach(x => { for (const k of ['buyerId', 'recipientId', 'skippedId']) if (x[k]) ids.add(x[k]); }); + const nm = await accounts.namesForMembers([...ids]).catch(() => ({})); + line = feedgroup.compose(txEvents, id => '#' + id + (nm[id] ? ' @' + nm[id] : ''), weiToPol, { requirePayout: mode === 'payouts' }); + } else if (ev.type === 'BuyerCounted' && mode !== 'payouts') line = '\u2B50 ' + who(ev.sponsorId) + ' now has ' + ev.newCount + ' qualifying buyer' + (ev.newCount === 1 ? '' : 's') + (ev.newCount === 2 ? ' \u00b7 level 2 open' : ev.newCount === 5 ? ' \u00b7 level 3 open' : ''); - else if (ev.type === 'Purchase' && mode !== 'payouts') line = '\u{1F9FE} ' + who(ev.buyerId) + ' bought a $' + Math.round(ev.priceCents / 100) + ' package'; else if (ev.type === 'AwardPaid') line = '\u{1F4B8} Award payout: ' + weiToPol(ev.amountWei) + ' POL \u2192 ' + who(ev.toId); else if (ev.type === 'MemberActivated' && mode === 'all') line = '\u{1F91D} ' + who(ev.id) + ' switched on payouts'; if (!line) return null; @@ -1112,8 +1121,10 @@ async function telegramOnEvent(ev) { } } } catch (e) { console.error('missed-payout post', e.message); } - if (sc.telegramChatId) { const t = build(String(sc.telegramEvents || 'payouts')); if (t) await telegramSend(sc.telegramChatId, t, sc.telegramTopicId); if (missed && String(sc.telegramEvents || 'payouts') !== 'none') await telegramSend(sc.telegramChatId, missed, sc.telegramTopicId).catch(() => {}); } - if (sc.telegramEchoChatId) { const t = build(String(sc.telegramEchoEvents || 'payouts')); if (t) await telegramSend(sc.telegramEchoChatId, '\u{1F7E0} InstantAdPay \u00b7 ' + t, sc.telegramEchoTopicId); } + // the main group used to get bare payout lines with no product name: nobody knew what they were from + const brand = t => '\u{1F7E0} InstantAdPay\n' + t; + if (sc.telegramChatId) { const t = await build(String(sc.telegramEvents || 'payouts')); if (t) await telegramSend(sc.telegramChatId, brand(t), sc.telegramTopicId); if (missed && String(sc.telegramEvents || 'payouts') !== 'none') await telegramSend(sc.telegramChatId, missed, sc.telegramTopicId).catch(() => {}); } + if (sc.telegramEchoChatId) { const t = await build(String(sc.telegramEchoEvents || 'payouts')); if (t) await telegramSend(sc.telegramEchoChatId, brand(t), sc.telegramEchoTopicId); } if (missed && sc.telegramEchoChatId && String(sc.telegramEchoEvents || 'payouts') !== 'none') await telegramSend(sc.telegramEchoChatId, '\u{1F7E0} InstantAdPay \u00b7 ' + missed, sc.telegramEchoTopicId).catch(() => {}); } // holding-tank arrivals -> one digest line in the shared payments topic (Marty, 2026-09-12): who is