Wallet-verified member messaging (matrix-lines permissions, admin visibility)

- messages.js: challenge/personal_sign/recover auth (vendored pinned
  js-sha3 0.9.3 + noble-secp256k1 1.7.1, server-side only; self-tested
  positive + tamper cases), 30d HttpOnly sessions, message store on the
  volume, matrix-line permissions (your downline direct or broadcast, your
  upline chain - nothing else, so spam is impossible by construction),
  daily rate limits (30 direct / 3 broadcasts), 1500-char plain text
- chain.js: memberIdByAccount (wallet -> position for sign-in)
- API: msg-challenge/-verify/-me/-inbox/-send/-read public + msg-unread
  (count only, no auth) + admin/messages (full visibility, disclosed to
  members in the UI)
- Dashboard: Messages card with unread bell, one-tap wallet sign-in,
  inbox with auto-read, compose with to-ID or whole-team broadcast
- Admin: Member Messages review table
- Chatbot canned answer + AI system prompt updated

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
martbost
2026-08-19 13:31:31 -05:00
parent e31b01e338
commit 9a1833f2b0
10 changed files with 2188 additions and 4 deletions
+1
View File
@@ -6,6 +6,7 @@
<div class="table-card"><div style="margin-bottom:12px"><h2 style="margin:0">Member ID Submissions</h2><p style="color:var(--muted);margin:4px 0 0;font-size:13px">New members who confirmed their purchase on the start page. Each one was posted to your Hermes Telegram chat — add them to the rotation.</p></div><div class="table-wrap"><table class="table"><thead><tr><th>When</th><th>Name / Handle</th><th>New ID</th><th>Joined under</th><th>Source</th><th>On-chain</th></tr></thead><tbody id="submissionRows"></tbody></table></div></div>
<div class="table-card"><div style="display:flex;justify-content:space-between;gap:12px;align-items:center;margin-bottom:12px;flex-wrap:wrap"><div><h2 style="margin:0">Your Organization vs. the Network</h2><p style="color:var(--muted);margin:4px 0 0;font-size:13px">How your team — rooted at your top ID — stacks up against the entire RM Circle smart contract. Live on-chain.</p></div><form id="orgShareForm" style="display:flex;gap:8px"><input id="orgRoot" class="input" style="max-width:110px" placeholder="21" inputmode="numeric"><button class="btn btn-secondary btn-sm">Refresh</button></form></div><div id="orgShare"><div class="empty">Reading the blockchain…</div></div></div>
<div class="table-card"><div style="display:flex;justify-content:space-between;gap:12px;align-items:center;margin-bottom:12px;flex-wrap:wrap"><div><h2 style="margin:0">Coaching Radar</h2><p style="color:var(--muted);margin:4px 0 0;font-size:13px">Live triage of your whole org: who to nudge, what to tell them, and how much POL is on the line. Computed fresh from the chain index on every refresh.</p></div><button id="coachRefresh" class="btn btn-secondary btn-sm">Refresh</button></div><div id="coachOut"><div class="empty">Loading…</div></div></div>
<div class="table-card"><div style="display:flex;justify-content:space-between;gap:12px;align-items:center;margin-bottom:12px;flex-wrap:wrap"><div><h2 style="margin:0">Member Messages</h2><p style="color:var(--muted);margin:4px 0 0;font-size:13px">Wallet-verified member-to-member messages, newest first. Admin can review for abuse — members are told this in the UI.</p></div><button id="msgRefresh" class="btn btn-secondary btn-sm">Refresh</button></div><div class="table-wrap"><table class="table"><thead><tr><th>When</th><th>From</th><th>To</th><th>Message</th><th>Read by</th></tr></thead><tbody id="msgRows"><tr><td colspan="5" class="empty">Loading…</td></tr></tbody></table></div></div>
<div class="table-card"><div style="margin-bottom:12px"><h2 style="margin:0">My Positions — Income</h2><p style="color:var(--muted);margin:4px 0 0;font-size:13px">Every payment received by your own positions, live from the contract. Comma-separated IDs — saved for next time.</p></div><form id="incomeForm" style="display:flex;gap:8px;margin-bottom:14px;flex-wrap:wrap"><input id="incomeIds" class="input" style="max-width:260px" placeholder="21,24,25" inputmode="numeric"><button class="btn btn-teal">Load</button></form><div id="incomeAlert"></div><div id="incomeRouting" style="margin-bottom:14px"></div><div id="incomeSummary" class="facts" style="grid-template-columns:repeat(4,1fr);margin-bottom:12px"></div><div id="incomeResult"></div></div>
<div class="table-card"><div style="margin-bottom:12px"><h2 style="margin:0">On-Chain Member Lookup</h2><p style="color:var(--muted);margin:4px 0 0;font-size:13px">Enter an RM Circle ID to read its registration, lineage, and every payment it has received — live from the smart contract.</p></div><form id="lookupForm" style="display:flex;gap:10px;margin-bottom:14px"><input id="lookupId" class="input" style="max-width:220px" placeholder="Member ID e.g. 46" inputmode="numeric"><button class="btn btn-teal">Look Up</button></form><div id="lookupResult"></div></div>
<div class="table-card"><div style="display:flex;justify-content:space-between;gap:12px;align-items:center;margin-bottom:12px;flex-wrap:wrap"><div><h2 style="margin:0">Matrix View</h2><p style="color:var(--muted);margin:4px 0 0;font-size:13px">The entire on-chain matrix — who landed where, with tier, level, directs, and earnings per position. Click a position to drill down.</p></div><div style="display:flex;gap:8px"><button id="treeLoadBtn" class="btn btn-secondary btn-sm">Load Matrix</button><button id="treeToggleBtn" class="btn btn-secondary btn-sm hidden">List view</button></div></div><div id="matrixNav" class="hidden" style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-bottom:12px"></div><div id="matrixTree"></div></div></div>
+10
View File
@@ -41,6 +41,7 @@ function render(){
const orgEl=document.getElementById('orgRoot');if(orgEl&&!orgEl.value)orgEl.value=state.config.orgRootId||'21';
if(!orgShareAutoLoaded&&orgEl&&orgEl.value){orgShareAutoLoaded=true;loadOrgShare();}
if(!coachAutoLoaded){coachAutoLoaded=true;loadCoaching();}
if(!msgsAutoLoaded){msgsAutoLoaded=true;loadAdminMsgs();}
const efi=document.getElementById('emailFromInput');if(efi&&!efi.value)efi.value=state.config.emailFrom||em.from||'';
const f=document.getElementById('configForm'),c=state.config;for(const k of ['siteName','programName','bridgeHeadline','bridgeSubheadline','premiumEntryPol','dappReferralBaseUrl','telegramUrl','supportLabel','bemobPostbackUrl','telegramBotToken','telegramChatId','telegramTopicId','teamRootId','teamAlertEmail','ownerAlertEmail'])if(f.elements[k])f.elements[k].value=c[k]??'';f.elements.showSponsorName.checked=!!c.showSponsorName;f.elements.showQueueProgress.checked=!!c.showQueueProgress;
}
@@ -121,6 +122,15 @@ async function loadCoaching(){
}
const cr=document.getElementById('coachRefresh');if(cr)cr.addEventListener('click',loadCoaching);
let coachAutoLoaded=false;
async function loadAdminMsgs(){
const tb=document.getElementById('msgRows');if(!tb)return;
try{
const d=await api('/api/admin/messages');
tb.innerHTML=(d.messages||[]).map(m=>`<tr><td>${esc(new Date(m.ts).toLocaleString())}</td><td><strong>#${m.fromId}</strong></td><td>${m.org?'📣 whole team':'#'+m.toId}</td><td style="white-space:pre-wrap;max-width:460px">${esc(m.body)}</td><td>${m.readCount}</td></tr>`).join('')||'<tr><td colspan="5" class="empty">No messages yet.</td></tr>';
}catch(x){tb.innerHTML=`<tr><td colspan="5" class="empty">${esc(x.message)}</td></tr>`}
}
const mr=document.getElementById('msgRefresh');if(mr)mr.addEventListener('click',loadAdminMsgs);
let msgsAutoLoaded=false;
let incomeAutoLoaded=false;
async function loadIncome(){
const ids=(document.getElementById('incomeIds').value||'').trim();
+2 -2
View File
@@ -48,8 +48,8 @@
a:()=>`Two ways money reaches your position — the full diagram is at <a href="/how-pay-works">rmcircle.team/how-pay-works</a>. <strong>(1) Entry rewards:</strong> 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. <strong>(2) Upgrade payments:</strong> 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 at that level yourself and qualified, so stay one level ahead of your team. 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. <strong>Premium</strong> is what our whole team builds at (${pol()} POL entry) — full payments. <strong>Standard</strong> 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 <strong>set when you join and can't be changed later</strong> (upgrading advances your level, not your tier), so always join <strong>Premium</strong> and make sure the people you bring on do too. Full breakdown: <a href="/how-pay-works">how-pay-works</a>.`},
{k:['dashboard','my dashboard','my page','my position','my team','check my','see my','pipeline','my stats','alerts','notify me','email me','get notified'],
a:()=>`Your <strong>Member Dashboard</strong> is at <a href="/my">rmcircle.team/my</a> — 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 <strong>Coach Your Team</strong> panel that tells you exactly who in your leg needs a nudge and what to say. You can also turn on <strong>email alerts</strong> there to be notified the moment you're paid or need to upgrade. To share, use your personal invite page: <strong>rmcircle.team/join/&lt;your ID&gt;</strong>.`},
{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 <strong>Member Dashboard</strong> is at <a href="/my">rmcircle.team/my</a> — 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 <strong>Coach Your Team</strong> panel that tells you exactly who in your leg needs a nudge and what to say. You can also turn on <strong>email alerts</strong> there, and use <strong>Messages</strong> — 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: <strong>rmcircle.team/join/&lt;your ID&gt;</strong>.`},
{k:['level','levels','upgrade','scintilla','ascensus','fabrica','culmen','apex','fastigium','vertex','corona','8 levels'],
a:()=>`There are 8 Premium levels: <strong>Scintilla, Ascensus, Fabrica, Culmen, Apex, Fastigium, Vertex, Corona</strong>. 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'],
+2 -1
View File
@@ -20,6 +20,7 @@
<div class="table-card" style="margin-bottom:18px"><div style="display:flex;justify-content:space-between;gap:12px;align-items:flex-start;flex-wrap:wrap"><div><h2 style="margin:0 0 4px">Your team</h2><p style="color:var(--muted);font-size:13px;margin:0 0 14px">Your position's matrix — the rollup line on each card counts everyone underneath, all the way down. Click a position to drill into that leg. Open slots are where the next placements land.</p></div><button id="dTreeToggle" class="btn btn-secondary btn-sm">List view</button></div><div id="dTreeNav" style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-bottom:12px"></div><div id="dTree"></div><div id="dGens" style="margin-top:14px"></div><p class="micro" style="margin:14px 0 0"><span class="mtp-qmark" style="position:static;display:inline-grid;vertical-align:middle">✓</span> qualified (2/2 directs) · <span class="mt-badge mt-prem">P</span> Premium · <span class="mt-badge">S</span> Standard · ⬇ everyone below that position (all generations) and the POL they've earned · <span style="color:var(--teal)">↧ spillover</span> = placed there by upline activity; only members who join with a position's own ID count toward its 2/2</p><div id="dSpillNote" class="hidden"></div></div>
<div class="table-card" style="margin-bottom:18px;border-color:rgba(123,224,161,.4)"><h2 style="margin:0 0 4px">Your pipeline</h2><p style="color:var(--muted);font-size:13px;margin:0 0 12px">Money forming below you. Each generation in your leg pays your position at exactly one level — when a member's level catches up to their depth, their <em>next</em> upgrade comes to you.</p><div id="dPipeline"></div></div>
<div id="dCoachCard" class="table-card" style="margin-bottom:18px;display:none;border-color:rgba(240,197,109,.35)"><h2 style="margin:0 0 4px">Coach your team</h2><p style="color:var(--muted);font-size:13px;margin:0 0 12px">The fastest way to grow your own income is helping the people below you take their next step. Here's who in <em>your</em> team could use a nudge today — updated live from the blockchain.</p><div id="dCoach"></div></div>
<div class="table-card" style="margin-bottom:18px;border-color:rgba(120,160,255,.35)"><h2 style="margin:0 0 4px">Messages <span id="msgBell" class="hidden" style="font-size:14px;background:var(--gold);color:#132;border-radius:20px;padding:2px 10px;vertical-align:middle;font-weight:800">🔔 0</span></h2><p style="color:var(--muted);font-size:13px;margin:0 0 12px">Wallet-verified messaging along your matrix lines — your upline and your team can reach you here, and you can reach them. No email needed; your wallet is your identity. <span class="micro">Messages are member-to-member; the team admin can review them for abuse.</span></p><div id="dMsg"><div class="empty">Loading…</div></div></div>
<div class="table-card" style="margin-bottom:18px;border-color:rgba(78,214,203,.35)"><h2 style="margin:0 0 4px">Email me my alerts</h2><p style="color:var(--muted);font-size:13px;margin:0 0 12px">Get an email the moment this position is <strong>paid</strong>, and when it <strong>needs an upgrade</strong> to catch incoming pay — so you never miss one. Opt in with your email; unsubscribe anytime.</p><div id="dAlerts"></div></div>
<div class="table-card" style="margin-bottom:18px"><h2 style="margin:0 0 4px">Share this position</h2><div id="dShare"></div><a class="btn btn-teal btn-sm" href="/tools" style="margin-top:14px">🎬 Promo Tools — posts, swipes, video clips &amp; banners →</a></div>
<div class="table-card" style="margin-bottom:18px;border-color:var(--gold)"><h2 style="margin:0 0 4px">Just joined under this position?</h2><p style="color:var(--muted);font-size:13px;margin:0 0 12px">Welcome to the team! Enter the <strong>new member ID</strong> the RM Circle dApp gave you — we'll verify it on the blockchain and let the team know you're in.</p><form id="dJoinForm" style="display:grid;gap:8px;max-width:480px"><input name="newId" class="input" inputmode="numeric" pattern="[0-9]{1,10}" maxlength="10" placeholder="Your NEW RM Circle ID (numbers only)" required><input name="memberName" class="input" maxlength="60" placeholder="Your name or Telegram @handle" required><button class="btn btn-primary">Submit My ID →</button></form><div id="dJoinMsg" style="margin-top:10px;font-size:14px"></div></div>
@@ -30,4 +31,4 @@
</section>
</main>
<footer class="wrap disclaimer">All figures are read live from the RM Circle smart contract on Polygon and are historical facts, not a promise of future results. Participation involves cryptocurrency and smart-contract risk. Never use funds you cannot afford to lose.<div class="footer-links"><a href="/">Home</a><a href="/contract">Contract Security</a><a href="/disclaimer">Disclaimers</a><a href="/tools">Promo Tools</a></div></footer>
<script src="/track.js"></script><script src="/qrlib.js"></script><script src="/my.js"></script><script src="/payouts.js" defer></script><script src="/chat.js" defer></script></body></html>
<script src="/track.js"></script><script src="/qrlib.js"></script><script src="/rmc-wallet.js"></script><script src="/my.js"></script><script src="/payouts.js" defer></script><script src="/chat.js" defer></script></body></html>
+64
View File
@@ -125,6 +125,7 @@
renderGens(d);
renderPipeline(d);
renderCoach(d);
renderMessages(d);
renderAlerts(d);
renderShare(d);
document.getElementById('dLineage').innerHTML=d.uplineChain&&d.uplineChain.length
@@ -158,6 +159,69 @@
}).join('');
el.innerHTML=`<div style="border-top:1px solid var(--line);padding-top:12px"><small style="text-transform:uppercase;letter-spacing:.08em;color:var(--muted);font-size:11px">Team depth — members per generation</small><div style="margin-top:8px">${rows}</div><p class="micro" style="margin:8px 0 0">Full generations duplicate: each one can hold twice the last. A generation pays this position at exactly one level — stay qualified and at that level to catch it.</p></div>`;
}
// Wallet-verified messaging: sign-in = one free personal_sign; identity is
// the wallet that owns a position; permissions follow the matrix lines.
function renderMessages(d){
const el=document.getElementById('dMsg'),bell=document.getElementById('msgBell');
if(!el)return;
fetch('/api/public/msg-unread?id='+d.id).then(r=>r.json()).then(u=>{
if(bell&&u&&u.count>0){bell.textContent='🔔 '+u.count+' new';bell.classList.remove('hidden');}
}).catch(()=>{});
loadMsgUI(d);
}
async function loadMsgUI(d){
const el=document.getElementById('dMsg');
let me=null;
try{const r=await fetch('/api/public/msg-me');if(r.ok)me=await r.json();}catch(e){}
if(!me){
el.innerHTML='<p class="micro" style="margin:0 0 10px">Sign in once with the wallet that owns your position — one free signature; it can\'t move funds or approve anything.</p><button id="msgAuthBtn" class="btn btn-primary">🔐 Connect wallet &amp; sign in</button><div id="msgAuthErr" class="micro" style="color:var(--danger);margin-top:8px"></div>';
const b=document.getElementById('msgAuthBtn');if(b)b.addEventListener('click',function(){msgAuth(d);});
return;
}
let data;
try{data=await(await fetch('/api/public/msg-inbox')).json();}catch(e){el.innerHTML='<div class="empty">Could not load messages — refresh to retry.</div>';return;}
const mine=Number(me.id)===Number(d.id);
const banner=mine?'':`<div class="callout" style="margin-bottom:10px">You're signed in as <strong>#${me.id}</strong> — this inbox is yours. (You're viewing #${d.id}'s page; the "to" box is pre-filled for them.)</div>`;
const rows=(data.inbox||[]).map(m=>`<div class="pp-row" style="padding:9px 12px${m.read?'':';border-color:rgba(240,197,109,.55)'}"><div class="pp-icon">${m.org?'📣':'✉️'}</div><div class="pp-body"><strong>From #${m.fromId}</strong> <span class="pp-meta" style="display:inline">· ${new Date(m.ts).toLocaleString()}${m.org?' · team broadcast':''}${m.read?'':' · <strong style="color:var(--gold)">NEW</strong>'}</span><div style="white-space:pre-wrap;margin-top:4px">${esc(m.body)}</div></div></div>`).join('')||'<div class="empty">No messages yet.</div>';
const sent=(data.sent||[]).slice(0,3).map(m=>`<div class="micro" style="margin:3px 0">→ ${m.org?'whole team':'#'+m.toId} · ${new Date(m.ts).toLocaleString()}: ${esc(m.body.slice(0,90))}${m.body.length>90?'…':''}</div>`).join('');
el.innerHTML=banner+rows+
(sent?`<div style="margin-top:10px"><span class="micro" style="text-transform:uppercase;letter-spacing:.08em">Recently sent</span>${sent}</div>`:'')+
`<div style="border-top:1px solid var(--line);margin-top:12px;padding-top:12px"><div style="font-weight:800;margin-bottom:6px">Send a message</div>
<div style="display:flex;gap:10px;flex-wrap:wrap;align-items:center;margin-bottom:8px">
<input id="msgTo" inputmode="numeric" placeholder="Member #" value="${mine?'':esc(String(d.id))}" style="max-width:110px;padding:9px 12px;border:1px solid var(--line);border-radius:10px;background:#08192880;color:var(--text);font-size:14px">
<label class="micro" style="display:flex;gap:6px;align-items:center;cursor:pointer"><input type="checkbox" id="msgOrg"> send to my whole team instead</label>
</div>
<textarea id="msgBody" maxlength="1500" rows="3" placeholder="Plain text, up to 1500 characters. You can message your team and your upline." style="width:100%;padding:10px 12px;border:1px solid var(--line);border-radius:10px;background:#08192880;color:var(--text);font-size:14px"></textarea>
<div style="display:flex;gap:10px;align-items:center;margin-top:8px"><button id="msgSendBtn" class="btn btn-primary">Send →</button><span id="msgStatus" class="micro"></span></div></div>`;
const unreadIds=(data.inbox||[]).filter(m=>!m.read).map(m=>m.mid);
if(unreadIds.length)fetch('/api/public/msg-read',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({mids:unreadIds})}).catch(()=>{});
const sb=document.getElementById('msgSendBtn');
if(sb)sb.addEventListener('click',async function(){
const st=document.getElementById('msgStatus');st.textContent='Sending…';sb.disabled=true;
try{
const payload={org:document.getElementById('msgOrg').checked,toId:(document.getElementById('msgTo').value||'').trim(),body:document.getElementById('msgBody').value};
const r=await(await fetch('/api/public/msg-send',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)})).json();
if(r.error){st.textContent=r.error;sb.disabled=false;return;}
st.textContent='Sent ✓';setTimeout(function(){loadMsgUI(d);},700);
}catch(e){st.textContent='Send failed — try again.';sb.disabled=false;}
});
}
async function msgAuth(d){
const err=document.getElementById('msgAuthErr');
try{
const eth=await window.RMCWallet.pick();
if(!eth){err.textContent='No wallet found in this browser. On a phone, open this page inside your wallet app\'s browser (MetaMask or Trust).';return;}
const accs=await eth.request({method:'eth_requestAccounts'});const account=accs[0];
const ch=await(await fetch('/api/public/msg-challenge',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({address:account})})).json();
if(!ch.message)throw new Error(ch.error||'Could not start sign-in.');
let hex='0x';for(const b of new TextEncoder().encode(ch.message))hex+=b.toString(16).padStart(2,'0');
const sig=await eth.request({method:'personal_sign',params:[hex,account]});
const v=await(await fetch('/api/public/msg-verify',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({address:account,signature:sig})})).json();
if(!v.ok)throw new Error(v.error||'Verification failed.');
loadMsgUI(d);
}catch(e){if(err)err.textContent=e.message||String(e);}
}
// "Coach your team" — the same triage the team admin runs, scoped to THIS
// position's leg: who below could use a nudge, and exactly what to tell them.
function renderCoach(d){