diff --git a/public/celebrate-1000.jpg b/public/celebrate-1000.jpg new file mode 100644 index 0000000..1b16e25 Binary files /dev/null and b/public/celebrate-1000.jpg differ diff --git a/server.js b/server.js index d45ad72..b3e1702 100644 --- a/server.js +++ b/server.js @@ -160,6 +160,51 @@ function submitRateLimited(ip) { // Post to the team Telegram. topicId overrides the default team-build topic // (config.telegramTopicId) — used to fan the same event out to a second forum // topic (e.g. the recruiting/new-members topic) with different copy. +// A photo with a caption to any chat/topic (member milestones). Node 22: FormData + Blob are built in. +async function sendTelegramPhotoTo(chatId, filePath, caption, topicId) { + const c = getConfig(); + if (!c.telegramBotToken || !chatId) return false; + try { + const fd = new FormData(); fd.append('chat_id', String(chatId)); fd.append('caption', caption); fd.append('parse_mode', 'HTML'); + if (topicId && /^[0-9]+$/.test(String(topicId))) fd.append('message_thread_id', String(topicId)); + fd.append('photo', new Blob([fs.readFileSync(filePath)], { type: 'image/jpeg' }), path.basename(filePath)); + const r = await fetch(`https://api.telegram.org/bot${c.telegramBotToken}/sendPhoto`, { method: 'POST', body: fd }); + if (!r.ok) console.warn('telegram sendPhoto', r.status, (await r.text()).slice(0, 200)); + return r.ok; + } catch (e) { console.warn('telegram sendPhoto', e.message); return false; } +} +// Member milestones (Marty, 2026-09-21): a celebration in the proof channel, the team feed and the shared +// payments topic when the contract passes 1,000 members (then 2,500 / 5,000 / 10,000). Once per milestone. +const MILESTONES_FILE = path.join(DATA_DIR, 'milestones.json'); +function milestoneCaption(n, total) { + const site = String(getConfig().siteName || 'The RM Circle'); + return '\u{1F389} ' + n.toLocaleString('en-US') + ' members. ' + site + ' just reached ' + n.toLocaleString('en-US') + ' positions on the contract (' + total.toLocaleString('en-US') + ' and counting), every one of them registered on Polygon and every payout between them settled wallet to wallet, in public.\n\nTo everyone who brought their two, and taught their two to do the same: this is your number. \u{1F64F}\n\nNext stop: ' + (n >= 10000 ? 'the next ten thousand' : (n === 1000 ? '2,500' : n === 2500 ? '5,000' : '10,000')) + '.'; +} +let milestoneBusy = false; +async function milestoneCheck() { + if (milestoneBusy) return; milestoneBusy = true; + try { + const c = getConfig(); if (String(c.milestonesEnabled == null ? '1' : c.milestonesEnabled) !== '1') return; + const root = Number(c.orgRootId || 21); const d = chain.getOrgShare(root); if (!d || !d.ready) return; + const total = Number(d.totalMembers) || 0; + const list = String(c.memberMilestones || '1000,2500,5000,10000').split(',').map(n => Number(n.trim())).filter(n => n > 0); + let st = {}; try { st = readJson(MILESTONES_FILE); } catch (e) {} + st.posted = st.posted || {}; + for (const n of list) { + if (total < n || st.posted[n]) continue; + const caption = milestoneCaption(n, total); + const img = path.join(ROOT, 'public', 'celebrate-' + n + '.jpg'); + const havePhoto = fs.existsSync(img); + const post = async (chatId, topicId) => { if (!chatId) return; if (havePhoto) { if (await sendTelegramPhotoTo(chatId, img, caption, topicId)) return; } sendTelegramTo(chatId, caption, topicId, null, 'HTML', 'milestone:' + n + ':' + String(chatId) + ':' + String(topicId || '')); }; + await post(c.telegramProofChatId, c.telegramProofTopicId || null); // the public payment-proof channel + await post(c.telegramChatId, c.telegramTopicId || null); // the team feed + if (c.telegramEchoChatId || c.telegramEchoTopicId) await post(c.telegramEchoChatId || c.telegramChatId, c.telegramEchoTopicId || null); // the shared payments topic + st.posted[n] = { at: Date.now(), total }; writeJson(MILESTONES_FILE, st); + console.log('member milestone posted:', n, 'total', total, havePhoto ? 'with photo' : 'text only'); + } + } catch (e) { console.error('milestone check', e.message); } finally { milestoneBusy = false; } +} +setTimeout(() => milestoneCheck().catch(() => {}), 90000).unref(); // once after boot, when the index is warm function sendTelegram(text, topicId, replyMarkup, dedupeKey) { const c = getConfig(); return sendTelegramTo(c.telegramChatId, text, topicId != null ? topicId : c.telegramTopicId, replyMarkup, undefined, dedupeKey); @@ -748,6 +793,7 @@ async function handleApi(req,res,pathname){ const limit=Number(q.get('limit')||40); return json(res,200,chain.getPayoutsPublic(offset,limit),{'Cache-Control':'public, max-age=20'}); } + if(req.method==='GET'&&pathname==='/api/admin/milestone/preview'){ if(!requireAdmin(req,res))return; const c=getConfig(); const d=chain.getOrgShare(Number(c.orgRootId||21)); const n=Number(new URL(req.url,'http://x').searchParams.get('n')||1000); let st={}; try{st=readJson(MILESTONES_FILE);}catch(e){} return json(res,200,{total:d&&d.totalMembers,n,caption:milestoneCaption(n,(d&&d.totalMembers)||n),photo:fs.existsSync(path.join(ROOT,'public','celebrate-'+n+'.jpg')),posted:(st.posted||{})[n]||null,targets:{proof:!!c.telegramProofChatId,team:!!c.telegramChatId,echo:!!(c.telegramEchoChatId||c.telegramEchoTopicId)}}); } if(req.method==='GET'&&pathname==='/api/public/org-stats'){ const root=Number(getConfig().orgRootId||21); const d=chain.getOrgShare(Number.isInteger(root)&&root>0?root:21); @@ -1881,6 +1927,7 @@ chain.startIndexer(evt=>{ // the next fetch instead of up to 2 minutes later. try{memberCache.clear();}catch(e){} try{tgbot.notifyEvent(evt);}catch(e){} + if(evt.type==='registered'){ setTimeout(()=>milestoneCheck().catch(()=>{}), 5000); } // after the indexer wires the new member try{ const c=getConfig(); const EK=tgEventKey(evt); // one id for this on-chain event, so no feed can post it twice