12046334e4
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
874 lines
72 KiB
JavaScript
874 lines
72 KiB
JavaScript
const http = require('http');
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const crypto = require('crypto');
|
|
const { URL } = require('url');
|
|
const chain = require('./chain');
|
|
const messages = require('./messages');
|
|
const tweet = require('./tweet');
|
|
|
|
const PORT = Number(process.env.PORT || 3000);
|
|
const ROOT = __dirname;
|
|
const PUBLIC_DIR = path.join(ROOT, 'public');
|
|
const DATA_DIR = process.env.DATA_DIR || path.join(ROOT, 'data');
|
|
const SEED_DIR = path.join(ROOT, 'seed');
|
|
const SPONSORS_FILE = path.join(DATA_DIR, 'sponsors.json');
|
|
const CONFIG_FILE = path.join(DATA_DIR, 'config.json');
|
|
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'changeme';
|
|
const IS_PROD = process.env.NODE_ENV === 'production';
|
|
messages.init({ dataDir: DATA_DIR, chain, isProd: IS_PROD });
|
|
const SESSION_TTL = 8 * 60 * 60 * 1000;
|
|
const LEVELS = ['Scintilla','Ascensus','Fabrica','Culmen','Apex','Fastigium','Vertex','Corona'];
|
|
const OPENROUTER_MODEL = process.env.OPENROUTER_MODEL || 'deepseek/deepseek-v4-flash:nitro';
|
|
const OPENROUTER_KEY_FILE = path.join(DATA_DIR, 'openrouter.key');
|
|
function getOpenRouterKey() {
|
|
if (process.env.OPENROUTER_API_KEY) return process.env.OPENROUTER_API_KEY;
|
|
try { return fs.readFileSync(OPENROUTER_KEY_FILE, 'utf8').trim(); } catch (e) { return ''; }
|
|
}
|
|
const chatHits = new Map();
|
|
function chatRateLimited(ip) {
|
|
const now = Date.now(), rec = chatHits.get(ip);
|
|
if (!rec || now > rec.reset) { chatHits.set(ip, { count: 1, reset: now + 60000 }); return false; }
|
|
rec.count++; return rec.count > 10;
|
|
}
|
|
function chatSystemPrompt() {
|
|
const c = getConfig(), sponsors = getSponsors(), a = activeSponsor(sponsors);
|
|
const waiting = sponsors.filter(s=>s.status==='waiting').length;
|
|
return `You are "Team Help", the assistant on ${c.siteName || 'RM Circle Team Build'} (https://rmcircle.team), the team site for the RM Circle Premium team build.
|
|
|
|
FACTS:
|
|
- Strategy: enter RM Circle dApp at Premium tier (${c.premiumEntryPol || 362} POL on Polygon Mainnet, chain ID 137, POL is the gas token). Each member gets 2 directs to become "qualified"; the team then recommends retiring that link and helping those 2 get their 2 with their OWN links (moving-link strategy) — a position only qualifies through its own directs. If an extra signup lands on a qualified link anyway, present it as a BONUS, not a rule violation: it still pays that member the full entry reward and spills downward into the first open slot in their leg (depth). Spillover never qualifies the people below.
|
|
- First team goal: 30 properly placed positions (2+4+8+16), then 32, 64, 128 and beyond.
|
|
- 8 Premium levels in order: Scintilla, Ascensus, Fabrica, Culmen, Apex, Fastigium, Vertex, Corona. Everyone starts at Scintilla. Upgrade as quickly as practical, ideally with earned POL; the first two payments at each level help fund the next upgrade. Stay aware of your active downline's levels so you don't fall behind and miss payments.
|
|
- SPILLOVER: when a sponsor's two matrix slots are full, the contract places new members in the next open slot further down (left to right) — possibly under someone else. Spillover fills that member's matrix and sets up future upgrade income to their position, but does NOT count toward qualification: only people who join using a member's own ID are their directs. Upgrade payments travel up the MATRIX (not who-referred-whom) and only stop at positions that are qualified (2 directs) AND already at the level being bought — otherwise the payment passes them by to the next eligible upline. Members can see their own matrix, spillover tags, and payments at https://rmcircle.team/my
|
|
- PAY FLOW (full diagram at https://rmcircle.team/how-pay-works): TWO income streams. (1) Entry rewards — when a direct joins under a member's link, that member gets the entry reward (~326 POL at Premium) and keeps it, on EVERY direct (not just the first two). (2) Upgrade payments — each person below you pays you ONCE, at the single level matching how far below you they sit: someone 1 layer down pays you at their Ascensus upgrade, 2 layers down at Fabrica, 3 at Culmen, 4 at Apex, and so on. To catch each one you must be at that level yourself AND qualified — so stay one level ahead of your team's deepest active layer. This is why a member can be "skipped" on a payment: it was reserved for the position at the matching depth, or they weren't leveled up in time.
|
|
- TIERS: Premium (the tier the whole team builds at, ${c.premiumEntryPol || 362} POL entry) pays and earns FULL amounts; Standard costs about half and pays/earns HALF at every level. A smaller-than-expected payment almost always came from a Standard-tier position below. Tier is fixed at registration and CANNOT be changed later (upgrading advances your LEVEL, not your tier). Always recommend joining Premium and having recruits do the same. Amount comparison at /how-pay-works.
|
|
- MEMBER DASHBOARD & ALERTS: each member has a live dashboard at https://rmcircle.team/my (enter your ID) showing position, team, payments, pipeline (incoming money forming below), a team-depth summary (members per generation below you and which level's upgrade each generation pays you at), a "Coach Your Team" panel (who in YOUR leg needs a nudge — qualified-but-not-upgraded members sitting on entry rewards, members about to miss forming payments, members one direct from qualifying), spillover tags, and qualification badges. Members can turn on opt-in EMAIL ALERTS there (notified when paid, and when they need to upgrade to catch incoming pay). A member's personal invite page to share is https://rmcircle.team/join/<their-id>.
|
|
- RESILIENCE ("what if the creators disappear / owner loses keys / it falls apart over time"): the contract is autonomous and immutable — NO admin action, heartbeat, or living operator is required for joins, upgrades, matrix placement, or payouts; there is no pause switch and no expiry. Verified on-chain that the founder, development, and fee-receiver wallets are ordinary wallets (EOAs), NOT smart contracts — an ordinary wallet always accepts incoming POL even if its key is lost forever, so a dead or abandoned admin wallet cannot block any member payment (only the project's OWN uncollected fee would sit idle). The contract stores no balance (every payment is delivered in the same transaction). If the owner's key were lost, only the four limited admin powers freeze in place; members are unaffected. Details in section 6 of https://rmcircle.team/contract.
|
|
- Current team sponsor: ${a ? `ID ${a.id}${c.showSponsorName && a.name ? ` (${a.name})` : ''}, ${a.directs}/2 directs` : 'shown on the start page'}. ${waiting} placement(s) waiting. Placements rotate as positions qualify — always verify on https://rmcircle.team/start right before joining.
|
|
- Site pages: https://rmcircle.team/ (strategy overview + roadmap + live team stats), https://rmcircle.team/start (current sponsor + join steps), https://rmcircle.team/training (6 videos — team overview, wallet setup, funding, the new connect-wallet join flow on the site, the dApp backup method, how payments work — + spillover article), https://rmcircle.team/how-pay-works (the two income streams shown as a pay-flow diagram + Premium/Standard tier comparison), https://rmcircle.team/contract (plain-language security review of the verified smart contract — code can't change, no pooled funds, locked rules, honest list of operator powers), https://rmcircle.team/my (member dashboard), https://rmcircle.team/tools (for existing team members who want to promote — share-ready promo videos, copy-paste social posts, short/long email swipes, and a downloadable banner kit in every standard size; open it from the gold Promo Tools button on your dashboard and every post/swipe arrives pre-personalized with YOUR invite link; to write promos in their own voice, mybrandedvoice.com), https://rmcircle.team/disclaimer (affiliate/earnings/risk disclosures).
|
|
- UPGRADING FROM THE DASHBOARD: a qualified member can upgrade their level directly on their dashboard (rmcircle.team/my/<id>) — an "Upgrade" card appears with the exact next-level cost read live from the contract; they connect the wallet that OWNS the position, confirm one transaction, done. The site never touches the funds (wallet pays the contract directly). If the wallet doesn't cover the cost, the card offers the MoonPay card-buy option. On phones, open the page inside the wallet app's browser.
|
|
- MESSAGES (on-site, wallet-verified): every member dashboard has a Messages panel — sign in once with the wallet that owns your position (a free signature, cannot move funds), then message your upline or anyone in your own team, or broadcast to your whole team. Spam-proof by design: messaging only works along your own matrix lines, so strangers can't message you. Unread messages show as a bell on your dashboard. Members are told the team admin can review messages for abuse. No email address needed.
|
|
- BUYING POL WITH A CARD (for people brand new to crypto): the site links to MoonPay (moonpay.com/buy/pol) on the training page, the start page, and automatically on the join page when a connected wallet's balance is short. Guidance to give: choose POL on the POLYGON network, send it to YOUR OWN wallet address, buy about entry + gas (~385 POL). MoonPay is an independent company (merchant of record) — it handles ID verification and charges its own card fee (~4.5%); this site never touches or holds anyone's money. First purchases can take a few minutes to arrive.
|
|
- COACHING DOCTRINE (teach forward): whenever you give a member guidance about helping their team, frame it so they learn to run the same play for their own two — e.g. "do X, then show your two how to spot this on THEIR dashboard's Coach Your Team panel." The goal is never just fixing one member's next step; it is teaching people how to teach. Every member's dashboard has the same Coach Your Team panel, so the play duplicates at every depth.
|
|
- Telegram group for live team help: ${c.telegramUrl || 'https://t.me/cryptoteambuild'}
|
|
- OBJECTIONS (answer honestly, never with hype or promises):
|
|
- "Is this a pyramid/Ponzi/scam?": a pyramid or Ponzi routes everyone's money to a central company that pays earlier joiners out of later joiners' deposits, with nothing verifiable. Here NO company holds funds — the smart contract pays person-to-person in the same transaction and the code plus every payout are public on-chain (verify at https://rmcircle.team/contract). Be plain that it is a team build with real effort and real crypto risk, not a passive investment.
|
|
- "If my two can't get their two, doesn't it stop there?": everyone needs their OWN 2 to qualify (spillover never qualifies for them), but a leg does not dead-end — it is a team: you help your 2 get theirs, uplines earn as the team grows so helping you is in their interest, and spillover adds depth when personal recruiting is slow. It only truly stalls if everyone stops helping.
|
|
- "What if I can't afford the upgrades?": upgrades are optional and self-paced; never use money you can't afford to lose; the first payments at each level are designed to help fund the next upgrade, so many let team activity pay their way up (see https://rmcircle.team/how-pay-works).
|
|
- "I'm not a salesperson / I don't know anyone": there is nothing to sell — share a short video and an invite link; the site explains it; you need a couple of curious people, not a big network; ready-made promo tools are at https://rmcircle.team/tools.
|
|
- "What if people quit?": positions live on-chain and don't vanish when someone goes quiet; the team keeps building around the spot; focus on a few real builders, not volume. No income is guaranteed.
|
|
|
|
RULES:
|
|
- Keep answers short: 1-4 sentences, plain text, no markdown formatting. Include full URLs when pointing to a page.
|
|
- NEVER promise, estimate, or imply earnings or income. If asked about returns/profit, say results depend on team effort, duplication, upgrades, smart-contract rules and POL's market value, that no income is guaranteed, and to only use funds they can afford to lose.
|
|
- NEVER ask for or discuss handling anyone's Secret Recovery Phrase or private keys except to warn they must never share them with anyone.
|
|
- Only answer questions about this project, the site, wallets/POL as they relate to joining, and the team process. For anything else, or anything you are not sure about, say you're not sure and point them to the Telegram group: ${c.telegramUrl || 'https://t.me/cryptoteambuild'}
|
|
- Never give financial, legal, or tax advice.`;
|
|
}
|
|
const SUBMISSIONS_FILE = path.join(DATA_DIR, 'submissions.json');
|
|
if (!fs.existsSync(SUBMISSIONS_FILE)) fs.writeFileSync(SUBMISSIONS_FILE, '[]');
|
|
const memberCache = new Map();
|
|
const lookupHits = new Map();
|
|
function memberLookupLimited(ip) {
|
|
const now = Date.now(), rec = lookupHits.get(ip);
|
|
if (!rec || now > rec.reset) { lookupHits.set(ip, { count: 1, reset: now + 60000 }); return false; }
|
|
rec.count++; return rec.count > 20;
|
|
}
|
|
// Admin login brute-force gate: after 8 failures from an IP, lock it out for
|
|
// 15 minutes (escalating). Timing-safe password compare above. In-memory —
|
|
// a restart clears it, which is fine (attacker loses their progress too).
|
|
const loginHits = new Map();
|
|
const LOGIN_MAX = 8, LOGIN_LOCK_MS = 15 * 60 * 1000;
|
|
function loginGate(ip) {
|
|
const r = loginHits.get(ip);
|
|
if (r && r.until > Date.now()) return { locked: true, mins: Math.ceil((r.until - Date.now()) / 60000) };
|
|
return { locked: false };
|
|
}
|
|
function loginFail(ip) {
|
|
const now = Date.now();
|
|
let r = loginHits.get(ip);
|
|
if (!r || (r.until && r.until < now && r.count >= LOGIN_MAX)) r = { count: 0, until: 0 };
|
|
r.count++;
|
|
if (r.count >= LOGIN_MAX) { r.until = now + LOGIN_LOCK_MS * Math.min(8, r.count - LOGIN_MAX + 1); loginHits.set(ip, r); return { locked: true, mins: Math.ceil((r.until - now) / 60000) }; }
|
|
loginHits.set(ip, r);
|
|
return { locked: false, left: LOGIN_MAX - r.count };
|
|
}
|
|
function loginReset(ip) { loginHits.delete(ip); }
|
|
const submitHits = new Map();
|
|
function submitRateLimited(ip) {
|
|
const now = Date.now(), rec = submitHits.get(ip);
|
|
if (!rec || now > rec.reset) { submitHits.set(ip, { count: 1, reset: now + 600000 }); return false; }
|
|
rec.count++; return rec.count > 5;
|
|
}
|
|
// 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.
|
|
function sendTelegram(text, topicId, replyMarkup) {
|
|
const c = getConfig();
|
|
if (!c.telegramBotToken || !c.telegramChatId) return;
|
|
const payload = { chat_id: c.telegramChatId, text };
|
|
const thread = topicId != null ? topicId : c.telegramTopicId;
|
|
if (thread && /^[0-9]+$/.test(String(thread))) payload.message_thread_id = Number(thread);
|
|
if (replyMarkup) payload.reply_markup = replyMarkup;
|
|
fetch(`https://api.telegram.org/bot${c.telegramBotToken}/sendMessage`, {
|
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(payload)
|
|
}).then(async r=>{ if(!r.ok) console.error('telegram sendMessage status', r.status, (await r.text().catch(()=>'')).slice(0,200)); })
|
|
.catch(e=>console.error('telegram error', e.message));
|
|
}
|
|
// Recruiting-framed version of a team event for the new-members topic: social
|
|
// proof + a call to action to the home page, no internal/contact detail.
|
|
function recruitMsg(evt) {
|
|
const home = 'rmcircle.team', tags = '#RMCircle #Polygon #Crypto';
|
|
if (evt.type === 'payout') {
|
|
const line = evt.kind === 'upline'
|
|
? `Member #${evt.toId} just earned ${evt.pol.toFixed(2)} POL${evt.gen ? ` — a Gen ${evt.gen} upgrade pass-up` : ''}, paid automatically as their team grew beneath them.`
|
|
: `Member #${evt.toId} just earned ${evt.pol.toFixed(2)} POL the moment a new teammate joined on their link.`;
|
|
return `💸 Another on-chain payout just landed! 🎉\n\n${line}\n\nInstant, automatic, and verifiable on the blockchain — no company holding the money. This is what building on The RM Circle looks like. 🚀\n\n👉 Start yours at ${home}\n\n${tags}`;
|
|
}
|
|
if (evt.type === 'registered') {
|
|
return `🔥 The team just grew!\n\nA new member joined The RM Circle and locked in their position on-chain. The momentum is real.\n\n👉 Claim your spot at ${home} 🚀\n\n${tags}`;
|
|
}
|
|
if (evt.type === 'upgraded') {
|
|
return `⚡ Level up!\n\nMember #${evt.id} just upgraded to ${evt.levelName} on The RM Circle — climbing the ranks and opening up bigger pass-ups.\n\n👉 Start building at ${home} 🚀\n\n${tags}`;
|
|
}
|
|
return '';
|
|
}
|
|
const SENDGRID_KEY_FILE = path.join(DATA_DIR, 'sendgrid.key');
|
|
function getSendgridKey() {
|
|
if (process.env.SENDGRID_API_KEY) return process.env.SENDGRID_API_KEY;
|
|
try { return fs.readFileSync(SENDGRID_KEY_FILE, 'utf8').trim(); } catch (e) { return ''; }
|
|
}
|
|
// SendGrid is domain-authenticated for marketingwithmarty.com and
|
|
// mybrandedvoice.com — the from address must stay on one of those or DKIM fails.
|
|
function emailFrom() { return getConfig().emailFrom || 'The RM Circle Team <no-reply@marketingwithmarty.com>'; }
|
|
function sendEmailRaw(toEmail, subject, text) {
|
|
const key = getSendgridKey();
|
|
if (!key || !toEmail) return;
|
|
const fromStr = emailFrom();
|
|
const m = fromStr.match(/^(.*)<([^>]+)>\s*$/);
|
|
const from = m ? { email: m[2].trim(), name: m[1].trim() || undefined } : { email: fromStr.trim() };
|
|
fetch('https://api.sendgrid.com/v3/mail/send', {
|
|
method: 'POST',
|
|
headers: { Authorization: `Bearer ${key}`, 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
personalizations: [{ to: [{ email: toEmail }] }],
|
|
from, subject,
|
|
content: [{ type: 'text/plain', value: text }]
|
|
})
|
|
}).then(r => { if (r.status >= 300) r.text().then(t => console.error('sendgrid status', r.status, t.slice(0, 200))); })
|
|
.catch(e => console.error('sendgrid error', e.message));
|
|
}
|
|
function sendPaidEmail(toEmail, memberName, evt, unsub) {
|
|
const kindLine = evt.kind === 'upline' ? `an upgrade pass-up from member #${evt.fromId}` : `a referral reward from member #${evt.fromId}'s entry`;
|
|
const verify = evt.tx ? `\n\nVerify it yourself on the blockchain:\nhttps://polygonscan.com/tx/${evt.tx}` : '';
|
|
const foot = unsub ? `\n\nStop these alerts: ${unsub}` : `\n\nYou're receiving this because your team admin has this address on file for team-build updates. Reply to this email to be removed.`;
|
|
const text = `Hi ${memberName || 'there'},\n\nGood news — your RM Circle position #${evt.toId} just received ${evt.pol.toFixed(2)} POL (${kindLine}).${verify}\n\nKeep the momentum going: check your level so the next payment in your leg doesn't pass you by.\nhttps://rmcircle.team/my/${evt.toId}\n\n— The RM Circle Team${foot}`;
|
|
sendEmailRaw(toEmail, `Your RM Circle position #${evt.toId} just got paid ${evt.pol.toFixed(2)} POL`, text);
|
|
}
|
|
function firePostback(clickid, txid, source) {
|
|
const pb = getConfig().bemobPostbackUrl;
|
|
if (!clickid || !pb || !/^https:\/\/[a-z0-9.-]+\/postback/i.test(pb)) return;
|
|
fetch(`${pb}${pb.includes('?')?'&':'?'}cid=${encodeURIComponent(clickid)}&payout=0&txid=${encodeURIComponent(txid)}`)
|
|
.then(r=>{ if(r.ok) recordEvent('postback', source); else console.error('bemob postback status', r.status); })
|
|
.catch(e=>console.error('bemob postback error', e.message));
|
|
}
|
|
async function handleSubmitId(req, res) {
|
|
const ip = String(req.headers['x-forwarded-for']||req.socket.remoteAddress||'').split(',')[0].trim();
|
|
if (submitRateLimited(ip)) return json(res, 429, { error: 'Too many submissions — please wait a few minutes.' });
|
|
const b = await bodyJson(req).catch(()=>null);
|
|
if (!b) return json(res, 400, { error: 'Invalid request.' });
|
|
const newId = String(b.newId||'').trim();
|
|
if (!/^[0-9]{1,10}$/.test(newId)) return json(res, 400, { error: 'Enter your numeric RM Circle ID (numbers only).' });
|
|
const memberName = String(b.memberName||'').replace(/[\u0000-\u001f\u007f]/g,'').trim().slice(0, 60);
|
|
if (!memberName) return json(res, 400, { error: 'Add your name or Telegram handle so the team can reach you.' });
|
|
const sponsorId = String(b.sponsorId||'').trim().slice(0, 20).replace(/[^0-9A-Za-z._-]/g,'') || '?';
|
|
const source = typeof b.source==='string' ? b.source : '';
|
|
const clickid = typeof b.clickid==='string' ? b.clickid.trim().slice(0,80).replace(/[^A-Za-z0-9._-]/g,'') : '';
|
|
let subs = []; try { subs = readJson(SUBMISSIONS_FILE); } catch(e) {}
|
|
if (subs.some(s=>s.newId===newId)) return json(res, 200, { ok: true, duplicate: true });
|
|
// on-chain verification: does this ID actually exist on the contract?
|
|
let onchain = null;
|
|
try {
|
|
onchain = await Promise.race([
|
|
chain.verifyMember(Number(newId)),
|
|
new Promise((_, rej) => setTimeout(() => rej(new Error('timeout')), 6000))
|
|
]);
|
|
} catch (e) { onchain = null; }
|
|
// the chain decides the path: a rotation join is one whose on-chain referrer is
|
|
// a rotation-queue sponsor that IS or HAS BEEN worked (status active or
|
|
// qualified) — not just the currently-active one. With auto-advance, the direct
|
|
// who completes a sponsor's 2/2 (and thus joined under it) submits their ID
|
|
// AFTER the rotation has already moved on, so keying off "active only" wrongly
|
|
// labeled them a leg join. A referrer that's a still-waiting queue position, or
|
|
// not in the queue at all, is a personal leg join.
|
|
const sponsorsNow = getSponsors();
|
|
const active = activeSponsor(sponsorsNow);
|
|
let joinPath = 'unknown';
|
|
if (onchain && onchain.registered) {
|
|
const refSp = sponsorsNow.find(s => String(s.id) === String(onchain.referrerId));
|
|
joinPath = (refSp && (refSp.status === 'active' || refSp.status === 'qualified')) ? 'rotation' : 'leg';
|
|
} else if (onchain && !onchain.registered) joinPath = 'notfound';
|
|
subs.push({ newId, memberName, sponsorId, source: source||'(direct)', clickid, ts: new Date().toISOString(), path: joinPath,
|
|
onchain: onchain ? { registered: onchain.registered, tier: onchain.tierName, level: onchain.levelName, referrerId: onchain.referrerId, uplineId: onchain.uplineId } : undefined });
|
|
writeJson(SUBMISSIONS_FILE, subs.slice(-1000));
|
|
recordEvent('purchase', source);
|
|
firePostback(clickid, `purchase-${clickid}`, source);
|
|
let msg;
|
|
if (joinPath === 'rotation') {
|
|
// rotation joins go straight into the queue as waiting positions — no manual step
|
|
let queueNote = '';
|
|
try {
|
|
let sponsors = getSponsors();
|
|
if (sponsors.some(s => String(s.id) === String(newId))) {
|
|
queueNote = 'Already in the rotation queue.';
|
|
} else {
|
|
const maxOrder = sponsors.reduce((m, s) => Math.max(m, s.sortOrder || 0), 0);
|
|
sponsors.push({ id: String(newId), name: memberName, parentId: String(onchain.referrerId), directs: 0, level: onchain.levelName || 'Scintilla', status: sponsors.some(s => s.status === 'active') ? 'waiting' : 'active', sortOrder: maxOrder + 10, clicks: 0, notes: `auto-added: rotation join under #${onchain.referrerId} ${new Date().toISOString().slice(0, 10)}` });
|
|
sponsors = normalizeStatuses(sponsors);
|
|
saveSponsors(sponsors);
|
|
const waitingAhead = sponsors.filter(s => s.status === 'waiting' && (s.sortOrder || 0) < maxOrder + 10).length;
|
|
queueNote = `Auto-added to the rotation queue (${waitingAhead} waiting ahead of them).`;
|
|
}
|
|
} catch (e) { queueNote = `⚠ Auto-add to queue failed (${e.message}) — add manually.`; console.error('queue auto-add', e.message); }
|
|
msg = `🔔 RM Circle: ROTATION JOIN CONFIRMED ✅\nName: ${memberName}\nNew ID: ${newId} (${onchain.tierName}, verified on-chain)\nJoined under rotation sponsor: #${onchain.referrerId}\nSource: ${source||'(direct)'}\n✅ ${queueNote}\n(Sponsor #${onchain.referrerId}'s direct count syncs from the chain automatically.)`;
|
|
} else if (joinPath === 'leg') {
|
|
msg = `🌱 RM Circle: TEAM-BUILD JOIN (not rotation)\nName: ${memberName}\nNew ID: ${newId} (${onchain.tierName}, verified on-chain)\nActual sponsor on-chain: #${onchain.referrerId}${sponsorId!=='?'&&String(onchain.referrerId)!==sponsorId?` (form said ${sponsorId})`:''}\nSource: ${source||'(direct)'}\n→ Leg growth under #${onchain.referrerId} — no rotation action needed. Add them to the rotation queue only if they want the team effort.`;
|
|
} else if (joinPath === 'notfound') {
|
|
msg = `🔔 RM Circle: ID SUBMITTED — ❌ NOT FOUND ON-CHAIN\nName: ${memberName}\nNew ID: ${newId}\nClaimed sponsor: ${sponsorId}\nSource: ${source||'(direct)'}\n→ ID has no registration on the contract — double-check with them before any queue action.`;
|
|
} else {
|
|
msg = `🔔 RM Circle: NEW MEMBER SUBMITTED\nName: ${memberName}\nNew ID: ${newId}\nClaimed sponsor: ${sponsorId}\nSource: ${source||'(direct)'}\n⏳ On-chain check unavailable — verify manually in admin (member lookup).`;
|
|
}
|
|
sendTelegram(msg);
|
|
return json(res, 200, { ok: true, path: joinPath, onchain: onchain ? { registered: onchain.registered, tier: onchain.tierName, level: onchain.levelName, referrerId: onchain.referrerId } : null });
|
|
}
|
|
async function handleChat(req, res) {
|
|
const ip = String(req.headers['x-forwarded-for']||req.socket.remoteAddress||'').split(',')[0].trim();
|
|
if (chatRateLimited(ip)) return json(res, 429, { error: 'Too many messages — give it a minute.' });
|
|
const apiKey = getOpenRouterKey();
|
|
if (!apiKey) return json(res, 200, { fallback: true });
|
|
const b = await bodyJson(req).catch(()=>null);
|
|
if (!b || !Array.isArray(b.messages)) return json(res, 400, { error: 'Invalid request' });
|
|
const msgs = b.messages.slice(-8)
|
|
.filter(m=>m&&(m.role==='user'||m.role==='assistant')&&typeof m.content==='string')
|
|
.map(m=>({ role: m.role, content: m.content.slice(0, 500) }));
|
|
if (!msgs.length || msgs[msgs.length-1].role !== 'user') return json(res, 400, { error: 'Invalid request' });
|
|
try {
|
|
const ctrl = new AbortController(); const timer = setTimeout(()=>ctrl.abort(), 20000);
|
|
const r = await fetch('https://openrouter.ai/api/v1/chat/completions', {
|
|
method: 'POST', signal: ctrl.signal,
|
|
headers: { 'Authorization': `Bearer ${apiKey}`, 'Content-Type': 'application/json', 'HTTP-Referer': 'https://rmcircle.team', 'X-Title': 'RM Circle Team Help' },
|
|
body: JSON.stringify({ model: OPENROUTER_MODEL, max_tokens: 350, temperature: 0.3, messages: [{ role: 'system', content: chatSystemPrompt() }, ...msgs] })
|
|
});
|
|
clearTimeout(timer);
|
|
if (!r.ok) { console.error('openrouter status', r.status); return json(res, 200, { fallback: true }); }
|
|
const d = await r.json();
|
|
const reply = d && d.choices && d.choices[0] && d.choices[0].message && d.choices[0].message.content;
|
|
if (!reply) return json(res, 200, { fallback: true });
|
|
return json(res, 200, { reply: String(reply).trim().slice(0, 2000) });
|
|
} catch (e) { console.error('openrouter error', e.message); return json(res, 200, { fallback: true }); }
|
|
}
|
|
// Sessions persist in the data volume so redeploys stop logging the admin out.
|
|
const SESSIONS_FILE = path.join(DATA_DIR, 'sessions.json');
|
|
const sessions = new Map();
|
|
try {
|
|
const saved = JSON.parse(fs.readFileSync(SESSIONS_FILE, 'utf8'));
|
|
const now = Date.now();
|
|
for (const [t, s] of Object.entries(saved)) if (s && s.expires > now) sessions.set(t, s);
|
|
} catch (e) {}
|
|
function saveSessions() {
|
|
try {
|
|
const now = Date.now();
|
|
for (const [t, s] of sessions) if (s.expires <= now) sessions.delete(t);
|
|
const tmp = SESSIONS_FILE + '.tmp';
|
|
fs.writeFileSync(tmp, JSON.stringify(Object.fromEntries(sessions)), { mode: 0o600 });
|
|
fs.renameSync(tmp, SESSIONS_FILE);
|
|
} catch (e) { console.error('session save failed', e.message); }
|
|
}
|
|
|
|
function ensureDataFile(name) {
|
|
fs.mkdirSync(DATA_DIR, { recursive: true });
|
|
const target = path.join(DATA_DIR, name);
|
|
if (!fs.existsSync(target)) fs.copyFileSync(path.join(SEED_DIR, name), target);
|
|
}
|
|
ensureDataFile('sponsors.json');
|
|
ensureDataFile('config.json');
|
|
const ANALYTICS_FILE = path.join(DATA_DIR, 'analytics.json');
|
|
if (!fs.existsSync(ANALYTICS_FILE)) fs.writeFileSync(ANALYTICS_FILE, JSON.stringify({ sources: {} }, null, 2));
|
|
|
|
function readJson(file) { return JSON.parse(fs.readFileSync(file, 'utf8')); }
|
|
function writeJson(file, data) {
|
|
const temp = `${file}.${crypto.randomUUID()}.tmp`;
|
|
fs.writeFileSync(temp, JSON.stringify(data, null, 2));
|
|
fs.renameSync(temp, file);
|
|
}
|
|
function getSponsors() { return readJson(SPONSORS_FILE).sort((a,b)=>(a.sortOrder||0)-(b.sortOrder||0)); }
|
|
function saveSponsors(s) { writeJson(SPONSORS_FILE, s); }
|
|
function getConfig() { return readJson(CONFIG_FILE); }
|
|
function activeSponsor(sponsors) { return sponsors.find(s=>s.status==='active') || sponsors.find(s=>s.status==='waiting') || null; }
|
|
function getAnalytics() { try { return readJson(ANALYTICS_FILE); } catch (e) { return { sources: {} }; } }
|
|
function recordEvent(event, source) {
|
|
if (!['bridge','start','click','training','postback','purchase','join','joinnow'].includes(event)) return;
|
|
const s = String(source||'').toLowerCase().trim().replace(/[^a-z0-9.()\-_:/ ]/g,'').slice(0,80) || '(direct)';
|
|
const a = getAnalytics(); if (!a.sources) a.sources = {};
|
|
if (!a.sources[s]) { if (Object.keys(a.sources).length >= 500) return; a.sources[s] = { bridge:0, start:0, click:0 }; }
|
|
a.sources[s][event] = (a.sources[s][event]||0) + 1;
|
|
writeJson(ANALYTICS_FILE, a);
|
|
}
|
|
function normalizeStatuses(sponsors, preferredActiveId=null) {
|
|
// Chain reconciliation: queue members can qualify through leg activity long
|
|
// BEFORE their rotation turn, and the event-driven auto-counter never sees
|
|
// it (bit us twice on 2026-08-19: #41, then #46 activated while already
|
|
// 2/2 on-chain). Never activate someone the chain says is qualified.
|
|
sponsors=sponsors.map(s=>{
|
|
if(s.status==='qualified')return s;
|
|
const dc=chain.liveDirects(Number(s.id));
|
|
if(dc!=null&&dc>=2)return {...s,directs:2,status:'qualified'};
|
|
if(dc!=null&&dc>(Number(s.directs)||0))return {...s,directs:dc};
|
|
return s;
|
|
});
|
|
const eligible=sponsors.filter(s=>s.status!=='qualified');
|
|
let activeId=preferredActiveId;
|
|
if(!activeId || !eligible.some(s=>s.id===activeId)){
|
|
const existing=eligible.find(s=>s.status==='active');
|
|
activeId=existing?existing.id:(eligible[0]?.id||null);
|
|
}
|
|
return sponsors.map(s=>s.status==='qualified'?s:{...s,status:s.id===activeId?'active':'waiting'});
|
|
}
|
|
function publicSponsorPayload(sponsor, config) {
|
|
if(!sponsor)return null;
|
|
return {id:sponsor.id,name:config.showSponsorName?sponsor.name:null,directs:sponsor.directs,goal:2,level:sponsor.level,referralUrl:`${config.dappReferralBaseUrl}${encodeURIComponent(sponsor.id)}`};
|
|
}
|
|
const CSP_BASE="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; font-src 'self' data:; form-action 'self'; frame-src https://www.youtube-nocookie.com";
|
|
function securityHeaders(extra={}) {
|
|
// Public pages must render inside safelist / traffic-exchange iframes, so framing stays open here; admin.html re-locks it via ADMIN_FRAME_HEADERS.
|
|
return {
|
|
'X-Content-Type-Options':'nosniff','Referrer-Policy':'strict-origin-when-cross-origin',
|
|
'Permissions-Policy':'camera=(), microphone=(), geolocation=()',
|
|
'Content-Security-Policy':`${CSP_BASE}; frame-ancestors *`,
|
|
...extra
|
|
};
|
|
}
|
|
const ADMIN_FRAME_HEADERS={'X-Frame-Options':'DENY','Content-Security-Policy':`${CSP_BASE}; frame-ancestors 'none'`};
|
|
function send(res,status,body,headers={}) { res.writeHead(status,securityHeaders(headers));res.end(body); }
|
|
function json(res,status,obj,headers={}) { send(res,status,JSON.stringify(obj),{'Content-Type':'application/json; charset=utf-8',...headers}); }
|
|
function parseCookies(req){const out={};for(const p of (req.headers.cookie||'').split(';')){const i=p.indexOf('=');if(i>0)out[p.slice(0,i).trim()]=decodeURIComponent(p.slice(i+1).trim())}return out}
|
|
function getSession(req){const token=parseCookies(req)['ctb.sid'];if(!token)return null;const s=sessions.get(token);if(!s)return null;if(s.expires<Date.now()){sessions.delete(token);return null}return {token,...s}}
|
|
function requireAdmin(req,res){if(!getSession(req)){json(res,401,{error:'Unauthorized'});return false}return true}
|
|
async function bodyJson(req){return await new Promise((resolve,reject)=>{let data='';req.on('data',c=>{data+=c;if(data.length>100000){reject(new Error('Payload too large'));req.destroy()}});req.on('end',()=>{if(!data)return resolve({});try{resolve(JSON.parse(data))}catch(e){reject(new Error('Invalid JSON'))}});req.on('error',reject)})}
|
|
function contentType(file){const ext=path.extname(file);return ({'.html':'text/html; charset=utf-8','.css':'text/css; charset=utf-8','.js':'application/javascript; charset=utf-8','.json':'application/json; charset=utf-8','.png':'image/png','.jpg':'image/jpeg','.jpeg':'image/jpeg','.webp':'image/webp','.svg':'image/svg+xml','.ico':'image/x-icon','.mp4':'video/mp4','.webm':'video/webm'}[ext]||'application/octet-stream')}
|
|
function staticFile(req,res,file,status=200){
|
|
if(!fs.existsSync(file)||!fs.statSync(file).isFile())return false;
|
|
const size=fs.statSync(file).size;
|
|
const base={'Content-Type':contentType(file),'Accept-Ranges':'bytes','Cache-Control':['.html','.css','.js'].includes(path.extname(file))?'no-cache':'public, max-age=3600',...(path.basename(file)==='admin.html'?ADMIN_FRAME_HEADERS:{})};
|
|
const m=status===200&&req.headers.range?String(req.headers.range).match(/^bytes=(\d*)-(\d*)$/):null;
|
|
if(m&&(m[1]!==''||m[2]!=='')){
|
|
const start=m[1]===''?Math.max(0,size-Number(m[2])):Number(m[1]);
|
|
const end=(m[1]!==''&&m[2]!=='')?Math.min(Number(m[2]),size-1):size-1;
|
|
if(start>end||start>=size){res.writeHead(416,securityHeaders({'Content-Range':`bytes */${size}`}));res.end();return true}
|
|
res.writeHead(206,securityHeaders({...base,'Content-Range':`bytes ${start}-${end}/${size}`,'Content-Length':end-start+1}));
|
|
if(req.method==='HEAD')res.end();else fs.createReadStream(file,{start,end}).pipe(res);
|
|
return true;
|
|
}
|
|
res.writeHead(status,securityHeaders({...base,'Content-Length':size}));
|
|
if(req.method==='HEAD')res.end();else fs.createReadStream(file).pipe(res);
|
|
return true;
|
|
}
|
|
|
|
async function handleApi(req,res,pathname){
|
|
if(req.method==='GET'&&pathname==='/health') return json(res,200,{ok:true});
|
|
if(req.method==='GET'&&pathname==='/api/public/config'){
|
|
const c=getConfig();return json(res,200,{siteName:c.siteName,programName:c.programName,bridgeHeadline:c.bridgeHeadline,bridgeSubheadline:c.bridgeSubheadline,premiumEntryPol:c.premiumEntryPol,telegramUrl:c.telegramUrl,supportLabel:c.supportLabel,showQueueProgress:c.showQueueProgress});
|
|
}
|
|
if(req.method==='GET'&&pathname==='/api/public/member'){
|
|
const ip=String(req.headers['x-forwarded-for']||req.socket.remoteAddress||'').split(',')[0].trim();
|
|
if(memberLookupLimited(ip))return json(res,429,{error:'Too many lookups — give it a minute.'});
|
|
const q0=new URL(req.url,'http://x').searchParams;
|
|
const id=Number(q0.get('id')||0);
|
|
if(!Number.isInteger(id)||id<1||id>281474976710655)return json(res,400,{error:'Enter a numeric member ID.'});
|
|
const cached=memberCache.get(id);
|
|
// fresh=1: the caller just completed their own transaction — bypass the
|
|
// cache once so the dashboard reflects it immediately
|
|
if(q0.get('fresh')!=='1'&&cached&&Date.now()-cached.ts<120000)return json(res,200,cached.data,{'Cache-Control':'public, max-age=15'});
|
|
try{
|
|
const r=await Promise.race([chain.memberPublic(id),new Promise((_,rej)=>setTimeout(()=>rej(new Error('Blockchain lookup timed out — try again.')),20000))]);
|
|
if(r.registered)r.referralUrl=`${getConfig().dappReferralBaseUrl}${encodeURIComponent(id)}`;
|
|
// align next-in-line with the human-curated rotation: prefer the ACTIVE
|
|
// rotation sponsor when they sit in this member's leg and need directs;
|
|
// else the first chain-order position that's a queue participant; else
|
|
// keep the chain's pure structural pick (covers legs outside the queue).
|
|
if(r.registered&&r.subtree){
|
|
try{
|
|
const sponsors=getSponsors();
|
|
const act=activeSponsor(sponsors);
|
|
const participants=new Set(sponsors.filter(s=>s.status!=='qualified').map(s=>String(s.id)));
|
|
const bfs=[];const q=[r.subtree.left,r.subtree.right].filter(Boolean);
|
|
while(q.length){const n=q.shift();bfs.push(n);if(n.left)q.push(n.left);if(n.right)q.push(n.right);}
|
|
let pick=null;
|
|
if(act)pick=bfs.find(n=>String(n.id)===String(act.id)&&(n.directCount||0)<2);
|
|
if(!pick)pick=bfs.find(n=>participants.has(String(n.id))&&(n.directCount||0)<2);
|
|
if(pick)r.nextInLine={id:pick.id,directCount:pick.directCount||0,levelName:pick.levelName};
|
|
}catch(e){}
|
|
}
|
|
if(r.nextInLine)r.nextInLine.referralUrl=`${getConfig().dappReferralBaseUrl}${encodeURIComponent(r.nextInLine.id)}`;
|
|
// Moving-link routing: where a join through THIS member's personal link
|
|
// actually lands. Not-yet-qualified -> their own position (still filling
|
|
// their 2). Qualified -> the next-to-qualify in their leg (nextInLine,
|
|
// already aligned to the curated rotation above), so their promotion
|
|
// builds the team down in order instead of spilling onto themselves.
|
|
// Whole leg qualified -> the global rotation sponsor. Last resort -> their
|
|
// own position (spillover, the prior behavior).
|
|
if(r.registered){
|
|
const base=getConfig().dappReferralBaseUrl;
|
|
if((r.directCount||0)<2){
|
|
r.joinTarget={id,referralUrl:r.referralUrl,reason:'self'};
|
|
}else if(r.nextInLine){
|
|
r.joinTarget={id:r.nextInLine.id,referralUrl:r.nextInLine.referralUrl,invitedBy:id,reason:'leg',directCount:r.nextInLine.directCount};
|
|
}else{
|
|
let g=null;try{g=activeSponsor(getSponsors());}catch(e){}
|
|
if(g&&String(g.id)!==String(id)&&(g.directs||0)<2){
|
|
r.joinTarget={id:g.id,referralUrl:`${base}${encodeURIComponent(g.id)}`,invitedBy:id,reason:'global',directCount:g.directs};
|
|
}else{
|
|
r.joinTarget={id,referralUrl:r.referralUrl,reason:'spillover'};
|
|
}
|
|
}
|
|
}
|
|
// Next-step plan + funded badge. Wallet balance is checked server-side and
|
|
// ONLY the boolean (covers next upgrade?) is exposed — never the raw amount.
|
|
if(r.registered){
|
|
const lvl=r.level||1, tier=r.tier===2?2:1, qualified=(r.directCount||0)>=2;
|
|
if(!qualified){
|
|
r.nextStep={kind:'qualify',need:2-(r.directCount||0)};
|
|
}else if(lvl<8){
|
|
const cost=(r.upgradeCosts&&r.upgradeCosts[tier]&&r.upgradeCosts[tier][lvl-1])||0;
|
|
let funded=null;
|
|
try{ if(r.account&&cost){ const bal=await chain.balanceOf(r.account); if(bal!=null) funded=bal>=cost; } }catch(e){}
|
|
r.nextStep={kind:'upgrade',nextLevel:lvl+1,cost,funded};
|
|
}else{
|
|
r.nextStep={kind:'max'};
|
|
}
|
|
}
|
|
memberCache.set(id,{data:r,ts:Date.now()});
|
|
if(memberCache.size>500)memberCache.delete(memberCache.keys().next().value);
|
|
return json(res,200,r,{'Cache-Control':'public, max-age=60'});
|
|
}catch(e){return json(res,502,{error:e.message||'Lookup failed'})}
|
|
}
|
|
if(req.method==='GET'&&pathname==='/api/public/payouts'){
|
|
const q=new URL(req.url,'http://x').searchParams;
|
|
const offset=Number(q.get('offset')||0);
|
|
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/public/org-stats'){
|
|
const root=Number(getConfig().orgRootId||21);
|
|
const d=chain.getOrgShare(Number.isInteger(root)&&root>0?root:21);
|
|
// expose only the aggregate showcase numbers (all public on-chain data)
|
|
return json(res,200,{ready:d.ready,found:!!d.found,memberPct:d.memberPct,orgMembers:d.orgMembers,generations:d.generations,orgPol:d.orgPol,totalMembers:d.totalMembers},{'Cache-Control':'public, max-age=60'});
|
|
}
|
|
if(req.method==='GET'&&pathname==='/api/public/alert-status'){
|
|
const id=Number(new URL(req.url,'http://x').searchParams.get('id')||0);
|
|
const rec=getMemberAlerts()[id];
|
|
return json(res,200,{subscribed:!!(rec&&rec.email),email:rec&&rec.email?maskEmail(rec.email):null});
|
|
}
|
|
if(req.method==='POST'&&pathname==='/api/public/alert-signup'){
|
|
const ip=String(req.headers['x-forwarded-for']||req.socket.remoteAddress||'').split(',')[0].trim();
|
|
if(submitRateLimited(ip))return json(res,429,{error:'Too many requests — wait a few minutes.'});
|
|
const b=await bodyJson(req).catch(()=>null); if(!b)return json(res,400,{error:'Invalid request.'});
|
|
const id=Number(b.id); if(!Number.isInteger(id)||id<1||id>281474976710655)return json(res,400,{error:'Enter your numeric member ID.'});
|
|
const email=String(b.email||'').trim();
|
|
const ma=getMemberAlerts();
|
|
if(!email){ if(ma[id]){delete ma[id];saveMemberAlerts(ma);} return json(res,200,{ok:true,subscribed:false}); }
|
|
if(!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)||email.length>120)return json(res,400,{error:'Enter a valid email address.'});
|
|
let onchain=null; try{ onchain=await Promise.race([chain.verifyMember(id),new Promise((_,rej)=>setTimeout(()=>rej(new Error('t')),6000))]); }catch(e){ onchain=null; }
|
|
if(onchain&&!onchain.registered)return json(res,400,{error:`ID ${id} isn't registered on the smart contract — double-check the number.`});
|
|
ma[id]={email:email.slice(0,120),ts:new Date().toISOString()};
|
|
saveMemberAlerts(ma);
|
|
sendEmailRaw(email,`Alerts on for RM Circle position #${id}`,`You're now subscribed to alerts for RM Circle position #${id}.\n\nYou'll get an email when this position is paid, and when it needs an upgrade to catch incoming pay.\n\nSee your position anytime: https://rmcircle.team/my/${id}\nStop these alerts: ${unsubUrl(id)}\n\n— The RM Circle Team`);
|
|
return json(res,200,{ok:true,subscribed:true,email:maskEmail(email)});
|
|
}
|
|
if(req.method==='GET'&&pathname==='/api/public/moonpay-url'){
|
|
// Card on-ramp deep link. With MoonPay partner keys configured (admin
|
|
// config), returns a SIGNED checkout URL prefilled with the member's own
|
|
// wallet address + POL amount; otherwise a generic MoonPay buy page.
|
|
// Zero custody either way — MoonPay is merchant of record, crypto goes
|
|
// straight to the member's wallet.
|
|
const q=new URL(req.url,'http://x').searchParams;
|
|
const addr=(q.get('address')||'').trim();
|
|
let pol=Math.round(Number(q.get('pol'))||0); if(!pol||pol<62)pol=Math.max(62,Math.ceil((Number(getConfig().premiumEntryPol)||362)*1.05)+5); if(pol>100000)pol=100000;
|
|
const c=getConfig(), pk=(c.moonpayPublicKey||'').trim(), sk=(c.moonpaySecretKey||'').trim();
|
|
if(pk&&sk&&/^0x[0-9a-fA-F]{40}$/.test(addr)){
|
|
const qs='?apiKey='+encodeURIComponent(pk)+'¤cyCode=pol_polygon&walletAddress='+encodeURIComponent(addr)+'"eCurrencyAmount='+pol;
|
|
const sig=crypto.createHmac('sha256',sk).update(qs).digest('base64');
|
|
return json(res,200,{url:'https://buy.moonpay.com/'+qs+'&signature='+encodeURIComponent(sig),signed:true,pol});
|
|
}
|
|
return json(res,200,{url:'https://www.moonpay.com/buy/matic',signed:false,pol});
|
|
}
|
|
// ---- wallet-verified member messaging ----
|
|
if(req.method==='POST'&&pathname==='/api/public/msg-challenge'){
|
|
const b=await bodyJson(req);
|
|
if(!b||typeof b.address!=='string'||!messages.ADDR_RE.test(b.address))return json(res,400,{error:'Invalid wallet address.'});
|
|
return json(res,200,{message:messages.makeChallenge(b.address)});
|
|
}
|
|
if(req.method==='POST'&&pathname==='/api/public/msg-verify'){
|
|
const b=await bodyJson(req);
|
|
if(!b||typeof b.address!=='string'||!messages.ADDR_RE.test(b.address)||typeof b.signature!=='string')return json(res,400,{error:'Invalid request.'});
|
|
const r=messages.verifyChallenge(b.address,b.signature);
|
|
if(r.error)return json(res,401,{error:r.error});
|
|
return json(res,200,{ok:true,id:r.id},{'Set-Cookie':messages.sessionCookie(r.token)});
|
|
}
|
|
if(req.method==='GET'&&pathname==='/api/public/msg-me'){
|
|
const s=messages.authFromCookie(req);
|
|
if(!s)return json(res,401,{error:'Not signed in.'});
|
|
return json(res,200,{id:s.id,unread:messages.unreadCount(s.id)});
|
|
}
|
|
if(req.method==='GET'&&pathname==='/api/public/msg-inbox'){
|
|
const s=messages.authFromCookie(req);
|
|
if(!s)return json(res,401,{error:'Not signed in.'});
|
|
return json(res,200,messages.inbox(s));
|
|
}
|
|
if(req.method==='POST'&&pathname==='/api/public/msg-send'){
|
|
const s=messages.authFromCookie(req);
|
|
if(!s)return json(res,401,{error:'Not signed in.'});
|
|
const b=await bodyJson(req);
|
|
const r=messages.send(s,b||{});
|
|
return json(res,r.error?400:200,r);
|
|
}
|
|
if(req.method==='POST'&&pathname==='/api/public/msg-read'){
|
|
const s=messages.authFromCookie(req);
|
|
if(!s)return json(res,401,{error:'Not signed in.'});
|
|
const b=await bodyJson(req);
|
|
return json(res,200,messages.markRead(s,Array.isArray(b&&b.mids)?b.mids.slice(0,100):[]));
|
|
}
|
|
if(req.method==='GET'&&pathname==='/api/public/msg-unread'){
|
|
const id=Number(new URL(req.url,'http://x').searchParams.get('id')||0);
|
|
return json(res,200,{count:messages.unreadCount(id)},{'Cache-Control':'no-store'});
|
|
}
|
|
if(req.method==='GET'&&pathname==='/api/public/current-sponsor'){
|
|
const c=getConfig();
|
|
// DORMANT until config.publicRotationMode='chain': company-wide rotation —
|
|
// the next open position under publicRotationRootId (default #2), read
|
|
// straight from the chain. Negotiated with the RM Circle founders
|
|
// 2026-08-19; do NOT enable until Marty says flip (his team is migrating
|
|
// links first, and his new right-leg position must be secured first).
|
|
if((c.publicRotationMode||'queue')==='chain'){
|
|
const root=Number(c.publicRotationRootId)||2;
|
|
const exclude=new Set(String(c.rotationExcludeIds||'').split(',').map(n=>Number(n.trim())).filter(n=>n>0));
|
|
const pick=chain.nextOpenPosition(root,exclude);
|
|
if(pick)return json(res,200,{sponsor:{id:String(pick.id),name:null,directs:pick.directCount,goal:2,level:pick.level,referralUrl:`${c.dappReferralBaseUrl}${encodeURIComponent(pick.id)}`},waitingCount:0,mode:'chain',message:'Company rotation — you join under the next open team position. It advances automatically as positions qualify.'});
|
|
}
|
|
const sponsors=getSponsors(),a=activeSponsor(sponsors);if(!a)return json(res,404,{error:'No active sponsor is currently assigned.'});
|
|
return json(res,200,{sponsor:publicSponsorPayload(a,c),waitingCount:sponsors.filter(s=>s.status==='waiting').length,message:'Always use the current sponsor shown on this page. Team placement rotates as members qualify.'});
|
|
}
|
|
if(req.method==='POST'&&pathname==='/api/public/join-click'){
|
|
const b=await bodyJson(req).catch(()=>({}));recordEvent('click',b.source);
|
|
const clickid=typeof b.clickid==='string'?b.clickid.trim().slice(0,80).replace(/[^A-Za-z0-9._-]/g,''):'';
|
|
firePostback(clickid,`join-${clickid}`,b.source);
|
|
let sponsors=getSponsors();const a=activeSponsor(sponsors);if(a){sponsors=sponsors.map(s=>s.id===a.id?{...s,clicks:(s.clicks||0)+1}:s);saveSponsors(sponsors)}return json(res,200,{ok:true});
|
|
}
|
|
if(req.method==='POST'&&pathname==='/api/public/chat')return await handleChat(req,res);
|
|
if(req.method==='POST'&&pathname==='/api/public/submit-id')return await handleSubmitId(req,res);
|
|
if(req.method==='POST'&&pathname==='/api/public/track'){
|
|
const b=await bodyJson(req).catch(()=>({}));recordEvent(b.event,b.source);return json(res,200,{ok:true});
|
|
}
|
|
if(req.method==='POST'&&pathname==='/api/admin/login'){
|
|
const ip=String(req.headers['x-forwarded-for']||req.socket.remoteAddress||'').split(',')[0].trim();
|
|
const gate=loginGate(ip);
|
|
if(gate.locked)return json(res,429,{error:`Too many attempts. Try again in ${gate.mins} minute${gate.mins===1?'':'s'}.`});
|
|
const b=await bodyJson(req).catch(e=>null);if(!b)return json(res,400,{error:'Invalid request'});
|
|
const ok=typeof b.password==='string'&&b.password.length===ADMIN_PASSWORD.length&&crypto.timingSafeEqual(Buffer.from(b.password),Buffer.from(ADMIN_PASSWORD));
|
|
if(!ok){const g=loginFail(ip);return json(res,401,{error:g.locked?`Too many attempts. Locked for ${g.mins} minutes.`:`Invalid password.${g.left<=3?` ${g.left} attempt${g.left===1?'':'s'} left before lockout.`:''}`});}
|
|
loginReset(ip);
|
|
const token=crypto.randomBytes(32).toString('hex');sessions.set(token,{expires:Date.now()+SESSION_TTL});saveSessions();const cookie=`ctb.sid=${encodeURIComponent(token)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${SESSION_TTL/1000}${IS_PROD?'; Secure':''}`;return json(res,200,{ok:true},{'Set-Cookie':cookie});
|
|
}
|
|
if(req.method==='POST'&&pathname==='/api/admin/logout'){
|
|
const s=getSession(req);if(s){sessions.delete(s.token);saveSessions();}return json(res,200,{ok:true},{'Set-Cookie':'ctb.sid=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0'});
|
|
}
|
|
if(pathname.startsWith('/api/admin/')&&!requireAdmin(req,res))return;
|
|
if(req.method==='GET'&&pathname==='/api/admin/matrix-tree'){
|
|
return json(res,200,chain.getMatrixTree());
|
|
}
|
|
if(req.method==='GET'&&pathname==='/api/admin/messages'){
|
|
return json(res,200,{messages:messages.adminList()});
|
|
}
|
|
if(req.method==='GET'&&pathname==='/api/admin/org-share'){
|
|
const raw=new URL(req.url,'http://x').searchParams.get('root');
|
|
const root=Number(raw||parseOwnerIds()[0]||21);
|
|
if(!Number.isInteger(root)||root<1||root>281474976710655)return json(res,400,{error:'Enter a numeric root ID.'});
|
|
return json(res,200,chain.getOrgShare(root));
|
|
}
|
|
if(req.method==='GET'&&pathname==='/api/admin/coaching'){
|
|
const raw=new URL(req.url,'http://x').searchParams.get('root');
|
|
const root=Number(raw||getConfig().orgRootId||parseOwnerIds()[0]||21);
|
|
if(!Number.isInteger(root)||root<1||root>281474976710655)return json(res,400,{error:'Enter a numeric root ID.'});
|
|
const d=chain.getCoachingScan(root,20);
|
|
if(d.ready){
|
|
// decorate with known names from the queue + submissions
|
|
const names={};
|
|
try{for(const s of getSponsors())names[String(s.id)]=s.name;}catch(e){}
|
|
try{for(const s of readJson(SUBMISSIONS_FILE))if(s.newId&&!names[String(s.newId)])names[String(s.newId)]=s.memberName;}catch(e){}
|
|
const ownedIds=new Set(parseOwnerIds());
|
|
for(const list of [d.atRisk,d.rollForward,d.oneAway])for(const r of list){if(names[String(r.id)])r.name=names[String(r.id)];if(ownedIds.has(Number(r.id)))r.owned=true;}
|
|
}
|
|
return json(res,200,d);
|
|
}
|
|
if(req.method==='GET'&&pathname==='/api/admin/income'){
|
|
const raw=new URL(req.url,'http://x').searchParams.get('ids')||'';
|
|
const ids=[...new Set(raw.split(',').map(s=>parseInt(String(s).trim(),10)).filter(n=>Number.isInteger(n)&&n>0&&n<=281474976710655))].slice(0,12);
|
|
if(!ids.length)return json(res,400,{error:'Enter one or more numeric IDs (comma-separated).'});
|
|
try{
|
|
const results=await Promise.race([
|
|
Promise.all(ids.map(id=>chain.getIncome(id).catch(()=>({registered:false,id})))),
|
|
new Promise((_,rej)=>setTimeout(()=>rej(new Error('Blockchain lookup timed out — try again.')),25000))
|
|
]);
|
|
const rows=[],perId={};let grand=0,grandListed=0;
|
|
for(const r of results){
|
|
if(!r.registered){perId[r.id]={registered:false};continue;}
|
|
perId[r.id]={registered:true,levelName:r.levelName,tierName:r.tierName,totalEarnedPol:r.totalEarnedPol,count:r.income.length};
|
|
grand+=r.totalEarnedPol;
|
|
for(const p of r.income){rows.push({toId:r.id,fromId:p.fromId,pol:p.pol,ts:p.ts,desc:p.desc});grandListed+=p.pol;}
|
|
}
|
|
rows.sort((a,b)=>(b.ts||0)-(a.ts||0));
|
|
let upgradeNeeds=[];try{upgradeNeeds=chain.getOwnerUpgradeNeeds(ids).needs;}catch(e){}
|
|
let routing=null;try{routing=chain.getOrgRouting(Number(getConfig().orgRootId)||21,ids);}catch(e){}
|
|
return json(res,200,{ids,perId,rows:rows.slice(0,500),grandEarnedPol:+grand.toFixed(2),grandListedPol:+grandListed.toFixed(2),upgradeNeeds,routing});
|
|
}catch(e){return json(res,502,{error:e.message||'Lookup failed'})}
|
|
}
|
|
if(req.method==='GET'&&pathname==='/api/admin/member-lookup'){
|
|
const id=Number(new URL(req.url,'http://x').searchParams.get('id')||0);
|
|
if(!Number.isInteger(id)||id<1||id>281474976710655)return json(res,400,{error:'Enter a numeric member ID.'});
|
|
try{
|
|
const r=await Promise.race([chain.memberLookup(id),new Promise((_,rej)=>setTimeout(()=>rej(new Error('Chain RPC timeout — try again.')),25000))]);
|
|
return json(res,200,r);
|
|
}catch(e){return json(res,502,{error:e.message||'Lookup failed'})}
|
|
}
|
|
if(req.method==='GET'&&pathname==='/api/admin/state'){let subs=[];try{subs=readJson(SUBMISSIONS_FILE).slice(-50).reverse()}catch(e){}return json(res,200,{sponsors:getSponsors(),config:getConfig(),analytics:getAnalytics(),submissions:subs,aiChat:{configured:!!getOpenRouterKey(),model:OPENROUTER_MODEL},email:{configured:!!getSendgridKey(),from:emailFrom()}});}
|
|
if(req.method==='POST'&&pathname==='/api/admin/sendgrid-key'){
|
|
const b=await bodyJson(req);const key=typeof b.key==='string'?b.key.trim():null;
|
|
if(key===null)return json(res,400,{error:'Invalid request.'});
|
|
if(key===''){try{fs.unlinkSync(SENDGRID_KEY_FILE)}catch(e){}return json(res,200,{configured:!!getSendgridKey()});}
|
|
if(!/^SG\./.test(key)||key.length<40||/\s/.test(key))return json(res,400,{error:'That does not look like a SendGrid API key (starts with SG.).'});
|
|
fs.writeFileSync(SENDGRID_KEY_FILE,key,{mode:0o600});
|
|
return json(res,200,{configured:true});
|
|
}
|
|
if(req.method==='POST'&&pathname==='/api/admin/openrouter-key'){
|
|
const b=await bodyJson(req);const key=typeof b.key==='string'?b.key.trim():null;
|
|
if(key===null)return json(res,400,{error:'Invalid request.'});
|
|
if(key===''){try{fs.unlinkSync(OPENROUTER_KEY_FILE)}catch(e){}return json(res,200,{configured:!!getOpenRouterKey()});}
|
|
if(key.length<20||/\s/.test(key))return json(res,400,{error:'That does not look like a valid API key.'});
|
|
fs.writeFileSync(OPENROUTER_KEY_FILE,key,{mode:0o600});
|
|
return json(res,200,{configured:true});
|
|
}
|
|
if(req.method==='POST'&&pathname==='/api/admin/sponsors'){
|
|
const b=await bodyJson(req);const {id,name,parentId='',level='Scintilla',notes='',email=''}=b;if(!id||!name)return json(res,400,{error:'ID and name are required.'});if(!LEVELS.includes(level))return json(res,400,{error:'Invalid level.'});if(email&&!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(String(email).trim()))return json(res,400,{error:'Invalid email address.'});let sponsors=getSponsors();if(sponsors.some(s=>String(s.id)===String(id)))return json(res,409,{error:'That sponsor ID already exists.'});
|
|
const maxOrder=sponsors.reduce((m,s)=>Math.max(m,s.sortOrder||0),0);sponsors.push({id:String(id).trim(),name:String(name).trim(),parentId:String(parentId||'').trim(),directs:0,level,status:sponsors.some(s=>s.status==='active')?'waiting':'active',sortOrder:maxOrder+10,clicks:0,notes:String(notes||'').trim(),email:String(email||'').trim().slice(0,120)});sponsors=normalizeStatuses(sponsors);saveSponsors(sponsors);return json(res,201,{sponsors});
|
|
}
|
|
if(req.method==='PATCH'&&pathname==='/api/admin/config'){
|
|
const b=await bodyJson(req),cur=getConfig(),next={...cur};for(const k of ['siteName','programName','bridgeHeadline','bridgeSubheadline','premiumEntryPol','dappReferralBaseUrl','telegramUrl','supportLabel','showSponsorName','showQueueProgress','bemobPostbackUrl','telegramBotToken','telegramChatId','telegramTopicId','telegramRecruitTopicId','teamRootId','emailFrom','teamAlertEmail','ownerIds','ownerAlertEmail','orgRootId','tweetEnabled','tweetCtaUrl','tweetHashtags','blotatoTwitterId','dappFallbackPublic','moonpayPublicKey','moonpaySecretKey','publicRotationMode','publicRotationRootId','rotationExcludeIds'])if(Object.prototype.hasOwnProperty.call(b,k))next[k]=b[k];next.premiumEntryPol=Number(next.premiumEntryPol)||362;next.updatedAt=new Date().toISOString();writeJson(CONFIG_FILE,next);return json(res,200,{config:next});
|
|
}
|
|
const m=pathname.match(/^\/api\/admin\/sponsors\/([^/]+)(?:\/(increment|activate|qualify|reset|move))?$/);
|
|
if(m){const id=decodeURIComponent(m[1]),action=m[2]||null;let sponsors=getSponsors(),idx=sponsors.findIndex(s=>s.id===id);if(idx<0)return json(res,404,{error:'Sponsor not found.'});
|
|
if(req.method==='PATCH'&&!action){const b=await bodyJson(req);if(Object.prototype.hasOwnProperty.call(b,'level')&&!LEVELS.includes(b.level))return json(res,400,{error:'Invalid level.'});if(Object.prototype.hasOwnProperty.call(b,'email')&&b.email&&!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(String(b.email).trim()))return json(res,400,{error:'Invalid email address.'});for(const k of ['name','parentId','directs','level','notes','email'])if(Object.prototype.hasOwnProperty.call(b,k))sponsors[idx][k]=k==='email'?String(b[k]||'').trim().slice(0,120):b[k];sponsors[idx].name=String(sponsors[idx].name||'').trim().slice(0,80)||sponsors[idx].name;sponsors[idx].directs=Math.max(0,Math.min(2,Number(sponsors[idx].directs)||0));saveSponsors(sponsors);return json(res,200,{sponsors});}
|
|
if(req.method==='DELETE'&&!action){const wasActive=sponsors[idx].status==='active';sponsors.splice(idx,1);if(wasActive)sponsors=normalizeStatuses(sponsors);saveSponsors(sponsors);return json(res,200,{sponsors});}
|
|
if(req.method==='POST'&&action==='increment'){sponsors[idx].directs=Math.min(2,(Number(sponsors[idx].directs)||0)+1);saveSponsors(sponsors);return json(res,200,{sponsors});}
|
|
if(req.method==='POST'&&action==='activate'){if(sponsors[idx].status==='qualified')return json(res,400,{error:'Qualified sponsors cannot be activated until reset.'});sponsors=normalizeStatuses(sponsors,id);saveSponsors(sponsors);return json(res,200,{sponsors});}
|
|
if(req.method==='POST'&&action==='qualify'){sponsors[idx]={...sponsors[idx],directs:2,status:'qualified'};sponsors=normalizeStatuses(sponsors);saveSponsors(sponsors);return json(res,200,{sponsors,active:activeSponsor(sponsors)});}
|
|
if(req.method==='POST'&&action==='reset'){sponsors[idx]={...sponsors[idx],directs:0,status:'waiting'};sponsors=normalizeStatuses(sponsors);saveSponsors(sponsors);return json(res,200,{sponsors});}
|
|
if(req.method==='POST'&&action==='move'){const b=await bodyJson(req);const swap=b.direction==='up'?idx-1:idx+1;if(swap>=0&&swap<sponsors.length){const t=sponsors[idx].sortOrder;sponsors[idx].sortOrder=sponsors[swap].sortOrder;sponsors[swap].sortOrder=t;saveSponsors(sponsors)}return json(res,200,{sponsors:getSponsors()});}
|
|
}
|
|
return json(res,404,{error:'API endpoint not found'});
|
|
}
|
|
|
|
// Serve /my/:id and /join/:id with personalized Open Graph / Twitter tags so a
|
|
// shared personal referral link previews nicely (social scrapers don't run JS,
|
|
// so the per-ID meta must be in the static HTML). Strips any baked-in og/twitter
|
|
// tags and injects a fresh, ID-specific set using the branded card image.
|
|
function serveMemberPage(req,res,file,kind,id){
|
|
let html; try{ html=fs.readFileSync(file,'utf8'); }catch(e){ return staticFile(req,res,path.join(PUBLIC_DIR,'404.html'),404); }
|
|
const base='https://rmcircle.team';
|
|
const url=kind==='join'?`${base}/join/${id}`:`${base}/my/${id}`;
|
|
const title=kind==='join'
|
|
? `You're invited to the RM Circle team by Member #${id}`
|
|
: `Member #${id}'s RM Circle team — live on the blockchain`;
|
|
const desc=kind==='join'
|
|
? `A team-first crypto build where every payout is verified on Polygon. See Member #${id}'s real on-chain position and join their team. No income is guaranteed.`
|
|
: `Member #${id}'s live position, team, and payments — read straight from the blockchain. Get your 2, help your 2. No income is guaranteed.`;
|
|
const img=`${base}/og-card.jpg`;
|
|
const og=[
|
|
`<meta property="og:type" content="website">`,
|
|
`<meta property="og:site_name" content="RM Circle Team Build">`,
|
|
`<meta property="og:title" content="${title}">`,
|
|
`<meta property="og:description" content="${desc}">`,
|
|
`<meta property="og:url" content="${url}">`,
|
|
`<meta property="og:image" content="${img}"><meta property="og:image:width" content="1200"><meta property="og:image:height" content="630">`,
|
|
`<meta name="twitter:card" content="summary_large_image"><meta name="twitter:title" content="${title}"><meta name="twitter:description" content="${desc}"><meta name="twitter:image" content="${img}">`
|
|
].join('');
|
|
html=html.replace(/<meta\s+(?:property="og:[^"]*"|name="twitter:[^"]*")[^>]*>/gi,'');
|
|
html=html.replace(/(<head[^>]*>)/i,`$1${og}`);
|
|
res.writeHead(200,securityHeaders({'Content-Type':'text/html; charset=utf-8','Cache-Control':'no-cache'}));
|
|
res.end(html);
|
|
}
|
|
const server=http.createServer(async(req,res)=>{
|
|
try{
|
|
const u=new URL(req.url,`http://${req.headers.host||'localhost'}`),pathname=decodeURIComponent(u.pathname);
|
|
if(pathname==='/health'||pathname.startsWith('/api/'))return await handleApi(req,res,pathname);
|
|
if(req.method!=='GET'&&req.method!=='HEAD')return send(res,405,'Method Not Allowed',{'Content-Type':'text/plain; charset=utf-8'});
|
|
if(pathname==='/unsubscribe'){
|
|
const id=u.searchParams.get('id')||'', t=u.searchParams.get('t')||'';
|
|
let ok=false;
|
|
if(/^\d{1,15}$/.test(id)&&t&&t===unsubToken(id)){ const ma=getMemberAlerts(); if(ma[id]){delete ma[id];saveMemberAlerts(ma);} ok=true; }
|
|
const safeId=/^\d{1,15}$/.test(id)?id:'';
|
|
const body=`<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="robots" content="noindex"><title>${ok?'Unsubscribed':'Invalid link'}</title><link rel="stylesheet" href="/styles.css"></head><body class="admin-bg"><div class="login-panel" style="margin:12vh auto;text-align:center"><h1>${ok?'You are unsubscribed ✓':'Invalid or expired link'}</h1><p style="color:var(--muted)">${ok?`Position #${safeId} will no longer receive alert emails. You can turn them back on anytime from your dashboard.`:'We could not process that unsubscribe link. You can manage alerts from your dashboard.'}</p><a class="btn btn-primary" href="/my/${safeId}">Open dashboard</a></div></body></html>`;
|
|
res.writeHead(200,securityHeaders({'Content-Type':'text/html; charset=utf-8','Cache-Control':'no-store'}));return res.end(body);
|
|
}
|
|
{ let mj;
|
|
if((mj=pathname.match(/^\/my\/(\d{1,15})$/)))return serveMemberPage(req,res,path.join(PUBLIC_DIR,'my.html'),'my',mj[1]);
|
|
if((mj=pathname.match(/^\/join\/(\d{1,15})$/)))return serveMemberPage(req,res,path.join(PUBLIC_DIR,'join.html'),'join',mj[1]);
|
|
}
|
|
let file;
|
|
if(pathname==='/')file=path.join(PUBLIC_DIR,'index.html');else if(pathname==='/start'||pathname==='/start/')file=path.join(PUBLIC_DIR,'start.html');else if(pathname==='/training'||pathname==='/training/')file=path.join(PUBLIC_DIR,'training.html');else if(pathname==='/admin'||pathname==='/admin/')file=path.join(PUBLIC_DIR,'admin.html');else if(pathname==='/my'||pathname==='/my/'||/^\/my\/\d{1,15}$/.test(pathname))file=path.join(PUBLIC_DIR,'my.html');else if(pathname==='/contract'||pathname==='/contract/')file=path.join(PUBLIC_DIR,'contract.html');else if(pathname==='/disclaimer'||pathname==='/disclaimer/')file=path.join(PUBLIC_DIR,'disclaimer.html');else if(pathname==='/how-pay-works'||pathname==='/how-pay-works/')file=path.join(PUBLIC_DIR,'how-pay-works.html');else if(pathname==='/tools'||pathname==='/tools/')file=path.join(PUBLIC_DIR,'tools.html');else if(pathname==='/direct-join'||pathname==='/direct-join/'){if(!getSession(req)){res.writeHead(302,{Location:'/admin'});return res.end();}file=path.join(ROOT,'private','direct-join.html');}else if(pathname==='/join-now'||pathname==='/join-now/'){if(!getConfig().dappFallbackPublic){res.writeHead(302,{Location:'/start'});return res.end();}file=path.join(ROOT,'private','join-now.html');}else if(/^\/join\/\d{1,15}$/.test(pathname))file=path.join(PUBLIC_DIR,'join.html');else if(pathname==='/join'||pathname==='/join/'){res.writeHead(302,{Location:'/join-now'});return res.end();}else{
|
|
const safe=path.normalize(pathname).replace(/^([.][.][/\\])+/, '').replace(/^[/\\]+/,'');file=path.join(PUBLIC_DIR,safe);if(!file.startsWith(PUBLIC_DIR))file='';
|
|
}
|
|
if(file&&staticFile(req,res,file))return;return staticFile(req,res,path.join(PUBLIC_DIR,'404.html'),404);
|
|
}catch(e){console.error(e);json(res,500,{error:'Internal server error'});}
|
|
});
|
|
server.listen(PORT,()=>{console.log(`RM Circle Team Build sponsor router running on http://localhost:${PORT}`);if(ADMIN_PASSWORD==='changeme')console.warn('WARNING: Set ADMIN_PASSWORD before production deployment.');});
|
|
// Team-activity alerts: any NEW on-chain event at/below config.teamRootId goes
|
|
// to the Telegram group topic, with the sponsor's contact email when we have it.
|
|
// Owner upgrade watcher: emails/Telegrams when an owned position (config.ownerIds)
|
|
// has a payment about to arrive it can't catch yet, so Marty can upgrade in time.
|
|
const OWNER_ALERTS_FILE = path.join(DATA_DIR, 'owner-alerts.json');
|
|
function loadOwnerAlerts(){ try{ return new Set(readJson(OWNER_ALERTS_FILE)); }catch(e){ return new Set(); } }
|
|
function parseOwnerIds(){ return [...new Set(String(getConfig().ownerIds||'').split(',').map(s=>parseInt(String(s).trim(),10)).filter(n=>Number.isInteger(n)&&n>0))].slice(0,12); }
|
|
// Self-service member alert subscriptions: memberId -> { email, ts }
|
|
const MEMBER_ALERTS_FILE = path.join(DATA_DIR, 'member-alerts.json');
|
|
if (!fs.existsSync(MEMBER_ALERTS_FILE)) fs.writeFileSync(MEMBER_ALERTS_FILE, '{}');
|
|
function getMemberAlerts(){ try{ return readJson(MEMBER_ALERTS_FILE)||{}; }catch(e){ return {}; } }
|
|
function saveMemberAlerts(o){ writeJson(MEMBER_ALERTS_FILE,o); }
|
|
const ALERT_SECRET = crypto.createHash('sha256').update('rmc-alerts::'+ADMIN_PASSWORD).digest('hex');
|
|
function unsubToken(id){ return crypto.createHmac('sha256',ALERT_SECRET).update('unsub:'+String(id)).digest('hex').slice(0,24); }
|
|
function unsubUrl(id){ return `https://rmcircle.team/unsubscribe?id=${id}&t=${unsubToken(id)}`; }
|
|
function maskEmail(e){ const i=String(e).indexOf('@'); if(i<1)return '•••'; return e[0]+'•••'+e.slice(i); }
|
|
// Upgrade-need alerts — EMAIL ONLY — for owner positions (config.ownerAlertEmail)
|
|
// and for any member who opted in via their dashboard. Runs every 5 min from state.
|
|
function checkUpgradeAlerts(){
|
|
try{
|
|
const c=getConfig();
|
|
const watch={}; // id -> [{email, owner}]
|
|
if(c.ownerAlertEmail) for(const id of parseOwnerIds()){(watch[id]=watch[id]||[]).push({email:c.ownerAlertEmail,owner:true});}
|
|
const ma=getMemberAlerts();
|
|
for(const [idStr,rec] of Object.entries(ma)) if(rec&&rec.email){const id=Number(idStr);(watch[id]=watch[id]||[]).push({email:rec.email,owner:false});}
|
|
const ids=Object.keys(watch).map(Number);
|
|
if(!ids.length) return;
|
|
const res=chain.getOwnerUpgradeNeeds(ids);
|
|
if(!res.ready) return;
|
|
const alerted=loadOwnerAlerts(); const active=new Set();
|
|
for(const n of res.needs){
|
|
const who=n.members.map(m=>'#'+m).join(', ');
|
|
const action=n.reason==='qualify'?`Position #${n.id} needs its 2 directs to catch this.`:`Upgrade position #${n.id} (now ${n.levelName}) to ${n.neededLevelName} to catch it.`;
|
|
for(const w of (watch[n.id]||[])){
|
|
const key=`${w.email}:${n.id}:${n.reason}:${n.neededLevel}`; active.add(key);
|
|
if(alerted.has(key)) continue; alerted.add(key);
|
|
const unsub=w.owner?'':`\n\nStop these alerts: ${unsubUrl(n.id)}`;
|
|
sendEmailRaw(w.email,
|
|
`RM Circle: upgrade #${n.id} to ${n.neededLevelName} — ${n.amountAtRisk} POL incoming`,
|
|
`Heads up — position #${n.id} has money about to arrive it can't catch yet.\n\n#${n.id} is at ${n.levelName}. ${who} ${n.members.length===1?'is':'are'} ONE upgrade away from paying #${n.id} about ${n.amountAtRisk} POL — but that only stops at #${n.id} if it's at ${n.neededLevelName} and qualified.\n\n${action}\n\nDo it before they upgrade, or the payment passes to the next eligible position above (it doesn't come back).${unsub}\n\n— RM Circle auto-watch`);
|
|
}
|
|
}
|
|
let changed=false;
|
|
for(const k of [...alerted]) if(!active.has(k)){ alerted.delete(k); changed=true; }
|
|
if(changed||active.size) writeJson(OWNER_ALERTS_FILE,[...alerted]);
|
|
}catch(e){ console.error('upgrade alert check', e.message); }
|
|
}
|
|
setInterval(checkUpgradeAlerts, 5*60*1000).unref();
|
|
setTimeout(checkUpgradeAlerts, 30000).unref();
|
|
chain.startIndexer(evt=>{
|
|
// Any on-chain event makes cached member views stale (the new member, the
|
|
// sponsor, every upline's team/pipeline/coach data). Events are infrequent;
|
|
// just drop the whole member cache so dashboards reflect joins/upgrades on
|
|
// the next fetch instead of up to 2 minutes later.
|
|
try{memberCache.clear();}catch(e){}
|
|
try{
|
|
const c=getConfig();
|
|
// teamRootId accepts a comma list ("21,136") — alerts fire for ANY listed org
|
|
const roots=String(c.teamRootId||'').split(',').map(n=>Number(n.trim())).filter(n=>n>0);
|
|
const ids=evt.type==='payout'?[evt.toId,evt.fromId]:[evt.id];
|
|
if(roots.length&&ids.some(i=>roots.some(r=>chain.isInTeam(i,r)))){
|
|
const contact=id=>{const s=getSponsors().find(x=>String(x.id)===String(id));return s&&s.email?`\nContact: ${s.name?s.name+' — ':''}${s.email}`:''};
|
|
let text;
|
|
if(evt.type==='registered')text=`📈 TEAM BUILD: new position!\n#${evt.id} registered under #${evt.referrerId} (${evt.tierName}).`;
|
|
else if(evt.type==='upgraded')text=`🚀 TEAM BUILD: #${evt.id} upgraded to ${evt.levelName}.`;
|
|
else text=`💸 TEAM BUILD: #${evt.toId} just got PAID ${evt.pol.toFixed(2)} POL${evt.kind==='upline'?` (${evt.gen?`Gen ${evt.gen} `:''}upgrade pass-up from #${evt.fromId})`:` (referral reward from #${evt.fromId})`}.${contact(evt.toId)}`;
|
|
if(evt.tx)text+=`\nhttps://polygonscan.com/tx/${evt.tx}`;
|
|
sendTelegram(text);
|
|
// recruiting-framed copy of the SAME event to the new-members topic
|
|
// (social proof, CTA -> home). Fires only when a recruit topic is set.
|
|
if(c.telegramRecruitTopicId){ const rm=recruitMsg(evt); if(rm) sendTelegram(rm, c.telegramRecruitTopicId, {inline_keyboard:[[{text:'🚀 Get Started — rmcircle.team',url:'https://rmcircle.team/?utm_source=tg'}]]}); }
|
|
// admin email alert — same team-gated events, so deep-leg action still surfaces
|
|
if(c.teamAlertEmail){
|
|
const subj=evt.type==='registered'?`RM Circle team build: #${evt.id} registered under #${evt.referrerId}`
|
|
:evt.type==='upgraded'?`RM Circle team build: #${evt.id} upgraded to ${evt.levelName}`
|
|
:`RM Circle team build: #${evt.toId} paid ${evt.pol.toFixed(2)} POL`;
|
|
sendEmailRaw(c.teamAlertEmail,subj,text.replace(/^[^\s]+ /,''));
|
|
}
|
|
}
|
|
}catch(e){console.error('team alert error',e.message)}
|
|
// "you've been paid" email — sponsor-record contact, and self-service subscribers
|
|
try{
|
|
if(evt.type==='payout'){
|
|
const sent=new Set();
|
|
const sp=getSponsors().find(x=>String(x.id)===String(evt.toId));
|
|
if(sp&&sp.email){sendPaidEmail(sp.email,sp.name,evt);sent.add(sp.email.toLowerCase());}
|
|
const rec=getMemberAlerts()[evt.toId];
|
|
if(rec&&rec.email&&!sent.has(rec.email.toLowerCase()))sendPaidEmail(rec.email,'there',evt,unsubUrl(evt.toId));
|
|
}
|
|
}catch(e){console.error('paid email error',e.message)}
|
|
// Auto-tweet on-chain payout proof to @cryptoteambuild via Blotato (Marty
|
|
// 2026-08-15). Org-gated to the #21 organization; OFF unless config.tweetEnabled.
|
|
try{
|
|
if(evt.type==='payout'){
|
|
const orgRoot=Number(getConfig().orgRootId)||21;
|
|
if(chain.isInTeam(evt.toId,orgRoot))tweet.queuePayoutTweet(evt,getConfig());
|
|
}
|
|
}catch(e){console.error('tweet hook error',e.message)}
|
|
// Auto-count directs + AUTO-ADVANCE (Marty 2026-08-15): a new registration
|
|
// whose referrer sits in the rotation queue increments that sponsor's directs.
|
|
// When it reaches 2/2 the sponsor is auto-qualified and the next waiting
|
|
// position activates — no manual Qualify click (changed from the earlier
|
|
// manual design so the rotation never sits stuck at a 2/2 sponsor).
|
|
try{
|
|
if(evt.type==='registered'&&evt.referrerId!=null){
|
|
let sponsors=getSponsors();
|
|
const idx=sponsors.findIndex(x=>String(x.id)===String(evt.referrerId));
|
|
if(idx>=0&&sponsors[idx].status!=='qualified'&&(Number(sponsors[idx].directs)||0)<2){
|
|
const newDirects=Math.min(2,(Number(sponsors[idx].directs)||0)+1);
|
|
const s=sponsors[idx];
|
|
if(newDirects>=2){
|
|
sponsors[idx]={...sponsors[idx],directs:2,status:'qualified'};
|
|
sponsors=normalizeStatuses(sponsors);
|
|
saveSponsors(sponsors);
|
|
const next=activeSponsor(sponsors);
|
|
sendTelegram(`✅ AUTO-ADVANCE: queue sponsor #${s.id}${s.name?` (${s.name})`:''} reached 2/2 and is now QUALIFIED.\nRotation moved to ${next?`#${next.id}${next.name?` (${next.name})`:''}`:'— nobody waiting (add the next position to the queue)'}.`);
|
|
}else{
|
|
sponsors[idx].directs=newDirects;
|
|
saveSponsors(sponsors);
|
|
sendTelegram(`🤖 AUTO-COUNT: #${evt.id} is a DIRECT for queue sponsor #${s.id}${s.name?` (${s.name})`:''} — now ${newDirects}/2.`);
|
|
}
|
|
}
|
|
}
|
|
}catch(e){console.error('auto-direct error',e.message)}
|
|
// Queue level sync: when a queue member upgrades on-chain, their Level in the
|
|
// rotation queue follows automatically (same op as the admin level dropdown).
|
|
try{
|
|
if(evt.type==='upgraded'&&evt.id!=null){
|
|
const sponsors=getSponsors();
|
|
const idx=sponsors.findIndex(x=>String(x.id)===String(evt.id));
|
|
if(idx>=0&&LEVELS.includes(evt.levelName)&&sponsors[idx].level!==evt.levelName){
|
|
sponsors[idx].level=evt.levelName;
|
|
saveSponsors(sponsors);
|
|
sendTelegram(`🤖 AUTO-LEVEL: queue sponsor #${sponsors[idx].id}${sponsors[idx].name?` (${sponsors[idx].name})`:''} upgraded on-chain — queue level updated to ${evt.levelName}.`);
|
|
}
|
|
}
|
|
}catch(e){console.error('auto-level error',e.message)}
|
|
});
|