Close the conversion loop: member ID submission with Hermes Telegram notify
- Start page step 6 is now a bold gold-highlighted "Submit your NEW RM Circle ID" form with larger step numbers throughout; captures the sponsor the visitor was shown at join time. - POST /api/public/submit-id: validates numeric ID, dedupes, stores on the volume, records a per-source "purchase" analytics event, fires a BeMob postback (txid=purchase-<clickid>) to close paid campaigns, and posts the new ID + sponsor + source to the configured Telegram chat (MB Hermes pattern). Rate-limited. - Admin: Member ID Submissions table, "Confirmed joins" tile, and Telegram bot token / chat ID settings. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -47,6 +47,49 @@ RULES:
|
||||
- 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 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;
|
||||
}
|
||||
function sendTelegram(text) {
|
||||
const c = getConfig();
|
||||
if (!c.telegramBotToken || !c.telegramChatId) return;
|
||||
fetch(`https://api.telegram.org/bot${c.telegramBotToken}/sendMessage`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ chat_id: c.telegramChatId, text })
|
||||
}).then(r=>{ if(!r.ok) console.error('telegram sendMessage status', r.status); })
|
||||
.catch(e=>console.error('telegram error', e.message));
|
||||
}
|
||||
function firePostback(clickid, txid, source) {
|
||||
const pb = getConfig().bemobPostbackUrl;
|
||||
if (!clickid || !pb || !/^https:\/\/[a-z0-9.-]+\/postback/i.test(pb)) return;
|
||||
fetch(`${pb}${pb.includes('?')?'&':'?'}cid=${encodeURIComponent(clickid)}&payout=0&txid=${encodeURIComponent(txid)}`)
|
||||
.then(r=>{ if(r.ok) recordEvent('postback', source); else console.error('bemob postback status', r.status); })
|
||||
.catch(e=>console.error('bemob postback error', e.message));
|
||||
}
|
||||
async function handleSubmitId(req, res) {
|
||||
const ip = String(req.headers['x-forwarded-for']||req.socket.remoteAddress||'').split(',')[0].trim();
|
||||
if (submitRateLimited(ip)) return json(res, 429, { error: 'Too many submissions — please wait a few minutes.' });
|
||||
const b = await bodyJson(req).catch(()=>null);
|
||||
if (!b) return json(res, 400, { error: 'Invalid request.' });
|
||||
const newId = String(b.newId||'').trim();
|
||||
if (!/^[0-9]{1,10}$/.test(newId)) return json(res, 400, { error: 'Enter your numeric RM Circle ID (numbers only).' });
|
||||
const 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 });
|
||||
subs.push({ newId, sponsorId, source: source||'(direct)', clickid, ts: new Date().toISOString() });
|
||||
writeJson(SUBMISSIONS_FILE, subs.slice(-1000));
|
||||
recordEvent('purchase', source);
|
||||
firePostback(clickid, `purchase-${clickid}`, source);
|
||||
sendTelegram(`🔔 RM Circle: NEW MEMBER CONFIRMED\nNew ID: ${newId}\nJoined under sponsor: ${sponsorId}\nSource: ${source||'(direct)'}\n→ Add ID ${newId} to the rotation queue.`);
|
||||
return json(res, 200, { ok: true });
|
||||
}
|
||||
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.' });
|
||||
@@ -97,7 +140,7 @@ function getConfig() { return readJson(CONFIG_FILE); }
|
||||
function activeSponsor(sponsors) { return sponsors.find(s=>s.status==='active') || sponsors.find(s=>s.status==='waiting') || null; }
|
||||
function getAnalytics() { try { return readJson(ANALYTICS_FILE); } catch (e) { return { sources: {} }; } }
|
||||
function recordEvent(event, source) {
|
||||
if (!['bridge','start','click','training','postback'].includes(event)) return;
|
||||
if (!['bridge','start','click','training','postback','purchase'].includes(event)) return;
|
||||
const s = String(source||'').toLowerCase().trim().replace(/[^a-z0-9.()\-_:/ ]/g,'').slice(0,80) || '(direct)';
|
||||
const a = getAnalytics(); if (!a.sources) a.sources = {};
|
||||
if (!a.sources[s]) { if (Object.keys(a.sources).length >= 500) return; a.sources[s] = { bridge:0, start:0, click:0 }; }
|
||||
@@ -162,16 +205,11 @@ async function handleApi(req,res,pathname){
|
||||
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,''):'';
|
||||
const pb=getConfig().bemobPostbackUrl;
|
||||
if(clickid&&pb&&/^https:\/\/[a-z0-9.-]+\/postback/i.test(pb)){
|
||||
const src=b.source;
|
||||
fetch(`${pb}${pb.includes('?')?'&':'?'}cid=${encodeURIComponent(clickid)}&payout=0&txid=${encodeURIComponent('join-'+clickid)}`)
|
||||
.then(r=>{if(r.ok)recordEvent('postback',src);else console.error('bemob postback status',r.status)})
|
||||
.catch(e=>console.error('bemob postback error',e.message));
|
||||
}
|
||||
firePostback(clickid,`join-${clickid}`,b.source);
|
||||
let sponsors=getSponsors();const a=activeSponsor(sponsors);if(a){sponsors=sponsors.map(s=>s.id===a.id?{...s,clicks:(s.clicks||0)+1}:s);saveSponsors(sponsors)}return json(res,200,{ok:true});
|
||||
}
|
||||
if(req.method==='POST'&&pathname==='/api/public/chat')return await handleChat(req,res);
|
||||
if(req.method==='POST'&&pathname==='/api/public/submit-id')return await handleSubmitId(req,res);
|
||||
if(req.method==='POST'&&pathname==='/api/public/track'){
|
||||
const b=await bodyJson(req).catch(()=>({}));recordEvent(b.event,b.source);return json(res,200,{ok:true});
|
||||
}
|
||||
@@ -183,7 +221,7 @@ async function handleApi(req,res,pathname){
|
||||
const s=getSession(req);if(s)sessions.delete(s.token);return json(res,200,{ok:true},{'Set-Cookie':'ctb.sid=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0'});
|
||||
}
|
||||
if(pathname.startsWith('/api/admin/')&&!requireAdmin(req,res))return;
|
||||
if(req.method==='GET'&&pathname==='/api/admin/state')return json(res,200,{sponsors:getSponsors(),config:getConfig(),analytics:getAnalytics(),aiChat:{configured:!!getOpenRouterKey(),model:OPENROUTER_MODEL}});
|
||||
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}});}
|
||||
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.'});
|
||||
@@ -197,7 +235,7 @@ async function handleApi(req,res,pathname){
|
||||
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()});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'])if(Object.prototype.hasOwnProperty.call(b,k))next[k]=b[k];next.premiumEntryPol=Number(next.premiumEntryPol)||362;next.updatedAt=new Date().toISOString();writeJson(CONFIG_FILE,next);return json(res,200,{config:next});
|
||||
const b=await bodyJson(req),cur=getConfig(),next={...cur};for(const k of ['siteName','programName','bridgeHeadline','bridgeSubheadline','premiumEntryPol','dappReferralBaseUrl','telegramUrl','supportLabel','showSponsorName','showQueueProgress','bemobPostbackUrl','telegramBotToken','telegramChatId'])if(Object.prototype.hasOwnProperty.call(b,k))next[k]=b[k];next.premiumEntryPol=Number(next.premiumEntryPol)||362;next.updatedAt=new Date().toISOString();writeJson(CONFIG_FILE,next);return json(res,200,{config:next});
|
||||
}
|
||||
const m=pathname.match(/^\/api\/admin\/sponsors\/([^/]+)(?:\/(increment|activate|qualify|reset|move))?$/);
|
||||
if(m){const id=decodeURIComponent(m[1]),action=m[2]||null;let sponsors=getSponsors(),idx=sponsors.findIndex(s=>s.id===id);if(idx<0)return json(res,404,{error:'Sponsor not found.'});
|
||||
|
||||
Reference in New Issue
Block a user