From ca5605e0567dab9fe7172db51d180881f7466217 Mon Sep 17 00:00:00 2001 From: martbost Date: Wed, 12 Aug 2026 08:47:02 -0500 Subject: [PATCH] Add AI chat backend via OpenRouter with knowledge-base fallback POST /api/public/chat proxies to OpenRouter (model from OPENROUTER_MODEL, default deepseek/deepseek-v4-flash:nitro) with a system prompt built from live site config, sponsor queue, and strict guardrails (no income claims, seed-phrase warnings, Telegram fallback). Rate-limited per IP. Without OPENROUTER_API_KEY set, or on any API error, the widget transparently falls back to the built-in knowledge base, so the chat always works. Co-Authored-By: Claude Fable 5 --- public/chat.js | 18 ++++++++++++++-- public/styles.css | 2 ++ server.js | 54 +++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 72 insertions(+), 2 deletions(-) diff --git a/public/chat.js b/public/chat.js index 6433392..0377161 100644 --- a/public/chat.js +++ b/public/chat.js @@ -70,8 +70,22 @@ 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} - function ask(q){addMsg(q,'user');setTimeout(()=>{addMsg(answer(q),'bot');},250)} + 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.saasy.top');const path=site?u.replace('https://rmcircle.saasy.top',''):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(){ diff --git a/public/styles.css b/public/styles.css index 0fffcbd..8b139b0 100644 --- a/public/styles.css +++ b/public/styles.css @@ -44,3 +44,5 @@ a{color:inherit}.wrap{width:min(1160px,calc(100% - 32px));margin:auto}.nav{heigh .chat-foot{padding:8px 14px 12px;font-size:12px;color:var(--muted);text-align:center} .chat-foot a{color:var(--teal)} .chat-hidden{display:none} +.chat-typing{letter-spacing:3px;animation:chatpulse 1s infinite} +@keyframes chatpulse{50%{opacity:.35}} diff --git a/server.js b/server.js index dfd2cb1..d5813fe 100644 --- a/server.js +++ b/server.js @@ -15,6 +15,59 @@ const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'changeme'; const IS_PROD = process.env.NODE_ENV === 'production'; const SESSION_TTL = 8 * 60 * 60 * 1000; const LEVELS = ['Scintilla','Ascensus','Fabrica','Culmen','Apex','Fastigium','Vertex','Corona']; +const OPENROUTER_API_KEY = process.env.OPENROUTER_API_KEY || ''; +const OPENROUTER_MODEL = process.env.OPENROUTER_MODEL || 'deepseek/deepseek-v4-flash:nitro'; +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 || 'Crypto Team Build'} (https://rmcircle.saasy.top), the team site for the RM Circle Premium team build — a project of the Crypto Team Build Network. + +FACTS: +- 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 EXACTLY 2 directs, then the position is "qualified" and its referral link is retired. The team then helps those 2 get their 2 (moving-link strategy). Build depth, not width. Never add extra directs to a qualified link. +- 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. +- Current team sponsor: ${a ? `ID ${a.id}${c.showSponsorName && a.name ? ` (${a.name})` : ''}, ${a.directs}/2 directs` : 'shown on the start page'}. ${waiting} placement(s) waiting. Placements rotate as positions qualify — always verify on https://rmcircle.saasy.top/start right before joining. +- Site pages: https://rmcircle.saasy.top/ (strategy overview + roadmap), https://rmcircle.saasy.top/start (current sponsor + join steps), https://rmcircle.saasy.top/training (4 videos: 1. How the team build works, 2. Create your MetaMask wallet, 3. Funding your wallet, 4. Buying the Premium position). +- Telegram group for live team help: ${c.telegramUrl || 'https://t.me/cryptoteambuild'} + +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.`; +} +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.' }); + if (!OPENROUTER_API_KEY) 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 ${OPENROUTER_API_KEY}`, 'Content-Type': 'application/json', 'HTTP-Referer': 'https://rmcircle.saasy.top', 'X-Title': 'RM Circle Team Help' }, + body: JSON.stringify({ model: OPENROUTER_MODEL, max_tokens: 350, temperature: 0.3, messages: [{ role: 'system', content: chatSystemPrompt() }, ...msgs] }) + }); + 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 }); } +} const sessions = new Map(); function ensureDataFile(name) { @@ -105,6 +158,7 @@ async function handleApi(req,res,pathname){ const b=await bodyJson(req).catch(()=>({}));recordEvent('click',b.source); let sponsors=getSponsors();const a=activeSponsor(sponsors);if(a){sponsors=sponsors.map(s=>s.id===a.id?{...s,clicks:(s.clicks||0)+1}:s);saveSponsors(sponsors)}return json(res,200,{ok:true}); } + if(req.method==='POST'&&pathname==='/api/public/chat')return await handleChat(req,res); if(req.method==='POST'&&pathname==='/api/public/track'){ const b=await bodyJson(req).catch(()=>({}));recordEvent(b.event,b.source);return json(res,200,{ok:true}); }