8858470244
The stop-waiting handout QR previously hit the plain join page with no angle continuation. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1052 lines
100 KiB
JavaScript
1052 lines
100 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 tgbot = require('./tgbot');
|
||
tgbot.init({ dataDir: DATA_DIR, chain, getConfig, messages, baseUrl: 'https://rmcircle.team' });
|
||
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". Their personal invite link (rmcircle.team/join/<their-id>) NEVER retires: it's a MOVING LINK built into the site — once the member is qualified it automatically routes each new signup to the next position in their leg that needs a direct, so members keep promoting one link forever and their promotion qualifies their team. Their personal effort shifts to helping their 2 get their 2 — a position only qualifies through its own directs. If a signup lands straight on a qualified position's raw ID anyway (the dApp path), 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
|
||
- TWO SHARE LINKS (on the dashboard "Share this position" panel): (1) TEAM LINK = the default rmcircle.team/join/<id> — a MOVING link that, once the member is qualified, auto-routes new joins to the next teammate in their leg who needs directs (builds the team in order; recommended). (2) DIRECT LINK = rmcircle.team/join/<id>?direct=1 — opts OUT of rotation so every join lands DIRECTLY under that member as spillover (builds their own depth). Both pay the member the entry reward on anyone they personally bring. Recommend the Team link (team-first), but the Direct link exists for members who want to stack spillover under themselves. Angle links (?v=pocket|phone|two|graveyard) work on either.
|
||
- 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.
|
||
- THE TRIPLE PLAY (optional fast-start; people ask "should I get one position or three?"): Most start with ONE position — get 2 directs, help them, duplicate (the standard path, all anyone needs). OPTIONAL alternative: buy a top position PLUS its own 2 directs at the same time. Those two count as the top's directs, so the TOP is instantly qualified (2/2) and catching upgrade pass-ups from day one; then you promote only the top position's moving link, which auto-routes new joiners to fill/qualify the positions beneath. Because ONE WALLET ADDRESS CAN ONLY HOLD ONE POSITION (blockchain rule), this needs THREE separate addresses. Foolproof = a separate wallet (or browser profile) per position. You CAN use "Add account" in one MetaMask (Account 1/2/3 are separate addresses), BUT MetaMask's account switch doesn't always follow into a connected site — if the join page still shows the old address the join reverts with "already registered", so you must confirm the NEW address is shown before signing each (or disconnect+reconnect the site with the new account). Separate wallets avoid this. HARD framing to always include: costs ~3x a single entry; ONLY money you can comfortably afford to lose; NEVER borrowed; NEVER front/fund it for someone else (it puts one person in control of positions on another's wallet); it's a faster START, not an income guarantee; and one position is perfectly fine. Never push it — present it and let them choose. UPGRADE STRATEGY for a Triple Play: most members (and the founder on #21) CONCENTRATE UPGRADES ON THE TOP position and leave wallets 2 & 3 lower — because pass-ups the lower positions aren't leveled to catch bubble UP to the first eligible position (the catcher-of-last-resort mechanic), so keeping the TOP most-leveled funnels the deep catches into the top wallet, and since you own all three the money stays in your family; upgrading all three works too but costs 3x the upgrades, so concentrating on the top is leaner. Upgrades always optional/self-paced/funded by earnings, never out of pocket. Full steps: https://rmcircle.team/how-pay-works#triple-play.
|
||
- LEVEL CAP / "does it stop at 8 levels?": there are 8 levels (Scintilla..Corona); Corona is the max LEVEL — you can't upgrade past it. But EARNING is not capped. (1) You catch an upgrade pass-up from every person in your first seven generations as they level up, and those generations hold up to 254 positions that keep filling via recruiting + spillover — one payment per person, but people keep arriving. (2) Pass-ups travel UP past anyone who can't catch them: an upgrade payment rises to the first upline who is qualified (2 directs) AND leveled high enough (level >= the level being bought), searching up to the 16-layer MAX_MATRIX_DEPTH; if nobody qualifies in range it goes to the project fee wallet. So a Corona member is the "catcher of last resort" — at max level they're eligible for every pass-up, never the bottleneck, and they catch the ones under-leveled members below them let slip. That's the mechanical reason to keep leveling up. Full explainer: https://rmcircle.team/how-pay-works#cap. Still contract mechanics, never an income promise.
|
||
- 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 — each recommendation has a one-tap "Send this nudge" button that sends a ready-written teach-forward message over the wallet-verified Messages system, delivered on-site and to Telegram if the member linked it), 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 (THE CIRCLE METHOD — the team's 10-lesson course in 3 modules; it is the MEMBERS-AREA product: Lesson 1 is the free public preview, Lessons 2-10 unlock by signing in with the wallet that owns a position (one free signature, right on the training page) or automatically inside the Telegram Mini App; every lesson's title and description stays visible so prospects can see what's included. M1 Get Your Two: L1 mindset, L2 warm list, L3 the conversation, L4 objections. M2 Help Your Two: L5 dashboard-as-coaching-desk, L6 first 48 hours, L7 stalled people & pass-ups, L8 timing upgrades to catches. M3 Teach the Teachers: L9 run the same play, L10 the 20-minute weekly rhythm. ROUTING RULE — for MEMBERS answer with the lesson: how do I find people→L2 (/training#lesson-2); what do I say→L3; pyramid objection→L4; new member just joined→L6; someone stalled→L7; should I upgrade→L8; overwhelmed→L10. For someone NOT yet a member, answer the substance directly yourself and mention that the full lesson is included with their position (never send a prospect a locked link as the answer). Deep links: /training#lesson-N — plus 8 how-to videos — team overview, wallet setup, funding, the new connect-wallet join flow on the site, the dApp backup method, how payments work, a full 14-min Member Dashboard walkthrough, and an 8-min REAL e-gift-card cash-out walkthrough (/training#egift-video, MEMBERS-ONLY like the Method lessons: POL → CWallet → dollar swap → gift card, ending with the virtual card in a mobile wallet ready to tap-to-pay) — + spillover article; the join-funnel and transparency videos are always public), https://rmcircle.team/how-pay-works (the two income streams shown as a pay-flow diagram + Premium/Standard tier comparison + a "What each level opens up" scaling table at /how-pay-works#level-scale showing how each level lets you catch a deeper, wider, higher-paying generation of pass-ups — explicitly framed as the contract's mechanical maximum at full fill, NOT a prediction or income promise; plus a "Does it cap out at 8 levels?" explainer at /how-pay-works#cap), 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/weekly-rhythm (printable 20-minute Weekly Rhythm routine + 4-week habit tracker from Method Lesson 10, personalized like the Fast Start sheet), https://rmcircle.team/fast-start (printable 48-Hour Fast Start checklist — personalized with the member's invite link and a scannable QR code when opened from their dashboard; prints clean black-on-white, and prints in whatever language the member selected with the 🌐 button), https://rmcircle.team/my (member dashboard — its "Your team" panel opens with an organization bar: total members in your org, generations deep, qualified count below you, POL earned below you, and its approximate USD value at an hourly-cached POL price; the matrix under it drills leg by leg), https://rmcircle.team/tools (for existing team members who want to promote — share-ready promo videos (including the “Pocket Change” curiosity hook video — 25 ways people flush pocket change weekly with nothing to show for it, then the side-hustle flip; it deliberately shows no URL so the poster's invite link in the caption/description carries the credit, and matching pocket-change post copy sits in the Social posts section), copy-paste social posts, short/long email swipes, a downloadable banner kit in every standard size (incl. a 1280×720 Telegram group-ad image with a tap-the-link-below CTA — members pair it with their own Telegram-native invite link in the caption), a Printable Handouts maker at /flyers (linked from /tools#flyers and the dashboard) — five bold full-color half-sheet handout designs with detailed artwork (one per angle: pocket change, two people, phone, side-hustle graveyard, stop waiting) that print two copies per letter page with a cut line so members can print, cut, and hand out stacks, for offline/belly-to-belly promotion (coffee shops, gyms, community & church bulletin boards, laundromats); enter the member ID once and every handout personalizes with the member's own QR code and invite link overlaid on the artwork (angle-matched ?v= links so the landing page continues the hook); print in color for impact and the QR scans in black-and-white too, and an Official RM Circle Media library (13 vertical social videos + 15 graphics from the creators — pair them with your own invite link in the caption; each curiosity video also has a MATCHED invite link (adds ?v=<hook> to the member's /join link) that makes the landing page continue that video's hook — recommend it when members ask which link to use with a video); open it from the gold Promo Tools button on your dashboard and every post/swipe arrives pre-personalized with YOUR invite link; NEW Promote-on-Telegram section at /tools#telegram — the member's Telegram-native Mini App invite links (t.me links that open the whole tour INSIDE Telegram; tapping your own link previews the prospect view), paste-ready Telegram group posts + DMs, and the setup message to forward to their team; Text-a-friend section at /tools#text-a-friend — 5 SMS-sized messages (general + one per angle video, matched to that video's landing page) with one-tap share buttons: Text it (opens the phone's messaging app pre-filled), WhatsApp (wa.me pre-fill), Telegram (shares the member's Mini App invite), Copy for Messenger/Instagram DMs; to write promos in their own voice, mybrandedvoice.com; plus an Objection Handling bank at the bottom — truth + ready-to-send reply per objection, incl. the what's-the-product / members-area answer), 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.
|
||
- TELEGRAM COMPANION BOT: members can link their position (dashboard → Messages → "Connect Telegram", wallet-verified) to get instant payout DMs, native Telegram delivery of team messages (reply in Telegram to answer — matrix-line rules still apply), joined-on-your-link pings, and their invite/angle links via the "links" command. This finally lets members reach their downline as real people instead of just IDs — while handles stay private (the bot relays). Linked members can also tap the bot's ☰ menu button to open the MINI APP — the full live dashboard, promo tools, and Circle Method training right inside Telegram with zero login (Telegram itself proves who they are). Prospects can JOIN from the Mini App too: a member's Telegram-native invite link (the "links" command in the bot shows it) opens the sponsor's invite page right inside Telegram, and the join itself finishes in the prospect's own wallet app's secure browser — same zero-custody flow as the website. The website stays fully available too; the Mini App is a convenience door, not a replacement.
|
||
- 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). When explaining gas, use the car analogy: just like a car needs fuel to get anywhere, every blockchain transaction burns a tiny bit of POL to move — keep a little in the tank beyond the entry, because a wallet with an empty tank cannot make the trip. 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.
|
||
- LANGUAGE: always reply in the language the member writes in — translate program terms naturally and keep level names (Scintilla, Ascensus, ...) as-is. Site pages have a floating 🌐 Translate button (bottom-left) that machine-translates any page and remembers the choice.
|
||
- 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.
|
||
- SPENDING/CASH-OUT (the "now how do I use it?" question): guide at /training#spending, and an 8-min video of a REAL cash-out done on screen at /training#egift-video (recommend it — seeing it done answers most doubts). Easiest path: e-gift cards — send POL to CWallet (cwallet.com), swap to a USD token internally (near-free swaps), buy gift cards for everyday brands; eGifter (egifter.com) accepts crypto directly. Straight cash: regulated exchange in their country (send → sell → bank withdrawal, KYC applies). Or leave catches in the wallet to fund the next level (Lesson 8 doctrine). ALWAYS add: independent custodial services — only move what you're about to spend; region availability and fees vary; possible tax implications — keep records; never financial advice.
|
||
- 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).
|
||
- "My prospect wants to join but has no money": coach the member to sort which of three objections it really is. (1) Most common — "sounds expensive/complicated": entry is ${c.premiumEntryPol || 362} POL — right now roughly ${entryUsdLine()} — payable with a regular debit card via the join page; many prospects imagine far more. ALWAYS quote the current dollar figure from this prompt, never a remembered one, and add that POL's price moves. (2) Priority dressed as money: send the "Pocket Change" curiosity video from /tools and go silent (it calls out weekly wasted spending — fully evergreen, no dollar figures) — never argue it. (3) Genuinely broke: the honest answer is "don't join yet" — never rent money, never desperation recruiting; ask "when's payday?" and follow up then. Waiting costs the prospect nothing: invite links don't expire and the team's growth is publicly visible on-chain while they wait. HARD RULE: nobody ever fronts or loans someone's entry — not the sponsor, not the team; it kills commitment and creates one person controlling positions registered to others' wallets. Teach-forward: have the member log how each conversation goes, because their people will hit the same objection.
|
||
- "Someone below me won't upgrade / is stalled": a paused member NEVER blocks money. Each person below you pays you separately, once, at one level — nothing waits on pairs or chains of upgrades. Payments a paused member can't catch pass straight UP through them to the next ready position (often the asker's), and everyone below the paused member keeps flowing normally; the only thing on hold is the single payment that person would send at the asker's matching level. Never pressure anyone — paused members keep collecting entry rewards from new team growth and often return once the upgrade funds itself. Point the asker at the pipeline panel on their dashboard to see who IS moving, and (teach-forward) have them show their two the same panel.
|
||
- "Is there a members area?": YES — a position unlocks: the Circle Method course (10 lessons + the real cash-out walkthrough) in the training members area, the live coaching dashboard at /my/<id>, promo tools personalized with their link at /tools, printable playbooks, wallet-verified team messaging, and the Telegram companion bot + Mini App. Access is by the wallet that owns the position — one free signature, no passwords — or automatic inside the Mini App.
|
||
- "What's the actual product? / What do I get?": answer with the business-in-a-box inventory that comes with a position on THIS team: (1) The Circle Method — a complete 10-lesson recruiting & coaching course in the members area (Lesson 1 is a free preview; the rest unlocks with your position); (2) a promo arsenal — 4 professionally produced curiosity videos with matched landing pages, ready-made posts, descriptions, email swipes and a full banner kit, all auto-personalized with their invite link; (3) printable playbooks (48-Hour Fast Start + Weekly Rhythm) with their own QR code; (4) an AI coach on every page answering in 21+ languages around the clock; (5) a live coaching dashboard reading the blockchain — pipeline, org bar, coach panel, wallet-verified team messaging. Be clear this toolkit is provided by this team around the public smart contract; the contract itself is the payment engine, the toolkit is what this team adds.
|
||
- "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 polPrice={usd:0,at:0};
|
||
async function getPolUsd(){
|
||
if(polPrice.usd&&Date.now()-polPrice.at<3600000)return polPrice.usd;
|
||
try{
|
||
const r=await Promise.race([
|
||
fetch('https://api.coingecko.com/api/v3/simple/price?ids=polygon-ecosystem-token&vs_currencies=usd').then(x=>x.json()),
|
||
new Promise((_,rej)=>setTimeout(()=>rej(new Error('price timeout')),8000))
|
||
]);
|
||
const usd=Number(r&&r['polygon-ecosystem-token']&&r['polygon-ecosystem-token'].usd);
|
||
if(usd>0){polPrice.usd=usd;polPrice.at=Date.now();}
|
||
}catch(e){}
|
||
return polPrice.usd||0; // 0 = unknown; dashboard simply hides the USD tile
|
||
}
|
||
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, no internal/contact detail. CTA destination is
|
||
// config.recruitCtaUrl (e.g. Marty's live dashboard as proof-first landing);
|
||
// defaults to the home page.
|
||
function recruitMsg(evt) {
|
||
const home = String(getConfig().recruitCtaUrl || 'rmcircle.team').replace(/^https?:\/\//, ''), 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));
|
||
}
|
||
// CTB Rewards featured offer: relay a verified 30s visit so CTB credits the
|
||
// user's coins. Contract per D:\Projects\CTBRewards\FEATURED-OFFER-SPEC.md:
|
||
// POST {clickid, event:'engaged', ts} + X-Offer-Secret; any 200 = done.
|
||
const ctbRelayed = new Set();
|
||
function fireCtbOfferPostback(clickid) {
|
||
const c = getConfig(), url = String(c.ctbOfferPostbackUrl || '').trim(), secret = String(c.ctbOfferSecret || '').trim();
|
||
if (!url || !secret || !/^https:\/\/[a-z0-9.-]+\//i.test(url)) return;
|
||
fetch(url, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json', 'X-Offer-Secret': secret },
|
||
body: JSON.stringify({ clickid, event: 'engaged', ts: Math.floor(Date.now() / 1000) })
|
||
})
|
||
.then(async r => {
|
||
const d = await r.json().catch(() => ({}));
|
||
if (r.ok) { recordEvent('ctb-offer-' + (d.status || 'ok'), 'ctbrewards'); console.log('ctb offer postback:', clickid.slice(0, 24), d.status, d.coins || ''); }
|
||
else console.error('ctb offer postback status', r.status, d.status || '');
|
||
})
|
||
.catch(e => console.error('ctb offer 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','engaged'].includes(event) && !String(event).startsWith('ctb-offer-')) 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)}`};
|
||
}
|
||
|
||
// --- on-demand UI translation: strings cached forever on the volume, misses
|
||
// filled by the same OpenRouter model the chatbot uses. Public site text only.
|
||
const TR_FILE = path.join(DATA_DIR, 'translations.json');
|
||
const TR_LANGS = new Set(['es','pt','fr','de','it','nl','pl','ro','ru','uk','tr','ar','hi','fil','vi','id','th','zh','ja','ko','sw']);
|
||
const TR_LANG_NAMES = {es:'Spanish',pt:'Portuguese',fr:'French',de:'German',it:'Italian',nl:'Dutch',pl:'Polish',ro:'Romanian',ru:'Russian',uk:'Ukrainian',tr:'Turkish',ar:'Arabic',hi:'Hindi',fil:'Filipino (Tagalog)',vi:'Vietnamese',id:'Indonesian',th:'Thai',zh:'Simplified Chinese',ja:'Japanese',ko:'Korean',sw:'Swahili'};
|
||
let trCache=null,trDirty=false;
|
||
function trLoad(){ if(trCache)return trCache; try{trCache=JSON.parse(fs.readFileSync(TR_FILE,'utf8'))}catch(e){trCache={}} return trCache; }
|
||
setInterval(()=>{ if(trDirty){trDirty=false;try{fs.writeFileSync(TR_FILE,JSON.stringify(trCache))}catch(e){}} },15000).unref();
|
||
const trIpHits=new Map();
|
||
function entryUsdLine(){
|
||
const pol=Number(getConfig().premiumEntryPol)||362;
|
||
if(!polPrice.usd)return 'a small one-time amount (see the join page for the current dollar figure)';
|
||
const entry=pol*polPrice.usd, allin=entry*1.055+2; // card fee ~4.5% + a little gas
|
||
return `$${entry.toFixed(0)} (about $${(Math.ceil(allin/5)*5).toFixed(0)} all-in with the card fee and gas)`;
|
||
}
|
||
function trLimited(ip){ const now=Date.now(); const h=trIpHits.get(ip)||{n:0,ts:now}; if(now-h.ts>600000){h.n=0;h.ts=now} h.n++; trIpHits.set(ip,h); if(trIpHits.size>2000)trIpHits.clear(); return h.n>60; }
|
||
async function handleTranslate(req,res){
|
||
const ip=String(req.headers['x-forwarded-for']||req.socket.remoteAddress||'').split(',')[0].trim();
|
||
// direct container-internal calls (no proxy header) skip the limiter — used by the cache warmer
|
||
const internal=!req.headers['x-forwarded-for']&&/^(127\.|10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.|::1|::ffff:(127\.|10\.|172\.|192\.168\.))/.test(String(req.socket.remoteAddress||''));
|
||
if(!internal&&trLimited(ip))return json(res,429,{error:'Too many translation requests — give it a minute.'});
|
||
const b=await bodyJson(req).catch(()=>null);
|
||
const tl=b?String(b.tl||''):'';
|
||
const texts=b&&Array.isArray(b.texts)?b.texts.slice(0,25).map(t=>String(t).slice(0,300)):null;
|
||
if(!TR_LANGS.has(tl)||!texts||!texts.length)return json(res,400,{error:'Bad request'});
|
||
if(texts.reduce((a,t)=>a+t.length,0)>9000)return json(res,400,{error:'Too much text'});
|
||
const cache=trLoad();
|
||
const keyOf=t=>tl+'|'+crypto.createHash('sha1').update(t).digest('hex').slice(0,16);
|
||
const out=new Array(texts.length); const miss=[];
|
||
texts.forEach((t,i)=>{ const c=cache[keyOf(t)]; if(c!=null)out[i]=c; else miss.push(i); });
|
||
if(miss.length){
|
||
const apiKey=getOpenRouterKey();
|
||
let done=false;
|
||
if(apiKey){
|
||
try{
|
||
const ctrl=new AbortController(); const timer=setTimeout(()=>ctrl.abort(),25000);
|
||
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 Translate'},
|
||
body:JSON.stringify({model:OPENROUTER_MODEL,max_tokens:6000,temperature:0,messages:[
|
||
{role:'system',content:`You translate website UI strings from English to ${TR_LANG_NAMES[tl]}. The input is numbered items, each preceded by a line @@N@@. Reply with the SAME @@N@@ marker lines, each followed by that item's translation (multi-line items keep their line breaks). Output nothing except markers and translations — no preamble, no code fences. Keep these words untranslated wherever they appear: Scintilla, Ascensus, Fabrica, Culmen, Apex, Fastigium, Vertex, Corona, POL, Polygon, RM Circle, MoonPay, MetaMask, Trust Wallet. Keep numbers, #ids, emoji and punctuation intact. Natural, friendly tone.`},
|
||
{role:'user',content:miss.map((i,j)=>`@@${j+1}@@\n${texts[i]}`).join('\n')}
|
||
]})
|
||
});
|
||
clearTimeout(timer);
|
||
if(r.ok){
|
||
const d=await r.json();
|
||
let reply=d&&d.choices&&d.choices[0]&&d.choices[0].message&&d.choices[0].message.content||'';
|
||
reply=reply.replace(/^```[a-z]*\s*/,'').replace(/```\s*$/,'').trim();
|
||
const parts=reply.split(/^@@(\d+)@@\s*$/m);
|
||
// parts: [lead, "1", text1, "2", text2, ...]
|
||
const arr=[];
|
||
for(let k=1;k+1<parts.length;k+=2){ arr[Number(parts[k])-1]=String(parts[k+1]).trim(); }
|
||
// per-item acceptance: one malformed item must never poison its batch
|
||
let got=0;
|
||
miss.forEach((idx,j)=>{
|
||
const tr=typeof arr[j]==='string'&&arr[j].length?String(arr[j]).slice(0,600):null;
|
||
if(tr){ out[idx]=tr; cache[keyOf(texts[idx])]=tr; got++; }
|
||
});
|
||
if(got){ trDirty=true; done=true; }
|
||
if(got<miss.length)console.error(`translate ${tl}: ${got}/${miss.length} items parsed`);
|
||
}else{ console.error('translate openrouter status',r.status); }
|
||
}catch(e){ console.error('translate error',e.message); }
|
||
}
|
||
if(!done)miss.forEach(i=>{out[i]=texts[i]});
|
||
}
|
||
return json(res,200,{t:out});
|
||
}
|
||
|
||
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();await getPolUsd().catch(()=>{});return json(res,200,{polUsd:polPrice.usd||0,siteName:c.siteName,programName:c.programName,bridgeHeadline:c.bridgeHeadline,bridgeSubheadline:c.bridgeSubheadline,premiumEntryPol:c.premiumEntryPol,telegramUrl:c.telegramUrl,supportLabel:c.supportLabel,showQueueProgress:c.showQueueProgress,walletNotice:c.walletNotice||''});
|
||
}
|
||
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'};
|
||
}
|
||
}
|
||
const usd=await getPolUsd(); if(usd>0){r.polUsd=usd;r.polUsdAt=polPrice.at;}
|
||
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.startsWith('/api/tg-hook/')){
|
||
if(pathname.slice('/api/tg-hook/'.length)!==tgbot.webhookSecret())return json(res,404,{error:'Not found'});
|
||
const b=await bodyJson(req).catch(()=>null);
|
||
if(b)tgbot.handleUpdate(b);
|
||
return json(res,200,{ok:true});
|
||
}
|
||
if(req.method==='POST'&&pathname==='/api/public/tg-link'){
|
||
const s2=messages.authFromCookie(req);
|
||
if(!s2)return json(res,401,{error:'Not signed in.'});
|
||
const r=tgbot.makeLinkCode(s2.id);
|
||
if(!r.url)return json(res,200,{error:'The Telegram bot is warming up — try again in a minute.'});
|
||
return json(res,200,{url:r.url,linked:!!tgbot.memberChat(s2.id)});
|
||
}
|
||
if(req.method==='POST'&&pathname==='/api/public/tg-webapp-auth'){
|
||
// Telegram Mini App auth bridge: signed initData (HMAC-verified against the
|
||
// companion bot token) proves the Telegram account; the wallet-verified
|
||
// link in tg-links.json maps it to a member — so linked members land on
|
||
// their dashboard with zero login. Never creates links, only reads them.
|
||
const ip=String(req.headers['x-forwarded-for']||req.socket.remoteAddress||'').split(',')[0].trim();
|
||
if(memberLookupLimited(ip))return json(res,429,{error:'Too many requests — wait a minute.'});
|
||
const b=await bodyJson(req).catch(()=>null);
|
||
if(!b||typeof b.initData!=='string')return json(res,400,{error:'Invalid request.'});
|
||
const v=tgbot.verifyInitData(b.initData);
|
||
if(v.error)return json(res,401,{error:'Could not verify the Telegram launch data — close and reopen the app.'});
|
||
const memberId=tgbot.chatMember(v.userId);
|
||
if(!memberId)return json(res,200,{ok:true,linked:false});
|
||
const tok=messages.mintSession(memberId);
|
||
if(!tok)return json(res,500,{error:'Session error — try again.'});
|
||
return json(res,200,{ok:true,linked:true,id:memberId},{'Set-Cookie':messages.sessionCookie(tok)});
|
||
}
|
||
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||{});
|
||
if(!r.error&&b&&!b.org&&b.toId)try{tgbot.notifyMessage(s.id,Number(b.toId),String(b.body||''))}catch(e){}
|
||
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/translate')return await handleTranslate(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/track'){
|
||
const b=await bodyJson(req).catch(()=>({}));recordEvent(b.event,b.source);
|
||
// CTB Rewards featured offer: a 30s-dwell "engaged" event carrying a
|
||
// ctb1_ clickid is relayed server-to-server so CTB can credit the coins.
|
||
// CTB is idempotent per clickid; the local Set just cuts repeat traffic.
|
||
if(b.event==='engaged'){
|
||
const cid=typeof b.clickid==='string'?b.clickid.trim().slice(0,80).replace(/[^A-Za-z0-9._-]/g,''):'';
|
||
if(cid.startsWith('ctb1_')&&!ctbRelayed.has(cid)){
|
||
ctbRelayed.add(cid); if(ctbRelayed.size>2000)ctbRelayed.clear();
|
||
fireCtbOfferPostback(cid);
|
||
}
|
||
}
|
||
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','companionBotToken','miniAppShortName','telegramChatId','telegramTopicId','telegramRecruitTopicId','teamRootId','emailFrom','teamAlertEmail','ownerIds','ownerAlertEmail','orgRootId','ctbOfferPostbackUrl','ctbOfferSecret','recruitCtaUrl','walletNotice','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 ANGLE_OG={
|
||
pocket:{t:'You already spent it this week.',d:'The pocket-change illusion, called out in 105 seconds. Watch.',i:'/v/og-angle-pocket.jpg'},
|
||
phone:{t:"Everybody's making money on your phone. Except you.",d:'90 seconds that flips it. Watch.',i:'/v/og-angle-phone.jpg'},
|
||
two:{t:"You know two people. That's all this takes.",d:'The #1 excuse, retired in 90 seconds. Watch.',i:'/v/og-angle-two.jpg'},
|
||
graveyard:{t:'Your side-hustle graveyard has enough tenants.',d:"This one's built different. 90 seconds. Watch.",i:'/v/og-angle-graveyard.jpg'},
|
||
stopwaiting:{t:'Stop waiting. Start receiving.',d:'The whole thing, explained in one short video. Watch.',i:'/og-card.jpg'}
|
||
};
|
||
const angleKey=kind==='join'?new URL(req.url,'http://x').searchParams.get('v'):null;
|
||
const angle=angleKey?ANGLE_OG[angleKey]:null;
|
||
const url=kind==='join'?`${base}/join/${id}${angle?`?v=${angleKey}`:''}`:`${base}/my/${id}`;
|
||
const title=angle?angle.t:(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=angle?angle.d:(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=angle?`${base}${angle.i}`:`${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'});
|
||
// Members-area gate: the Circle Method lessons 2-10 + the e-gift cash-out
|
||
// walkthrough are the PRODUCT — their video FILES require a wallet-verified
|
||
// member session (web sign-in or the Mini App auth bridge). Lesson 1, all
|
||
// posters, and every join-funnel/transparency video stay public on purpose:
|
||
// Lesson 1 is the free preview, the rest is the trust engine.
|
||
if((/^\/v\/rmc-method-m\d+l\d+-[0-9a-f]+\.mp4$/.test(pathname)&&!/^\/v\/rmc-method-m1l1-/.test(pathname))||pathname==='/training/egift_cards.mp4'){
|
||
if(!messages.authFromCookie(req))return json(res,403,{error:'Members area — sign in on the training page with the wallet that owns your position, or open it through the Telegram Mini App.'},{'Cache-Control':'no-store'});
|
||
}
|
||
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==='/app'||pathname==='/app/')file=path.join(PUBLIC_DIR,'app.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==='/fast-start'||pathname==='/fast-start/')file=path.join(PUBLIC_DIR,'fast-start.html');else if(pathname==='/flyers'||pathname==='/flyers/')file=path.join(PUBLIC_DIR,'flyers.html');else if(pathname==='/weekly-rhythm'||pathname==='/weekly-rhythm/')file=path.join(PUBLIC_DIR,'weekly-rhythm.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{tgbot.notifyEvent(evt);}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){ const cta='https://'+String(c.recruitCtaUrl||'rmcircle.team').replace(/^https?:\/\//,''); sendTelegram(rm, c.telegramRecruitTopicId, {inline_keyboard:[[{text:'🚀 Get Started — rmcircle.team',url:cta+(cta.includes('?')?'&':'?')+'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)}
|
||
});
|