From c3711495072760743368daaadbb90f61fa89b69b Mon Sep 17 00:00:00 2001 From: martbost Date: Wed, 16 Sep 2026 11:35:57 -0500 Subject: [PATCH] Chatbot: canned + AI-prompt answer for reading the generation table (level you hold vs level they buy; Vertex row is gen 7, not a total) Co-Authored-By: Claude Fable 5.1 --- public/chat.js | 322 +++-- server.js | 3764 ++++++++++++++++++++++++------------------------ 2 files changed, 2044 insertions(+), 2042 deletions(-) diff --git a/public/chat.js b/public/chat.js index 7a2ee0a..f7a4c80 100644 --- a/public/chat.js +++ b/public/chat.js @@ -1,160 +1,162 @@ -(function(){ - let config=null,sponsorInfo=null; - async function loadData(){ - try{const r=await fetch('/api/public/config');if(r.ok)config=await r.json()}catch(e){} - try{const r=await fetch('/api/public/current-sponsor');if(r.ok)sponsorInfo=await r.json()}catch(e){} - } - function esc(s){return String(s??'').replace(/[&<>'"]/g,c=>({'&':'&','<':'<','>':'>',"'":''','"':'"'}[c]))} - function tg(){return (config&&config.telegramUrl)||'https://t.me/cryptoteambuild'} - function pol(){return (config&&config.premiumEntryPol)||362} - function usd(){var u=config&&config.polUsd;return u>0?' (about $'+Math.round(pol()*u)+' at today’s POL price — it moves)':''} - function tgLink(label){return `${esc(label||'Open the Telegram group ↗')}`} - - const KB=[ - {k:['what is','rm circle','about','this site','this project','crypto team build','who are you','network'], - a:()=>`We enter the RM Circle dApp at the Premium tier (${pol()} POL on Polygon) and build as one team: each member gets exactly 2 directs, then helps those 2 get their 2 — building depth instead of width. Start with the training videos.`}, - {k:['how does it work','strategy','how it works','plan','roadmap','system','duplicate','duplication'], - a:()=>`The core strategy: get your 2, help your 2 get their 2, upgrade with profits, repeat — one link, forever. Once a position has 2 directs it's qualified, and its invite link automatically starts routing new people to the next position in the leg that needs directs — nobody stops promoting; the link moves the effort down for you. The full picture is in Video 1 of the training, and the roadmap poster is on the home page.`}, - {k:['cost','price','entry','how much','362','fee','pol needed','expensive'], - a:()=>`Premium entry is ${pol()} POL${usd()} on Polygon Mainnet, plus a small extra POL balance for network gas. POL's dollar value changes with the market. Only ever use funds you can afford to lose.`}, - {k:['pol','polygon','network','chain','gas','matic','mainnet'], - a:()=>`The build runs on Polygon Mainnet (chain ID 137). POL is the network's native token — it pays for your Premium entry AND for transaction "gas." Easiest way to think about gas: just like a car needs fuel to get from A to B, every blockchain transaction burns a tiny bit of POL to move. That's why you keep a little extra in the tank beyond your entry — a wallet that's bone-dry can't make the trip. In MetaMask, make sure Polygon is the selected network before doing anything.`}, - {k:['metamask','wallet','create wallet','install','set up wallet','setup wallet'], - a:()=>`Install MetaMask only from the official site or your device's official app store, then create your wallet. Video 2 in the training walks through it step by step. Never share your Secret Recovery Phrase with anyone — not the team, not this site, not a "support agent".`}, - {k:['fund','funding','buy pol','get pol','deposit','exchange','transfer','send pol'], - a:()=>`You'll need about ${pol()} POL plus a little extra for gas${usd()}, on Polygon Mainnet. Video 3 in the training shows how to fund your wallet. Brand new to crypto? You can buy POL directly with a debit/credit card, Apple Pay, or Google Pay via MoonPay — pick POL on the Polygon network (MoonPay may show POL's old name, MATIC — same token) and send it to your own wallet address. The join page also offers this automatically if your connected wallet is short. Double-check you're getting POL on Polygon — not another chain.`}, - {k:['join','buy','purchase','premium position','enter','sign up','signup','register','get started','start'], - a:()=>`Ready to join? Go to the Getting Started page — it shows the current team sponsor and lets you connect your wallet and buy your position right on the site, with the sponsor ID filled in automatically. Video 4 in the training walks through the whole process (and Video 5 covers the dApp backup method).`}, - {k:['sponsor','current sponsor','who do i join under','placement','whose link','referral'], - a:()=>{const s=sponsorInfo&&sponsorInfo.sponsor;return s?`The current team sponsor is ID ${esc(s.id)}${s.name?' ('+esc(s.name)+')':''} — always verify on the Getting Started page right before you join, because placements rotate as positions qualify. Don't use an old screenshot or saved link.`:`The current sponsor is always shown live on the Getting Started page — check there right before joining, because placements rotate as positions qualify.`}}, - {k:['qualified','qualification','2 directs','two directs','directs','retire'], - a:()=>`A position is qualified once it has 2 directs. Nothing to retire after that — your invite link keeps working and automatically routes new signups to the next position in your leg that needs a direct, so your promotion starts qualifying your team. Your personal effort shifts to helping your 2 get their 2 — their own directs are the only thing that qualifies them. And if someone joins straight on your position's raw ID after 2/2, it's a bonus, not a problem: the entry reward still pays your position and they spill downward, adding depth to your leg. Just remember spillover never qualifies the people below — their own directs do.`}, - {k:['safe','scam','rug','rug pull','security','audit','trust','legit','contract code','smart contract','can they change','steal'], - a:()=>`Fair question — don't take anyone's word for it. We reviewed the complete verified source of the RM Circle smart contract and published a plain-language breakdown at rmcircle.team/contract: the code can never change (no upgrade mechanism exists), member money never sits in the contract (every payment distributes in the same transaction), prices and payout rules are locked forever, and the operator's powers are limited to a short, honest list. Every claim links to the blockchain so you can verify it yourself. Code security isn't an income guarantee though — results depend on real participation, and POL's value moves.`}, - {k:['disappear','disappears','creator','creators','abandon','abandoned','walk away','goes away','shut down','shuts down','still work','keep running','keeps running','autonomous','dies','lost keys','without them','stop working','turned off','if they leave','what if they','forever','over time'], - a:()=>`Great question — and we checked it on-chain, not just in theory. The contract needs no one to keep it running: joins, upgrades, placement and every payout are fully automatic — no button anyone has to press, no expiry. If the creators walked away, lost their keys, or vanished, member payments keep flowing exactly as coded. We also verified that the founder, development and fee wallets are ordinary wallets, not programs — an ordinary wallet always accepts an incoming payment even if its key is lost forever, so a dead admin wallet can't jam a single member payment (at worst the project's own fee sits there uncollected). And the contract holds no stored balance — every payment is delivered in the same transaction. Full write-up in section 6 of rmcircle.team/contract.`}, - {k:['pyramid','ponzi','pyramid scheme','ponzi scheme','mlm','recruiting scheme','is this a scheme'], - a:()=>`A pyramid or Ponzi scheme funnels everyone's money to a central company and pays earlier joiners out of later joiners' deposits — and you can't verify any of it. This is the opposite: no company holds the money. A public smart contract on Polygon sends each payment person-to-person in the same transaction it arrives, and you can read the code and every payout yourself on-chain — nothing pooled, nothing hidden, rules that can't be changed. It is a team build, so it takes real effort and carries real crypto risk — not a passive investment. But you don't have to trust anyone; verify it at rmcircle.team/contract. No income is guaranteed.`}, - {k:['telegram bot','connect telegram','payout ping','telegram notification','message my downline','contact my downline','reach my downline','mini app','miniapp','telegram app','dashboard in telegram'], - a:()=>`Link your position to our Telegram companion bot: open your dashboard → Messages → sign in with your wallet → tap "Connect Telegram". Once linked you get an instant DM whenever your position catches a payment, team messages reach you natively in Telegram (reply right there to answer), you're pinged when someone joins on your link, and "links" gives you all your invite links. Linked members can also tap the bot's ☰ menu button to open the Mini App — your full live dashboard, promo tools, and the Circle Method right inside Telegram, no login needed. The bot's "links" command includes a Telegram-native invite link that opens your invite page instantly inside Telegram — great for sharing in groups and DMs; the prospect's join finishes in their own wallet app (zero custody, as always). Messaging still follows your matrix lines only — same rules as the site.`}, - {k:['cash out','cashout','spend my crypto','withdraw','off ramp','off-ramp','gift card','giftcard','get my money out','turn into cash','convert to dollars'], - a:()=>`Three good paths, easiest first: (1) E-gift cards — send POL to CWallet (cwallet.com), swap to a US-dollar token there (their internal swaps are virtually free), and buy gift cards for brands you already use — groceries, gas, Amazon. eGifter (egifter.com) also takes crypto directly. (2) Straight cash-out via a regulated exchange in your country (Coinbase, Kraken…): send, sell, withdraw to your bank. (3) Keep it working — many members leave catches in the wallet to fund their next level. Full guide: Spending what you earn — and Video 8 shows a real cash-out done on screen (members area), ending with the card in a mobile wallet ready to tap-to-pay. Honest notes: those are independent custodial services — only move what you're about to spend; taxes may apply where you live; not financial advice.`}, - {k:['circle method','recruiting course','how do i recruit','recruiting training','get my two','get your two','how to invite','module 1','lessons'], - a:()=>`That's The Circle Method — our 10-lesson course, three modules, ~30 minutes total. Module 1 "Get Your Two": mindset, warm list, the exact words, objections. Module 2 "Help Your Two": your dashboard as a coaching desk, the first 48 hours, stalled people, timing upgrades. Module 3 "Teach the Teachers": run the same play + the 20-minute weekly rhythm. Lesson 1 is free to watch; the rest is part of the members area that comes with your position — unlock it on the training page with the wallet that owns your position (one free signature), or it's already unlocked inside the Telegram Mini App. The Method's golden rule: the answer to every question is a lesson — when your people ask something, send the lesson that answers it.`}, - {k:['whats the product','what is the product','actual product','what do i get','what am i buying','circle suite','the suite','software','tools included','marketing tools','toolkit'], - a:()=>`Most programs hand you a referral link and wish you luck. Your position here is also a licence to The Circle Suite — the team's marketing toolkit. Connect the wallet that owns your position (one free signature) and your live contract level lights up your tools. Level 1 today: Promo Center, Printable Handouts, Fast Start + Weekly Rhythm, Generation Pay chart, The Circle Method, your Live Dashboard and the AI coach. Higher levels open more as you climb — Copy Engine and Page Builder at Ascensus, Email Engine and Video Maker at Fabrica, Voice Profile and Funnels at Culmen, the Traffic Desk at Apex, Funnel Factory at Fastigium, Leader Ops at Vertex and the Founder Desk at Corona. Tiles still in development say so honestly, and they unlock automatically as you upgrade. The Suite is included with membership and is never sold separately.`}, - {k:['one position','three positions','multiple positions','how many positions','buy more positions','triple play','trifecta','instant qualify','instantly qualify','self qualify','qualify myself','more than one position','two positions','several positions','extra positions'], - a:()=>`Most people start with one position — get your 2 directs, help them get theirs, duplicate. That path works and it's all you need. There's also an optional fast-start we call the Triple Play: you buy your top position, then buy 2 more positions directly under it. Those two count as your top position's directs, so your top is instantly qualified (2/2) and ready to catch upgrade pass-ups as your matrix fills — no waiting to find two people first. From there you just promote your top position's link, and it auto-routes new joiners to fill and qualify the positions beneath you. Honest caveats: it costs about 3× the entry and each position needs its own separate wallet address — the foolproof way is a different wallet (or browser profile) for each; you can use "Add account" in one MetaMask, but you must confirm the join page shows the NEW address before each join or it'll say "already registered." So only do it with money you can comfortably afford to lose — never borrowed, and nobody should ever fund it for you. It's a faster start, not a guarantee — the positions still grow through real team-building. One position is perfectly fine; the Triple Play is just there for people who want a running start. Upgrade tip: most people (and how the founder runs #21) concentrate upgrades on the top position — because pass-ups your lower positions aren't leveled to catch flow UP into your top wallet, so you build one strong position instead of upgrading all three. See the full breakdown.`}, - {k:['cap','capped','cap out','max level','maximum level','8 levels','eight levels','after corona','top level','stop earning','earning cap','level cap','beyond corona'], - a:()=>`Your level caps at Corona (level 8) — you can't upgrade higher. But your earning doesn't cap. Two reasons: (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 over time. (2) Pass-ups travel UP past anyone who can't catch them — when someone upgrades, the payment rises to the first upline who's qualified and leveled high enough (up to the contract's 16-layer depth cap; otherwise it goes to the project fee wallet). So Corona is the position that misses nothing: at max level you're eligible for every pass-up, you're never the bottleneck, and you catch the ones under-leveled members below you let slip. It's the whole reason to keep leveling up. Full breakdown: rmcircle.team/how-pay-works#cap — contract mechanics, verifiable on-chain, never a promise of income.`}, - {k:['members area','member area','whats the product','what is the product','what am i buying','what do i get','members only','locked','unlock','sign in to training'], - a:()=>`Yes — every position includes a real members area: The Circle Method (10-lesson recruiting & coaching course + a real cash-out walkthrough) on the training page, the weekly team webinar replays, your live coaching dashboard, promo tools personalized with your invite link, printable playbooks, wallet-verified team messaging, and the Telegram companion bot + Mini App. No passwords — access is proven by the wallet that owns your position (one free signature that can't move funds), and inside the Telegram Mini App it unlocks automatically. Lesson 1 and all the how-it-works videos are public so you can inspect everything before joining.`}, - {k:['no money','cant afford','can not afford',"can't afford",'broke','they dont have','waiting on payday','no funds to join'], - a:()=>`"No money" is usually one of three things — sort it first. (1) Most often it means "this sounds expensive/complicated": entry is ${pol()} POL${usd()||" — a small one-time amount"} — payable with a regular debit card right on the join page. (2) Sometimes it's a priority thing: send the "Pocket Change" video from Promo Tools and let it do the talking. (3) If they're genuinely broke: tell them honestly "don't join yet" — never rent money, never money they can't afford to lose. Ask "when's payday?" and follow up then — their invite link doesn't expire and the team keeps building publicly while they wait. One hard rule: never pay someone's entry for them.`}, - {k:['own the level','need to own','have to buy the level','catch without','still at scintilla','upline not upgraded','sponsor not upgraded','only pays 2','only pays two','only 2 payments','seems small','2 then 4','level is your reach'], - a:()=>`Two facts that surprise people, both straight from the contract code: (1) To catch your two matrix children's FIRST upgrade (Ascensus), you only need to be qualified with your two directs — you do NOT need to own Ascensus yourself, because the contract checks the catcher against the level the buyer is jumping FROM (Scintilla, which everyone has). Owning levels extends your REACH: catching a Fabrica payment from 2 generations down requires Ascensus, and so on — your level is your reach, which is why the team stays one level ahead of its deepest active layer. There's a 97-second video on exactly this — "Your level is your reach" — on the training page (Video 7). And to watch the whole discipline run on a REAL position (every upgrade funded by the catch before it), see "The Textbook Play". (2) Yes, each depth pays a fixed count — 2, then 4, then 8, one payment per person — but each deeper generation pays twice as much per person at twice the width (full-generation value quadruples per level), generations keep filling over time via recruiting and spillover, and pass-ups from under-leveled members add catches on top. Exact numbers: how-pay-works#exact-costs — mechanical maximums, never promised income. Want it as a printable chart personalized to YOUR position (shows which generations you're catching now vs. which need your next level)? rmcircle.team/generation-pay — enter your ID and print.`}, - {k:['entry price','upgrade price','different price','entry vs upgrade','why is the upgrade','entry fee different','two prices','inflated','price chart','cost chart','entry column','upgrade column'], - a:()=>`They're two different purchases, paid to two different people — nothing is double-counted. Entry is the one-time price to create your position (Premium level 1: 360.58 POL — charts often show ~362 with a small send buffer); 95% of the base goes to your sponsor, and the contract keeps a 5% admin charge on entries only. Upgrade is what an existing position pays to climb one level (621.40 POL to reach level 2), and 100% of every upgrade travels up the matrix to a qualified member — zero admin fee. The upgrade ladder also doubles EXACTLY at every rung (2 × 621.40 = 1,242.80, to the penny), so the first two pass-ups you catch at your level always equal your own next climb. See the full contract-exact table at how-pay-works#exact-costs — every number is a locked constant you can verify with getAllCosts() on Polygonscan.`}, - {k:['fast start','checklist','printable','print','48 hour','48-hour','getting started sheet'], - a:()=>`Grab the 48-Hour Fast Start sheet — a printable one-page checklist for your first two days. Open it from YOUR dashboard and it comes pre-filled with your invite link and a scannable QR code, then hit the print button (it prints clean black-on-white, in whatever language you've picked with the 🌐 button).`}, - {k:['flyer','flyers','poster','posters','bulletin board','coffee shop','put up','print flyer','offline','belly to belly','belly-to-belly','tear off','tear-off','qr code flyer','gym','laundromat'], - a:()=>`Yes — there's a Printable Handouts maker (also linked on your dashboard and the Promo Tools page under "🪧 Printable handouts"). Enter your ID once and every handout personalizes with YOUR QR code and invite link. Five bold full-color designs — one per angle (pocket change, two people, phone, side-hustle graveyard, stop waiting) — each with detailed artwork and the QR overlaid on it. Every design prints TWO copies per letter page with a cut line: print, cut, and you've got a stack to hand out or leave anywhere. Print in color for impact — the QR scans in black-and-white too. Great for coffee shops, gyms, community and church bulletin boards, laundromats — old-school belly-to-belly still works.`}, - {k:['translate','translation','language','español','espanol','spanish','french','portuguese','not in english','other language'], - a:()=>`Tap the 🌐 Translate button in the bottom-left corner of any page — pick your language once and every page stays translated while you browse. Your phone's browser also usually offers its own "Translate page" option. And you can type to me here in ANY language — I'll answer in yours.`}, - {k:['how many people','team size','organization size','org size','how big is my team','total team','people in my team','members below'], - a:()=>`Open your dashboard at rmcircle.team/my and look at the top of the "Your team" panel — the organization bar shows your total member count, how many generations deep your team runs, how many positions below you are qualified, the total POL earned below you, and its approximate US-dollar value at the latest POL price (refreshed about hourly). The matrix cards under it break the same numbers down leg by leg (the ⬇ rollup line on each card counts everyone underneath, all the way down).`}, - {k:['wont upgrade',"won't upgrade",'not upgrading','refuses to upgrade','stalled','stuck member','not ready to upgrade','below me wont','waiting on someone','blocked by'], - a:()=>`Good news — a member who isn't upgrading never blocks your money. Each person below you pays you separately, once, at one level; nothing waits on pairs or chains. If someone pauses, payments they can't catch simply pass UP to the next ready position (often yours), and everyone below them keeps flowing normally. The only thing on hold is the single payment that person would send at your matching level. Never pressure anyone — upgrades are optional and self-paced, and paused members often return once their own entry rewards stack up and the upgrade funds itself. Your pipeline panel shows who's actually moving — coach them.`}, - {k:['stops there','stop there','dead end','runs out','run out of people','get their two','get their 2','their own two','only two people','what if my two','cant get their'], - a:()=>`Honest answer: everyone needs their own 2 to qualify — no one else's activity qualifies them. But a leg doesn't just dead-end. It's a team: your job is to help your 2 get theirs, and your upline earns as your team grows, so helping you win is in their interest too. And when uplines bring in more than 2, the extras spill down and fill the matrix beneath you — adding depth even when someone's own recruiting is slow. It only truly stalls if everyone stops helping, which is the one thing a team is built to prevent. (Spillover fills your matrix but never qualifies anyone — their own directs do that.)`}, - {k:['afford','cant afford','can t afford','no money to upgrade','forced to upgrade','have to upgrade','keep paying','cant keep up'], - a:()=>`Upgrades are optional and self-paced — you're never on a timer, and you should never use money you can't afford to lose. You upgrade to catch payments from deeper in your team, and the first payments at each level are designed to help fund the next upgrade, so many people let team activity pay their way up instead of out of pocket. Start where you're comfortable and move at your own pace. See how it lines up at how-pay-works.`}, - {k:['salesperson','not a seller','sales pitch','know anyone','no one to bring','nobody to','not good at sales','bad at recruiting','hate selling','who would i'], - a:()=>`Good news — there's nothing to sell. You share a short video and your invite link, and the site does the explaining for you. You don't need a big network or a sales pitch — just a couple of people who are curious. The team even gives you ready-made promo tools (posts, swipes, videos) so you're not starting from a blank page. Get your 2, help them get their 2 — that's the whole ask.`}, - {k:['quit','they quit','people quit','drop out','give up','go inactive','stop doing','flake','lose interest'], - a:()=>`Some people go quiet — that's normal anywhere. But a position lives on the blockchain and doesn't vanish when someone steps back, and the team keeps building around a quiet spot (spillover can still fill beneath it). You don't need everyone active — you need a couple of real builders. Focus on the people who show up, go deep with them, and the leg keeps growing. Build with people who genuinely want it — and remember, no income is guaranteed.`}, - {k:['spillover','spill over','spill','under me but not qualified','people under me','not my direct','passed over','missed payment','skipped','why am i not qualified'], - a:()=>`Spillover happens when someone's sponsor already has both matrix slots full — the smart contract then places the new member in the next open slot further down, which can be under YOU. Spillover fills your matrix and sets up future upgrade payments to your position, but it does not count toward your qualification: only people who join using your ID are your directs. That's why you can see 2 under you and still show 1/2. And important: upgrade payments only stop at positions that are qualified (2 directs) AND at the required level — otherwise the payment passes you by to the next eligible upline. Check your position on your Member Dashboard — spillover placements are tagged there.`}, - {k:['how do i get paid','how do i earn','commission','commissions','how payments work','how does the money','payout','payouts','pay flow','when do i get paid','how do i make money','income','two ways','how you get paid'], - a:()=>`Two ways money reaches your position — the full diagram is at rmcircle.team/how-pay-works. (1) Entry rewards: when a direct joins under your link you get their entry reward (about 326 POL at Premium) and you keep it — on every direct you bring. (2) Upgrade payments: each person below you pays you once, at the ONE level that matches how far below you they sit — someone 1 layer below pays you when they hit Ascensus, 2 layers below pays you at Fabrica, 3 at Culmen, and so on. To catch each, you must be qualified and at or above the level that person is LEAVING — layer 1's Ascensus buy needs you at Scintilla (qualified), layer 3's Culmen buy needs you at Fabrica or higher — so keep your level at or above your team's deepest active layer. No income is guaranteed.`}, - {k:['tier','standard','premium tier','standard tier','premium vs standard','which tier','half','smaller payment','less than expected','why is my payment'], - a:()=>`There are two tiers. Premium is what our whole team builds at (${pol()} POL entry) — full payments. Standard costs about half and pays/earns half at every level. So if a payment ever comes in smaller than expected, it usually came from a Standard-tier position below you. Your tier is set when you join and can't be changed later (upgrading advances your level, not your tier), so always join Premium and make sure the people you bring on do too. Full breakdown: how-pay-works.`}, - {k:['dashboard','my dashboard','my page','my position','my team','check my','see my','pipeline','my stats','alerts','notify me','email me','get notified','message','messages','contact my sponsor','contact my upline','contact my downline','reach my team'], - a:()=>`Your Member Dashboard is at rmcircle.team/my — enter your ID to see your position, your team (with a depth summary showing members per generation), your payments, your pipeline (money forming below you), any spillover under you, and a Coach Your Team panel that tells you exactly who in your leg needs a nudge and what to say — each recommendation has a "Send this nudge" button that delivers a ready-written message straight to that member (sign in to Messages once with your wallet; it reaches their dashboard and their Telegram if linked). When you're qualified, you can also upgrade your level right from your dashboard — connect the wallet that owns your position and confirm; the exact cost is read live from the contract. You can also turn on email alerts there, and use Messages — wallet-verified messaging with your upline and your team (sign in once with your wallet; no email needed; only people on your matrix lines can message you). To share, use your personal invite page: rmcircle.team/join/<your ID>.`}, - {k:['level','levels','upgrade','scintilla','ascensus','fabrica','culmen','apex','fastigium','vertex','corona','8 levels'], - a:()=>`There are 8 Premium levels: Scintilla, Ascensus, Fabrica, Culmen, Apex, Fastigium, Vertex, Corona. Everyone starts at Scintilla. Upgrade as quickly as practical — ideally using earned POL — because the first two payments at each level are designed to help fund your next upgrade. Stay aware of your active downline's levels so you don't fall behind.`}, - {k:['30 positions','goal','milestone','matrix','how many people','team size'], - a:()=>`The first major team goal is 30 properly placed positions: 2 → 4 → 8 → 16 across the first four generations. Then we keep going — 32, 64, 128 and beyond.`}, - {k:['moving link','why not my link','own link','my referral','use my link','extra legs'], - a:()=>`We use a moving-link strategy — and it's automatic: every member's invite link self-rotates. While a position still needs directs, its link credits it; the moment it's qualified, the same link starts routing new signups to the next position in that leg that needs help. Nobody retires anything and nobody stops promoting — the link hunts for the next spot on its own. Prefer new people to land under YOU instead of rotating? Your dashboard also gives you a Direct link (adds ?direct=1) that places every join directly under your position as spillover. Team link (default) = help your team qualify; Direct link = build your own depth. Joining fresh? The Getting Started page always shows the current team placement.`}, - {k:['direct link','team link','keep my link','spillover under me','stop rotating','not rotate','build my own','two links','which link','sticky link'], - a:()=>`Your dashboard gives you two links under "Share this position": Team link (the default /join/<id>) — once you're qualified it routes new joins to the next teammate who needs directs, building your team in order; and Direct link (/join/<id>?direct=1) — every join lands directly under you as spillover, building your own depth. Both credit you the entry reward on anyone you personally bring. Team link is recommended (it grows the whole leg), but the Direct link is there if you'd rather stack spillover under yourself. Copy either from your dashboard.`}, - {k:['risk','safe','scam','lose money','guarantee','guaranteed','income','earnings','legit'], - a:()=>`Straight answer: participation involves cryptocurrency and smart-contract risk, and no income is guaranteed. Results depend on team effort, duplication, upgrades, smart-contract rules, and POL's market value. Only use funds you can afford to lose.`}, - {k:['video','videos','training','watch','tutorial','learn','how to'], - a:()=>`All the training videos are on the Training page: (1) How the team build works, (2) Create your MetaMask wallet, (3) Funding your wallet, (4) Joining right on the site with wallet connect, (5) the dApp backup method, (6) how payments work, and (7) the full Member Dashboard walkthrough — pipeline, upgrading on-page, Messages, coaching, and your promo tools. Watch them in order.`}, - {k:['seed phrase','secret recovery','recovery phrase','private key','12 words','passphrase'], - a:()=>`Never share your Secret Recovery Phrase or private keys with anyone — not this site, not a sponsor, not "support". Anyone asking for it is trying to steal your funds. MetaMask will never ask for it either, except when you restore a wallet yourself.`}, - {k:['promote','promo','promo tools','tools','swipe','swipes','email swipe','banners','banner','graphics','marketing','share this','invite people','video clips','how do i share','social post','facebook post','twitter','my voice','branded voice'], - a:()=>`Grab share-ready promo tools at rmcircle.team/tools — best way in is the gold Promo Tools button on your dashboard: arriving from there, every post and swipe is automatically personalized with YOUR invite link, so you copy and share as-is. You get promo videos (tap Copy link to share), a Promote-on-Telegram section (your Telegram-native Mini App invite links that open the whole tour inside Telegram, paste-ready group posts and DMs, and the connect-your-team message to forward), copy-paste social posts for X & Facebook, a Text-a-friend section (short SMS-sized messages for each angle video with one-tap Text it / WhatsApp / Telegram share buttons), email & message swipes (short and long), a downloadable banner kit in every standard size, a library of official RM Circle videos and graphics from the creators (vertical clips ready for Reels/TikTok/Shorts), and a builder for your personal invite link. Want it all in your own words? Learn to train an AI on your writing at mybrandedvoice.com. Keep it honest — point people to the videos and the contract review, never promise income.`}, - {k:['telegram','group','chat','human','support','contact','talk','someone','admin','help me'], - a:()=>`For live help from the team, join the Telegram group: ${tgLink()}. Tell them where you're stuck and someone will walk you through it.`}, - {k:['after join','next','what happens','confirmed','joined','i bought','i am in'], - a:()=>`Once your position is confirmed: tell the team your new RM Circle ID and your sponsor ID (in the Telegram group: ${tgLink('here')}) so your placement gets mapped. Then your job is simple — get your 2 directs, and when you're qualified, help your 2 get their 2.`}, - {k:['waiting','queue','line','how long','when my turn'], - a:()=>{const w=sponsorInfo&&typeof sponsorInfo.waitingCount==='number'?sponsorInfo.waitingCount:null;return `${w!==null?`Right now there ${w===1?'is':'are'} ${w} team placement${w===1?'':'s'} waiting behind the current sponsor. `:''}The queue moves every time a position qualifies with its 2 directs — the more the team helps each other, the faster it rotates.`}}, - ]; - - function answer(q){ - const t=q.toLowerCase().replace(/[^a-z0-9 ]/g,' '); - let best=null,bestScore=0; - for(const e of KB){ - let score=0; - for(const k of e.k){if(t.includes(k))score+=k.includes(' ')?2:1} - if(score>bestScore){bestScore=score;best=e} - } - if(best&&bestScore>0)return best.a(); - return `I don't have a good answer for that one. The team can help you directly — ${tgLink('join our Telegram group ↗')} and ask there. You can also check the training videos or the strategy overview.`; - } - - const CHIPS=['How does it work?','What does it cost?','Who is the current sponsor?','How do I set up a wallet?','Is there risk?']; - - const fab=document.createElement('button'); - fab.className='chat-fab';fab.setAttribute('aria-label','Open team help chat');fab.innerHTML='💬'; - const panel=document.createElement('div'); - panel.className='chat-panel chat-hidden'; - panel.innerHTML=`
Team Help
Ask about the RM Circle team build
Can't find it? ${tgLink('Get live help on Telegram ↗')}
`; - document.body.appendChild(fab);document.body.appendChild(panel); - const msgs=panel.querySelector('.chat-msgs'),chipsEl=panel.querySelector('.chat-chips'),form=panel.querySelector('.chat-input'),input=form.querySelector('input'); - - function addMsg(html,who){const d=document.createElement('div');d.className=`chat-msg ${who}`;if(who==='user'){d.textContent=html}else{d.innerHTML=html}msgs.appendChild(d);msgs.scrollTop=msgs.scrollHeight;return d} - function linkify(text){let h=esc(text);h=h.replace(/(https?:\/\/[^\s&<]+[^\s&<.,)])/g,u=>{const site=u.startsWith('https://rmcircle.team');const path=site?u.replace('https://rmcircle.team',''):u;return `${u}`});return h.replace(/\n/g,'
')} - const history=[];let aiDown=false; - function stripHtml(h){const d=document.createElement('div');d.innerHTML=h;return d.textContent||''} - async function ask(q){ - addMsg(q,'user');history.push({role:'user',content:q}); - if(aiDown){const a=answer(q);addMsg(a,'bot');history.push({role:'assistant',content:stripHtml(a)});return} - const typing=addMsg('…','bot'); - try{ - const r=await fetch('/api/public/chat',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({messages:history.slice(-8)})}); - const d=await r.json(); - typing.remove(); - if(d.reply){addMsg(linkify(d.reply),'bot');history.push({role:'assistant',content:d.reply})} - else{if(d.fallback)aiDown=true;const a=answer(q);addMsg(a,'bot');history.push({role:'assistant',content:stripHtml(a)})} - }catch(e){typing.remove();aiDown=true;const a=answer(q);addMsg(a,'bot');history.push({role:'assistant',content:stripHtml(a)})} - } - - let started=false; - function open(){ - panel.classList.remove('chat-hidden');fab.classList.add('chat-hidden');input.focus(); - if(!started){started=true;loadData().then(()=>{ - panel.querySelector('.chat-foot').innerHTML=`Can't find it? ${tgLink('Get live help on Telegram ↗')}`; - addMsg(`Hi! I can answer questions about the RM Circle team build — the strategy, wallet setup, funding, joining, levels, and more. What do you want to know?`,'bot'); - for(const c of CHIPS){const b=document.createElement('button');b.type='button';b.className='chat-chip';b.textContent=c;b.addEventListener('click',()=>{chipsEl.classList.add('chat-hidden');ask(c)});chipsEl.appendChild(b)} - })} - } - function close(){panel.classList.add('chat-hidden');fab.classList.remove('chat-hidden')} - fab.addEventListener('click',open); - panel.querySelector('.chat-close').addEventListener('click',close); - form.addEventListener('submit',e=>{e.preventDefault();const q=input.value.trim();if(!q)return;input.value='';chipsEl.classList.add('chat-hidden');ask(q)}); -})(); +(function(){ + let config=null,sponsorInfo=null; + async function loadData(){ + try{const r=await fetch('/api/public/config');if(r.ok)config=await r.json()}catch(e){} + try{const r=await fetch('/api/public/current-sponsor');if(r.ok)sponsorInfo=await r.json()}catch(e){} + } + function esc(s){return String(s??'').replace(/[&<>'"]/g,c=>({'&':'&','<':'<','>':'>',"'":''','"':'"'}[c]))} + function tg(){return (config&&config.telegramUrl)||'https://t.me/cryptoteambuild'} + function pol(){return (config&&config.premiumEntryPol)||362} + function usd(){var u=config&&config.polUsd;return u>0?' (about $'+Math.round(pol()*u)+' at today’s POL price — it moves)':''} + function tgLink(label){return `${esc(label||'Open the Telegram group ↗')}`} + + const KB=[ + {k:['what is','rm circle','about','this site','this project','crypto team build','who are you','network'], + a:()=>`We enter the RM Circle dApp at the Premium tier (${pol()} POL on Polygon) and build as one team: each member gets exactly 2 directs, then helps those 2 get their 2 — building depth instead of width. Start with the training videos.`}, + {k:['how does it work','strategy','how it works','plan','roadmap','system','duplicate','duplication'], + a:()=>`The core strategy: get your 2, help your 2 get their 2, upgrade with profits, repeat — one link, forever. Once a position has 2 directs it's qualified, and its invite link automatically starts routing new people to the next position in the leg that needs directs — nobody stops promoting; the link moves the effort down for you. The full picture is in Video 1 of the training, and the roadmap poster is on the home page.`}, + {k:['cost','price','entry','how much','362','fee','pol needed','expensive'], + a:()=>`Premium entry is ${pol()} POL${usd()} on Polygon Mainnet, plus a small extra POL balance for network gas. POL's dollar value changes with the market. Only ever use funds you can afford to lose.`}, + {k:['pol','polygon','network','chain','gas','matic','mainnet'], + a:()=>`The build runs on Polygon Mainnet (chain ID 137). POL is the network's native token — it pays for your Premium entry AND for transaction "gas." Easiest way to think about gas: just like a car needs fuel to get from A to B, every blockchain transaction burns a tiny bit of POL to move. That's why you keep a little extra in the tank beyond your entry — a wallet that's bone-dry can't make the trip. In MetaMask, make sure Polygon is the selected network before doing anything.`}, + {k:['metamask','wallet','create wallet','install','set up wallet','setup wallet'], + a:()=>`Install MetaMask only from the official site or your device's official app store, then create your wallet. Video 2 in the training walks through it step by step. Never share your Secret Recovery Phrase with anyone — not the team, not this site, not a "support agent".`}, + {k:['fund','funding','buy pol','get pol','deposit','exchange','transfer','send pol'], + a:()=>`You'll need about ${pol()} POL plus a little extra for gas${usd()}, on Polygon Mainnet. Video 3 in the training shows how to fund your wallet. Brand new to crypto? You can buy POL directly with a debit/credit card, Apple Pay, or Google Pay via MoonPay — pick POL on the Polygon network (MoonPay may show POL's old name, MATIC — same token) and send it to your own wallet address. The join page also offers this automatically if your connected wallet is short. Double-check you're getting POL on Polygon — not another chain.`}, + {k:['join','buy','purchase','premium position','enter','sign up','signup','register','get started','start'], + a:()=>`Ready to join? Go to the Getting Started page — it shows the current team sponsor and lets you connect your wallet and buy your position right on the site, with the sponsor ID filled in automatically. Video 4 in the training walks through the whole process (and Video 5 covers the dApp backup method).`}, + {k:['sponsor','current sponsor','who do i join under','placement','whose link','referral'], + a:()=>{const s=sponsorInfo&&sponsorInfo.sponsor;return s?`The current team sponsor is ID ${esc(s.id)}${s.name?' ('+esc(s.name)+')':''} — always verify on the Getting Started page right before you join, because placements rotate as positions qualify. Don't use an old screenshot or saved link.`:`The current sponsor is always shown live on the Getting Started page — check there right before joining, because placements rotate as positions qualify.`}}, + {k:['qualified','qualification','2 directs','two directs','directs','retire'], + a:()=>`A position is qualified once it has 2 directs. Nothing to retire after that — your invite link keeps working and automatically routes new signups to the next position in your leg that needs a direct, so your promotion starts qualifying your team. Your personal effort shifts to helping your 2 get their 2 — their own directs are the only thing that qualifies them. And if someone joins straight on your position's raw ID after 2/2, it's a bonus, not a problem: the entry reward still pays your position and they spill downward, adding depth to your leg. Just remember spillover never qualifies the people below — their own directs do.`}, + {k:['safe','scam','rug','rug pull','security','audit','trust','legit','contract code','smart contract','can they change','steal'], + a:()=>`Fair question — don't take anyone's word for it. We reviewed the complete verified source of the RM Circle smart contract and published a plain-language breakdown at rmcircle.team/contract: the code can never change (no upgrade mechanism exists), member money never sits in the contract (every payment distributes in the same transaction), prices and payout rules are locked forever, and the operator's powers are limited to a short, honest list. Every claim links to the blockchain so you can verify it yourself. Code security isn't an income guarantee though — results depend on real participation, and POL's value moves.`}, + {k:['disappear','disappears','creator','creators','abandon','abandoned','walk away','goes away','shut down','shuts down','still work','keep running','keeps running','autonomous','dies','lost keys','without them','stop working','turned off','if they leave','what if they','forever','over time'], + a:()=>`Great question — and we checked it on-chain, not just in theory. The contract needs no one to keep it running: joins, upgrades, placement and every payout are fully automatic — no button anyone has to press, no expiry. If the creators walked away, lost their keys, or vanished, member payments keep flowing exactly as coded. We also verified that the founder, development and fee wallets are ordinary wallets, not programs — an ordinary wallet always accepts an incoming payment even if its key is lost forever, so a dead admin wallet can't jam a single member payment (at worst the project's own fee sits there uncollected). And the contract holds no stored balance — every payment is delivered in the same transaction. Full write-up in section 6 of rmcircle.team/contract.`}, + {k:['pyramid','ponzi','pyramid scheme','ponzi scheme','mlm','recruiting scheme','is this a scheme'], + a:()=>`A pyramid or Ponzi scheme funnels everyone's money to a central company and pays earlier joiners out of later joiners' deposits — and you can't verify any of it. This is the opposite: no company holds the money. A public smart contract on Polygon sends each payment person-to-person in the same transaction it arrives, and you can read the code and every payout yourself on-chain — nothing pooled, nothing hidden, rules that can't be changed. It is a team build, so it takes real effort and carries real crypto risk — not a passive investment. But you don't have to trust anyone; verify it at rmcircle.team/contract. No income is guaranteed.`}, + {k:['telegram bot','connect telegram','payout ping','telegram notification','message my downline','contact my downline','reach my downline','mini app','miniapp','telegram app','dashboard in telegram'], + a:()=>`Link your position to our Telegram companion bot: open your dashboard → Messages → sign in with your wallet → tap "Connect Telegram". Once linked you get an instant DM whenever your position catches a payment, team messages reach you natively in Telegram (reply right there to answer), you're pinged when someone joins on your link, and "links" gives you all your invite links. Linked members can also tap the bot's ☰ menu button to open the Mini App — your full live dashboard, promo tools, and the Circle Method right inside Telegram, no login needed. The bot's "links" command includes a Telegram-native invite link that opens your invite page instantly inside Telegram — great for sharing in groups and DMs; the prospect's join finishes in their own wallet app (zero custody, as always). Messaging still follows your matrix lines only — same rules as the site.`}, + {k:['cash out','cashout','spend my crypto','withdraw','off ramp','off-ramp','gift card','giftcard','get my money out','turn into cash','convert to dollars'], + a:()=>`Three good paths, easiest first: (1) E-gift cards — send POL to CWallet (cwallet.com), swap to a US-dollar token there (their internal swaps are virtually free), and buy gift cards for brands you already use — groceries, gas, Amazon. eGifter (egifter.com) also takes crypto directly. (2) Straight cash-out via a regulated exchange in your country (Coinbase, Kraken…): send, sell, withdraw to your bank. (3) Keep it working — many members leave catches in the wallet to fund their next level. Full guide: Spending what you earn — and Video 8 shows a real cash-out done on screen (members area), ending with the card in a mobile wallet ready to tap-to-pay. Honest notes: those are independent custodial services — only move what you're about to spend; taxes may apply where you live; not financial advice.`}, + {k:['circle method','recruiting course','how do i recruit','recruiting training','get my two','get your two','how to invite','module 1','lessons'], + a:()=>`That's The Circle Method — our 10-lesson course, three modules, ~30 minutes total. Module 1 "Get Your Two": mindset, warm list, the exact words, objections. Module 2 "Help Your Two": your dashboard as a coaching desk, the first 48 hours, stalled people, timing upgrades. Module 3 "Teach the Teachers": run the same play + the 20-minute weekly rhythm. Lesson 1 is free to watch; the rest is part of the members area that comes with your position — unlock it on the training page with the wallet that owns your position (one free signature), or it's already unlocked inside the Telegram Mini App. The Method's golden rule: the answer to every question is a lesson — when your people ask something, send the lesson that answers it.`}, + {k:['whats the product','what is the product','actual product','what do i get','what am i buying','circle suite','the suite','software','tools included','marketing tools','toolkit'], + a:()=>`Most programs hand you a referral link and wish you luck. Your position here is also a licence to The Circle Suite — the team's marketing toolkit. Connect the wallet that owns your position (one free signature) and your live contract level lights up your tools. Level 1 today: Promo Center, Printable Handouts, Fast Start + Weekly Rhythm, Generation Pay chart, The Circle Method, your Live Dashboard and the AI coach. Higher levels open more as you climb — Copy Engine and Page Builder at Ascensus, Email Engine and Video Maker at Fabrica, Voice Profile and Funnels at Culmen, the Traffic Desk at Apex, Funnel Factory at Fastigium, Leader Ops at Vertex and the Founder Desk at Corona. Tiles still in development say so honestly, and they unlock automatically as you upgrade. The Suite is included with membership and is never sold separately.`}, + {k:['one position','three positions','multiple positions','how many positions','buy more positions','triple play','trifecta','instant qualify','instantly qualify','self qualify','qualify myself','more than one position','two positions','several positions','extra positions'], + a:()=>`Most people start with one position — get your 2 directs, help them get theirs, duplicate. That path works and it's all you need. There's also an optional fast-start we call the Triple Play: you buy your top position, then buy 2 more positions directly under it. Those two count as your top position's directs, so your top is instantly qualified (2/2) and ready to catch upgrade pass-ups as your matrix fills — no waiting to find two people first. From there you just promote your top position's link, and it auto-routes new joiners to fill and qualify the positions beneath you. Honest caveats: it costs about 3× the entry and each position needs its own separate wallet address — the foolproof way is a different wallet (or browser profile) for each; you can use "Add account" in one MetaMask, but you must confirm the join page shows the NEW address before each join or it'll say "already registered." So only do it with money you can comfortably afford to lose — never borrowed, and nobody should ever fund it for you. It's a faster start, not a guarantee — the positions still grow through real team-building. One position is perfectly fine; the Triple Play is just there for people who want a running start. Upgrade tip: most people (and how the founder runs #21) concentrate upgrades on the top position — because pass-ups your lower positions aren't leveled to catch flow UP into your top wallet, so you build one strong position instead of upgrading all three. See the full breakdown.`}, + {k:['cap','capped','cap out','max level','maximum level','8 levels','eight levels','after corona','top level','stop earning','earning cap','level cap','beyond corona'], + a:()=>`Your level caps at Corona (level 8) — you can't upgrade higher. But your earning doesn't cap. Two reasons: (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 over time. (2) Pass-ups travel UP past anyone who can't catch them — when someone upgrades, the payment rises to the first upline who's qualified and leveled high enough (up to the contract's 16-layer depth cap; otherwise it goes to the project fee wallet). So Corona is the position that misses nothing: at max level you're eligible for every pass-up, you're never the bottleneck, and you catch the ones under-leveled members below you let slip. It's the whole reason to keep leveling up. Full breakdown: rmcircle.team/how-pay-works#cap — contract mechanics, verifiable on-chain, never a promise of income.`}, + {k:['members area','member area','whats the product','what is the product','what am i buying','what do i get','members only','locked','unlock','sign in to training'], + a:()=>`Yes — every position includes a real members area: The Circle Method (10-lesson recruiting & coaching course + a real cash-out walkthrough) on the training page, the weekly team webinar replays, your live coaching dashboard, promo tools personalized with your invite link, printable playbooks, wallet-verified team messaging, and the Telegram companion bot + Mini App. No passwords — access is proven by the wallet that owns your position (one free signature that can't move funds), and inside the Telegram Mini App it unlocks automatically. Lesson 1 and all the how-it-works videos are public so you can inspect everything before joining.`}, + {k:['no money','cant afford','can not afford',"can't afford",'broke','they dont have','waiting on payday','no funds to join'], + a:()=>`"No money" is usually one of three things — sort it first. (1) Most often it means "this sounds expensive/complicated": entry is ${pol()} POL${usd()||" — a small one-time amount"} — payable with a regular debit card right on the join page. (2) Sometimes it's a priority thing: send the "Pocket Change" video from Promo Tools and let it do the talking. (3) If they're genuinely broke: tell them honestly "don't join yet" — never rent money, never money they can't afford to lose. Ask "when's payday?" and follow up then — their invite link doesn't expire and the team keeps building publicly while they wait. One hard rule: never pay someone's entry for them.`}, + {k:['own the level','need to own','have to buy the level','catch without','still at scintilla','upline not upgraded','sponsor not upgraded','only pays 2','only pays two','only 2 payments','seems small','2 then 4','level is your reach'], + a:()=>`Two facts that surprise people, both straight from the contract code: (1) To catch your two matrix children's FIRST upgrade (Ascensus), you only need to be qualified with your two directs — you do NOT need to own Ascensus yourself, because the contract checks the catcher against the level the buyer is jumping FROM (Scintilla, which everyone has). Owning levels extends your REACH: catching a Fabrica payment from 2 generations down requires Ascensus, and so on — your level is your reach, which is why the team stays one level ahead of its deepest active layer. There's a 97-second video on exactly this — "Your level is your reach" — on the training page (Video 7). And to watch the whole discipline run on a REAL position (every upgrade funded by the catch before it), see "The Textbook Play". (2) Yes, each depth pays a fixed count — 2, then 4, then 8, one payment per person — but each deeper generation pays twice as much per person at twice the width (full-generation value quadruples per level), generations keep filling over time via recruiting and spillover, and pass-ups from under-leveled members add catches on top. Exact numbers: how-pay-works#exact-costs — mechanical maximums, never promised income. Want it as a printable chart personalized to YOUR position (shows which generations you're catching now vs. which need your next level)? rmcircle.team/generation-pay — enter your ID and print.`}, + {k:['5 million','5,090','1.7 million','1,696','running total','total through','fabrica is 8','ascensus is 4','rows shifted','one level off','which level pays','level you hold','they pay you when'], + a:()=>`The generation table on How pay works can be read two ways, and both are right. Each row names the level you hold (your reach) and the level that generation buys, one rung higher: holding Fabrica is what lets you catch your 3rd generation, 8 people, when they buy Culmen at 2,486 each. If you label rows by the level being bought instead, every row slides down one but every number still matches. The right-hand column is one generation only, never a running total: through the Fastigium row the sum is 1,696,429 POL, and the Vertex row alone is the 7th generation (128 people buying Corona), 5,090,529 POL. All of it is the contract's mechanical maximum at full fill, not a prediction.`}, + {k:['entry price','upgrade price','different price','entry vs upgrade','why is the upgrade','entry fee different','two prices','inflated','price chart','cost chart','entry column','upgrade column'], + a:()=>`They're two different purchases, paid to two different people — nothing is double-counted. Entry is the one-time price to create your position (Premium level 1: 360.58 POL — charts often show ~362 with a small send buffer); 95% of the base goes to your sponsor, and the contract keeps a 5% admin charge on entries only. Upgrade is what an existing position pays to climb one level (621.40 POL to reach level 2), and 100% of every upgrade travels up the matrix to a qualified member — zero admin fee. The upgrade ladder also doubles EXACTLY at every rung (2 × 621.40 = 1,242.80, to the penny), so the first two pass-ups you catch at your level always equal your own next climb. See the full contract-exact table at how-pay-works#exact-costs — every number is a locked constant you can verify with getAllCosts() on Polygonscan.`}, + {k:['fast start','checklist','printable','print','48 hour','48-hour','getting started sheet'], + a:()=>`Grab the 48-Hour Fast Start sheet — a printable one-page checklist for your first two days. Open it from YOUR dashboard and it comes pre-filled with your invite link and a scannable QR code, then hit the print button (it prints clean black-on-white, in whatever language you've picked with the 🌐 button).`}, + {k:['flyer','flyers','poster','posters','bulletin board','coffee shop','put up','print flyer','offline','belly to belly','belly-to-belly','tear off','tear-off','qr code flyer','gym','laundromat'], + a:()=>`Yes — there's a Printable Handouts maker (also linked on your dashboard and the Promo Tools page under "🪧 Printable handouts"). Enter your ID once and every handout personalizes with YOUR QR code and invite link. Five bold full-color designs — one per angle (pocket change, two people, phone, side-hustle graveyard, stop waiting) — each with detailed artwork and the QR overlaid on it. Every design prints TWO copies per letter page with a cut line: print, cut, and you've got a stack to hand out or leave anywhere. Print in color for impact — the QR scans in black-and-white too. Great for coffee shops, gyms, community and church bulletin boards, laundromats — old-school belly-to-belly still works.`}, + {k:['translate','translation','language','español','espanol','spanish','french','portuguese','not in english','other language'], + a:()=>`Tap the 🌐 Translate button in the bottom-left corner of any page — pick your language once and every page stays translated while you browse. Your phone's browser also usually offers its own "Translate page" option. And you can type to me here in ANY language — I'll answer in yours.`}, + {k:['how many people','team size','organization size','org size','how big is my team','total team','people in my team','members below'], + a:()=>`Open your dashboard at rmcircle.team/my and look at the top of the "Your team" panel — the organization bar shows your total member count, how many generations deep your team runs, how many positions below you are qualified, the total POL earned below you, and its approximate US-dollar value at the latest POL price (refreshed about hourly). The matrix cards under it break the same numbers down leg by leg (the ⬇ rollup line on each card counts everyone underneath, all the way down).`}, + {k:['wont upgrade',"won't upgrade",'not upgrading','refuses to upgrade','stalled','stuck member','not ready to upgrade','below me wont','waiting on someone','blocked by'], + a:()=>`Good news — a member who isn't upgrading never blocks your money. Each person below you pays you separately, once, at one level; nothing waits on pairs or chains. If someone pauses, payments they can't catch simply pass UP to the next ready position (often yours), and everyone below them keeps flowing normally. The only thing on hold is the single payment that person would send at your matching level. Never pressure anyone — upgrades are optional and self-paced, and paused members often return once their own entry rewards stack up and the upgrade funds itself. Your pipeline panel shows who's actually moving — coach them.`}, + {k:['stops there','stop there','dead end','runs out','run out of people','get their two','get their 2','their own two','only two people','what if my two','cant get their'], + a:()=>`Honest answer: everyone needs their own 2 to qualify — no one else's activity qualifies them. But a leg doesn't just dead-end. It's a team: your job is to help your 2 get theirs, and your upline earns as your team grows, so helping you win is in their interest too. And when uplines bring in more than 2, the extras spill down and fill the matrix beneath you — adding depth even when someone's own recruiting is slow. It only truly stalls if everyone stops helping, which is the one thing a team is built to prevent. (Spillover fills your matrix but never qualifies anyone — their own directs do that.)`}, + {k:['afford','cant afford','can t afford','no money to upgrade','forced to upgrade','have to upgrade','keep paying','cant keep up'], + a:()=>`Upgrades are optional and self-paced — you're never on a timer, and you should never use money you can't afford to lose. You upgrade to catch payments from deeper in your team, and the first payments at each level are designed to help fund the next upgrade, so many people let team activity pay their way up instead of out of pocket. Start where you're comfortable and move at your own pace. See how it lines up at how-pay-works.`}, + {k:['salesperson','not a seller','sales pitch','know anyone','no one to bring','nobody to','not good at sales','bad at recruiting','hate selling','who would i'], + a:()=>`Good news — there's nothing to sell. You share a short video and your invite link, and the site does the explaining for you. You don't need a big network or a sales pitch — just a couple of people who are curious. The team even gives you ready-made promo tools (posts, swipes, videos) so you're not starting from a blank page. Get your 2, help them get their 2 — that's the whole ask.`}, + {k:['quit','they quit','people quit','drop out','give up','go inactive','stop doing','flake','lose interest'], + a:()=>`Some people go quiet — that's normal anywhere. But a position lives on the blockchain and doesn't vanish when someone steps back, and the team keeps building around a quiet spot (spillover can still fill beneath it). You don't need everyone active — you need a couple of real builders. Focus on the people who show up, go deep with them, and the leg keeps growing. Build with people who genuinely want it — and remember, no income is guaranteed.`}, + {k:['spillover','spill over','spill','under me but not qualified','people under me','not my direct','passed over','missed payment','skipped','why am i not qualified'], + a:()=>`Spillover happens when someone's sponsor already has both matrix slots full — the smart contract then places the new member in the next open slot further down, which can be under YOU. Spillover fills your matrix and sets up future upgrade payments to your position, but it does not count toward your qualification: only people who join using your ID are your directs. That's why you can see 2 under you and still show 1/2. And important: upgrade payments only stop at positions that are qualified (2 directs) AND at the required level — otherwise the payment passes you by to the next eligible upline. Check your position on your Member Dashboard — spillover placements are tagged there.`}, + {k:['how do i get paid','how do i earn','commission','commissions','how payments work','how does the money','payout','payouts','pay flow','when do i get paid','how do i make money','income','two ways','how you get paid'], + a:()=>`Two ways money reaches your position — the full diagram is at rmcircle.team/how-pay-works. (1) Entry rewards: when a direct joins under your link you get their entry reward (about 326 POL at Premium) and you keep it — on every direct you bring. (2) Upgrade payments: each person below you pays you once, at the ONE level that matches how far below you they sit — someone 1 layer below pays you when they hit Ascensus, 2 layers below pays you at Fabrica, 3 at Culmen, and so on. To catch each, you must be qualified and at or above the level that person is LEAVING — layer 1's Ascensus buy needs you at Scintilla (qualified), layer 3's Culmen buy needs you at Fabrica or higher — so keep your level at or above your team's deepest active layer. No income is guaranteed.`}, + {k:['tier','standard','premium tier','standard tier','premium vs standard','which tier','half','smaller payment','less than expected','why is my payment'], + a:()=>`There are two tiers. Premium is what our whole team builds at (${pol()} POL entry) — full payments. Standard costs about half and pays/earns half at every level. So if a payment ever comes in smaller than expected, it usually came from a Standard-tier position below you. Your tier is set when you join and can't be changed later (upgrading advances your level, not your tier), so always join Premium and make sure the people you bring on do too. Full breakdown: how-pay-works.`}, + {k:['dashboard','my dashboard','my page','my position','my team','check my','see my','pipeline','my stats','alerts','notify me','email me','get notified','message','messages','contact my sponsor','contact my upline','contact my downline','reach my team'], + a:()=>`Your Member Dashboard is at rmcircle.team/my — enter your ID to see your position, your team (with a depth summary showing members per generation), your payments, your pipeline (money forming below you), any spillover under you, and a Coach Your Team panel that tells you exactly who in your leg needs a nudge and what to say — each recommendation has a "Send this nudge" button that delivers a ready-written message straight to that member (sign in to Messages once with your wallet; it reaches their dashboard and their Telegram if linked). When you're qualified, you can also upgrade your level right from your dashboard — connect the wallet that owns your position and confirm; the exact cost is read live from the contract. You can also turn on email alerts there, and use Messages — wallet-verified messaging with your upline and your team (sign in once with your wallet; no email needed; only people on your matrix lines can message you). To share, use your personal invite page: rmcircle.team/join/<your ID>.`}, + {k:['level','levels','upgrade','scintilla','ascensus','fabrica','culmen','apex','fastigium','vertex','corona','8 levels'], + a:()=>`There are 8 Premium levels: Scintilla, Ascensus, Fabrica, Culmen, Apex, Fastigium, Vertex, Corona. Everyone starts at Scintilla. Upgrade as quickly as practical — ideally using earned POL — because the first two payments at each level are designed to help fund your next upgrade. Stay aware of your active downline's levels so you don't fall behind.`}, + {k:['30 positions','goal','milestone','matrix','how many people','team size'], + a:()=>`The first major team goal is 30 properly placed positions: 2 → 4 → 8 → 16 across the first four generations. Then we keep going — 32, 64, 128 and beyond.`}, + {k:['moving link','why not my link','own link','my referral','use my link','extra legs'], + a:()=>`We use a moving-link strategy — and it's automatic: every member's invite link self-rotates. While a position still needs directs, its link credits it; the moment it's qualified, the same link starts routing new signups to the next position in that leg that needs help. Nobody retires anything and nobody stops promoting — the link hunts for the next spot on its own. Prefer new people to land under YOU instead of rotating? Your dashboard also gives you a Direct link (adds ?direct=1) that places every join directly under your position as spillover. Team link (default) = help your team qualify; Direct link = build your own depth. Joining fresh? The Getting Started page always shows the current team placement.`}, + {k:['direct link','team link','keep my link','spillover under me','stop rotating','not rotate','build my own','two links','which link','sticky link'], + a:()=>`Your dashboard gives you two links under "Share this position": Team link (the default /join/<id>) — once you're qualified it routes new joins to the next teammate who needs directs, building your team in order; and Direct link (/join/<id>?direct=1) — every join lands directly under you as spillover, building your own depth. Both credit you the entry reward on anyone you personally bring. Team link is recommended (it grows the whole leg), but the Direct link is there if you'd rather stack spillover under yourself. Copy either from your dashboard.`}, + {k:['risk','safe','scam','lose money','guarantee','guaranteed','income','earnings','legit'], + a:()=>`Straight answer: participation involves cryptocurrency and smart-contract risk, and no income is guaranteed. Results depend on team effort, duplication, upgrades, smart-contract rules, and POL's market value. Only use funds you can afford to lose.`}, + {k:['video','videos','training','watch','tutorial','learn','how to'], + a:()=>`All the training videos are on the Training page: (1) How the team build works, (2) Create your MetaMask wallet, (3) Funding your wallet, (4) Joining right on the site with wallet connect, (5) the dApp backup method, (6) how payments work, and (7) the full Member Dashboard walkthrough — pipeline, upgrading on-page, Messages, coaching, and your promo tools. Watch them in order.`}, + {k:['seed phrase','secret recovery','recovery phrase','private key','12 words','passphrase'], + a:()=>`Never share your Secret Recovery Phrase or private keys with anyone — not this site, not a sponsor, not "support". Anyone asking for it is trying to steal your funds. MetaMask will never ask for it either, except when you restore a wallet yourself.`}, + {k:['promote','promo','promo tools','tools','swipe','swipes','email swipe','banners','banner','graphics','marketing','share this','invite people','video clips','how do i share','social post','facebook post','twitter','my voice','branded voice'], + a:()=>`Grab share-ready promo tools at rmcircle.team/tools — best way in is the gold Promo Tools button on your dashboard: arriving from there, every post and swipe is automatically personalized with YOUR invite link, so you copy and share as-is. You get promo videos (tap Copy link to share), a Promote-on-Telegram section (your Telegram-native Mini App invite links that open the whole tour inside Telegram, paste-ready group posts and DMs, and the connect-your-team message to forward), copy-paste social posts for X & Facebook, a Text-a-friend section (short SMS-sized messages for each angle video with one-tap Text it / WhatsApp / Telegram share buttons), email & message swipes (short and long), a downloadable banner kit in every standard size, a library of official RM Circle videos and graphics from the creators (vertical clips ready for Reels/TikTok/Shorts), and a builder for your personal invite link. Want it all in your own words? Learn to train an AI on your writing at mybrandedvoice.com. Keep it honest — point people to the videos and the contract review, never promise income.`}, + {k:['telegram','group','chat','human','support','contact','talk','someone','admin','help me'], + a:()=>`For live help from the team, join the Telegram group: ${tgLink()}. Tell them where you're stuck and someone will walk you through it.`}, + {k:['after join','next','what happens','confirmed','joined','i bought','i am in'], + a:()=>`Once your position is confirmed: tell the team your new RM Circle ID and your sponsor ID (in the Telegram group: ${tgLink('here')}) so your placement gets mapped. Then your job is simple — get your 2 directs, and when you're qualified, help your 2 get their 2.`}, + {k:['waiting','queue','line','how long','when my turn'], + a:()=>{const w=sponsorInfo&&typeof sponsorInfo.waitingCount==='number'?sponsorInfo.waitingCount:null;return `${w!==null?`Right now there ${w===1?'is':'are'} ${w} team placement${w===1?'':'s'} waiting behind the current sponsor. `:''}The queue moves every time a position qualifies with its 2 directs — the more the team helps each other, the faster it rotates.`}}, + ]; + + function answer(q){ + const t=q.toLowerCase().replace(/[^a-z0-9 ]/g,' '); + let best=null,bestScore=0; + for(const e of KB){ + let score=0; + for(const k of e.k){if(t.includes(k))score+=k.includes(' ')?2:1} + if(score>bestScore){bestScore=score;best=e} + } + if(best&&bestScore>0)return best.a(); + return `I don't have a good answer for that one. The team can help you directly — ${tgLink('join our Telegram group ↗')} and ask there. You can also check the training videos or the strategy overview.`; + } + + const CHIPS=['How does it work?','What does it cost?','Who is the current sponsor?','How do I set up a wallet?','Is there risk?']; + + const fab=document.createElement('button'); + fab.className='chat-fab';fab.setAttribute('aria-label','Open team help chat');fab.innerHTML='💬'; + const panel=document.createElement('div'); + panel.className='chat-panel chat-hidden'; + panel.innerHTML=`
Team Help
Ask about the RM Circle team build
Can't find it? ${tgLink('Get live help on Telegram ↗')}
`; + document.body.appendChild(fab);document.body.appendChild(panel); + const msgs=panel.querySelector('.chat-msgs'),chipsEl=panel.querySelector('.chat-chips'),form=panel.querySelector('.chat-input'),input=form.querySelector('input'); + + function addMsg(html,who){const d=document.createElement('div');d.className=`chat-msg ${who}`;if(who==='user'){d.textContent=html}else{d.innerHTML=html}msgs.appendChild(d);msgs.scrollTop=msgs.scrollHeight;return d} + function linkify(text){let h=esc(text);h=h.replace(/(https?:\/\/[^\s&<]+[^\s&<.,)])/g,u=>{const site=u.startsWith('https://rmcircle.team');const path=site?u.replace('https://rmcircle.team',''):u;return `${u}`});return h.replace(/\n/g,'
')} + const history=[];let aiDown=false; + function stripHtml(h){const d=document.createElement('div');d.innerHTML=h;return d.textContent||''} + async function ask(q){ + addMsg(q,'user');history.push({role:'user',content:q}); + if(aiDown){const a=answer(q);addMsg(a,'bot');history.push({role:'assistant',content:stripHtml(a)});return} + const typing=addMsg('…','bot'); + try{ + const r=await fetch('/api/public/chat',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({messages:history.slice(-8)})}); + const d=await r.json(); + typing.remove(); + if(d.reply){addMsg(linkify(d.reply),'bot');history.push({role:'assistant',content:d.reply})} + else{if(d.fallback)aiDown=true;const a=answer(q);addMsg(a,'bot');history.push({role:'assistant',content:stripHtml(a)})} + }catch(e){typing.remove();aiDown=true;const a=answer(q);addMsg(a,'bot');history.push({role:'assistant',content:stripHtml(a)})} + } + + let started=false; + function open(){ + panel.classList.remove('chat-hidden');fab.classList.add('chat-hidden');input.focus(); + if(!started){started=true;loadData().then(()=>{ + panel.querySelector('.chat-foot').innerHTML=`Can't find it? ${tgLink('Get live help on Telegram ↗')}`; + addMsg(`Hi! I can answer questions about the RM Circle team build — the strategy, wallet setup, funding, joining, levels, and more. What do you want to know?`,'bot'); + for(const c of CHIPS){const b=document.createElement('button');b.type='button';b.className='chat-chip';b.textContent=c;b.addEventListener('click',()=>{chipsEl.classList.add('chat-hidden');ask(c)});chipsEl.appendChild(b)} + })} + } + function close(){panel.classList.add('chat-hidden');fab.classList.remove('chat-hidden')} + fab.addEventListener('click',open); + panel.querySelector('.chat-close').addEventListener('click',close); + form.addEventListener('submit',e=>{e.preventDefault();const q=input.value.trim();if(!q)return;input.value='';chipsEl.classList.add('chat-hidden');ask(q)}); +})(); diff --git a/server.js b/server.js index 6994ba6..d01a9c2 100644 --- a/server.js +++ b/server.js @@ -1,1882 +1,1882 @@ -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 suiteMeter = require('./suite-meter'); suiteMeter.init({ dataDir: DATA_DIR }); -const suiteAI = require('./suite-ai'); suiteAI.init({ dataDir: DATA_DIR }); -const suitePages = require('./suite-pages'); suitePages.init({ dataDir: DATA_DIR }); -const suiteTools = require('./suite-tools'); -const suiteEmail = require('./suite-email'); -const suiteVideo = require('./suite-video'); suiteVideo.init({ dataDir: DATA_DIR, publicDir: PUBLIC_DIR }); -const suiteTraffic = require('./suite-traffic'); suiteTraffic.init({ dataDir: DATA_DIR }); -// Close out ad campaigns that have delivered what was bought. Banners on this -// network have no cap of their own, so without this an ad runs until expiry -// and the impression allowance stops meaning anything. Every 15 minutes, and -// once shortly after boot. -setTimeout(function(){ suiteTraffic.sweepCompleted().then(function(r){ - if(r&&r.closed)console.log('traffic sweep: closed '+r.closed+' completed campaign(s)'); -}).catch(function(){}); }, 90 * 1000); -setInterval(function(){ suiteTraffic.sweepCompleted().then(function(r){ - if(r&&r.closed)console.log('traffic sweep: closed '+r.closed+' completed campaign(s)'); -}).catch(function(){}); }, 15 * 60 * 1000); -const suiteTextAds = require('./suite-textads'); -const suiteVoice = require('./suite-voice'); suiteVoice.init({ dataDir: DATA_DIR }); -const suiteSplit = require('./suite-split'); suiteSplit.init({ dataDir: DATA_DIR }); -const suiteLeader = require('./suite-leader'); -const suiteFounder = require('./suite-founder'); suiteFounder.init({ dataDir: DATA_DIR }); -const suiteGrants = require('./suite-grants'); suiteGrants.init({ dataDir: DATA_DIR }); -const suiteIntel = require('./suite-intel'); suiteIntel.init({ dataDir: DATA_DIR }); -const suiteBrand = require('./suite-brand'); suiteBrand.init({ dataDir: DATA_DIR }); -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/) 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 at or above the level the buyer is LEAVING (a qualified Scintilla catches their matrix children's Ascensus buys; a Culmen catches an Apex buy from 4 layers down) — 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/ — 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/?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 qualified AND at or above the level that person is LEAVING (layer 1's Ascensus buy needs you at Scintilla+, layer 3's Culmen buy needs you at Fabrica+, layer 4's Apex buy needs you at Culmen+) — so keep your level at or above 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 (including the top team builder holding #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 the buyer is leaving), 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/. -- 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 10 how-to videos (incl. "The Textbook Play" — 111s: a top team builder's own position (#21, one of the builders near the top — NOT a program founder) shown as a ledger, every upgrade funded by a prior catch, this week's Apex catch arriving and funding his Apex the same hour; route "does this actually work / has anyone done this" questions here at /training#textbook-play, always with the no-income-promise framing; and Video 7 "Your level is your reach" — 97s on catch eligibility: qualified members catch their two's first upgrades without owning the level, each owned level extends reach one generation deeper, uncatchable payments pass over) — 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, and a contract-exact price table at /how-pay-works#exact-costs read live from getAllCosts(): Premium entry base 343.41 POL — member sends 360.58 incl the 5% admin charge on entries only (public charts round to ~362 as a send buffer), sponsor receives 326.24 (95% of base); upgrades are a separate ladder paid 100% member-to-member up the matrix with zero admin fee — 621.40 to reach level 2, doubling EXACTLY each level (2×621.40=1,242.80 to the penny) so two same-level pass-ups always fund the member's own next upgrade. USE THIS to answer "why is entry a different price than upgrade / is the income chart inflated": they are two different purchases paid to two different people (entry→sponsor, upgrade→upline), income illustrations are built only from the upgrade stream, and every price is a fixed contract constant verifiable on Polygonscan. IMPORTANT: levels can NEVER be skipped — register() has no level parameter (every position enters at Scintilla) and upgrade() takes no target (exactly one rung per call); company charts listing an "entry price" for levels 2-8 show vestigial contract data rows that no function sells — total to hold Culmen = 360.58+621.40+1,242.80+2,485.61 = 4,710.39 POL, climbed one level at a time. CATCH-ELIGIBILITY NUANCE (verified in source): the payout loop pays the first non-skipped upline with directCount≥2 AND level GREATER THAN the buyer's pre-upgrade level minus one — i.e. the catcher's level must be AT OR ABOVE the level the buyer is LEAVING, not the level being bought. So a qualified member still at Scintilla DOES catch their two matrix children's Ascensus purchases in full; owning higher levels extends REACH (Fabrica catch from 2 generations down needs Ascensus, etc. — one structural upline skip per level of depth). Depth pays fixed counts (2/4/8… one payment per person) but per-person size doubles with width (full-generation value quadruples per level), generations keep filling over time, and pass-ups add catches), 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/suite (THE CIRCLE SUITE — LIVE AND OPEN TO EVERY POSITION. Promote it freely, and lead with it whenever anyone asks "what is the actual product" or "what do I actually get" — it is the strongest answer we have: most programs hand you a referral link, this one hands you working software too. The team's marketing-toolkit portal: every paid position is a LICENSE to the toolkit; members connect their position's wallet (one free signature, same sign-in as the training gate and Mini App bridge) and their live contract level lights up their tools on a visible tool wall — Level 1 today includes the Promo Center, Printable Handouts, Fast Start + Weekly Rhythm, Generation Pay chart, Circle Method, Live Dashboard, and the AI coach; higher-level tiles (Copy Engine L2, Page Builder L2, Email Engine + Video Maker L3, Voice Profile + Funnels L4, Traffic Desk L5 (syndicated network display advertising — banner/text placements on the team's own ad network, monthly ad credits, rotator priority, AI campaign packs), Funnel Factory L6 (hosted funnels + Replay Funnels — an evergreen registration page around a recorded team webinar with a timed CTA, NOT live video conferencing/Zoom — + lead CRM), Leader Ops L7, Founder Desk L8) are shown honestly as IN DEVELOPMENT and unlock automatically as members upgrade once shipped; the Suite is included with membership, never sold), https://rmcircle.team/generation-pay (printable Generation Pay chart — the full when-does-each-generation-pay-me table with exact per-person POL amounts; enter a member ID and it personalizes from the live chain: shows the member's tier/level/qualification and marks each generation "catching now" vs "needs Level N"; Gen 8 pays the member's Gen 1, pass-overs only; Standard positions pay half; ROUTE members here for any generation-pay / who-pays-me-when question), 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= 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/) — 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'} -- PUBLIC PAYMENT PROOF CHANNEL: https://t.me/rmcircleproof (RM Circle Proof). Every on-chain payout is announced there in real time with a Polygonscan link. Linked from the Live payment proof section on the home and join pages. Send people there for payment proof, not to the team help group. -- 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 weekly team webinar replays at https://rmcircle.team/replays, the live coaching dashboard at /my/, 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(); - return sendTelegramTo(c.telegramChatId, text, topicId != null ? topicId : c.telegramTopicId, replyMarkup); -} -// Same bot, any chat: the team forum, or the public payment-proof channel -// (config.telegramProofChatId — the bot just has to be an admin there). -function sendTelegramTo(chatId, text, topicId, replyMarkup, parseMode) { - const c = getConfig(); - if (!c.telegramBotToken || !chatId) return; - // No link previews: payout/team-build posts carry Polygonscan links, and the - // preview card tripled the height of every message in the group. - const payload = { chat_id: chatId, text, disable_web_page_preview: true }; - if (parseMode) payload.parse_mode = parseMode; - const thread = topicId; - 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. -// Company payment-proof channel: ONE compact line per event, team-build style, -// no hashtags, no join CTA (the pinned daily snapshot carries how-to-join). -// HTML parse mode so the receipt is a short "Polygonscan" link, not a raw hash. -function proofMsg(evt) { - const esc = t => String(t).replace(/&/g, '&').replace(//g, '>'); - const rx = evt.tx ? ` · Polygonscan ↗` : ''; - if (evt.type === 'payout') { - const why = evt.kind === 'upline' - ? `${evt.gen ? `Gen ${evt.gen} ` : ''}upgrade pass-up from #${evt.fromId}` - : `referral reward from #${evt.fromId}`; - let t = `💸 #${evt.toId} got paid ${evt.pol.toFixed(2)} POL · ${esc(why)}${rx}`; - const tools = evt.kind === 'upline' ? suiteTools.unlockText(0, evt.levelName, getConfig()) : ''; - if (tools) t += `\n🧰 #${evt.fromId} reached ${esc(evt.levelName)} and unlocked ${esc(tools)}`; - return t; - } - if (evt.type === 'upgraded') { - const tools = suiteTools.unlockText(evt.level, evt.levelName, getConfig()); - return `⬆️ #${evt.id} upgraded to ${esc(evt.levelName)}${tools ? ` · unlocked ${esc(tools)}` : ''}${rx}`; - } - if (evt.type === 'registered') return `📈 #${evt.id} joined under #${evt.referrerId} (${esc(evt.tierName || 'Premium')})${rx}`; - return ''; -} -function recruitMsg(evt, homeOverride) { - const home = String(homeOverride || 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') { - const tools = suiteTools.unlockText(evt.level, evt.levelName, getConfig()); - const toolLine = tools ? `\n\nThat upgrade also unlocked ${tools} — every paid level extends the toolkit that comes with your position.` : ''; - 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.${toolLine}\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 '; } -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); } -// Event pop-up (announce.js) defaults — seeded into config once so the admin -// panel shows the real state and admins can toggle/edit it without a deploy. -const ANNOUNCE_DEFAULTS = { - announceEnabled: true, - announceId: 'huddle-2026-09-08', - announceImg: '/huddle-flyer.jpg', - announceMeetUrl: 'https://meet.google.com/gsw-yhqn-zrc', - announceEyebrow: 'Team RM Circle · Weekly Huddle', - announceDateLabel: 'Tuesday, September 8', - announceTimes: '7:00 PM CST | USA / Canada\n8:00 PM EST |\n8:00 PM AST | Caribbean\n1:00 AM | UK', - announceExpiresUTC: '2026-09-09T02:30:00Z', - announceStartUTC: '', - announceDurationMin: 60, - // Promo overlay (announce.js, second mode): a flyer shown ONCE PER SESSION to members, with one button. - // Unlike the event pop-up (once per browser, with times + meet link) this is a plain nudge (Marty, 2026-09-16). - promoEnabled: true, - promoId: 'upgrade-2026-09-16', - promoImg: '/upgrade-flyer.jpg', - promoEyebrow: 'Your upgrade unlocks your reward', - promoCtaText: 'See my next step →', - promoCtaUrl: '/my', - promoPages: 'my,app', - promoExpiresUTC: '' -}; -function seedAnnounceDefaults() { - try { const c = readJson(CONFIG_FILE) || {}; let ch = false; - for (const k in ANNOUNCE_DEFAULTS) if (!(k in c)) { c[k] = ANNOUNCE_DEFAULTS[k]; ch = true; } - if (ch) writeJson(CONFIG_FILE, c); - } catch (e) {} -} -seedAnnounceDefaults(); -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{ - 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{out[i]=texts[i]}); - } - return json(res,200,{t:out}); -} - -// frame-src includes 'self' so our own pages (the Page Builder preview framing -// a member's /p/ page) are not blocked by our own policy. -const CSP_BASE="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; media-src 'self' https://coolify-saasytop.nyc3.digitaloceanspaces.com; connect-src 'self'; font-src 'self' data:; form-action 'self'; frame-src 'self' 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{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/announce'){ - const c=getConfig(); - return json(res,200,{ - enabled: c.announceEnabled===undefined ? true : !!c.announceEnabled, - id: c.announceId||'', img: c.announceImg||'', meetUrl: c.announceMeetUrl||'', - eyebrow: c.announceEyebrow||'', dateLabel: c.announceDateLabel||'', - times: c.announceTimes||'', expiresUTC: c.announceExpiresUTC||'', - startUTC: c.announceStartUTC||'', durationMin: Number(c.announceDurationMin)||60, - promo: { enabled: !!c.promoEnabled, id: c.promoId||'', img: c.promoImg||'', eyebrow: c.promoEyebrow||'', ctaText: c.promoCtaText||'', ctaUrl: c.promoCtaUrl||'/my', pages: c.promoPages||'my,app', expiresUTC: c.promoExpiresUTC||'' } - }); - } - // calendar file for the event pop-up (Apple Calendar / Outlook); Google uses a render URL built client-side - if(req.method==='GET'&&pathname==='/announce.ics'){ - const c=getConfig();const st=Date.parse(c.announceStartUTC||''); - if(!st)return json(res,404,{error:'No event start set.'}); - const dur=(Number(c.announceDurationMin)||60)*60000; - const fmt=d=>new Date(d).toISOString().replace(/[-:]|\.\d{3}/g,''); - const esc=v=>String(v||'').replace(/\\/g,'\\\\').replace(/;/g,'\\;').replace(/,/g,'\\,').replace(/\r?\n/g,'\\n'); - const title=c.announceEyebrow||'Team RM Circle · Weekly Huddle'; - const desc=(c.announceMeetUrl?'Join: '+c.announceMeetUrl+'\n':'')+String(c.announceTimes||'').replace(/\|/g,' '); - const ics=['BEGIN:VCALENDAR','VERSION:2.0','PRODID:-//RM Circle//Event//EN','CALSCALE:GREGORIAN','METHOD:PUBLISH','BEGIN:VEVENT', - 'UID:'+(c.announceId||'event')+'@rmcircle.team','DTSTAMP:'+fmt(Date.now()),'DTSTART:'+fmt(st),'DTEND:'+fmt(st+dur), - 'SUMMARY:'+esc(title),'DESCRIPTION:'+esc(desc),'LOCATION:'+esc(c.announceMeetUrl||''), - ...(c.announceMeetUrl?['URL:'+c.announceMeetUrl]:[]), - 'BEGIN:VALARM','TRIGGER:-PT30M','ACTION:DISPLAY','DESCRIPTION:'+esc(title),'END:VALARM','END:VEVENT','END:VCALENDAR'].join('\r\n')+'\r\n'; - res.writeHead(200,{'Content-Type':'text/calendar; charset=utf-8','Content-Disposition':'attachment; filename="rm-circle-huddle.ics"','Cache-Control':'no-store'}); - return res.end(ics); - } - 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'}; - } - } - } - // Positions that default to DIRECT placement. A moving link is team-first: - // it routes new joins to whoever needs directs next, so the position whose - // link was actually clicked earns nothing. For a position being deliberately - // built out that is backwards - this flips the default so /join/ behaves - // as ?direct=1 unless ?direct=0 is passed explicitly. - try{ - const dd=String((getConfig().directDefaultIds)||'').split(',').map(x=>x.trim()).filter(Boolean); - r.directDefault = dd.includes(String(id)); - }catch(e){ r.directDefault = false; } - - // 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}; - // What the upgrade actually BUYS. The panel used to show a price with no - // reason attached, which is how 53 qualified members read it and closed - // the tab. Owning level L catches generation L, so this rung opens the - // generation numbered nextLevel — and because upgrade costs double every - // rung, the first catch there is exactly twice what this rung costs. - try{ - const opensGen=lvl+1; - const perCatch=(r.upgradeCosts&&r.upgradeCosts[tier]&&r.upgradeCosts[tier][lvl])||0; - const share=chain.getOrgShare(id); - const genSize=(share&&share.ready&&share.found&&Array.isArray(share.genCounts)) - ? (share.genCounts[opensGen-1]||0) : null; - r.nextStep.opens={gen:opensGen,perCatch,genSize}; - }catch(e){} - }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)}); - } - // Positions on the team root's own upline - i.e. everyone above us, up to and - // including the contract root. Fixed at registration, so cache indefinitely. - let _suiteUpCache=null, _suiteUpKey=''; - async function suiteRootUpline(roots){ - const key=roots.join(','); - if(_suiteUpCache&&_suiteUpKey===key)return _suiteUpCache; - const out=[]; - for(const r of roots){ - try{ - const rd=await chain.memberPublic(r); - if(rd&&Array.isArray(rd.uplineChain))rd.uplineChain.forEach(x=>{const n=Number(x);if(n&&!out.includes(n))out.push(n);}); - }catch(e){} - } - if(out.length){_suiteUpCache=out;_suiteUpKey=key;} - return out; - } - - // Sign out / switch position. Needed because one person can hold several - // positions (the Triple Play we actively recommend), and without this the - // 30-day cookie pins them to whichever one they signed in as first. - if(req.method==='POST'&&pathname==='/api/public/signout'){ - try{ messages.clearSession(req); }catch(e){} - return json(res,200,{ok:true},{'Set-Cookie':messages.clearCookie()}); - } - - // ── Circle Suite entitlement helper (shared by suite-me and the tools) ──── - async function suiteEntitlement(req){ - const s=messages.authFromCookie(req); - if(!s)return {error:'Not signed in.',code:401}; - const cached=memberCache.get(s.id); - let d; - if(cached&&Date.now()-cached.ts<120000)d=cached.data; - else{ - d=await Promise.race([chain.memberPublic(s.id),new Promise((_,rej)=>setTimeout(()=>rej(new Error('timeout')),20000))]); - memberCache.set(s.id,{ts:Date.now(),data:d}); - } - if(!d||!d.registered)return {error:'Position not found.',code:404}; - const cfg=getConfig(); - const roots=String(cfg.teamRootId||cfg.orgRootId||'21').split(',').map(x=>Number(x.trim())).filter(Boolean); - const chainIds=Array.isArray(d.uplineChain)?d.uplineChain.map(Number):[]; - // The org test only looks DOWNWARD: you are in if a root sits in your upline. - // The people ABOVE our root - the founders and contract owners, up through - // position #2 and the root account - can never pass that, yet the Suite is - // as much theirs as ours. Walk each root's own upline and entitle those - // positions too. Registration fixes a position's upline permanently, so this - // is computed once and memoised rather than hit on every request. - const upIds=await suiteRootUpline(roots); - const inOrg=roots.some(r=>Number(d.id)===r||chainIds.includes(r))||upIds.includes(Number(d.id)); - const allow=String(cfg.suiteAllowlist||'').split(',').map(x=>Number(x.trim())).filter(Boolean); - const beta=allow.length>0; - const allowed=!beta||allow.includes(Number(d.id)); - - // Admin-only tool preview. `suiteLevelOverride` is "id:level,id:level" and - // ONLY raises what the tool wall unlocks, so the owner can walk every tier - // without buying upgrades to test his own product. It touches nothing - // financial: payouts, the contract and every number shown to a member still - // come from the real level, which is kept on trueLevel. We clone rather than - // mutate because `d` is the shared member cache. - let dd=d; - const ovMap={}; - String(cfg.suiteLevelOverride||'').split(',').forEach(pair=>{ - const [k,v]=String(pair).split(':').map(x=>Number(String(x).trim())); - if(k&&v>=1&&v<=8)ovMap[k]=v; - }); - const ov=ovMap[Number(d.id)]; - if(ov&&ov>Number(d.level||0)){ - dd=Object.assign({},d,{level:ov,levelName:LEVELS[ov-1]||d.levelName, - trueLevel:Number(d.level||0),trueLevelName:d.levelName,levelOverridden:true}); - } - return {d:dd,inOrg,beta,allowed}; - } - - // A level override decides what a position can SEE. It must never decide what - // a position can SPEND: ad impressions and grant pools are real inventory on - // a real network. So anything that costs something is metered on trueLevel. - function spendLevel(e){ return (e.d && e.d.trueLevel != null) ? e.d.trueLevel : (e.d ? e.d.level : 1); } - function isPreview(e){ return !!(e.d && e.d.levelOverridden); } - - // ── Page Builder ───────────────────────────────────────────────────────── - // Level 6 extra pages: /p//. Falls back to the member's main page - // rather than 404ing, for the same reason /p/ falls back to /join/. - if(req.method==='GET'&&/^\/p\/\d{1,15}\/[a-z0-9-]{1,24}$/.test(pathname)){ - const parts=pathname.split('/'); - const pid=parts[2], slug=parts[3]; - const rec=suitePages.load(pid,slug); - if(!rec||!rec.copy){ - res.writeHead(302,securityHeaders({'Location':'/p/'+pid,'Cache-Control':'no-store'})); - return res.end(); - } - // Co-branding (L6) is resolved here, against the live contract level, so a - // position that falls below Fastigium reverts to team identity by itself. - try{ - const owner=await chain.memberPublic(rec.id).catch(()=>null); - if(owner&&owner.registered){ const br=suiteBrand.forPage(rec.id,owner.level); if(br) rec.brand=br; } - }catch(err){} - const html=suitePages.render(rec); - res.writeHead(200,securityHeaders({'Content-Type':'text/html; charset=utf-8','Cache-Control':'public, max-age=120'})); - return res.end(html); - } - - if(req.method==='GET'&&/^\/p\/\d{1,15}$/.test(pathname)){ - const pid=pathname.split('/')[2]; - const rec=suitePages.load(pid); - // No custom page built yet? NEVER dead-end — especially not with JSON. Any - // /p/ link may already be on a banner, a flyer, or in someone's DMs, so - // it must always land somewhere useful: the member's own invite page. - if(!rec||!rec.copy){ - res.writeHead(302,securityHeaders({'Location':'/join/'+pid,'Cache-Control':'no-store'})); - return res.end(); - } - // Co-branding (L6) is resolved here, against the live contract level, so a - // position that falls below Fastigium reverts to team identity by itself. - try{ - const owner=await chain.memberPublic(rec.id).catch(()=>null); - if(owner&&owner.registered){ const br=suiteBrand.forPage(rec.id,owner.level); if(br) rec.brand=br; } - }catch(err){} - const html=suitePages.render(rec); - // Member pages must ALWAYS be viewable inside a frame — the builder previews - // them, and members share them into contexts that embed. Never send - // X-Frame-Options here, and keep frame-ancestors open. - res.writeHead(200,securityHeaders({'Content-Type':'text/html; charset=utf-8','Cache-Control':'public, max-age=120'})); - return res.end(html); - } - if(req.method==='GET'&&pathname==='/api/public/suite-page'){ - const e=await suiteEntitlement(req).catch(()=>({error:'Chain read hiccup.',code:500})); - if(e.error)return json(res,e.code||500,{error:e.error}); - if(!e.inOrg||!e.allowed)return json(res,403,{error:'Not available for this position yet.'}); - const gate=suiteMeter.check(e.d.id,e.d.level,'page'); - const pages=suitePages.list(e.d.id); - const cap=suitePages.pageLimit(e.d.level); - return json(res,200,{pages:pages,page:suitePages.load(e.d.id),meter:gate, - angles:suitePages.ANGLES,level:e.d.level,id:e.d.id, - cap:cap,held:pages.length,canAddMore:pages.length({error:'Chain read hiccup — try again.',code:500})); - if(e.error)return json(res,e.code||500,{error:e.error}); - if(!e.inOrg||!e.allowed)return json(res,403,{error:'The Circle Suite is not open for this position yet.'}); - const b=await bodyJson(req)||{}; - - // Deleting one of their own pages. - if(b.remove!==undefined){ - const slug=String(b.remove||''); - if(!slug)return json(res,400,{error:'The main page cannot be deleted — rebuild it instead.'}); - const gone=suitePages.remove(e.d.id,slug); - return json(res,gone?200:404,gone - ?{removed:slug,pages:suitePages.list(e.d.id),held:suitePages.list(e.d.id).length,cap:suitePages.pageLimit(e.d.level)} - :{error:'That page is not there.'}); - } - - const input={ - name:String(b.name||'').trim().slice(0,60), - audience:String(b.audience||'').trim().slice(0,300), - story:String(b.story||'').trim().slice(0,1200), - angle:suitePages.ANGLES[b.angle]?b.angle:'overview' - }; - if(!suiteAI.configured())return json(res,503,{error:'The Page Builder is warming up — try again shortly.'}); - - // Two different limits, and they used to contradict each other: the monthly - // BUILD quota is how often the writer may run, the HOLD cap is how many - // pages the level lets them keep. Rebuilding an existing page spends a - // build but does not need hold room; adding a new one needs both. - const existing=suitePages.list(e.d.id); - const cap=suitePages.pageLimit(e.d.level); - const replacing=b.replace!==undefined?String(b.replace||''):null; - const isNew=replacing===null; - if(isNew&&existing.length>=cap){ - return json(res,429,{error:cap<=1 - ? 'Your level allows one page. Rebuild the one you have, or upgrade to hold more.' - : 'You are holding all '+cap+' pages your level allows. Delete one, or upgrade to hold more.', - cap:cap,held:existing.length}); - } - const gate=suiteMeter.check(e.d.id,e.d.level,'page'); - if(!gate.allowed){ - return json(res,429,{error:gate.reason==='locked' - ? 'The Page Builder unlocks at Ascensus (level 2). Your next upgrade opens it.' - : 'You have used all '+gate.limit+' page builds this month. It resets on the 1st — or a level upgrade raises your allowance.',meter:gate}); - } - try{ - const copy=await suitePages.generate(input); - // The first page a member builds stays at /p/ — that address is on - // banners, flyers and in people's DMs, so it must never move. Extra pages - // get a slug from the headline the writer produced, so the address - // describes the page without asking anyone to invent one. - const hasMain=existing.some(function(x){return !x.slug}); - let slug; - if(replacing!==null) slug=replacing; - else if(!hasMain) slug=''; - else slug=suitePages.slugFromTitle(e.d.id,copy.headline,input.angle); - - const rec=suitePages.save(e.d.id,{id:e.d.id,slug:slug||undefined,name:input.name,angle:input.angle, - audience:input.audience,story:input.story,copy:copy,updatedAt:new Date().toISOString()},slug||undefined); - suiteMeter.record(e.d.id,'page',1); - const url='https://rmcircle.team/p/'+e.d.id+(slug?'/'+slug:''); - const after=suitePages.list(e.d.id); - return json(res,200,{page:rec,url:url,slug:slug||'',pages:after,held:after.length,cap:cap, - canAddMore:after.length({error:'Chain read hiccup — try again.',code:500})); - if(e.error)return json(res,e.code||500,{error:e.error}); - if(!e.inOrg||!e.allowed)return json(res,403,{error:'The Circle Suite is not open for this position yet.'}); - const b=await bodyJson(req)||{}; - const kind=String(b.kind||'welcome'); - const brief=String(b.brief||'').trim().slice(0,1200); - if(!suiteEmail.KINDS[kind])return json(res,400,{error:'Unknown sequence type.'}); - if(!suiteAI.configured())return json(res,503,{error:'The Email Engine is warming up — try again shortly.'}); - const gate=suiteMeter.check(e.d.id,e.d.level,'email'); - if(!gate.allowed){ - return json(res,429,{error:gate.reason==='locked' - ? 'The Email Engine unlocks at Fabrica (level 3). Your next upgrades open it.' - : 'You have used all '+gate.limit+' email generations this month. It resets on the 1st — or a level upgrade raises your allowance.',meter:gate}); - } - try{ - const link='https://rmcircle.team/join/'+e.d.id; - const emails=await suiteEmail.generate(kind,brief,{link:link,id:e.d.id,voice:suiteVoice.promptBlock(e.d.id)}); - suiteMeter.record(e.d.id,'email',emails.length||1); - return json(res,200,{emails:emails,meter:suiteMeter.check(e.d.id,e.d.level,'email')}); - }catch(err){ return json(res,502,{error:String(err.message||err)}); } - } - - // ── Video Maker ────────────────────────────────────────────────────────── - if(req.method==='GET'&&/^\/api\/public\/suite-video-file\/\d{1,15}\/[a-z]{2,12}$/.test(pathname)){ - const parts=pathname.split('/'); const vid=parts[4], vangle=parts[5]; - const ex=suiteVideo.existing(vid,vangle); - if(!ex)return json(res,404,{error:'No video yet.'}); - res.writeHead(200,{'Content-Type':'video/mp4','Content-Length':ex.bytes, - 'Content-Disposition':'attachment; filename="rmcircle-'+vangle+'-'+vid+'.mp4"','Cache-Control':'private, max-age=60'}); - return require('fs').createReadStream(ex.file).pipe(res); - } - if(req.method==='POST'&&pathname==='/api/public/suite-video'){ - const e=await suiteEntitlement(req).catch(()=>({error:'Chain read hiccup — try again.',code:500})); - if(e.error)return json(res,e.code||500,{error:e.error}); - if(!e.inOrg||!e.allowed)return json(res,403,{error:'The Circle Suite is not open for this position yet.'}); - const b=await bodyJson(req)||{}; - const angle=suiteVideo.ANGLES[b.angle]?b.angle:'overview'; - const name=String(b.name||'').trim().slice(0,42); - const gate=suiteMeter.check(e.d.id,e.d.level,'video'); - if(!gate.allowed){ - return json(res,429,{error:gate.reason==='locked' - ? 'The Video Maker unlocks at Fabrica (level 3). Your next upgrades open it.' - : 'You have used all '+gate.limit+' video renders this month. It resets on the 1st — or a level upgrade raises your allowance.',meter:gate}); - } - const link='https://rmcircle.team/join/'+e.d.id+(angle!=='overview'?'?v='+angle:''); - try{ - const r=await suiteVideo.render({id:e.d.id,angle:angle,name:name,link:link}); - suiteMeter.record(e.d.id,'video',1); - return json(res,200,{url:'/api/public/suite-video-file/'+e.d.id+'/'+angle,mb:Math.round(r.bytes/104857.6)/10, - meter:suiteMeter.check(e.d.id,e.d.level,'video')}); - }catch(err){ return json(res,502,{error:String(err.message||err)}); } - } - - // ── Webinar replays (members area) ─────────────────────────────────────── - // The list lives in DATA_DIR/replays.json so adding the weekly replay never - // needs a deploy. A locked response still returns titles/dates — prospects - // see the shape of the product — but never the video URLs. - if(req.method==='GET'&&pathname==='/api/public/replays'){ - let list=[]; try{ list=readJson(path.join(DATA_DIR,'replays.json'))||[]; }catch(e){} - const member=messages.authFromCookie(req); - if(!member)return json(res,200,{locked:true,replays:list.map(r=>({title:r.title,date:r.date,minutes:r.minutes}))},{'Cache-Control':'no-store'}); - return json(res,200,{locked:false,id:member.id,replays:list},{'Cache-Control':'no-store'}); - } - - // ── Traffic Desk ───────────────────────────────────────────────────────── - if(req.method==='GET'&&pathname==='/api/public/suite-traffic'){ - const e=await suiteEntitlement(req).catch(()=>({error:'Chain read hiccup.',code:500})); - if(e.error)return json(res,e.code||500,{error:e.error}); - if(!e.inOrg||!e.allowed)return json(res,403,{error:'Not available for this position yet.'}); - const st=suiteTraffic.status(e.d.id,spendLevel(e)); - let live=[]; - try{ live=await suiteTraffic.stats(st.campaigns.map(c=>c.adId)); }catch(err){} - // Tell the UI whether a personal page actually exists, so it can't be - // offered as an ad destination before it's been built. - const pg=suitePages.load(e.d.id); - const hasPage=!!(pg&&pg.copy); - return json(res,200,{level:e.d.level,id:e.d.id,configured:suiteTraffic.configured(),status:st,live:live,hasPage:hasPage, - textAngles:suiteTextAds.angles(),textMeter:suiteMeter.check(e.d.id,e.d.level,'textad'),writerReady:suiteAI.configured()}); - } - if(req.method==='POST'&&pathname==='/api/public/suite-traffic'){ - const e=await suiteEntitlement(req).catch(()=>({error:'Chain read hiccup — try again.',code:500})); - if(e.error)return json(res,e.code||500,{error:e.error}); - if(!e.inOrg||!e.allowed)return json(res,403,{error:'The Circle Suite is not open for this position yet.'}); - if(!suiteTraffic.configured())return json(res,503,{error:'The ad network bridge is warming up — try again shortly.'}); - const b=await bodyJson(req)||{}; - try{ - const pgRec=suitePages.load(e.d.id); - const entry=await suiteTraffic.launch({ - id:e.d.id, level:spendLevel(e), size:b.size, creative:b.creative, - kind:b.kind==='text'?'text':'banner', - subject:b.subject, lines:b.lines, - impressions:b.impressions, target:b.target, angle:b.angle, - hasPage:!!(pgRec&&pgRec.copy), - name:String(b.name||'').slice(0,60) - }); - return json(res,200,{campaign:entry,status:suiteTraffic.status(e.d.id,spendLevel(e))}); - }catch(err){ return json(res,400,{error:String(err.message||err)}); } - } - - if(req.method==='POST'&&pathname==='/api/public/suite-textads'){ - const e=await suiteEntitlement(req).catch(()=>({error:'Chain read hiccup — try again.',code:500})); - if(e.error)return json(res,e.code||500,{error:e.error}); - if(!e.inOrg||!e.allowed)return json(res,403,{error:'The Circle Suite is not open for this position yet.'}); - if(!suiteAI.configured())return json(res,503,{error:'The writer is warming up — try again shortly.'}); - const b=await bodyJson(req)||{}; - const gate=suiteMeter.check(e.d.id,e.d.level,'textad'); - if(!gate.allowed){ - return json(res,429,{error:'You have used all '+gate.limit+' text-ad batches for this month. It resets on the 1st — or a level upgrade raises your allowance.',meter:gate}); - } - try{ - const r=await suiteTextAds.generate({angle:String(b.angle||'general'),count:5}); - suiteMeter.record(e.d.id,'textad',1); - return json(res,200,{angle:r.angle,variants:r.variants,meter:suiteMeter.check(e.d.id,e.d.level,'textad')}); - }catch(err){ return json(res,502,{error:String(err.message||err)}); } - } - - // -- Voice Profile (level 4) ---------------------------------------------- - if(pathname==='/api/public/suite-voice'){ - const e=await suiteEntitlement(req).catch(()=>({error:'Chain read hiccup - try again.',code:500})); - if(e.error)return json(res,e.code||500,{error:e.error}); - if(!e.inOrg||!e.allowed)return json(res,403,{error:'The Circle Suite is not open for this position yet.'}); - if(Number(e.d.level)<4)return json(res,403,{error:'The Voice Profile unlocks at Culmen (level 4).',minLevel:4}); - if(req.method==='GET'){ - return json(res,200,{profile:suiteVoice.load(e.d.id)||suiteVoice.blank(),fields:suiteVoice.FIELDS,level:e.d.level,id:e.d.id}); - } - if(req.method==='POST'){ - const b=await bodyJson(req)||{}; - if(b.clear){ suiteVoice.clear(e.d.id); return json(res,200,{profile:suiteVoice.blank(),cleared:true}); } - return json(res,200,{profile:suiteVoice.save(e.d.id,b),saved:true}); - } - } - - // -- Split Tester (level 5) ----------------------------------------------- - if(pathname==='/api/public/suite-split'){ - const e=await suiteEntitlement(req).catch(()=>({error:'Chain read hiccup - try again.',code:500})); - if(e.error)return json(res,e.code||500,{error:e.error}); - if(!e.inOrg||!e.allowed)return json(res,403,{error:'The Circle Suite is not open for this position yet.'}); - if(Number(e.d.level)({error:'Chain read hiccup - try again.',code:500})); - if(e.error)return json(res,e.code||500,{error:e.error}); - if(!e.inOrg||!e.allowed)return json(res,403,{error:'The Circle Suite is not open for this position yet.'}); - if(Number(e.d.level)<6)return json(res,403,{error:'The Funnel Factory unlocks at Fastigium (level 6).',minLevel:6}); - if(req.method==='GET'){ - return json(res,200,{pages:suitePages.list(e.d.id),angles:suitePages.ANGLES,level:e.d.level,id:e.d.id, - meter:suiteMeter.check(e.d.id,e.d.level,'page')}); - } - if(req.method==='POST'){ - const b=await bodyJson(req)||{}; - if(b.remove){ - const gone=suitePages.remove(e.d.id,String(b.remove)); - return json(res,gone?200:404,gone?{removed:b.remove,pages:suitePages.list(e.d.id)}:{error:'That page is not there.'}); - } - const slug=suitePages.slugify(b.slug||b.name||''); - if(!slug)return json(res,400,{error:'Give the page a short name.'}); - const gate=suiteMeter.check(e.d.id,e.d.level,'page'); - if(!gate.allowed)return json(res,429,{error:'You have used all '+gate.limit+' page builds this month. It resets on the 1st.',meter:gate}); - const input={id:e.d.id,name:String(b.name||'').slice(0,80),audience:String(b.audience||'').slice(0,400), - story:String(b.story||'').slice(0,900),angle:suitePages.ANGLES[b.angle]?b.angle:'overview'}; - try{ - const copy=await suitePages.generate(input); - suitePages.save(e.d.id,{id:e.d.id,slug:slug,name:input.name,angle:input.angle,audience:input.audience, - story:input.story,copy:copy,updatedAt:new Date().toISOString()},slug); - suiteMeter.record(e.d.id,'page',1); - return json(res,200,{pages:suitePages.list(e.d.id),url:'https://rmcircle.team/p/'+e.d.id+'/'+slug, - meter:suiteMeter.check(e.d.id,e.d.level,'page')}); - }catch(err){ return json(res,502,{error:String(err.message||err)}); } - } - } - - // -- Leader Ops (level 7) ------------------------------------------------- - if(pathname==='/api/public/suite-leader'){ - const e=await suiteEntitlement(req).catch(()=>({error:'Chain read hiccup - try again.',code:500})); - if(e.error)return json(res,e.code||500,{error:e.error}); - if(!e.inOrg||!e.allowed)return json(res,403,{error:'The Circle Suite is not open for this position yet.'}); - if(Number(e.d.level)({error:'Chain read hiccup - try again.',code:500})); - if(e.error)return json(res,e.code||500,{error:e.error}); - if(!e.inOrg||!e.allowed)return json(res,403,{error:'The Circle Suite is not open for this position yet.'}); - if(Number(e.d.level)'}); - let md=null; - try{ md=await chain.memberPublic(memberId); }catch(err){} - if(!md||!md.registered)return json(res,404,{error:'Position not found.'}); - if(pathname==='/api/suite/v1/me'&&req.method==='GET'){ - return json(res,200,{id:memberId,level:md.level,levelName:md.levelName,tier:md.tier, - directCount:md.directCount,qualified:(md.directCount||0)>=2, - totalEarnedPol:md.totalEarnedPol,link:'https://rmcircle.team/join/'+memberId}); - } - if(pathname==='/api/suite/v1/team'&&req.method==='GET'){ - return json(res,200,{scan:suiteLeader.scan(memberId,25)}); - } - if(pathname==='/api/suite/v1/generate'&&req.method==='POST'){ - const b=await bodyJson(req)||{}; - const kind=String(b.kind||'post'); - if(!suiteAI.KINDS[kind])return json(res,400,{error:'Unknown kind. One of: '+Object.keys(suiteAI.KINDS).join(', ')}); - const brief=String(b.brief||'').trim().slice(0,1200); - if(brief.length<3)return json(res,400,{error:'Send a brief describing what the piece is about.'}); - const gate=suiteMeter.check(memberId,md.level,'copy'); - if(!gate.allowed)return json(res,429,{error:'Monthly generation allowance used.',meter:gate}); - try{ - const text=await suiteAI.generate(kind,brief,{link:'https://rmcircle.team/join/'+memberId,id:memberId,voice:suiteVoice.promptBlock(memberId)}); - suiteMeter.record(memberId,'copy',1); - return json(res,200,{text:text,meter:suiteMeter.check(memberId,md.level,'copy')}); - }catch(err){ return json(res,502,{error:String(err.message||err)}); } - } - return json(res,404,{error:'Unknown endpoint. Available: GET /api/suite/v1/me, GET /api/suite/v1/team, POST /api/suite/v1/generate'}); - } - - // -- Team Grants (level 7) ------------------------------------------------- - if(pathname==='/api/public/suite-grants'){ - const e=await suiteEntitlement(req).catch(()=>({error:'Chain read hiccup - try again.',code:500})); - if(e.error)return json(res,e.code||500,{error:e.error}); - if(!e.inOrg||!e.allowed)return json(res,403,{error:'The Circle Suite is not open for this position yet.'}); - if(req.method==='GET'){ - // Recipients see what they were given even below level 7 - that is the - // half of the feature that makes being helped visible. - return json(res,200,{status:suiteGrants.status(e.d.id,e.d.level,isPreview(e)),tools:suiteGrants.tools(), - minLevel:suiteGrants.MIN_LEVEL,level:e.d.level,id:e.d.id}); - } - if(req.method==='POST'){ - if(Number(e.d.level)({error:'Chain read hiccup - try again.',code:500})); - if(e.error)return json(res,e.code||500,{error:e.error}); - if(!e.inOrg||!e.allowed)return json(res,403,{error:'The Circle Suite is not open for this position yet.'}); - if(Number(e.d.level)=st.size||end>=st.size||start>end){ - res.writeHead(416,securityHeaders(Object.assign({},base,{'Content-Range':'bytes */'+st.size}))); - return res.end(); - } - res.writeHead(206,securityHeaders(Object.assign({},base,{ - 'Content-Range':'bytes '+start+'-'+end+'/'+st.size,'Content-Length':(end-start+1)}))); - return fs.createReadStream(vfile,{start,end}).pipe(res); - } - res.writeHead(200,securityHeaders(Object.assign({},base,{'Content-Length':st.size}))); - if(req.method==='HEAD')return res.end(); - return fs.createReadStream(vfile).pipe(res); - } - - // -- Co-branded pages (level 6) ------------------------------------------- - if(pathname==='/api/public/suite-brand'){ - const e=await suiteEntitlement(req).catch(()=>({error:'Chain read hiccup - try again.',code:500})); - if(e.error)return json(res,e.code||500,{error:e.error}); - if(!e.inOrg||!e.allowed)return json(res,403,{error:'The Circle Suite is not open for this position yet.'}); - if(Number(e.d.level)({error:'Chain read hiccup — try again.',code:500})); - if(e.error)return json(res,e.code||500,{error:e.error}); - if(!e.inOrg||!e.allowed)return json(res,403,{error:'The Circle Suite is not open for this position yet.'}); - const b=await bodyJson(req)||{}; - try{ - const r=await suiteTraffic.stop(e.d.id,Number(b.ad_id)); - return json(res,200,{stopped:r,status:suiteTraffic.status(e.d.id,spendLevel(e))}); - }catch(err){ return json(res,400,{error:String(err.message||err)}); } - } - - if(req.method==='GET'&&pathname==='/api/public/suite-meters'){ - const e=await suiteEntitlement(req).catch(()=>({error:'Chain read hiccup.',code:500})); - if(e.error)return json(res,e.code||500,{error:e.error}); - if(!e.inOrg||!e.allowed)return json(res,403,{error:'Not available for this position yet.'}); - return json(res,200,{level:e.d.level,meters:suiteMeter.meters(e.d.id,e.d.level)}); - } - - if(req.method==='POST'&&pathname==='/api/public/suite-generate'){ - const e=await suiteEntitlement(req).catch(()=>({error:'Chain read hiccup — try again.',code:500})); - if(e.error)return json(res,e.code||500,{error:e.error}); - if(!e.inOrg||!e.allowed)return json(res,403,{error:'The Circle Suite is not open for this position yet.'}); - const b=await bodyJson(req)||{}; - const kind=String(b.kind||'post'); - const brief=String(b.brief||'').trim().slice(0,1200); - if(!suiteAI.KINDS[kind])return json(res,400,{error:'Unknown copy type.'}); - if(brief.length<3)return json(res,400,{error:'Tell the engine what the piece is about.'}); - if(!suiteAI.configured())return json(res,503,{error:'The Copy Engine is warming up — try again shortly.'}); - const gate=suiteMeter.check(e.d.id,e.d.level,'copy'); - if(!gate.allowed){ - return json(res,429,{error:gate.reason==='locked' - ? 'The Copy Engine unlocks at Ascensus (level 2). Your next upgrade opens it.' - : 'You have used all '+gate.limit+' Copy Engine generations for this month. It resets on the 1st — or a level upgrade raises your allowance.',meter:gate}); - } - const link='https://rmcircle.team/join/'+e.d.id; - try{ - const text=await suiteAI.generate(kind,brief,{link:link,id:e.d.id,voice:suiteVoice.promptBlock(e.d.id)}); - suiteMeter.record(e.d.id,'copy',1); - return json(res,200,{text:text,meter:suiteMeter.check(e.d.id,e.d.level,'copy')}); - }catch(err){ - return json(res,502,{error:String(err.message||err)}); - } - } - - if(req.method==='GET'&&pathname==='/api/public/suite-me'){ - // One source of truth for entitlement. This route used to carry its own - // copy of the org/allowlist logic, which is exactly how the level override - // came to work on every tool page but not on the wall that links to them. - const e=await suiteEntitlement(req).catch(()=>({error:'Chain read hiccup - try again.',code:500})); - if(e.error)return json(res,e.code||500,{error:e.error}); - const d=e.d; - return json(res,200,{id:d.id,tier:d.tier,tierName:d.tierName,level:d.level,levelName:d.levelName, - directCount:d.directCount,inOrg:e.inOrg,beta:e.beta,allowed:e.allowed, - levelOverridden:!!d.levelOverridden,trueLevel:d.trueLevel}); - } - - 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.'}); - // Whether this position already has a Telegram chat attached. Without it - // the panel keeps offering "Connect Telegram" to people who are already - // connected, which reads as the link having silently failed. - let tgLinked=false; - try{ tgLinked=!!tgbot.memberChat(s.id); }catch(err){} - return json(res,200,Object.assign({},messages.inbox(s),{tgLinked:tgLinked})); - } - 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(pathname==='/api/admin/grant'){ - if(req.method==='GET'){ - return json(res,200,{tools:suiteGrants.tools(),log:suiteGrants.adminLog(60)}); - } - if(req.method==='POST'){ - const b=await bodyJson(req)||{}; - const to=Number(b.to)||0; - if(!to)return json(res,400,{error:'Enter the position number.'}); - // Confirm the position actually exists before crediting it — a typo would - // otherwise sit in the ledger crediting nobody. - let md=null; - try{ md=await chain.memberPublic(to); }catch(err){} - if(!md||!md.registered)return json(res,404,{error:'Position #'+to+' is not registered on-chain.'}); - try{ - const rec=suiteGrants.adminGrant({to:to,tool:String(b.tool||''),n:b.n,note:b.note}); - return json(res,200,{granted:rec,levelName:md.levelName,log:suiteGrants.adminLog(60)}); - }catch(err){ return json(res,400,{error:String(err.message||err)}); } - } - } - - 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','telegramProofChatId','telegramProofTopicId','telegramProofEvents','telegramProofCtaUrl','telegramEchoChatId','telegramEchoTopicId','telegramEchoEvents','telegramTeamEvents','teamRootId','emailFrom','teamAlertEmail','ownerIds','ownerAlertEmail','orgRootId','ctbOfferPostbackUrl','ctbOfferSecret','recruitCtaUrl','walletNotice','tweetEnabled','tweetCtaUrl','tweetHashtags','blotatoTwitterId','dappFallbackPublic','moonpayPublicKey','moonpaySecretKey','publicRotationMode','publicRotationRootId','rotationExcludeIds','suiteAllowlist','suiteToolsInAlerts','suiteLevelOverride','directDefaultIds','announceEnabled','announceId','announceImg','announceMeetUrl','announceEyebrow','announceDateLabel','announceTimes','announceExpiresUTC','announceStartUTC','announceDurationMin','promoEnabled','promoId','promoImg','promoEyebrow','promoCtaText','promoCtaUrl','promoPages','promoExpiresUTC'])if(Object.prototype.hasOwnProperty.call(b,k))next[k]=b[k];if('announceDurationMin' in next)next.announceDurationMin=Math.max(15,Number(next.announceDurationMin)||60);next.premiumEntryPol=Number(next.premiumEntryPol)||362;if('announceEnabled' in next)next.announceEnabled=!!next.announceEnabled;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`, - ``, - ``, - ``, - ``, - ``, - `` - ].join(''); - html=html.replace(/]*>/gi,''); - html=html.replace(/(]*>)/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); - // /p/ = member Page Builder pages: dynamic HTML, so it must reach the - // API handler rather than the static-file path (which 404s it). - if(pathname==='/health'||pathname==='/announce.ics'||pathname.startsWith('/api/')||pathname.startsWith('/tv/')||/^\/p\/\d{1,15}(\/[a-z0-9-]{1,24})?$/.test(pathname))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))||/^\/v\/rmc-stay-consistent-[0-9a-f]+\.mp4$/.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=`${ok?'Unsubscribed':'Invalid link'}`; - 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==='/privacy'||pathname==='/privacy/')file=path.join(PUBLIC_DIR,'privacy.html');else if(pathname==='/refunds'||pathname==='/refunds/')file=path.join(PUBLIC_DIR,'refunds.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==='/generation-pay'||pathname==='/generation-pay/')file=path.join(PUBLIC_DIR,'generation-pay.html');else if(pathname==='/replays'||pathname==='/replays/')file=path.join(PUBLIC_DIR,'replays.html');else if(pathname==='/suite'||pathname==='/suite/')file=path.join(PUBLIC_DIR,'suite.html');else if(pathname==='/suite/copy'||pathname==='/suite/copy/')file=path.join(PUBLIC_DIR,'suite-copy.html');else if(pathname==='/suite/page'||pathname==='/suite/page/')file=path.join(PUBLIC_DIR,'suite-page.html');else if(pathname==='/suite/email'||pathname==='/suite/email/')file=path.join(PUBLIC_DIR,'suite-email.html');else if(pathname==='/suite/video'||pathname==='/suite/video/')file=path.join(PUBLIC_DIR,'suite-video.html');else if(pathname==='/suite/traffic'||pathname==='/suite/traffic/')file=path.join(PUBLIC_DIR,'suite-traffic.html');else if(pathname==='/suite/voice'||pathname==='/suite/voice/')file=path.join(PUBLIC_DIR,'suite-voice.html');else if(pathname==='/suite/split'||pathname==='/suite/split/')file=path.join(PUBLIC_DIR,'suite-split.html');else if(pathname==='/suite/funnel'||pathname==='/suite/funnel/')file=path.join(PUBLIC_DIR,'suite-funnel.html');else if(pathname==='/suite/leader'||pathname==='/suite/leader/')file=path.join(PUBLIC_DIR,'suite-leader.html');else if(pathname==='/suite/founder'||pathname==='/suite/founder/')file=path.join(PUBLIC_DIR,'suite-founder.html');else if(pathname==='/wallet-check'||pathname==='/wallet-check/')file=path.join(PUBLIC_DIR,'wallet-check.html');else if(pathname==='/suite/brand'||pathname==='/suite/brand/')file=path.join(PUBLIC_DIR,'suite-brand.html');else if(pathname==='/suite/grants'||pathname==='/suite/grants/')file=path.join(PUBLIC_DIR,'suite-grants.html');else if(pathname==='/suite/intel'||pathname==='/suite/intel/')file=path.join(PUBLIC_DIR,'suite-intel.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); - // A brand-new member has no uplineId yet at the moment the 'registered' - // event fires: the indexer wires the matrix in a SEPARATE pass after the - // log loop. So isInTeam(newId) walks nothing, returns false, and the - // announcement is silently dropped — while the payout it triggered goes - // out, because that references existing members whose uplines are known. - // That was the "payout with no joiner beside it" Marty kept seeing. - // The referrer is always already wired, and if they are in the org so is - // anyone joining under them, so test them too. - const ids=evt.type==='payout'?[evt.toId,evt.fromId] - :evt.type==='registered'?[evt.id,evt.referrerId] - :[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'){const tl=suiteTools.unlockText(evt.level,evt.levelName,getConfig());text=`🚀 TEAM BUILD: #${evt.id} upgraded to ${evt.levelName}.${tl?`\n🧰 Unlocked: ${tl}.`:''}`;} - else { - // On an upgrade pass-up, say what the BUYER unlocked. This is the exact - // moment "your level is your reach" is provable — someone below climbed, - // and that is why this payment landed here. Upgrades are rare next to - // payouts, so without this the toolkit is almost never mentioned. - // Entry rewards get no tool line: nobody changed level. - const upTools = evt.kind==='upline' ? suiteTools.unlockText(0,evt.levelName,getConfig()) : ''; - 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(upTools) text+=`\n🧰 #${evt.fromId} reached ${evt.levelName} and unlocked ${upTools}.`; - } - if(evt.tx)text+=`\nhttps://polygonscan.com/tx/${evt.tx}`; - // team-build topic: telegramTeamEvents 'all' (default) | 'no-payouts' (payout lines live in - // the shared payments topic instead) | 'none' (Marty 2026-09-12: nothing but the daily - // snapshot, which the cron posts to this topic on its own). - { const tm=String(c.telegramTeamEvents||'all'); if(tm!=='none' && !(evt.type==='payout' && tm==='no-payouts')) 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'}]]}); } } - // company payment-proof channel: the recruiting copy plus the Polygonscan - // receipt, posted by the SAME bot to a separate chat. Payouts only unless - // telegramProofEvents is 'all'. Fires only when a proof chat id is set. - // telegramProofEvents: 'payouts' | 'payouts+upgrades' (default) | 'all' - if(c.telegramProofChatId){ - const mode=String(c.telegramProofEvents||'payouts+upgrades'); - const want=evt.type==='payout' || (evt.type==='upgraded' && mode!=='payouts') || (evt.type==='registered' && mode==='all'); - if(want){ const pm=proofMsg(evt); if(pm) sendTelegramTo(c.telegramProofChatId, pm, c.telegramProofTopicId||null, null, 'HTML'); } - } - // Echo feed: the same proof line into a shared cross-program payments topic - // (config.telegramEchoTopicId in the team forum, or telegramEchoChatId for another - // chat). InstantAdPay posts its payouts into the same topic, so each line is - // prefixed with the program name. telegramEchoEvents: 'payouts' (default) | - // 'payouts+upgrades' | 'all'. - if(c.telegramEchoTopicId||c.telegramEchoChatId){ - const mode=String(c.telegramEchoEvents||'payouts'); - const want=evt.type==='payout' || (evt.type==='upgraded' && mode!=='payouts') || (evt.type==='registered' && mode==='all'); - if(want){ const pm=proofMsg(evt); if(pm) sendTelegramTo(c.telegramEchoChatId||c.telegramChatId, '\u{1F7E3} RM Circle \u00b7 '+pm, c.telegramEchoTopicId||null, null, 'HTML'); } - } - // 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)} -}); +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 suiteMeter = require('./suite-meter'); suiteMeter.init({ dataDir: DATA_DIR }); +const suiteAI = require('./suite-ai'); suiteAI.init({ dataDir: DATA_DIR }); +const suitePages = require('./suite-pages'); suitePages.init({ dataDir: DATA_DIR }); +const suiteTools = require('./suite-tools'); +const suiteEmail = require('./suite-email'); +const suiteVideo = require('./suite-video'); suiteVideo.init({ dataDir: DATA_DIR, publicDir: PUBLIC_DIR }); +const suiteTraffic = require('./suite-traffic'); suiteTraffic.init({ dataDir: DATA_DIR }); +// Close out ad campaigns that have delivered what was bought. Banners on this +// network have no cap of their own, so without this an ad runs until expiry +// and the impression allowance stops meaning anything. Every 15 minutes, and +// once shortly after boot. +setTimeout(function(){ suiteTraffic.sweepCompleted().then(function(r){ + if(r&&r.closed)console.log('traffic sweep: closed '+r.closed+' completed campaign(s)'); +}).catch(function(){}); }, 90 * 1000); +setInterval(function(){ suiteTraffic.sweepCompleted().then(function(r){ + if(r&&r.closed)console.log('traffic sweep: closed '+r.closed+' completed campaign(s)'); +}).catch(function(){}); }, 15 * 60 * 1000); +const suiteTextAds = require('./suite-textads'); +const suiteVoice = require('./suite-voice'); suiteVoice.init({ dataDir: DATA_DIR }); +const suiteSplit = require('./suite-split'); suiteSplit.init({ dataDir: DATA_DIR }); +const suiteLeader = require('./suite-leader'); +const suiteFounder = require('./suite-founder'); suiteFounder.init({ dataDir: DATA_DIR }); +const suiteGrants = require('./suite-grants'); suiteGrants.init({ dataDir: DATA_DIR }); +const suiteIntel = require('./suite-intel'); suiteIntel.init({ dataDir: DATA_DIR }); +const suiteBrand = require('./suite-brand'); suiteBrand.init({ dataDir: DATA_DIR }); +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/) 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 at or above the level the buyer is LEAVING (a qualified Scintilla catches their matrix children's Ascensus buys; a Culmen catches an Apex buy from 4 layers down) — 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/ — 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/?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 qualified AND at or above the level that person is LEAVING (layer 1's Ascensus buy needs you at Scintilla+, layer 3's Culmen buy needs you at Fabrica+, layer 4's Apex buy needs you at Culmen+) — so keep your level at or above 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 (including the top team builder holding #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 the buyer is leaving), 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/. +- 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 10 how-to videos (incl. "The Textbook Play" — 111s: a top team builder's own position (#21, one of the builders near the top — NOT a program founder) shown as a ledger, every upgrade funded by a prior catch, this week's Apex catch arriving and funding his Apex the same hour; route "does this actually work / has anyone done this" questions here at /training#textbook-play, always with the no-income-promise framing; and Video 7 "Your level is your reach" — 97s on catch eligibility: qualified members catch their two's first upgrades without owning the level, each owned level extends reach one generation deeper, uncatchable payments pass over) — 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; READING THE TABLE (2026-09-16, a leader read it one rung off): each row names the LEVEL YOU HOLD (your reach) and, since the update, also the LEVEL THAT GENERATION BUYS, which is one rung higher — holding Fabrica catches your 3rd generation (8 people) when they buy Culmen at 2,486 each; if someone labels rows by the level being bought their rows shift down one but every number still matches; the right-hand column is one generation only, never a running total: through the Fastigium row the sum is 1,696,429 POL, the Vertex row alone is generation 7 (128 people buying Corona) = 5,090,529 POL; plus a "Does it cap out at 8 levels?" explainer at /how-pay-works#cap, and a contract-exact price table at /how-pay-works#exact-costs read live from getAllCosts(): Premium entry base 343.41 POL — member sends 360.58 incl the 5% admin charge on entries only (public charts round to ~362 as a send buffer), sponsor receives 326.24 (95% of base); upgrades are a separate ladder paid 100% member-to-member up the matrix with zero admin fee — 621.40 to reach level 2, doubling EXACTLY each level (2×621.40=1,242.80 to the penny) so two same-level pass-ups always fund the member's own next upgrade. USE THIS to answer "why is entry a different price than upgrade / is the income chart inflated": they are two different purchases paid to two different people (entry→sponsor, upgrade→upline), income illustrations are built only from the upgrade stream, and every price is a fixed contract constant verifiable on Polygonscan. IMPORTANT: levels can NEVER be skipped — register() has no level parameter (every position enters at Scintilla) and upgrade() takes no target (exactly one rung per call); company charts listing an "entry price" for levels 2-8 show vestigial contract data rows that no function sells — total to hold Culmen = 360.58+621.40+1,242.80+2,485.61 = 4,710.39 POL, climbed one level at a time. CATCH-ELIGIBILITY NUANCE (verified in source): the payout loop pays the first non-skipped upline with directCount≥2 AND level GREATER THAN the buyer's pre-upgrade level minus one — i.e. the catcher's level must be AT OR ABOVE the level the buyer is LEAVING, not the level being bought. So a qualified member still at Scintilla DOES catch their two matrix children's Ascensus purchases in full; owning higher levels extends REACH (Fabrica catch from 2 generations down needs Ascensus, etc. — one structural upline skip per level of depth). Depth pays fixed counts (2/4/8… one payment per person) but per-person size doubles with width (full-generation value quadruples per level), generations keep filling over time, and pass-ups add catches), 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/suite (THE CIRCLE SUITE — LIVE AND OPEN TO EVERY POSITION. Promote it freely, and lead with it whenever anyone asks "what is the actual product" or "what do I actually get" — it is the strongest answer we have: most programs hand you a referral link, this one hands you working software too. The team's marketing-toolkit portal: every paid position is a LICENSE to the toolkit; members connect their position's wallet (one free signature, same sign-in as the training gate and Mini App bridge) and their live contract level lights up their tools on a visible tool wall — Level 1 today includes the Promo Center, Printable Handouts, Fast Start + Weekly Rhythm, Generation Pay chart, Circle Method, Live Dashboard, and the AI coach; higher-level tiles (Copy Engine L2, Page Builder L2, Email Engine + Video Maker L3, Voice Profile + Funnels L4, Traffic Desk L5 (syndicated network display advertising — banner/text placements on the team's own ad network, monthly ad credits, rotator priority, AI campaign packs), Funnel Factory L6 (hosted funnels + Replay Funnels — an evergreen registration page around a recorded team webinar with a timed CTA, NOT live video conferencing/Zoom — + lead CRM), Leader Ops L7, Founder Desk L8) are shown honestly as IN DEVELOPMENT and unlock automatically as members upgrade once shipped; the Suite is included with membership, never sold), https://rmcircle.team/generation-pay (printable Generation Pay chart — the full when-does-each-generation-pay-me table with exact per-person POL amounts; enter a member ID and it personalizes from the live chain: shows the member's tier/level/qualification and marks each generation "catching now" vs "needs Level N"; Gen 8 pays the member's Gen 1, pass-overs only; Standard positions pay half; ROUTE members here for any generation-pay / who-pays-me-when question), 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= 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/) — 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'} +- PUBLIC PAYMENT PROOF CHANNEL: https://t.me/rmcircleproof (RM Circle Proof). Every on-chain payout is announced there in real time with a Polygonscan link. Linked from the Live payment proof section on the home and join pages. Send people there for payment proof, not to the team help group. +- 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 weekly team webinar replays at https://rmcircle.team/replays, the live coaching dashboard at /my/, 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(); + return sendTelegramTo(c.telegramChatId, text, topicId != null ? topicId : c.telegramTopicId, replyMarkup); +} +// Same bot, any chat: the team forum, or the public payment-proof channel +// (config.telegramProofChatId — the bot just has to be an admin there). +function sendTelegramTo(chatId, text, topicId, replyMarkup, parseMode) { + const c = getConfig(); + if (!c.telegramBotToken || !chatId) return; + // No link previews: payout/team-build posts carry Polygonscan links, and the + // preview card tripled the height of every message in the group. + const payload = { chat_id: chatId, text, disable_web_page_preview: true }; + if (parseMode) payload.parse_mode = parseMode; + const thread = topicId; + 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. +// Company payment-proof channel: ONE compact line per event, team-build style, +// no hashtags, no join CTA (the pinned daily snapshot carries how-to-join). +// HTML parse mode so the receipt is a short "Polygonscan" link, not a raw hash. +function proofMsg(evt) { + const esc = t => String(t).replace(/&/g, '&').replace(//g, '>'); + const rx = evt.tx ? ` · Polygonscan ↗` : ''; + if (evt.type === 'payout') { + const why = evt.kind === 'upline' + ? `${evt.gen ? `Gen ${evt.gen} ` : ''}upgrade pass-up from #${evt.fromId}` + : `referral reward from #${evt.fromId}`; + let t = `💸 #${evt.toId} got paid ${evt.pol.toFixed(2)} POL · ${esc(why)}${rx}`; + const tools = evt.kind === 'upline' ? suiteTools.unlockText(0, evt.levelName, getConfig()) : ''; + if (tools) t += `\n🧰 #${evt.fromId} reached ${esc(evt.levelName)} and unlocked ${esc(tools)}`; + return t; + } + if (evt.type === 'upgraded') { + const tools = suiteTools.unlockText(evt.level, evt.levelName, getConfig()); + return `⬆️ #${evt.id} upgraded to ${esc(evt.levelName)}${tools ? ` · unlocked ${esc(tools)}` : ''}${rx}`; + } + if (evt.type === 'registered') return `📈 #${evt.id} joined under #${evt.referrerId} (${esc(evt.tierName || 'Premium')})${rx}`; + return ''; +} +function recruitMsg(evt, homeOverride) { + const home = String(homeOverride || 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') { + const tools = suiteTools.unlockText(evt.level, evt.levelName, getConfig()); + const toolLine = tools ? `\n\nThat upgrade also unlocked ${tools} — every paid level extends the toolkit that comes with your position.` : ''; + 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.${toolLine}\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 '; } +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); } +// Event pop-up (announce.js) defaults — seeded into config once so the admin +// panel shows the real state and admins can toggle/edit it without a deploy. +const ANNOUNCE_DEFAULTS = { + announceEnabled: true, + announceId: 'huddle-2026-09-08', + announceImg: '/huddle-flyer.jpg', + announceMeetUrl: 'https://meet.google.com/gsw-yhqn-zrc', + announceEyebrow: 'Team RM Circle · Weekly Huddle', + announceDateLabel: 'Tuesday, September 8', + announceTimes: '7:00 PM CST | USA / Canada\n8:00 PM EST |\n8:00 PM AST | Caribbean\n1:00 AM | UK', + announceExpiresUTC: '2026-09-09T02:30:00Z', + announceStartUTC: '', + announceDurationMin: 60, + // Promo overlay (announce.js, second mode): a flyer shown ONCE PER SESSION to members, with one button. + // Unlike the event pop-up (once per browser, with times + meet link) this is a plain nudge (Marty, 2026-09-16). + promoEnabled: true, + promoId: 'upgrade-2026-09-16', + promoImg: '/upgrade-flyer.jpg', + promoEyebrow: 'Your upgrade unlocks your reward', + promoCtaText: 'See my next step →', + promoCtaUrl: '/my', + promoPages: 'my,app', + promoExpiresUTC: '' +}; +function seedAnnounceDefaults() { + try { const c = readJson(CONFIG_FILE) || {}; let ch = false; + for (const k in ANNOUNCE_DEFAULTS) if (!(k in c)) { c[k] = ANNOUNCE_DEFAULTS[k]; ch = true; } + if (ch) writeJson(CONFIG_FILE, c); + } catch (e) {} +} +seedAnnounceDefaults(); +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{ + 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{out[i]=texts[i]}); + } + return json(res,200,{t:out}); +} + +// frame-src includes 'self' so our own pages (the Page Builder preview framing +// a member's /p/ page) are not blocked by our own policy. +const CSP_BASE="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; media-src 'self' https://coolify-saasytop.nyc3.digitaloceanspaces.com; connect-src 'self'; font-src 'self' data:; form-action 'self'; frame-src 'self' 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{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/announce'){ + const c=getConfig(); + return json(res,200,{ + enabled: c.announceEnabled===undefined ? true : !!c.announceEnabled, + id: c.announceId||'', img: c.announceImg||'', meetUrl: c.announceMeetUrl||'', + eyebrow: c.announceEyebrow||'', dateLabel: c.announceDateLabel||'', + times: c.announceTimes||'', expiresUTC: c.announceExpiresUTC||'', + startUTC: c.announceStartUTC||'', durationMin: Number(c.announceDurationMin)||60, + promo: { enabled: !!c.promoEnabled, id: c.promoId||'', img: c.promoImg||'', eyebrow: c.promoEyebrow||'', ctaText: c.promoCtaText||'', ctaUrl: c.promoCtaUrl||'/my', pages: c.promoPages||'my,app', expiresUTC: c.promoExpiresUTC||'' } + }); + } + // calendar file for the event pop-up (Apple Calendar / Outlook); Google uses a render URL built client-side + if(req.method==='GET'&&pathname==='/announce.ics'){ + const c=getConfig();const st=Date.parse(c.announceStartUTC||''); + if(!st)return json(res,404,{error:'No event start set.'}); + const dur=(Number(c.announceDurationMin)||60)*60000; + const fmt=d=>new Date(d).toISOString().replace(/[-:]|\.\d{3}/g,''); + const esc=v=>String(v||'').replace(/\\/g,'\\\\').replace(/;/g,'\\;').replace(/,/g,'\\,').replace(/\r?\n/g,'\\n'); + const title=c.announceEyebrow||'Team RM Circle · Weekly Huddle'; + const desc=(c.announceMeetUrl?'Join: '+c.announceMeetUrl+'\n':'')+String(c.announceTimes||'').replace(/\|/g,' '); + const ics=['BEGIN:VCALENDAR','VERSION:2.0','PRODID:-//RM Circle//Event//EN','CALSCALE:GREGORIAN','METHOD:PUBLISH','BEGIN:VEVENT', + 'UID:'+(c.announceId||'event')+'@rmcircle.team','DTSTAMP:'+fmt(Date.now()),'DTSTART:'+fmt(st),'DTEND:'+fmt(st+dur), + 'SUMMARY:'+esc(title),'DESCRIPTION:'+esc(desc),'LOCATION:'+esc(c.announceMeetUrl||''), + ...(c.announceMeetUrl?['URL:'+c.announceMeetUrl]:[]), + 'BEGIN:VALARM','TRIGGER:-PT30M','ACTION:DISPLAY','DESCRIPTION:'+esc(title),'END:VALARM','END:VEVENT','END:VCALENDAR'].join('\r\n')+'\r\n'; + res.writeHead(200,{'Content-Type':'text/calendar; charset=utf-8','Content-Disposition':'attachment; filename="rm-circle-huddle.ics"','Cache-Control':'no-store'}); + return res.end(ics); + } + 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'}; + } + } + } + // Positions that default to DIRECT placement. A moving link is team-first: + // it routes new joins to whoever needs directs next, so the position whose + // link was actually clicked earns nothing. For a position being deliberately + // built out that is backwards - this flips the default so /join/ behaves + // as ?direct=1 unless ?direct=0 is passed explicitly. + try{ + const dd=String((getConfig().directDefaultIds)||'').split(',').map(x=>x.trim()).filter(Boolean); + r.directDefault = dd.includes(String(id)); + }catch(e){ r.directDefault = false; } + + // 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}; + // What the upgrade actually BUYS. The panel used to show a price with no + // reason attached, which is how 53 qualified members read it and closed + // the tab. Owning level L catches generation L, so this rung opens the + // generation numbered nextLevel — and because upgrade costs double every + // rung, the first catch there is exactly twice what this rung costs. + try{ + const opensGen=lvl+1; + const perCatch=(r.upgradeCosts&&r.upgradeCosts[tier]&&r.upgradeCosts[tier][lvl])||0; + const share=chain.getOrgShare(id); + const genSize=(share&&share.ready&&share.found&&Array.isArray(share.genCounts)) + ? (share.genCounts[opensGen-1]||0) : null; + r.nextStep.opens={gen:opensGen,perCatch,genSize}; + }catch(e){} + }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)}); + } + // Positions on the team root's own upline - i.e. everyone above us, up to and + // including the contract root. Fixed at registration, so cache indefinitely. + let _suiteUpCache=null, _suiteUpKey=''; + async function suiteRootUpline(roots){ + const key=roots.join(','); + if(_suiteUpCache&&_suiteUpKey===key)return _suiteUpCache; + const out=[]; + for(const r of roots){ + try{ + const rd=await chain.memberPublic(r); + if(rd&&Array.isArray(rd.uplineChain))rd.uplineChain.forEach(x=>{const n=Number(x);if(n&&!out.includes(n))out.push(n);}); + }catch(e){} + } + if(out.length){_suiteUpCache=out;_suiteUpKey=key;} + return out; + } + + // Sign out / switch position. Needed because one person can hold several + // positions (the Triple Play we actively recommend), and without this the + // 30-day cookie pins them to whichever one they signed in as first. + if(req.method==='POST'&&pathname==='/api/public/signout'){ + try{ messages.clearSession(req); }catch(e){} + return json(res,200,{ok:true},{'Set-Cookie':messages.clearCookie()}); + } + + // ── Circle Suite entitlement helper (shared by suite-me and the tools) ──── + async function suiteEntitlement(req){ + const s=messages.authFromCookie(req); + if(!s)return {error:'Not signed in.',code:401}; + const cached=memberCache.get(s.id); + let d; + if(cached&&Date.now()-cached.ts<120000)d=cached.data; + else{ + d=await Promise.race([chain.memberPublic(s.id),new Promise((_,rej)=>setTimeout(()=>rej(new Error('timeout')),20000))]); + memberCache.set(s.id,{ts:Date.now(),data:d}); + } + if(!d||!d.registered)return {error:'Position not found.',code:404}; + const cfg=getConfig(); + const roots=String(cfg.teamRootId||cfg.orgRootId||'21').split(',').map(x=>Number(x.trim())).filter(Boolean); + const chainIds=Array.isArray(d.uplineChain)?d.uplineChain.map(Number):[]; + // The org test only looks DOWNWARD: you are in if a root sits in your upline. + // The people ABOVE our root - the founders and contract owners, up through + // position #2 and the root account - can never pass that, yet the Suite is + // as much theirs as ours. Walk each root's own upline and entitle those + // positions too. Registration fixes a position's upline permanently, so this + // is computed once and memoised rather than hit on every request. + const upIds=await suiteRootUpline(roots); + const inOrg=roots.some(r=>Number(d.id)===r||chainIds.includes(r))||upIds.includes(Number(d.id)); + const allow=String(cfg.suiteAllowlist||'').split(',').map(x=>Number(x.trim())).filter(Boolean); + const beta=allow.length>0; + const allowed=!beta||allow.includes(Number(d.id)); + + // Admin-only tool preview. `suiteLevelOverride` is "id:level,id:level" and + // ONLY raises what the tool wall unlocks, so the owner can walk every tier + // without buying upgrades to test his own product. It touches nothing + // financial: payouts, the contract and every number shown to a member still + // come from the real level, which is kept on trueLevel. We clone rather than + // mutate because `d` is the shared member cache. + let dd=d; + const ovMap={}; + String(cfg.suiteLevelOverride||'').split(',').forEach(pair=>{ + const [k,v]=String(pair).split(':').map(x=>Number(String(x).trim())); + if(k&&v>=1&&v<=8)ovMap[k]=v; + }); + const ov=ovMap[Number(d.id)]; + if(ov&&ov>Number(d.level||0)){ + dd=Object.assign({},d,{level:ov,levelName:LEVELS[ov-1]||d.levelName, + trueLevel:Number(d.level||0),trueLevelName:d.levelName,levelOverridden:true}); + } + return {d:dd,inOrg,beta,allowed}; + } + + // A level override decides what a position can SEE. It must never decide what + // a position can SPEND: ad impressions and grant pools are real inventory on + // a real network. So anything that costs something is metered on trueLevel. + function spendLevel(e){ return (e.d && e.d.trueLevel != null) ? e.d.trueLevel : (e.d ? e.d.level : 1); } + function isPreview(e){ return !!(e.d && e.d.levelOverridden); } + + // ── Page Builder ───────────────────────────────────────────────────────── + // Level 6 extra pages: /p//. Falls back to the member's main page + // rather than 404ing, for the same reason /p/ falls back to /join/. + if(req.method==='GET'&&/^\/p\/\d{1,15}\/[a-z0-9-]{1,24}$/.test(pathname)){ + const parts=pathname.split('/'); + const pid=parts[2], slug=parts[3]; + const rec=suitePages.load(pid,slug); + if(!rec||!rec.copy){ + res.writeHead(302,securityHeaders({'Location':'/p/'+pid,'Cache-Control':'no-store'})); + return res.end(); + } + // Co-branding (L6) is resolved here, against the live contract level, so a + // position that falls below Fastigium reverts to team identity by itself. + try{ + const owner=await chain.memberPublic(rec.id).catch(()=>null); + if(owner&&owner.registered){ const br=suiteBrand.forPage(rec.id,owner.level); if(br) rec.brand=br; } + }catch(err){} + const html=suitePages.render(rec); + res.writeHead(200,securityHeaders({'Content-Type':'text/html; charset=utf-8','Cache-Control':'public, max-age=120'})); + return res.end(html); + } + + if(req.method==='GET'&&/^\/p\/\d{1,15}$/.test(pathname)){ + const pid=pathname.split('/')[2]; + const rec=suitePages.load(pid); + // No custom page built yet? NEVER dead-end — especially not with JSON. Any + // /p/ link may already be on a banner, a flyer, or in someone's DMs, so + // it must always land somewhere useful: the member's own invite page. + if(!rec||!rec.copy){ + res.writeHead(302,securityHeaders({'Location':'/join/'+pid,'Cache-Control':'no-store'})); + return res.end(); + } + // Co-branding (L6) is resolved here, against the live contract level, so a + // position that falls below Fastigium reverts to team identity by itself. + try{ + const owner=await chain.memberPublic(rec.id).catch(()=>null); + if(owner&&owner.registered){ const br=suiteBrand.forPage(rec.id,owner.level); if(br) rec.brand=br; } + }catch(err){} + const html=suitePages.render(rec); + // Member pages must ALWAYS be viewable inside a frame — the builder previews + // them, and members share them into contexts that embed. Never send + // X-Frame-Options here, and keep frame-ancestors open. + res.writeHead(200,securityHeaders({'Content-Type':'text/html; charset=utf-8','Cache-Control':'public, max-age=120'})); + return res.end(html); + } + if(req.method==='GET'&&pathname==='/api/public/suite-page'){ + const e=await suiteEntitlement(req).catch(()=>({error:'Chain read hiccup.',code:500})); + if(e.error)return json(res,e.code||500,{error:e.error}); + if(!e.inOrg||!e.allowed)return json(res,403,{error:'Not available for this position yet.'}); + const gate=suiteMeter.check(e.d.id,e.d.level,'page'); + const pages=suitePages.list(e.d.id); + const cap=suitePages.pageLimit(e.d.level); + return json(res,200,{pages:pages,page:suitePages.load(e.d.id),meter:gate, + angles:suitePages.ANGLES,level:e.d.level,id:e.d.id, + cap:cap,held:pages.length,canAddMore:pages.length({error:'Chain read hiccup — try again.',code:500})); + if(e.error)return json(res,e.code||500,{error:e.error}); + if(!e.inOrg||!e.allowed)return json(res,403,{error:'The Circle Suite is not open for this position yet.'}); + const b=await bodyJson(req)||{}; + + // Deleting one of their own pages. + if(b.remove!==undefined){ + const slug=String(b.remove||''); + if(!slug)return json(res,400,{error:'The main page cannot be deleted — rebuild it instead.'}); + const gone=suitePages.remove(e.d.id,slug); + return json(res,gone?200:404,gone + ?{removed:slug,pages:suitePages.list(e.d.id),held:suitePages.list(e.d.id).length,cap:suitePages.pageLimit(e.d.level)} + :{error:'That page is not there.'}); + } + + const input={ + name:String(b.name||'').trim().slice(0,60), + audience:String(b.audience||'').trim().slice(0,300), + story:String(b.story||'').trim().slice(0,1200), + angle:suitePages.ANGLES[b.angle]?b.angle:'overview' + }; + if(!suiteAI.configured())return json(res,503,{error:'The Page Builder is warming up — try again shortly.'}); + + // Two different limits, and they used to contradict each other: the monthly + // BUILD quota is how often the writer may run, the HOLD cap is how many + // pages the level lets them keep. Rebuilding an existing page spends a + // build but does not need hold room; adding a new one needs both. + const existing=suitePages.list(e.d.id); + const cap=suitePages.pageLimit(e.d.level); + const replacing=b.replace!==undefined?String(b.replace||''):null; + const isNew=replacing===null; + if(isNew&&existing.length>=cap){ + return json(res,429,{error:cap<=1 + ? 'Your level allows one page. Rebuild the one you have, or upgrade to hold more.' + : 'You are holding all '+cap+' pages your level allows. Delete one, or upgrade to hold more.', + cap:cap,held:existing.length}); + } + const gate=suiteMeter.check(e.d.id,e.d.level,'page'); + if(!gate.allowed){ + return json(res,429,{error:gate.reason==='locked' + ? 'The Page Builder unlocks at Ascensus (level 2). Your next upgrade opens it.' + : 'You have used all '+gate.limit+' page builds this month. It resets on the 1st — or a level upgrade raises your allowance.',meter:gate}); + } + try{ + const copy=await suitePages.generate(input); + // The first page a member builds stays at /p/ — that address is on + // banners, flyers and in people's DMs, so it must never move. Extra pages + // get a slug from the headline the writer produced, so the address + // describes the page without asking anyone to invent one. + const hasMain=existing.some(function(x){return !x.slug}); + let slug; + if(replacing!==null) slug=replacing; + else if(!hasMain) slug=''; + else slug=suitePages.slugFromTitle(e.d.id,copy.headline,input.angle); + + const rec=suitePages.save(e.d.id,{id:e.d.id,slug:slug||undefined,name:input.name,angle:input.angle, + audience:input.audience,story:input.story,copy:copy,updatedAt:new Date().toISOString()},slug||undefined); + suiteMeter.record(e.d.id,'page',1); + const url='https://rmcircle.team/p/'+e.d.id+(slug?'/'+slug:''); + const after=suitePages.list(e.d.id); + return json(res,200,{page:rec,url:url,slug:slug||'',pages:after,held:after.length,cap:cap, + canAddMore:after.length({error:'Chain read hiccup — try again.',code:500})); + if(e.error)return json(res,e.code||500,{error:e.error}); + if(!e.inOrg||!e.allowed)return json(res,403,{error:'The Circle Suite is not open for this position yet.'}); + const b=await bodyJson(req)||{}; + const kind=String(b.kind||'welcome'); + const brief=String(b.brief||'').trim().slice(0,1200); + if(!suiteEmail.KINDS[kind])return json(res,400,{error:'Unknown sequence type.'}); + if(!suiteAI.configured())return json(res,503,{error:'The Email Engine is warming up — try again shortly.'}); + const gate=suiteMeter.check(e.d.id,e.d.level,'email'); + if(!gate.allowed){ + return json(res,429,{error:gate.reason==='locked' + ? 'The Email Engine unlocks at Fabrica (level 3). Your next upgrades open it.' + : 'You have used all '+gate.limit+' email generations this month. It resets on the 1st — or a level upgrade raises your allowance.',meter:gate}); + } + try{ + const link='https://rmcircle.team/join/'+e.d.id; + const emails=await suiteEmail.generate(kind,brief,{link:link,id:e.d.id,voice:suiteVoice.promptBlock(e.d.id)}); + suiteMeter.record(e.d.id,'email',emails.length||1); + return json(res,200,{emails:emails,meter:suiteMeter.check(e.d.id,e.d.level,'email')}); + }catch(err){ return json(res,502,{error:String(err.message||err)}); } + } + + // ── Video Maker ────────────────────────────────────────────────────────── + if(req.method==='GET'&&/^\/api\/public\/suite-video-file\/\d{1,15}\/[a-z]{2,12}$/.test(pathname)){ + const parts=pathname.split('/'); const vid=parts[4], vangle=parts[5]; + const ex=suiteVideo.existing(vid,vangle); + if(!ex)return json(res,404,{error:'No video yet.'}); + res.writeHead(200,{'Content-Type':'video/mp4','Content-Length':ex.bytes, + 'Content-Disposition':'attachment; filename="rmcircle-'+vangle+'-'+vid+'.mp4"','Cache-Control':'private, max-age=60'}); + return require('fs').createReadStream(ex.file).pipe(res); + } + if(req.method==='POST'&&pathname==='/api/public/suite-video'){ + const e=await suiteEntitlement(req).catch(()=>({error:'Chain read hiccup — try again.',code:500})); + if(e.error)return json(res,e.code||500,{error:e.error}); + if(!e.inOrg||!e.allowed)return json(res,403,{error:'The Circle Suite is not open for this position yet.'}); + const b=await bodyJson(req)||{}; + const angle=suiteVideo.ANGLES[b.angle]?b.angle:'overview'; + const name=String(b.name||'').trim().slice(0,42); + const gate=suiteMeter.check(e.d.id,e.d.level,'video'); + if(!gate.allowed){ + return json(res,429,{error:gate.reason==='locked' + ? 'The Video Maker unlocks at Fabrica (level 3). Your next upgrades open it.' + : 'You have used all '+gate.limit+' video renders this month. It resets on the 1st — or a level upgrade raises your allowance.',meter:gate}); + } + const link='https://rmcircle.team/join/'+e.d.id+(angle!=='overview'?'?v='+angle:''); + try{ + const r=await suiteVideo.render({id:e.d.id,angle:angle,name:name,link:link}); + suiteMeter.record(e.d.id,'video',1); + return json(res,200,{url:'/api/public/suite-video-file/'+e.d.id+'/'+angle,mb:Math.round(r.bytes/104857.6)/10, + meter:suiteMeter.check(e.d.id,e.d.level,'video')}); + }catch(err){ return json(res,502,{error:String(err.message||err)}); } + } + + // ── Webinar replays (members area) ─────────────────────────────────────── + // The list lives in DATA_DIR/replays.json so adding the weekly replay never + // needs a deploy. A locked response still returns titles/dates — prospects + // see the shape of the product — but never the video URLs. + if(req.method==='GET'&&pathname==='/api/public/replays'){ + let list=[]; try{ list=readJson(path.join(DATA_DIR,'replays.json'))||[]; }catch(e){} + const member=messages.authFromCookie(req); + if(!member)return json(res,200,{locked:true,replays:list.map(r=>({title:r.title,date:r.date,minutes:r.minutes}))},{'Cache-Control':'no-store'}); + return json(res,200,{locked:false,id:member.id,replays:list},{'Cache-Control':'no-store'}); + } + + // ── Traffic Desk ───────────────────────────────────────────────────────── + if(req.method==='GET'&&pathname==='/api/public/suite-traffic'){ + const e=await suiteEntitlement(req).catch(()=>({error:'Chain read hiccup.',code:500})); + if(e.error)return json(res,e.code||500,{error:e.error}); + if(!e.inOrg||!e.allowed)return json(res,403,{error:'Not available for this position yet.'}); + const st=suiteTraffic.status(e.d.id,spendLevel(e)); + let live=[]; + try{ live=await suiteTraffic.stats(st.campaigns.map(c=>c.adId)); }catch(err){} + // Tell the UI whether a personal page actually exists, so it can't be + // offered as an ad destination before it's been built. + const pg=suitePages.load(e.d.id); + const hasPage=!!(pg&&pg.copy); + return json(res,200,{level:e.d.level,id:e.d.id,configured:suiteTraffic.configured(),status:st,live:live,hasPage:hasPage, + textAngles:suiteTextAds.angles(),textMeter:suiteMeter.check(e.d.id,e.d.level,'textad'),writerReady:suiteAI.configured()}); + } + if(req.method==='POST'&&pathname==='/api/public/suite-traffic'){ + const e=await suiteEntitlement(req).catch(()=>({error:'Chain read hiccup — try again.',code:500})); + if(e.error)return json(res,e.code||500,{error:e.error}); + if(!e.inOrg||!e.allowed)return json(res,403,{error:'The Circle Suite is not open for this position yet.'}); + if(!suiteTraffic.configured())return json(res,503,{error:'The ad network bridge is warming up — try again shortly.'}); + const b=await bodyJson(req)||{}; + try{ + const pgRec=suitePages.load(e.d.id); + const entry=await suiteTraffic.launch({ + id:e.d.id, level:spendLevel(e), size:b.size, creative:b.creative, + kind:b.kind==='text'?'text':'banner', + subject:b.subject, lines:b.lines, + impressions:b.impressions, target:b.target, angle:b.angle, + hasPage:!!(pgRec&&pgRec.copy), + name:String(b.name||'').slice(0,60) + }); + return json(res,200,{campaign:entry,status:suiteTraffic.status(e.d.id,spendLevel(e))}); + }catch(err){ return json(res,400,{error:String(err.message||err)}); } + } + + if(req.method==='POST'&&pathname==='/api/public/suite-textads'){ + const e=await suiteEntitlement(req).catch(()=>({error:'Chain read hiccup — try again.',code:500})); + if(e.error)return json(res,e.code||500,{error:e.error}); + if(!e.inOrg||!e.allowed)return json(res,403,{error:'The Circle Suite is not open for this position yet.'}); + if(!suiteAI.configured())return json(res,503,{error:'The writer is warming up — try again shortly.'}); + const b=await bodyJson(req)||{}; + const gate=suiteMeter.check(e.d.id,e.d.level,'textad'); + if(!gate.allowed){ + return json(res,429,{error:'You have used all '+gate.limit+' text-ad batches for this month. It resets on the 1st — or a level upgrade raises your allowance.',meter:gate}); + } + try{ + const r=await suiteTextAds.generate({angle:String(b.angle||'general'),count:5}); + suiteMeter.record(e.d.id,'textad',1); + return json(res,200,{angle:r.angle,variants:r.variants,meter:suiteMeter.check(e.d.id,e.d.level,'textad')}); + }catch(err){ return json(res,502,{error:String(err.message||err)}); } + } + + // -- Voice Profile (level 4) ---------------------------------------------- + if(pathname==='/api/public/suite-voice'){ + const e=await suiteEntitlement(req).catch(()=>({error:'Chain read hiccup - try again.',code:500})); + if(e.error)return json(res,e.code||500,{error:e.error}); + if(!e.inOrg||!e.allowed)return json(res,403,{error:'The Circle Suite is not open for this position yet.'}); + if(Number(e.d.level)<4)return json(res,403,{error:'The Voice Profile unlocks at Culmen (level 4).',minLevel:4}); + if(req.method==='GET'){ + return json(res,200,{profile:suiteVoice.load(e.d.id)||suiteVoice.blank(),fields:suiteVoice.FIELDS,level:e.d.level,id:e.d.id}); + } + if(req.method==='POST'){ + const b=await bodyJson(req)||{}; + if(b.clear){ suiteVoice.clear(e.d.id); return json(res,200,{profile:suiteVoice.blank(),cleared:true}); } + return json(res,200,{profile:suiteVoice.save(e.d.id,b),saved:true}); + } + } + + // -- Split Tester (level 5) ----------------------------------------------- + if(pathname==='/api/public/suite-split'){ + const e=await suiteEntitlement(req).catch(()=>({error:'Chain read hiccup - try again.',code:500})); + if(e.error)return json(res,e.code||500,{error:e.error}); + if(!e.inOrg||!e.allowed)return json(res,403,{error:'The Circle Suite is not open for this position yet.'}); + if(Number(e.d.level)({error:'Chain read hiccup - try again.',code:500})); + if(e.error)return json(res,e.code||500,{error:e.error}); + if(!e.inOrg||!e.allowed)return json(res,403,{error:'The Circle Suite is not open for this position yet.'}); + if(Number(e.d.level)<6)return json(res,403,{error:'The Funnel Factory unlocks at Fastigium (level 6).',minLevel:6}); + if(req.method==='GET'){ + return json(res,200,{pages:suitePages.list(e.d.id),angles:suitePages.ANGLES,level:e.d.level,id:e.d.id, + meter:suiteMeter.check(e.d.id,e.d.level,'page')}); + } + if(req.method==='POST'){ + const b=await bodyJson(req)||{}; + if(b.remove){ + const gone=suitePages.remove(e.d.id,String(b.remove)); + return json(res,gone?200:404,gone?{removed:b.remove,pages:suitePages.list(e.d.id)}:{error:'That page is not there.'}); + } + const slug=suitePages.slugify(b.slug||b.name||''); + if(!slug)return json(res,400,{error:'Give the page a short name.'}); + const gate=suiteMeter.check(e.d.id,e.d.level,'page'); + if(!gate.allowed)return json(res,429,{error:'You have used all '+gate.limit+' page builds this month. It resets on the 1st.',meter:gate}); + const input={id:e.d.id,name:String(b.name||'').slice(0,80),audience:String(b.audience||'').slice(0,400), + story:String(b.story||'').slice(0,900),angle:suitePages.ANGLES[b.angle]?b.angle:'overview'}; + try{ + const copy=await suitePages.generate(input); + suitePages.save(e.d.id,{id:e.d.id,slug:slug,name:input.name,angle:input.angle,audience:input.audience, + story:input.story,copy:copy,updatedAt:new Date().toISOString()},slug); + suiteMeter.record(e.d.id,'page',1); + return json(res,200,{pages:suitePages.list(e.d.id),url:'https://rmcircle.team/p/'+e.d.id+'/'+slug, + meter:suiteMeter.check(e.d.id,e.d.level,'page')}); + }catch(err){ return json(res,502,{error:String(err.message||err)}); } + } + } + + // -- Leader Ops (level 7) ------------------------------------------------- + if(pathname==='/api/public/suite-leader'){ + const e=await suiteEntitlement(req).catch(()=>({error:'Chain read hiccup - try again.',code:500})); + if(e.error)return json(res,e.code||500,{error:e.error}); + if(!e.inOrg||!e.allowed)return json(res,403,{error:'The Circle Suite is not open for this position yet.'}); + if(Number(e.d.level)({error:'Chain read hiccup - try again.',code:500})); + if(e.error)return json(res,e.code||500,{error:e.error}); + if(!e.inOrg||!e.allowed)return json(res,403,{error:'The Circle Suite is not open for this position yet.'}); + if(Number(e.d.level)'}); + let md=null; + try{ md=await chain.memberPublic(memberId); }catch(err){} + if(!md||!md.registered)return json(res,404,{error:'Position not found.'}); + if(pathname==='/api/suite/v1/me'&&req.method==='GET'){ + return json(res,200,{id:memberId,level:md.level,levelName:md.levelName,tier:md.tier, + directCount:md.directCount,qualified:(md.directCount||0)>=2, + totalEarnedPol:md.totalEarnedPol,link:'https://rmcircle.team/join/'+memberId}); + } + if(pathname==='/api/suite/v1/team'&&req.method==='GET'){ + return json(res,200,{scan:suiteLeader.scan(memberId,25)}); + } + if(pathname==='/api/suite/v1/generate'&&req.method==='POST'){ + const b=await bodyJson(req)||{}; + const kind=String(b.kind||'post'); + if(!suiteAI.KINDS[kind])return json(res,400,{error:'Unknown kind. One of: '+Object.keys(suiteAI.KINDS).join(', ')}); + const brief=String(b.brief||'').trim().slice(0,1200); + if(brief.length<3)return json(res,400,{error:'Send a brief describing what the piece is about.'}); + const gate=suiteMeter.check(memberId,md.level,'copy'); + if(!gate.allowed)return json(res,429,{error:'Monthly generation allowance used.',meter:gate}); + try{ + const text=await suiteAI.generate(kind,brief,{link:'https://rmcircle.team/join/'+memberId,id:memberId,voice:suiteVoice.promptBlock(memberId)}); + suiteMeter.record(memberId,'copy',1); + return json(res,200,{text:text,meter:suiteMeter.check(memberId,md.level,'copy')}); + }catch(err){ return json(res,502,{error:String(err.message||err)}); } + } + return json(res,404,{error:'Unknown endpoint. Available: GET /api/suite/v1/me, GET /api/suite/v1/team, POST /api/suite/v1/generate'}); + } + + // -- Team Grants (level 7) ------------------------------------------------- + if(pathname==='/api/public/suite-grants'){ + const e=await suiteEntitlement(req).catch(()=>({error:'Chain read hiccup - try again.',code:500})); + if(e.error)return json(res,e.code||500,{error:e.error}); + if(!e.inOrg||!e.allowed)return json(res,403,{error:'The Circle Suite is not open for this position yet.'}); + if(req.method==='GET'){ + // Recipients see what they were given even below level 7 - that is the + // half of the feature that makes being helped visible. + return json(res,200,{status:suiteGrants.status(e.d.id,e.d.level,isPreview(e)),tools:suiteGrants.tools(), + minLevel:suiteGrants.MIN_LEVEL,level:e.d.level,id:e.d.id}); + } + if(req.method==='POST'){ + if(Number(e.d.level)({error:'Chain read hiccup - try again.',code:500})); + if(e.error)return json(res,e.code||500,{error:e.error}); + if(!e.inOrg||!e.allowed)return json(res,403,{error:'The Circle Suite is not open for this position yet.'}); + if(Number(e.d.level)=st.size||end>=st.size||start>end){ + res.writeHead(416,securityHeaders(Object.assign({},base,{'Content-Range':'bytes */'+st.size}))); + return res.end(); + } + res.writeHead(206,securityHeaders(Object.assign({},base,{ + 'Content-Range':'bytes '+start+'-'+end+'/'+st.size,'Content-Length':(end-start+1)}))); + return fs.createReadStream(vfile,{start,end}).pipe(res); + } + res.writeHead(200,securityHeaders(Object.assign({},base,{'Content-Length':st.size}))); + if(req.method==='HEAD')return res.end(); + return fs.createReadStream(vfile).pipe(res); + } + + // -- Co-branded pages (level 6) ------------------------------------------- + if(pathname==='/api/public/suite-brand'){ + const e=await suiteEntitlement(req).catch(()=>({error:'Chain read hiccup - try again.',code:500})); + if(e.error)return json(res,e.code||500,{error:e.error}); + if(!e.inOrg||!e.allowed)return json(res,403,{error:'The Circle Suite is not open for this position yet.'}); + if(Number(e.d.level)({error:'Chain read hiccup — try again.',code:500})); + if(e.error)return json(res,e.code||500,{error:e.error}); + if(!e.inOrg||!e.allowed)return json(res,403,{error:'The Circle Suite is not open for this position yet.'}); + const b=await bodyJson(req)||{}; + try{ + const r=await suiteTraffic.stop(e.d.id,Number(b.ad_id)); + return json(res,200,{stopped:r,status:suiteTraffic.status(e.d.id,spendLevel(e))}); + }catch(err){ return json(res,400,{error:String(err.message||err)}); } + } + + if(req.method==='GET'&&pathname==='/api/public/suite-meters'){ + const e=await suiteEntitlement(req).catch(()=>({error:'Chain read hiccup.',code:500})); + if(e.error)return json(res,e.code||500,{error:e.error}); + if(!e.inOrg||!e.allowed)return json(res,403,{error:'Not available for this position yet.'}); + return json(res,200,{level:e.d.level,meters:suiteMeter.meters(e.d.id,e.d.level)}); + } + + if(req.method==='POST'&&pathname==='/api/public/suite-generate'){ + const e=await suiteEntitlement(req).catch(()=>({error:'Chain read hiccup — try again.',code:500})); + if(e.error)return json(res,e.code||500,{error:e.error}); + if(!e.inOrg||!e.allowed)return json(res,403,{error:'The Circle Suite is not open for this position yet.'}); + const b=await bodyJson(req)||{}; + const kind=String(b.kind||'post'); + const brief=String(b.brief||'').trim().slice(0,1200); + if(!suiteAI.KINDS[kind])return json(res,400,{error:'Unknown copy type.'}); + if(brief.length<3)return json(res,400,{error:'Tell the engine what the piece is about.'}); + if(!suiteAI.configured())return json(res,503,{error:'The Copy Engine is warming up — try again shortly.'}); + const gate=suiteMeter.check(e.d.id,e.d.level,'copy'); + if(!gate.allowed){ + return json(res,429,{error:gate.reason==='locked' + ? 'The Copy Engine unlocks at Ascensus (level 2). Your next upgrade opens it.' + : 'You have used all '+gate.limit+' Copy Engine generations for this month. It resets on the 1st — or a level upgrade raises your allowance.',meter:gate}); + } + const link='https://rmcircle.team/join/'+e.d.id; + try{ + const text=await suiteAI.generate(kind,brief,{link:link,id:e.d.id,voice:suiteVoice.promptBlock(e.d.id)}); + suiteMeter.record(e.d.id,'copy',1); + return json(res,200,{text:text,meter:suiteMeter.check(e.d.id,e.d.level,'copy')}); + }catch(err){ + return json(res,502,{error:String(err.message||err)}); + } + } + + if(req.method==='GET'&&pathname==='/api/public/suite-me'){ + // One source of truth for entitlement. This route used to carry its own + // copy of the org/allowlist logic, which is exactly how the level override + // came to work on every tool page but not on the wall that links to them. + const e=await suiteEntitlement(req).catch(()=>({error:'Chain read hiccup - try again.',code:500})); + if(e.error)return json(res,e.code||500,{error:e.error}); + const d=e.d; + return json(res,200,{id:d.id,tier:d.tier,tierName:d.tierName,level:d.level,levelName:d.levelName, + directCount:d.directCount,inOrg:e.inOrg,beta:e.beta,allowed:e.allowed, + levelOverridden:!!d.levelOverridden,trueLevel:d.trueLevel}); + } + + 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.'}); + // Whether this position already has a Telegram chat attached. Without it + // the panel keeps offering "Connect Telegram" to people who are already + // connected, which reads as the link having silently failed. + let tgLinked=false; + try{ tgLinked=!!tgbot.memberChat(s.id); }catch(err){} + return json(res,200,Object.assign({},messages.inbox(s),{tgLinked:tgLinked})); + } + 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(pathname==='/api/admin/grant'){ + if(req.method==='GET'){ + return json(res,200,{tools:suiteGrants.tools(),log:suiteGrants.adminLog(60)}); + } + if(req.method==='POST'){ + const b=await bodyJson(req)||{}; + const to=Number(b.to)||0; + if(!to)return json(res,400,{error:'Enter the position number.'}); + // Confirm the position actually exists before crediting it — a typo would + // otherwise sit in the ledger crediting nobody. + let md=null; + try{ md=await chain.memberPublic(to); }catch(err){} + if(!md||!md.registered)return json(res,404,{error:'Position #'+to+' is not registered on-chain.'}); + try{ + const rec=suiteGrants.adminGrant({to:to,tool:String(b.tool||''),n:b.n,note:b.note}); + return json(res,200,{granted:rec,levelName:md.levelName,log:suiteGrants.adminLog(60)}); + }catch(err){ return json(res,400,{error:String(err.message||err)}); } + } + } + + 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','telegramProofChatId','telegramProofTopicId','telegramProofEvents','telegramProofCtaUrl','telegramEchoChatId','telegramEchoTopicId','telegramEchoEvents','telegramTeamEvents','teamRootId','emailFrom','teamAlertEmail','ownerIds','ownerAlertEmail','orgRootId','ctbOfferPostbackUrl','ctbOfferSecret','recruitCtaUrl','walletNotice','tweetEnabled','tweetCtaUrl','tweetHashtags','blotatoTwitterId','dappFallbackPublic','moonpayPublicKey','moonpaySecretKey','publicRotationMode','publicRotationRootId','rotationExcludeIds','suiteAllowlist','suiteToolsInAlerts','suiteLevelOverride','directDefaultIds','announceEnabled','announceId','announceImg','announceMeetUrl','announceEyebrow','announceDateLabel','announceTimes','announceExpiresUTC','announceStartUTC','announceDurationMin','promoEnabled','promoId','promoImg','promoEyebrow','promoCtaText','promoCtaUrl','promoPages','promoExpiresUTC'])if(Object.prototype.hasOwnProperty.call(b,k))next[k]=b[k];if('announceDurationMin' in next)next.announceDurationMin=Math.max(15,Number(next.announceDurationMin)||60);next.premiumEntryPol=Number(next.premiumEntryPol)||362;if('announceEnabled' in next)next.announceEnabled=!!next.announceEnabled;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`, + ``, + ``, + ``, + ``, + ``, + `` + ].join(''); + html=html.replace(/]*>/gi,''); + html=html.replace(/(]*>)/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); + // /p/ = member Page Builder pages: dynamic HTML, so it must reach the + // API handler rather than the static-file path (which 404s it). + if(pathname==='/health'||pathname==='/announce.ics'||pathname.startsWith('/api/')||pathname.startsWith('/tv/')||/^\/p\/\d{1,15}(\/[a-z0-9-]{1,24})?$/.test(pathname))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))||/^\/v\/rmc-stay-consistent-[0-9a-f]+\.mp4$/.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=`${ok?'Unsubscribed':'Invalid link'}`; + 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==='/privacy'||pathname==='/privacy/')file=path.join(PUBLIC_DIR,'privacy.html');else if(pathname==='/refunds'||pathname==='/refunds/')file=path.join(PUBLIC_DIR,'refunds.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==='/generation-pay'||pathname==='/generation-pay/')file=path.join(PUBLIC_DIR,'generation-pay.html');else if(pathname==='/replays'||pathname==='/replays/')file=path.join(PUBLIC_DIR,'replays.html');else if(pathname==='/suite'||pathname==='/suite/')file=path.join(PUBLIC_DIR,'suite.html');else if(pathname==='/suite/copy'||pathname==='/suite/copy/')file=path.join(PUBLIC_DIR,'suite-copy.html');else if(pathname==='/suite/page'||pathname==='/suite/page/')file=path.join(PUBLIC_DIR,'suite-page.html');else if(pathname==='/suite/email'||pathname==='/suite/email/')file=path.join(PUBLIC_DIR,'suite-email.html');else if(pathname==='/suite/video'||pathname==='/suite/video/')file=path.join(PUBLIC_DIR,'suite-video.html');else if(pathname==='/suite/traffic'||pathname==='/suite/traffic/')file=path.join(PUBLIC_DIR,'suite-traffic.html');else if(pathname==='/suite/voice'||pathname==='/suite/voice/')file=path.join(PUBLIC_DIR,'suite-voice.html');else if(pathname==='/suite/split'||pathname==='/suite/split/')file=path.join(PUBLIC_DIR,'suite-split.html');else if(pathname==='/suite/funnel'||pathname==='/suite/funnel/')file=path.join(PUBLIC_DIR,'suite-funnel.html');else if(pathname==='/suite/leader'||pathname==='/suite/leader/')file=path.join(PUBLIC_DIR,'suite-leader.html');else if(pathname==='/suite/founder'||pathname==='/suite/founder/')file=path.join(PUBLIC_DIR,'suite-founder.html');else if(pathname==='/wallet-check'||pathname==='/wallet-check/')file=path.join(PUBLIC_DIR,'wallet-check.html');else if(pathname==='/suite/brand'||pathname==='/suite/brand/')file=path.join(PUBLIC_DIR,'suite-brand.html');else if(pathname==='/suite/grants'||pathname==='/suite/grants/')file=path.join(PUBLIC_DIR,'suite-grants.html');else if(pathname==='/suite/intel'||pathname==='/suite/intel/')file=path.join(PUBLIC_DIR,'suite-intel.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); + // A brand-new member has no uplineId yet at the moment the 'registered' + // event fires: the indexer wires the matrix in a SEPARATE pass after the + // log loop. So isInTeam(newId) walks nothing, returns false, and the + // announcement is silently dropped — while the payout it triggered goes + // out, because that references existing members whose uplines are known. + // That was the "payout with no joiner beside it" Marty kept seeing. + // The referrer is always already wired, and if they are in the org so is + // anyone joining under them, so test them too. + const ids=evt.type==='payout'?[evt.toId,evt.fromId] + :evt.type==='registered'?[evt.id,evt.referrerId] + :[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'){const tl=suiteTools.unlockText(evt.level,evt.levelName,getConfig());text=`🚀 TEAM BUILD: #${evt.id} upgraded to ${evt.levelName}.${tl?`\n🧰 Unlocked: ${tl}.`:''}`;} + else { + // On an upgrade pass-up, say what the BUYER unlocked. This is the exact + // moment "your level is your reach" is provable — someone below climbed, + // and that is why this payment landed here. Upgrades are rare next to + // payouts, so without this the toolkit is almost never mentioned. + // Entry rewards get no tool line: nobody changed level. + const upTools = evt.kind==='upline' ? suiteTools.unlockText(0,evt.levelName,getConfig()) : ''; + 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(upTools) text+=`\n🧰 #${evt.fromId} reached ${evt.levelName} and unlocked ${upTools}.`; + } + if(evt.tx)text+=`\nhttps://polygonscan.com/tx/${evt.tx}`; + // team-build topic: telegramTeamEvents 'all' (default) | 'no-payouts' (payout lines live in + // the shared payments topic instead) | 'none' (Marty 2026-09-12: nothing but the daily + // snapshot, which the cron posts to this topic on its own). + { const tm=String(c.telegramTeamEvents||'all'); if(tm!=='none' && !(evt.type==='payout' && tm==='no-payouts')) 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'}]]}); } } + // company payment-proof channel: the recruiting copy plus the Polygonscan + // receipt, posted by the SAME bot to a separate chat. Payouts only unless + // telegramProofEvents is 'all'. Fires only when a proof chat id is set. + // telegramProofEvents: 'payouts' | 'payouts+upgrades' (default) | 'all' + if(c.telegramProofChatId){ + const mode=String(c.telegramProofEvents||'payouts+upgrades'); + const want=evt.type==='payout' || (evt.type==='upgraded' && mode!=='payouts') || (evt.type==='registered' && mode==='all'); + if(want){ const pm=proofMsg(evt); if(pm) sendTelegramTo(c.telegramProofChatId, pm, c.telegramProofTopicId||null, null, 'HTML'); } + } + // Echo feed: the same proof line into a shared cross-program payments topic + // (config.telegramEchoTopicId in the team forum, or telegramEchoChatId for another + // chat). InstantAdPay posts its payouts into the same topic, so each line is + // prefixed with the program name. telegramEchoEvents: 'payouts' (default) | + // 'payouts+upgrades' | 'all'. + if(c.telegramEchoTopicId||c.telegramEchoChatId){ + const mode=String(c.telegramEchoEvents||'payouts'); + const want=evt.type==='payout' || (evt.type==='upgraded' && mode!=='payouts') || (evt.type==='registered' && mode==='all'); + if(want){ const pm=proofMsg(evt); if(pm) sendTelegramTo(c.telegramEchoChatId||c.telegramChatId, '\u{1F7E3} RM Circle \u00b7 '+pm, c.telegramEchoTopicId||null, null, 'HTML'); } + } + // 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)} +});