|
|
@@ -1,485 +1,485 @@
|
|
|
|
const http = require('http');
|
|
|
|
const http = require('http');
|
|
|
|
const fs = require('fs');
|
|
|
|
const fs = require('fs');
|
|
|
|
const path = require('path');
|
|
|
|
const path = require('path');
|
|
|
|
const crypto = require('crypto');
|
|
|
|
const crypto = require('crypto');
|
|
|
|
const { URL } = require('url');
|
|
|
|
const { URL } = require('url');
|
|
|
|
const chain = require('./chain');
|
|
|
|
const chain = require('./chain');
|
|
|
|
|
|
|
|
|
|
|
|
const PORT = Number(process.env.PORT || 3000);
|
|
|
|
const PORT = Number(process.env.PORT || 3000);
|
|
|
|
const ROOT = __dirname;
|
|
|
|
const ROOT = __dirname;
|
|
|
|
const PUBLIC_DIR = path.join(ROOT, 'public');
|
|
|
|
const PUBLIC_DIR = path.join(ROOT, 'public');
|
|
|
|
const DATA_DIR = process.env.DATA_DIR || path.join(ROOT, 'data');
|
|
|
|
const DATA_DIR = process.env.DATA_DIR || path.join(ROOT, 'data');
|
|
|
|
const SEED_DIR = path.join(ROOT, 'seed');
|
|
|
|
const SEED_DIR = path.join(ROOT, 'seed');
|
|
|
|
const SPONSORS_FILE = path.join(DATA_DIR, 'sponsors.json');
|
|
|
|
const SPONSORS_FILE = path.join(DATA_DIR, 'sponsors.json');
|
|
|
|
const CONFIG_FILE = path.join(DATA_DIR, 'config.json');
|
|
|
|
const CONFIG_FILE = path.join(DATA_DIR, 'config.json');
|
|
|
|
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'changeme';
|
|
|
|
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'changeme';
|
|
|
|
const IS_PROD = process.env.NODE_ENV === 'production';
|
|
|
|
const IS_PROD = process.env.NODE_ENV === 'production';
|
|
|
|
const SESSION_TTL = 8 * 60 * 60 * 1000;
|
|
|
|
const SESSION_TTL = 8 * 60 * 60 * 1000;
|
|
|
|
const LEVELS = ['Scintilla','Ascensus','Fabrica','Culmen','Apex','Fastigium','Vertex','Corona'];
|
|
|
|
const LEVELS = ['Scintilla','Ascensus','Fabrica','Culmen','Apex','Fastigium','Vertex','Corona'];
|
|
|
|
const OPENROUTER_MODEL = process.env.OPENROUTER_MODEL || 'deepseek/deepseek-v4-flash:nitro';
|
|
|
|
const OPENROUTER_MODEL = process.env.OPENROUTER_MODEL || 'deepseek/deepseek-v4-flash:nitro';
|
|
|
|
const OPENROUTER_KEY_FILE = path.join(DATA_DIR, 'openrouter.key');
|
|
|
|
const OPENROUTER_KEY_FILE = path.join(DATA_DIR, 'openrouter.key');
|
|
|
|
function getOpenRouterKey() {
|
|
|
|
function getOpenRouterKey() {
|
|
|
|
if (process.env.OPENROUTER_API_KEY) return process.env.OPENROUTER_API_KEY;
|
|
|
|
if (process.env.OPENROUTER_API_KEY) return process.env.OPENROUTER_API_KEY;
|
|
|
|
try { return fs.readFileSync(OPENROUTER_KEY_FILE, 'utf8').trim(); } catch (e) { return ''; }
|
|
|
|
try { return fs.readFileSync(OPENROUTER_KEY_FILE, 'utf8').trim(); } catch (e) { return ''; }
|
|
|
|
}
|
|
|
|
}
|
|
|
|
const chatHits = new Map();
|
|
|
|
const chatHits = new Map();
|
|
|
|
function chatRateLimited(ip) {
|
|
|
|
function chatRateLimited(ip) {
|
|
|
|
const now = Date.now(), rec = chatHits.get(ip);
|
|
|
|
const now = Date.now(), rec = chatHits.get(ip);
|
|
|
|
if (!rec || now > rec.reset) { chatHits.set(ip, { count: 1, reset: now + 60000 }); return false; }
|
|
|
|
if (!rec || now > rec.reset) { chatHits.set(ip, { count: 1, reset: now + 60000 }); return false; }
|
|
|
|
rec.count++; return rec.count > 10;
|
|
|
|
rec.count++; return rec.count > 10;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
function chatSystemPrompt() {
|
|
|
|
function chatSystemPrompt() {
|
|
|
|
const c = getConfig(), sponsors = getSponsors(), a = activeSponsor(sponsors);
|
|
|
|
const c = getConfig(), sponsors = getSponsors(), a = activeSponsor(sponsors);
|
|
|
|
const waiting = sponsors.filter(s=>s.status==='waiting').length;
|
|
|
|
const waiting = sponsors.filter(s=>s.status==='waiting').length;
|
|
|
|
return `You are "Team Help", the assistant on ${c.siteName || 'Crypto Team Build'} (https://rmcircle.saasy.top), the team site for the RM Circle Premium team build — a project of the Crypto Team Build Network.
|
|
|
|
return `You are "Team Help", the assistant on ${c.siteName || 'Crypto Team Build'} (https://rmcircle.saasy.top), the team site for the RM Circle Premium team build — a project of the Crypto Team Build Network.
|
|
|
|
|
|
|
|
|
|
|
|
FACTS:
|
|
|
|
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.
|
|
|
|
- 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.
|
|
|
|
- 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.
|
|
|
|
- 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.saasy.top/my
|
|
|
|
- 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.saasy.top/my
|
|
|
|
- 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.saasy.top/start right before joining.
|
|
|
|
- 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.saasy.top/start right before joining.
|
|
|
|
- Site pages: https://rmcircle.saasy.top/ (strategy overview + roadmap), https://rmcircle.saasy.top/start (current sponsor + join steps), https://rmcircle.saasy.top/training (4 videos: 1. How the team build works, 2. Create your MetaMask wallet, 3. Funding your wallet, 4. Buying the Premium position).
|
|
|
|
- Site pages: https://rmcircle.saasy.top/ (strategy overview + roadmap), https://rmcircle.saasy.top/start (current sponsor + join steps), https://rmcircle.saasy.top/training (4 videos: 1. How the team build works, 2. Create your MetaMask wallet, 3. Funding your wallet, 4. Buying the Premium position), https://rmcircle.saasy.top/contract (plain-language security review of the verified smart contract — code can't change, no pooled funds, locked rules, honest list of operator powers).
|
|
|
|
- Telegram group for live team help: ${c.telegramUrl || 'https://t.me/cryptoteambuild'}
|
|
|
|
- Telegram group for live team help: ${c.telegramUrl || 'https://t.me/cryptoteambuild'}
|
|
|
|
|
|
|
|
|
|
|
|
RULES:
|
|
|
|
RULES:
|
|
|
|
- Keep answers short: 1-4 sentences, plain text, no markdown formatting. Include full URLs when pointing to a page.
|
|
|
|
- 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 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.
|
|
|
|
- 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'}
|
|
|
|
- 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.`;
|
|
|
|
- Never give financial, legal, or tax advice.`;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
const SUBMISSIONS_FILE = path.join(DATA_DIR, 'submissions.json');
|
|
|
|
const SUBMISSIONS_FILE = path.join(DATA_DIR, 'submissions.json');
|
|
|
|
if (!fs.existsSync(SUBMISSIONS_FILE)) fs.writeFileSync(SUBMISSIONS_FILE, '[]');
|
|
|
|
if (!fs.existsSync(SUBMISSIONS_FILE)) fs.writeFileSync(SUBMISSIONS_FILE, '[]');
|
|
|
|
const memberCache = new Map();
|
|
|
|
const memberCache = new Map();
|
|
|
|
const lookupHits = new Map();
|
|
|
|
const lookupHits = new Map();
|
|
|
|
function memberLookupLimited(ip) {
|
|
|
|
function memberLookupLimited(ip) {
|
|
|
|
const now = Date.now(), rec = lookupHits.get(ip);
|
|
|
|
const now = Date.now(), rec = lookupHits.get(ip);
|
|
|
|
if (!rec || now > rec.reset) { lookupHits.set(ip, { count: 1, reset: now + 60000 }); return false; }
|
|
|
|
if (!rec || now > rec.reset) { lookupHits.set(ip, { count: 1, reset: now + 60000 }); return false; }
|
|
|
|
rec.count++; return rec.count > 20;
|
|
|
|
rec.count++; return rec.count > 20;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
const submitHits = new Map();
|
|
|
|
const submitHits = new Map();
|
|
|
|
function submitRateLimited(ip) {
|
|
|
|
function submitRateLimited(ip) {
|
|
|
|
const now = Date.now(), rec = submitHits.get(ip);
|
|
|
|
const now = Date.now(), rec = submitHits.get(ip);
|
|
|
|
if (!rec || now > rec.reset) { submitHits.set(ip, { count: 1, reset: now + 600000 }); return false; }
|
|
|
|
if (!rec || now > rec.reset) { submitHits.set(ip, { count: 1, reset: now + 600000 }); return false; }
|
|
|
|
rec.count++; return rec.count > 5;
|
|
|
|
rec.count++; return rec.count > 5;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
function sendTelegram(text) {
|
|
|
|
function sendTelegram(text) {
|
|
|
|
const c = getConfig();
|
|
|
|
const c = getConfig();
|
|
|
|
if (!c.telegramBotToken || !c.telegramChatId) return;
|
|
|
|
if (!c.telegramBotToken || !c.telegramChatId) return;
|
|
|
|
const payload = { chat_id: c.telegramChatId, text };
|
|
|
|
const payload = { chat_id: c.telegramChatId, text };
|
|
|
|
if (c.telegramTopicId && /^[0-9]+$/.test(String(c.telegramTopicId))) payload.message_thread_id = Number(c.telegramTopicId);
|
|
|
|
if (c.telegramTopicId && /^[0-9]+$/.test(String(c.telegramTopicId))) payload.message_thread_id = Number(c.telegramTopicId);
|
|
|
|
fetch(`https://api.telegram.org/bot${c.telegramBotToken}/sendMessage`, {
|
|
|
|
fetch(`https://api.telegram.org/bot${c.telegramBotToken}/sendMessage`, {
|
|
|
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
|
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
|
|
body: JSON.stringify(payload)
|
|
|
|
body: JSON.stringify(payload)
|
|
|
|
}).then(async r=>{ if(!r.ok) console.error('telegram sendMessage status', r.status, (await r.text().catch(()=>'')).slice(0,200)); })
|
|
|
|
}).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));
|
|
|
|
.catch(e=>console.error('telegram error', e.message));
|
|
|
|
}
|
|
|
|
}
|
|
|
|
const SENDGRID_KEY_FILE = path.join(DATA_DIR, 'sendgrid.key');
|
|
|
|
const SENDGRID_KEY_FILE = path.join(DATA_DIR, 'sendgrid.key');
|
|
|
|
function getSendgridKey() {
|
|
|
|
function getSendgridKey() {
|
|
|
|
if (process.env.SENDGRID_API_KEY) return process.env.SENDGRID_API_KEY;
|
|
|
|
if (process.env.SENDGRID_API_KEY) return process.env.SENDGRID_API_KEY;
|
|
|
|
try { return fs.readFileSync(SENDGRID_KEY_FILE, 'utf8').trim(); } catch (e) { return ''; }
|
|
|
|
try { return fs.readFileSync(SENDGRID_KEY_FILE, 'utf8').trim(); } catch (e) { return ''; }
|
|
|
|
}
|
|
|
|
}
|
|
|
|
// SendGrid is domain-authenticated for marketingwithmarty.com and
|
|
|
|
// SendGrid is domain-authenticated for marketingwithmarty.com and
|
|
|
|
// mybrandedvoice.com — the from address must stay on one of those or DKIM fails.
|
|
|
|
// 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 emailFrom() { return getConfig().emailFrom || 'The RM Circle Team <no-reply@marketingwithmarty.com>'; }
|
|
|
|
function sendEmailRaw(toEmail, subject, text) {
|
|
|
|
function sendEmailRaw(toEmail, subject, text) {
|
|
|
|
const key = getSendgridKey();
|
|
|
|
const key = getSendgridKey();
|
|
|
|
if (!key || !toEmail) return;
|
|
|
|
if (!key || !toEmail) return;
|
|
|
|
const fromStr = emailFrom();
|
|
|
|
const fromStr = emailFrom();
|
|
|
|
const m = fromStr.match(/^(.*)<([^>]+)>\s*$/);
|
|
|
|
const m = fromStr.match(/^(.*)<([^>]+)>\s*$/);
|
|
|
|
const from = m ? { email: m[2].trim(), name: m[1].trim() || undefined } : { email: fromStr.trim() };
|
|
|
|
const from = m ? { email: m[2].trim(), name: m[1].trim() || undefined } : { email: fromStr.trim() };
|
|
|
|
fetch('https://api.sendgrid.com/v3/mail/send', {
|
|
|
|
fetch('https://api.sendgrid.com/v3/mail/send', {
|
|
|
|
method: 'POST',
|
|
|
|
method: 'POST',
|
|
|
|
headers: { Authorization: `Bearer ${key}`, 'Content-Type': 'application/json' },
|
|
|
|
headers: { Authorization: `Bearer ${key}`, 'Content-Type': 'application/json' },
|
|
|
|
body: JSON.stringify({
|
|
|
|
body: JSON.stringify({
|
|
|
|
personalizations: [{ to: [{ email: toEmail }] }],
|
|
|
|
personalizations: [{ to: [{ email: toEmail }] }],
|
|
|
|
from, subject,
|
|
|
|
from, subject,
|
|
|
|
content: [{ type: 'text/plain', value: text }]
|
|
|
|
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))); })
|
|
|
|
}).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));
|
|
|
|
.catch(e => console.error('sendgrid error', e.message));
|
|
|
|
}
|
|
|
|
}
|
|
|
|
function sendPaidEmail(toEmail, memberName, evt) {
|
|
|
|
function sendPaidEmail(toEmail, memberName, evt) {
|
|
|
|
const kindLine = evt.kind === 'upline' ? `an upgrade pass-up from member #${evt.fromId}` : `a referral reward from member #${evt.fromId}'s entry`;
|
|
|
|
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 verify = evt.tx ? `\n\nVerify it yourself on the blockchain:\nhttps://polygonscan.com/tx/${evt.tx}` : '';
|
|
|
|
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.saasy.top/training\n\n— The RM Circle Team\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.saasy.top/training\n\n— The RM Circle Team\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.`;
|
|
|
|
sendEmailRaw(toEmail, `Your RM Circle position #${evt.toId} just got paid ${evt.pol.toFixed(2)} POL`, text);
|
|
|
|
sendEmailRaw(toEmail, `Your RM Circle position #${evt.toId} just got paid ${evt.pol.toFixed(2)} POL`, text);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
function firePostback(clickid, txid, source) {
|
|
|
|
function firePostback(clickid, txid, source) {
|
|
|
|
const pb = getConfig().bemobPostbackUrl;
|
|
|
|
const pb = getConfig().bemobPostbackUrl;
|
|
|
|
if (!clickid || !pb || !/^https:\/\/[a-z0-9.-]+\/postback/i.test(pb)) return;
|
|
|
|
if (!clickid || !pb || !/^https:\/\/[a-z0-9.-]+\/postback/i.test(pb)) return;
|
|
|
|
fetch(`${pb}${pb.includes('?')?'&':'?'}cid=${encodeURIComponent(clickid)}&payout=0&txid=${encodeURIComponent(txid)}`)
|
|
|
|
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); })
|
|
|
|
.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));
|
|
|
|
.catch(e=>console.error('bemob postback error', e.message));
|
|
|
|
}
|
|
|
|
}
|
|
|
|
async function handleSubmitId(req, res) {
|
|
|
|
async function handleSubmitId(req, res) {
|
|
|
|
const ip = String(req.headers['x-forwarded-for']||req.socket.remoteAddress||'').split(',')[0].trim();
|
|
|
|
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.' });
|
|
|
|
if (submitRateLimited(ip)) return json(res, 429, { error: 'Too many submissions — please wait a few minutes.' });
|
|
|
|
const b = await bodyJson(req).catch(()=>null);
|
|
|
|
const b = await bodyJson(req).catch(()=>null);
|
|
|
|
if (!b) return json(res, 400, { error: 'Invalid request.' });
|
|
|
|
if (!b) return json(res, 400, { error: 'Invalid request.' });
|
|
|
|
const newId = String(b.newId||'').trim();
|
|
|
|
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).' });
|
|
|
|
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);
|
|
|
|
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.' });
|
|
|
|
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 sponsorId = String(b.sponsorId||'').trim().slice(0, 20).replace(/[^0-9A-Za-z._-]/g,'') || '?';
|
|
|
|
const source = typeof b.source==='string' ? b.source : '';
|
|
|
|
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,'') : '';
|
|
|
|
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) {}
|
|
|
|
let subs = []; try { subs = readJson(SUBMISSIONS_FILE); } catch(e) {}
|
|
|
|
if (subs.some(s=>s.newId===newId)) return json(res, 200, { ok: true, duplicate: true });
|
|
|
|
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?
|
|
|
|
// on-chain verification: does this ID actually exist on the contract?
|
|
|
|
let onchain = null;
|
|
|
|
let onchain = null;
|
|
|
|
try {
|
|
|
|
try {
|
|
|
|
onchain = await Promise.race([
|
|
|
|
onchain = await Promise.race([
|
|
|
|
chain.verifyMember(Number(newId)),
|
|
|
|
chain.verifyMember(Number(newId)),
|
|
|
|
new Promise((_, rej) => setTimeout(() => rej(new Error('timeout')), 6000))
|
|
|
|
new Promise((_, rej) => setTimeout(() => rej(new Error('timeout')), 6000))
|
|
|
|
]);
|
|
|
|
]);
|
|
|
|
} catch (e) { onchain = null; }
|
|
|
|
} catch (e) { onchain = null; }
|
|
|
|
// the chain decides the path: rotation join (referrer = active rotation sponsor)
|
|
|
|
// the chain decides the path: rotation join (referrer = active rotation sponsor)
|
|
|
|
// vs leg join (someone's personal team build) — regardless of which page they used
|
|
|
|
// vs leg join (someone's personal team build) — regardless of which page they used
|
|
|
|
const active = activeSponsor(getSponsors());
|
|
|
|
const active = activeSponsor(getSponsors());
|
|
|
|
let joinPath = 'unknown';
|
|
|
|
let joinPath = 'unknown';
|
|
|
|
if (onchain && onchain.registered) joinPath = (active && String(onchain.referrerId) === String(active.id)) ? 'rotation' : 'leg';
|
|
|
|
if (onchain && onchain.registered) joinPath = (active && String(onchain.referrerId) === String(active.id)) ? 'rotation' : 'leg';
|
|
|
|
else if (onchain && !onchain.registered) joinPath = 'notfound';
|
|
|
|
else if (onchain && !onchain.registered) joinPath = 'notfound';
|
|
|
|
subs.push({ newId, memberName, sponsorId, source: source||'(direct)', clickid, ts: new Date().toISOString(), path: joinPath,
|
|
|
|
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 });
|
|
|
|
onchain: onchain ? { registered: onchain.registered, tier: onchain.tierName, level: onchain.levelName, referrerId: onchain.referrerId, uplineId: onchain.uplineId } : undefined });
|
|
|
|
writeJson(SUBMISSIONS_FILE, subs.slice(-1000));
|
|
|
|
writeJson(SUBMISSIONS_FILE, subs.slice(-1000));
|
|
|
|
recordEvent('purchase', source);
|
|
|
|
recordEvent('purchase', source);
|
|
|
|
firePostback(clickid, `purchase-${clickid}`, source);
|
|
|
|
firePostback(clickid, `purchase-${clickid}`, source);
|
|
|
|
let msg;
|
|
|
|
let msg;
|
|
|
|
if (joinPath === 'rotation') {
|
|
|
|
if (joinPath === 'rotation') {
|
|
|
|
// rotation joins go straight into the queue as waiting positions — no manual step
|
|
|
|
// rotation joins go straight into the queue as waiting positions — no manual step
|
|
|
|
let queueNote = '';
|
|
|
|
let queueNote = '';
|
|
|
|
try {
|
|
|
|
try {
|
|
|
|
let sponsors = getSponsors();
|
|
|
|
let sponsors = getSponsors();
|
|
|
|
if (sponsors.some(s => String(s.id) === String(newId))) {
|
|
|
|
if (sponsors.some(s => String(s.id) === String(newId))) {
|
|
|
|
queueNote = 'Already in the rotation queue.';
|
|
|
|
queueNote = 'Already in the rotation queue.';
|
|
|
|
} else {
|
|
|
|
} else {
|
|
|
|
const maxOrder = sponsors.reduce((m, s) => Math.max(m, s.sortOrder || 0), 0);
|
|
|
|
const maxOrder = sponsors.reduce((m, s) => Math.max(m, s.sortOrder || 0), 0);
|
|
|
|
sponsors.push({ id: String(newId), name: memberName, parentId: String(active.id), 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 ${new Date().toISOString().slice(0, 10)}` });
|
|
|
|
sponsors.push({ id: String(newId), name: memberName, parentId: String(active.id), 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 ${new Date().toISOString().slice(0, 10)}` });
|
|
|
|
sponsors = normalizeStatuses(sponsors);
|
|
|
|
sponsors = normalizeStatuses(sponsors);
|
|
|
|
saveSponsors(sponsors);
|
|
|
|
saveSponsors(sponsors);
|
|
|
|
const waitingAhead = sponsors.filter(s => s.status === 'waiting' && (s.sortOrder || 0) < maxOrder + 10).length;
|
|
|
|
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).`;
|
|
|
|
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); }
|
|
|
|
} 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(+1 direct for ID ${active.id} syncs from the chain automatically.)`;
|
|
|
|
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(+1 direct for ID ${active.id} syncs from the chain automatically.)`;
|
|
|
|
} else if (joinPath === 'leg') {
|
|
|
|
} 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.`;
|
|
|
|
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') {
|
|
|
|
} 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.`;
|
|
|
|
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 {
|
|
|
|
} 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).`;
|
|
|
|
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);
|
|
|
|
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 });
|
|
|
|
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) {
|
|
|
|
async function handleChat(req, res) {
|
|
|
|
const ip = String(req.headers['x-forwarded-for']||req.socket.remoteAddress||'').split(',')[0].trim();
|
|
|
|
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.' });
|
|
|
|
if (chatRateLimited(ip)) return json(res, 429, { error: 'Too many messages — give it a minute.' });
|
|
|
|
const apiKey = getOpenRouterKey();
|
|
|
|
const apiKey = getOpenRouterKey();
|
|
|
|
if (!apiKey) return json(res, 200, { fallback: true });
|
|
|
|
if (!apiKey) return json(res, 200, { fallback: true });
|
|
|
|
const b = await bodyJson(req).catch(()=>null);
|
|
|
|
const b = await bodyJson(req).catch(()=>null);
|
|
|
|
if (!b || !Array.isArray(b.messages)) return json(res, 400, { error: 'Invalid request' });
|
|
|
|
if (!b || !Array.isArray(b.messages)) return json(res, 400, { error: 'Invalid request' });
|
|
|
|
const msgs = b.messages.slice(-8)
|
|
|
|
const msgs = b.messages.slice(-8)
|
|
|
|
.filter(m=>m&&(m.role==='user'||m.role==='assistant')&&typeof m.content==='string')
|
|
|
|
.filter(m=>m&&(m.role==='user'||m.role==='assistant')&&typeof m.content==='string')
|
|
|
|
.map(m=>({ role: m.role, content: m.content.slice(0, 500) }));
|
|
|
|
.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' });
|
|
|
|
if (!msgs.length || msgs[msgs.length-1].role !== 'user') return json(res, 400, { error: 'Invalid request' });
|
|
|
|
try {
|
|
|
|
try {
|
|
|
|
const ctrl = new AbortController(); const timer = setTimeout(()=>ctrl.abort(), 20000);
|
|
|
|
const ctrl = new AbortController(); const timer = setTimeout(()=>ctrl.abort(), 20000);
|
|
|
|
const r = await fetch('https://openrouter.ai/api/v1/chat/completions', {
|
|
|
|
const r = await fetch('https://openrouter.ai/api/v1/chat/completions', {
|
|
|
|
method: 'POST', signal: ctrl.signal,
|
|
|
|
method: 'POST', signal: ctrl.signal,
|
|
|
|
headers: { 'Authorization': `Bearer ${apiKey}`, 'Content-Type': 'application/json', 'HTTP-Referer': 'https://rmcircle.saasy.top', 'X-Title': 'RM Circle Team Help' },
|
|
|
|
headers: { 'Authorization': `Bearer ${apiKey}`, 'Content-Type': 'application/json', 'HTTP-Referer': 'https://rmcircle.saasy.top', 'X-Title': 'RM Circle Team Help' },
|
|
|
|
body: JSON.stringify({ model: OPENROUTER_MODEL, max_tokens: 350, temperature: 0.3, messages: [{ role: 'system', content: chatSystemPrompt() }, ...msgs] })
|
|
|
|
body: JSON.stringify({ model: OPENROUTER_MODEL, max_tokens: 350, temperature: 0.3, messages: [{ role: 'system', content: chatSystemPrompt() }, ...msgs] })
|
|
|
|
});
|
|
|
|
});
|
|
|
|
clearTimeout(timer);
|
|
|
|
clearTimeout(timer);
|
|
|
|
if (!r.ok) { console.error('openrouter status', r.status); return json(res, 200, { fallback: true }); }
|
|
|
|
if (!r.ok) { console.error('openrouter status', r.status); return json(res, 200, { fallback: true }); }
|
|
|
|
const d = await r.json();
|
|
|
|
const d = await r.json();
|
|
|
|
const reply = d && d.choices && d.choices[0] && d.choices[0].message && d.choices[0].message.content;
|
|
|
|
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 });
|
|
|
|
if (!reply) return json(res, 200, { fallback: true });
|
|
|
|
return json(res, 200, { reply: String(reply).trim().slice(0, 2000) });
|
|
|
|
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 }); }
|
|
|
|
} 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.
|
|
|
|
// Sessions persist in the data volume so redeploys stop logging the admin out.
|
|
|
|
const SESSIONS_FILE = path.join(DATA_DIR, 'sessions.json');
|
|
|
|
const SESSIONS_FILE = path.join(DATA_DIR, 'sessions.json');
|
|
|
|
const sessions = new Map();
|
|
|
|
const sessions = new Map();
|
|
|
|
try {
|
|
|
|
try {
|
|
|
|
const saved = JSON.parse(fs.readFileSync(SESSIONS_FILE, 'utf8'));
|
|
|
|
const saved = JSON.parse(fs.readFileSync(SESSIONS_FILE, 'utf8'));
|
|
|
|
const now = Date.now();
|
|
|
|
const now = Date.now();
|
|
|
|
for (const [t, s] of Object.entries(saved)) if (s && s.expires > now) sessions.set(t, s);
|
|
|
|
for (const [t, s] of Object.entries(saved)) if (s && s.expires > now) sessions.set(t, s);
|
|
|
|
} catch (e) {}
|
|
|
|
} catch (e) {}
|
|
|
|
function saveSessions() {
|
|
|
|
function saveSessions() {
|
|
|
|
try {
|
|
|
|
try {
|
|
|
|
const now = Date.now();
|
|
|
|
const now = Date.now();
|
|
|
|
for (const [t, s] of sessions) if (s.expires <= now) sessions.delete(t);
|
|
|
|
for (const [t, s] of sessions) if (s.expires <= now) sessions.delete(t);
|
|
|
|
const tmp = SESSIONS_FILE + '.tmp';
|
|
|
|
const tmp = SESSIONS_FILE + '.tmp';
|
|
|
|
fs.writeFileSync(tmp, JSON.stringify(Object.fromEntries(sessions)), { mode: 0o600 });
|
|
|
|
fs.writeFileSync(tmp, JSON.stringify(Object.fromEntries(sessions)), { mode: 0o600 });
|
|
|
|
fs.renameSync(tmp, SESSIONS_FILE);
|
|
|
|
fs.renameSync(tmp, SESSIONS_FILE);
|
|
|
|
} catch (e) { console.error('session save failed', e.message); }
|
|
|
|
} catch (e) { console.error('session save failed', e.message); }
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function ensureDataFile(name) {
|
|
|
|
function ensureDataFile(name) {
|
|
|
|
fs.mkdirSync(DATA_DIR, { recursive: true });
|
|
|
|
fs.mkdirSync(DATA_DIR, { recursive: true });
|
|
|
|
const target = path.join(DATA_DIR, name);
|
|
|
|
const target = path.join(DATA_DIR, name);
|
|
|
|
if (!fs.existsSync(target)) fs.copyFileSync(path.join(SEED_DIR, name), target);
|
|
|
|
if (!fs.existsSync(target)) fs.copyFileSync(path.join(SEED_DIR, name), target);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
ensureDataFile('sponsors.json');
|
|
|
|
ensureDataFile('sponsors.json');
|
|
|
|
ensureDataFile('config.json');
|
|
|
|
ensureDataFile('config.json');
|
|
|
|
const ANALYTICS_FILE = path.join(DATA_DIR, 'analytics.json');
|
|
|
|
const ANALYTICS_FILE = path.join(DATA_DIR, 'analytics.json');
|
|
|
|
if (!fs.existsSync(ANALYTICS_FILE)) fs.writeFileSync(ANALYTICS_FILE, JSON.stringify({ sources: {} }, null, 2));
|
|
|
|
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 readJson(file) { return JSON.parse(fs.readFileSync(file, 'utf8')); }
|
|
|
|
function writeJson(file, data) {
|
|
|
|
function writeJson(file, data) {
|
|
|
|
const temp = `${file}.${crypto.randomUUID()}.tmp`;
|
|
|
|
const temp = `${file}.${crypto.randomUUID()}.tmp`;
|
|
|
|
fs.writeFileSync(temp, JSON.stringify(data, null, 2));
|
|
|
|
fs.writeFileSync(temp, JSON.stringify(data, null, 2));
|
|
|
|
fs.renameSync(temp, file);
|
|
|
|
fs.renameSync(temp, file);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
function getSponsors() { return readJson(SPONSORS_FILE).sort((a,b)=>(a.sortOrder||0)-(b.sortOrder||0)); }
|
|
|
|
function getSponsors() { return readJson(SPONSORS_FILE).sort((a,b)=>(a.sortOrder||0)-(b.sortOrder||0)); }
|
|
|
|
function saveSponsors(s) { writeJson(SPONSORS_FILE, s); }
|
|
|
|
function saveSponsors(s) { writeJson(SPONSORS_FILE, s); }
|
|
|
|
function getConfig() { return readJson(CONFIG_FILE); }
|
|
|
|
function getConfig() { return readJson(CONFIG_FILE); }
|
|
|
|
function activeSponsor(sponsors) { return sponsors.find(s=>s.status==='active') || sponsors.find(s=>s.status==='waiting') || null; }
|
|
|
|
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 getAnalytics() { try { return readJson(ANALYTICS_FILE); } catch (e) { return { sources: {} }; } }
|
|
|
|
function recordEvent(event, source) {
|
|
|
|
function recordEvent(event, source) {
|
|
|
|
if (!['bridge','start','click','training','postback','purchase','join'].includes(event)) return;
|
|
|
|
if (!['bridge','start','click','training','postback','purchase','join'].includes(event)) return;
|
|
|
|
const s = String(source||'').toLowerCase().trim().replace(/[^a-z0-9.()\-_:/ ]/g,'').slice(0,80) || '(direct)';
|
|
|
|
const s = String(source||'').toLowerCase().trim().replace(/[^a-z0-9.()\-_:/ ]/g,'').slice(0,80) || '(direct)';
|
|
|
|
const a = getAnalytics(); if (!a.sources) a.sources = {};
|
|
|
|
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 }; }
|
|
|
|
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;
|
|
|
|
a.sources[s][event] = (a.sources[s][event]||0) + 1;
|
|
|
|
writeJson(ANALYTICS_FILE, a);
|
|
|
|
writeJson(ANALYTICS_FILE, a);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
function normalizeStatuses(sponsors, preferredActiveId=null) {
|
|
|
|
function normalizeStatuses(sponsors, preferredActiveId=null) {
|
|
|
|
const eligible=sponsors.filter(s=>s.status!=='qualified');
|
|
|
|
const eligible=sponsors.filter(s=>s.status!=='qualified');
|
|
|
|
let activeId=preferredActiveId;
|
|
|
|
let activeId=preferredActiveId;
|
|
|
|
if(!activeId || !eligible.some(s=>s.id===activeId)){
|
|
|
|
if(!activeId || !eligible.some(s=>s.id===activeId)){
|
|
|
|
const existing=eligible.find(s=>s.status==='active');
|
|
|
|
const existing=eligible.find(s=>s.status==='active');
|
|
|
|
activeId=existing?existing.id:(eligible[0]?.id||null);
|
|
|
|
activeId=existing?existing.id:(eligible[0]?.id||null);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return sponsors.map(s=>s.status==='qualified'?s:{...s,status:s.id===activeId?'active':'waiting'});
|
|
|
|
return sponsors.map(s=>s.status==='qualified'?s:{...s,status:s.id===activeId?'active':'waiting'});
|
|
|
|
}
|
|
|
|
}
|
|
|
|
function publicSponsorPayload(sponsor, config) {
|
|
|
|
function publicSponsorPayload(sponsor, config) {
|
|
|
|
if(!sponsor)return null;
|
|
|
|
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)}`};
|
|
|
|
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'; img-src 'self' data:; connect-src 'self'; font-src 'self' data:; form-action 'self'; frame-src https://www.youtube-nocookie.com";
|
|
|
|
const CSP_BASE="default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; font-src 'self' data:; form-action 'self'; frame-src https://www.youtube-nocookie.com";
|
|
|
|
function securityHeaders(extra={}) {
|
|
|
|
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.
|
|
|
|
// Public pages must render inside safelist / traffic-exchange iframes, so framing stays open here; admin.html re-locks it via ADMIN_FRAME_HEADERS.
|
|
|
|
return {
|
|
|
|
return {
|
|
|
|
'X-Content-Type-Options':'nosniff','Referrer-Policy':'strict-origin-when-cross-origin',
|
|
|
|
'X-Content-Type-Options':'nosniff','Referrer-Policy':'strict-origin-when-cross-origin',
|
|
|
|
'Permissions-Policy':'camera=(), microphone=(), geolocation=()',
|
|
|
|
'Permissions-Policy':'camera=(), microphone=(), geolocation=()',
|
|
|
|
'Content-Security-Policy':`${CSP_BASE}; frame-ancestors *`,
|
|
|
|
'Content-Security-Policy':`${CSP_BASE}; frame-ancestors *`,
|
|
|
|
...extra
|
|
|
|
...extra
|
|
|
|
};
|
|
|
|
};
|
|
|
|
}
|
|
|
|
}
|
|
|
|
const ADMIN_FRAME_HEADERS={'X-Frame-Options':'DENY','Content-Security-Policy':`${CSP_BASE}; frame-ancestors 'none'`};
|
|
|
|
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 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 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 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 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}
|
|
|
|
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)})}
|
|
|
|
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 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){
|
|
|
|
function staticFile(req,res,file,status=200){
|
|
|
|
if(!fs.existsSync(file)||!fs.statSync(file).isFile())return false;
|
|
|
|
if(!fs.existsSync(file)||!fs.statSync(file).isFile())return false;
|
|
|
|
const size=fs.statSync(file).size;
|
|
|
|
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 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;
|
|
|
|
const m=status===200&&req.headers.range?String(req.headers.range).match(/^bytes=(\d*)-(\d*)$/):null;
|
|
|
|
if(m&&(m[1]!==''||m[2]!=='')){
|
|
|
|
if(m&&(m[1]!==''||m[2]!=='')){
|
|
|
|
const start=m[1]===''?Math.max(0,size-Number(m[2])):Number(m[1]);
|
|
|
|
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;
|
|
|
|
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}
|
|
|
|
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}));
|
|
|
|
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);
|
|
|
|
if(req.method==='HEAD')res.end();else fs.createReadStream(file,{start,end}).pipe(res);
|
|
|
|
return true;
|
|
|
|
return true;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
res.writeHead(status,securityHeaders({...base,'Content-Length':size}));
|
|
|
|
res.writeHead(status,securityHeaders({...base,'Content-Length':size}));
|
|
|
|
if(req.method==='HEAD')res.end();else fs.createReadStream(file).pipe(res);
|
|
|
|
if(req.method==='HEAD')res.end();else fs.createReadStream(file).pipe(res);
|
|
|
|
return true;
|
|
|
|
return true;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function handleApi(req,res,pathname){
|
|
|
|
async function handleApi(req,res,pathname){
|
|
|
|
if(req.method==='GET'&&pathname==='/health') return json(res,200,{ok:true});
|
|
|
|
if(req.method==='GET'&&pathname==='/health') return json(res,200,{ok:true});
|
|
|
|
if(req.method==='GET'&&pathname==='/api/public/config'){
|
|
|
|
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});
|
|
|
|
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'){
|
|
|
|
if(req.method==='GET'&&pathname==='/api/public/member'){
|
|
|
|
const ip=String(req.headers['x-forwarded-for']||req.socket.remoteAddress||'').split(',')[0].trim();
|
|
|
|
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.'});
|
|
|
|
if(memberLookupLimited(ip))return json(res,429,{error:'Too many lookups — give it a minute.'});
|
|
|
|
const id=Number(new URL(req.url,'http://x').searchParams.get('id')||0);
|
|
|
|
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.'});
|
|
|
|
if(!Number.isInteger(id)||id<1||id>281474976710655)return json(res,400,{error:'Enter a numeric member ID.'});
|
|
|
|
const cached=memberCache.get(id);
|
|
|
|
const cached=memberCache.get(id);
|
|
|
|
if(cached&&Date.now()-cached.ts<120000)return json(res,200,cached.data,{'Cache-Control':'public, max-age=60'});
|
|
|
|
if(cached&&Date.now()-cached.ts<120000)return json(res,200,cached.data,{'Cache-Control':'public, max-age=60'});
|
|
|
|
try{
|
|
|
|
try{
|
|
|
|
const r=await Promise.race([chain.memberPublic(id),new Promise((_,rej)=>setTimeout(()=>rej(new Error('Blockchain lookup timed out — try again.')),20000))]);
|
|
|
|
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)}`;
|
|
|
|
if(r.registered)r.referralUrl=`${getConfig().dappReferralBaseUrl}${encodeURIComponent(id)}`;
|
|
|
|
// align next-in-line with the human-curated rotation: prefer the ACTIVE
|
|
|
|
// 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;
|
|
|
|
// 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
|
|
|
|
// else the first chain-order position that's a queue participant; else
|
|
|
|
// keep the chain's pure structural pick (covers legs outside the queue).
|
|
|
|
// keep the chain's pure structural pick (covers legs outside the queue).
|
|
|
|
if(r.registered&&r.subtree){
|
|
|
|
if(r.registered&&r.subtree){
|
|
|
|
try{
|
|
|
|
try{
|
|
|
|
const sponsors=getSponsors();
|
|
|
|
const sponsors=getSponsors();
|
|
|
|
const act=activeSponsor(sponsors);
|
|
|
|
const act=activeSponsor(sponsors);
|
|
|
|
const participants=new Set(sponsors.filter(s=>s.status!=='qualified').map(s=>String(s.id)));
|
|
|
|
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);
|
|
|
|
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);}
|
|
|
|
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;
|
|
|
|
let pick=null;
|
|
|
|
if(act)pick=bfs.find(n=>String(n.id)===String(act.id)&&(n.directCount||0)<2);
|
|
|
|
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)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};
|
|
|
|
if(pick)r.nextInLine={id:pick.id,directCount:pick.directCount||0,levelName:pick.levelName};
|
|
|
|
}catch(e){}
|
|
|
|
}catch(e){}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
if(r.nextInLine)r.nextInLine.referralUrl=`${getConfig().dappReferralBaseUrl}${encodeURIComponent(r.nextInLine.id)}`;
|
|
|
|
if(r.nextInLine)r.nextInLine.referralUrl=`${getConfig().dappReferralBaseUrl}${encodeURIComponent(r.nextInLine.id)}`;
|
|
|
|
memberCache.set(id,{data:r,ts:Date.now()});
|
|
|
|
memberCache.set(id,{data:r,ts:Date.now()});
|
|
|
|
if(memberCache.size>500)memberCache.delete(memberCache.keys().next().value);
|
|
|
|
if(memberCache.size>500)memberCache.delete(memberCache.keys().next().value);
|
|
|
|
return json(res,200,r,{'Cache-Control':'public, max-age=60'});
|
|
|
|
return json(res,200,r,{'Cache-Control':'public, max-age=60'});
|
|
|
|
}catch(e){return json(res,502,{error:e.message||'Lookup failed'})}
|
|
|
|
}catch(e){return json(res,502,{error:e.message||'Lookup failed'})}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
if(req.method==='GET'&&pathname==='/api/public/payouts'){
|
|
|
|
if(req.method==='GET'&&pathname==='/api/public/payouts'){
|
|
|
|
return json(res,200,chain.getPayoutsPublic(),{'Cache-Control':'public, max-age=20'});
|
|
|
|
return json(res,200,chain.getPayoutsPublic(),{'Cache-Control':'public, max-age=20'});
|
|
|
|
}
|
|
|
|
}
|
|
|
|
if(req.method==='GET'&&pathname==='/api/public/current-sponsor'){
|
|
|
|
if(req.method==='GET'&&pathname==='/api/public/current-sponsor'){
|
|
|
|
const sponsors=getSponsors(),c=getConfig(),a=activeSponsor(sponsors);if(!a)return json(res,404,{error:'No active sponsor is currently assigned.'});
|
|
|
|
const sponsors=getSponsors(),c=getConfig(),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.'});
|
|
|
|
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'){
|
|
|
|
if(req.method==='POST'&&pathname==='/api/public/join-click'){
|
|
|
|
const b=await bodyJson(req).catch(()=>({}));recordEvent('click',b.source);
|
|
|
|
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,''):'';
|
|
|
|
const clickid=typeof b.clickid==='string'?b.clickid.trim().slice(0,80).replace(/[^A-Za-z0-9._-]/g,''):'';
|
|
|
|
firePostback(clickid,`join-${clickid}`,b.source);
|
|
|
|
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});
|
|
|
|
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/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/submit-id')return await handleSubmitId(req,res);
|
|
|
|
if(req.method==='POST'&&pathname==='/api/public/track'){
|
|
|
|
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});
|
|
|
|
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'){
|
|
|
|
if(req.method==='POST'&&pathname==='/api/admin/login'){
|
|
|
|
const b=await bodyJson(req).catch(e=>null);if(!b)return json(res,400,{error:'Invalid request'});if(typeof b.password!=='string'||b.password!==ADMIN_PASSWORD)return json(res,401,{error:'Invalid password'});
|
|
|
|
const b=await bodyJson(req).catch(e=>null);if(!b)return json(res,400,{error:'Invalid request'});if(typeof b.password!=='string'||b.password!==ADMIN_PASSWORD)return json(res,401,{error:'Invalid password'});
|
|
|
|
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});
|
|
|
|
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'){
|
|
|
|
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'});
|
|
|
|
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(pathname.startsWith('/api/admin/')&&!requireAdmin(req,res))return;
|
|
|
|
if(req.method==='GET'&&pathname==='/api/admin/matrix-tree'){
|
|
|
|
if(req.method==='GET'&&pathname==='/api/admin/matrix-tree'){
|
|
|
|
return json(res,200,chain.getMatrixTree());
|
|
|
|
return json(res,200,chain.getMatrixTree());
|
|
|
|
}
|
|
|
|
}
|
|
|
|
if(req.method==='GET'&&pathname==='/api/admin/member-lookup'){
|
|
|
|
if(req.method==='GET'&&pathname==='/api/admin/member-lookup'){
|
|
|
|
const id=Number(new URL(req.url,'http://x').searchParams.get('id')||0);
|
|
|
|
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.'});
|
|
|
|
if(!Number.isInteger(id)||id<1||id>281474976710655)return json(res,400,{error:'Enter a numeric member ID.'});
|
|
|
|
try{
|
|
|
|
try{
|
|
|
|
const r=await Promise.race([chain.memberLookup(id),new Promise((_,rej)=>setTimeout(()=>rej(new Error('Chain RPC timeout — try again.')),25000))]);
|
|
|
|
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);
|
|
|
|
return json(res,200,r);
|
|
|
|
}catch(e){return json(res,502,{error:e.message||'Lookup failed'})}
|
|
|
|
}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==='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'){
|
|
|
|
if(req.method==='POST'&&pathname==='/api/admin/sendgrid-key'){
|
|
|
|
const b=await bodyJson(req);const key=typeof b.key==='string'?b.key.trim():null;
|
|
|
|
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===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(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.).'});
|
|
|
|
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});
|
|
|
|
fs.writeFileSync(SENDGRID_KEY_FILE,key,{mode:0o600});
|
|
|
|
return json(res,200,{configured:true});
|
|
|
|
return json(res,200,{configured:true});
|
|
|
|
}
|
|
|
|
}
|
|
|
|
if(req.method==='POST'&&pathname==='/api/admin/openrouter-key'){
|
|
|
|
if(req.method==='POST'&&pathname==='/api/admin/openrouter-key'){
|
|
|
|
const b=await bodyJson(req);const key=typeof b.key==='string'?b.key.trim():null;
|
|
|
|
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===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===''){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.'});
|
|
|
|
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});
|
|
|
|
fs.writeFileSync(OPENROUTER_KEY_FILE,key,{mode:0o600});
|
|
|
|
return json(res,200,{configured:true});
|
|
|
|
return json(res,200,{configured:true});
|
|
|
|
}
|
|
|
|
}
|
|
|
|
if(req.method==='POST'&&pathname==='/api/admin/sponsors'){
|
|
|
|
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 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});
|
|
|
|
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'){
|
|
|
|
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','teamRootId','emailFrom','teamAlertEmail'])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 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','teamRootId','emailFrom','teamAlertEmail'])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))?$/);
|
|
|
|
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(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==='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==='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==='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==='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==='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==='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()});}
|
|
|
|
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'});
|
|
|
|
return json(res,404,{error:'API endpoint not found'});
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const server=http.createServer(async(req,res)=>{
|
|
|
|
const server=http.createServer(async(req,res)=>{
|
|
|
|
try{
|
|
|
|
try{
|
|
|
|
const u=new URL(req.url,`http://${req.headers.host||'localhost'}`),pathname=decodeURIComponent(u.pathname);
|
|
|
|
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(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(req.method!=='GET'&&req.method!=='HEAD')return send(res,405,'Method Not Allowed',{'Content-Type':'text/plain; charset=utf-8'});
|
|
|
|
let file;
|
|
|
|
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(/^\/join\/\d{1,15}$/.test(pathname))file=path.join(PUBLIC_DIR,'join.html');else if(pathname==='/join'||pathname==='/join/'){res.writeHead(302,{Location:'/start'});return res.end();}else{
|
|
|
|
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(/^\/join\/\d{1,15}$/.test(pathname))file=path.join(PUBLIC_DIR,'join.html');else if(pathname==='/join'||pathname==='/join/'){res.writeHead(302,{Location:'/start'});return res.end();}else{
|
|
|
|
const safe=path.normalize(pathname).replace(/^([.][.][/\\])+/, '').replace(/^[/\\]+/,'');file=path.join(PUBLIC_DIR,safe);if(!file.startsWith(PUBLIC_DIR))file='';
|
|
|
|
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);
|
|
|
|
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'});}
|
|
|
|
}catch(e){console.error(e);json(res,500,{error:'Internal server error'});}
|
|
|
|
});
|
|
|
|
});
|
|
|
|
server.listen(PORT,()=>{console.log(`Crypto Team Build sponsor router running on http://localhost:${PORT}`);if(ADMIN_PASSWORD==='changeme')console.warn('WARNING: Set ADMIN_PASSWORD before production deployment.');});
|
|
|
|
server.listen(PORT,()=>{console.log(`Crypto 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
|
|
|
|
// 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.
|
|
|
|
// to the Telegram group topic, with the sponsor's contact email when we have it.
|
|
|
|
chain.startIndexer(evt=>{
|
|
|
|
chain.startIndexer(evt=>{
|
|
|
|
try{
|
|
|
|
try{
|
|
|
|
const c=getConfig();
|
|
|
|
const c=getConfig();
|
|
|
|
const rootId=Number(c.teamRootId)||0;
|
|
|
|
const rootId=Number(c.teamRootId)||0;
|
|
|
|
const ids=evt.type==='payout'?[evt.toId,evt.fromId]:[evt.id];
|
|
|
|
const ids=evt.type==='payout'?[evt.toId,evt.fromId]:[evt.id];
|
|
|
|
if(rootId&&ids.some(i=>chain.isInTeam(i,rootId))){
|
|
|
|
if(rootId&&ids.some(i=>chain.isInTeam(i,rootId))){
|
|
|
|
const contact=id=>{const s=getSponsors().find(x=>String(x.id)===String(id));return s&&s.email?`\nContact: ${s.name?s.name+' — ':''}${s.email}`:''};
|
|
|
|
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;
|
|
|
|
let text;
|
|
|
|
if(evt.type==='registered')text=`📈 TEAM BUILD: new position!\n#${evt.id} registered under #${evt.referrerId} (${evt.tierName}).`;
|
|
|
|
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 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'?` (upgrade pass-up from #${evt.fromId})`:` (referral reward from #${evt.fromId})`}.${contact(evt.toId)}`;
|
|
|
|
else text=`💸 TEAM BUILD: #${evt.toId} just got PAID ${evt.pol.toFixed(2)} POL${evt.kind==='upline'?` (upgrade pass-up from #${evt.fromId})`:` (referral reward from #${evt.fromId})`}.${contact(evt.toId)}`;
|
|
|
|
if(evt.tx)text+=`\nhttps://polygonscan.com/tx/${evt.tx}`;
|
|
|
|
if(evt.tx)text+=`\nhttps://polygonscan.com/tx/${evt.tx}`;
|
|
|
|
sendTelegram(text);
|
|
|
|
sendTelegram(text);
|
|
|
|
// admin email alert — same team-gated events, so deep-leg action still surfaces
|
|
|
|
// admin email alert — same team-gated events, so deep-leg action still surfaces
|
|
|
|
if(c.teamAlertEmail){
|
|
|
|
if(c.teamAlertEmail){
|
|
|
|
const subj=evt.type==='registered'?`RM Circle team build: #${evt.id} registered under #${evt.referrerId}`
|
|
|
|
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}`
|
|
|
|
: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`;
|
|
|
|
:`RM Circle team build: #${evt.toId} paid ${evt.pol.toFixed(2)} POL`;
|
|
|
|
sendEmailRaw(c.teamAlertEmail,subj,text.replace(/^[^\s]+ /,''));
|
|
|
|
sendEmailRaw(c.teamAlertEmail,subj,text.replace(/^[^\s]+ /,''));
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}catch(e){console.error('team alert error',e.message)}
|
|
|
|
}catch(e){console.error('team alert error',e.message)}
|
|
|
|
// "you've been paid" email — any payout whose recipient has a contact email on file (not subtree-gated)
|
|
|
|
// "you've been paid" email — any payout whose recipient has a contact email on file (not subtree-gated)
|
|
|
|
try{
|
|
|
|
try{
|
|
|
|
if(evt.type==='payout'){
|
|
|
|
if(evt.type==='payout'){
|
|
|
|
const sp=getSponsors().find(x=>String(x.id)===String(evt.toId));
|
|
|
|
const sp=getSponsors().find(x=>String(x.id)===String(evt.toId));
|
|
|
|
if(sp&&sp.email)sendPaidEmail(sp.email,sp.name,evt);
|
|
|
|
if(sp&&sp.email)sendPaidEmail(sp.email,sp.name,evt);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}catch(e){console.error('paid email error',e.message)}
|
|
|
|
}catch(e){console.error('paid email error',e.message)}
|
|
|
|
// Auto-count directs (Marty 2026-08-14, "prevent manual effort"): a new
|
|
|
|
// Auto-count directs (Marty 2026-08-14, "prevent manual effort"): a new
|
|
|
|
// registration whose referrer sits in the rotation queue increments that
|
|
|
|
// registration whose referrer sits in the rotation queue increments that
|
|
|
|
// sponsor's directs automatically — same operation as the admin ⊕ button.
|
|
|
|
// sponsor's directs automatically — same operation as the admin ⊕ button.
|
|
|
|
// Deliberately does NOT auto-qualify at 2/2: rotating the team focus stays
|
|
|
|
// Deliberately does NOT auto-qualify at 2/2: rotating the team focus stays
|
|
|
|
// a human call, so it alerts instead.
|
|
|
|
// a human call, so it alerts instead.
|
|
|
|
try{
|
|
|
|
try{
|
|
|
|
if(evt.type==='registered'&&evt.referrerId!=null){
|
|
|
|
if(evt.type==='registered'&&evt.referrerId!=null){
|
|
|
|
const sponsors=getSponsors();
|
|
|
|
const sponsors=getSponsors();
|
|
|
|
const idx=sponsors.findIndex(x=>String(x.id)===String(evt.referrerId));
|
|
|
|
const idx=sponsors.findIndex(x=>String(x.id)===String(evt.referrerId));
|
|
|
|
if(idx>=0&&sponsors[idx].status!=='qualified'&&(Number(sponsors[idx].directs)||0)<2){
|
|
|
|
if(idx>=0&&sponsors[idx].status!=='qualified'&&(Number(sponsors[idx].directs)||0)<2){
|
|
|
|
sponsors[idx].directs=Math.min(2,(Number(sponsors[idx].directs)||0)+1);
|
|
|
|
sponsors[idx].directs=Math.min(2,(Number(sponsors[idx].directs)||0)+1);
|
|
|
|
saveSponsors(sponsors);
|
|
|
|
saveSponsors(sponsors);
|
|
|
|
const s=sponsors[idx];
|
|
|
|
const s=sponsors[idx];
|
|
|
|
sendTelegram(`🤖 AUTO-COUNT: #${evt.id} is a DIRECT for queue sponsor #${s.id}${s.name?` (${s.name})`:''} — now ${s.directs}/2.${s.directs>=2?'\n🎯 2/2 reached — open /admin and hit Qualify when you want the rotation to move.':''}`);
|
|
|
|
sendTelegram(`🤖 AUTO-COUNT: #${evt.id} is a DIRECT for queue sponsor #${s.id}${s.name?` (${s.name})`:''} — now ${s.directs}/2.${s.directs>=2?'\n🎯 2/2 reached — open /admin and hit Qualify when you want the rotation to move.':''}`);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}catch(e){console.error('auto-direct error',e.message)}
|
|
|
|
}catch(e){console.error('auto-direct error',e.message)}
|
|
|
|
// Queue level sync: when a queue member upgrades on-chain, their Level in the
|
|
|
|
// 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).
|
|
|
|
// rotation queue follows automatically (same op as the admin level dropdown).
|
|
|
|
try{
|
|
|
|
try{
|
|
|
|
if(evt.type==='upgraded'&&evt.id!=null){
|
|
|
|
if(evt.type==='upgraded'&&evt.id!=null){
|
|
|
|
const sponsors=getSponsors();
|
|
|
|
const sponsors=getSponsors();
|
|
|
|
const idx=sponsors.findIndex(x=>String(x.id)===String(evt.id));
|
|
|
|
const idx=sponsors.findIndex(x=>String(x.id)===String(evt.id));
|
|
|
|
if(idx>=0&&LEVELS.includes(evt.levelName)&&sponsors[idx].level!==evt.levelName){
|
|
|
|
if(idx>=0&&LEVELS.includes(evt.levelName)&&sponsors[idx].level!==evt.levelName){
|
|
|
|
sponsors[idx].level=evt.levelName;
|
|
|
|
sponsors[idx].level=evt.levelName;
|
|
|
|
saveSponsors(sponsors);
|
|
|
|
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}.`);
|
|
|
|
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)}
|
|
|
|
}catch(e){console.error('auto-level error',e.message)}
|
|
|
|
});
|
|
|
|
});
|
|
|
|