From 2a666d40630f74312af4999f4c1e3dbaa39d4983 Mon Sep 17 00:00:00 2001 From: martbost Date: Sat, 22 Aug 2026 20:28:36 -0500 Subject: [PATCH] Companion bot phase 2: /coach with lesson routing, /team org stats, /lesson links, weekly Rhythm digest (per-member schedule, Chicago time, delta tracking, rotating share angle) --- tgbot.js | 113 ++++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 111 insertions(+), 2 deletions(-) diff --git a/tgbot.js b/tgbot.js index 373d007..e0280f0 100644 --- a/tgbot.js +++ b/tgbot.js @@ -52,6 +52,7 @@ async function ensureWebhook(baseUrl) { function init(opts) { DATA_DIR = opts.dataDir; chain = opts.chain; getConfig = opts.getConfig; messages = opts.messages; ensureWebhook(opts.baseUrl || 'https://rmcircle.team').catch(e => console.error('tgbot init', e.message)); + setInterval(() => runDigests(), 20 * 60 * 1000).unref(); } // --- linking ---------------------------------------------------------------- @@ -99,6 +100,96 @@ async function notifyMessage(fromId, toId, body) { } catch (e) { console.error('tgbot notifyMessage', e.message); } } +// --- member data (reuses the site's own assembled API) ---------------------- +async function fetchMember(id) { + try { + const r = await fetch(`http://127.0.0.1:${process.env.PORT || 3000}/api/public/member?id=${id}`); + return await r.json(); + } catch (e) { return null; } +} +const LESSONS = { + 1: 'The Two-Person Mindset', 2: 'Your Warm List', 3: 'The Conversation', 4: 'Objections Without Flinching', + 5: 'Your Dashboard Is Your Coaching Desk', 6: 'The First 48 Hours', 7: 'Stalled People & Pass-Ups', + 8: 'Timing Upgrades to Catches', 9: 'Run the Same Play', 10: 'The Weekly Rhythm' +}; +const lessonLink = n => `https://rmcircle.team/training#lesson-${n}`; + +function coachText(d) { + const c = d && d.coach; + if (!c || !c.ready) return 'Coaching data is still indexing โ€” try again in a few minutes.'; + const out = []; + (c.rollForward || []).slice(0, 3).forEach(r => out.push(`๐Ÿ’ฌ #${r.id} is qualified and their Ascensus is already covered by ${Math.round(r.earnedPol)} POL of catches โ€” one friendly message converts this.\n Play: msg ${r.id} ยท send Lesson 8: ${lessonLink(8)}`)); + (c.atRisk || []).filter(r => r.qualified).slice(0, 3).forEach(r => out.push(`โš ๏ธ #${r.id} has ${Math.round(r.atRiskPol)} POL forming but needs ${r.neededLevelName} to catch it.\n Play: msg ${r.id} ยท send Lesson 8: ${lessonLink(8)}`)); + (c.atRisk || []).filter(r => !r.qualified).slice(0, 2).forEach(r => out.push(`โฐ #${r.id} has ${Math.round(r.atRiskPol)} POL forming but is ${r.directCount}/2 โ€” qualification is their fix.\n Play: send Lesson 2: ${lessonLink(2)}`)); + (c.oneAway || []).slice(0, 2).forEach(r => out.push(`๐ŸŽฏ #${r.id} is one direct away from qualifying.\n Play: send Lesson 3: ${lessonLink(3)}`)); + if (!out.length) return 'Nothing urgent on your coach list right now โ€” your leg is either moving or quiet. A great time for one share: type links'; + return `๐Ÿงญ Your coach list (the Method play for each):\n\n` + out.join('\n\n') + `\n\nRemember: the answer to every question is a lesson, not a lecture.`; +} + +function teamText(d) { + const st = d && d.subtree; + if (!st) return 'Team data is still indexing โ€” try again shortly.'; + let gens = 0, layer = [st]; + while (layer.length && gens < 60) { + const next = []; + layer.forEach(n => { if (n.left) next.push(n.left); if (n.right) next.push(n.right); }); + if (!next.length) break; gens++; layer = next; + } + const usd = d.polUsd > 0 ? ` (~$${Math.round(st.downPol * d.polUsd)})` : ''; + const step = d.nextStep && d.nextStep.cost ? `${Math.round(d.nextStep.cost)} POL โ€” wallet ${d.nextStep.funded ? 'already covers it โœ…' : 'not there yet (let catches stack)'}` : 'you are at the top level'; + return `๐Ÿ“Š Your organization, #${d.id}:\n๐Ÿ‘ฅ ${st.downCount} members ยท ${gens} generations deep\n๐Ÿ’ฐ ${Math.round(st.downPol).toLocaleString()} POL earned below you${usd}\nโฌ†๏ธ Your next step: ${step}\n\nFull dashboard: https://rmcircle.team/my/${d.id}`; +} + +// --- weekly rhythm digest --------------------------------------------------- +const ANGLE_ROTATION = ['pocket', 'phone', 'two', 'graveyard']; +function chicagoNow() { + const p = new Intl.DateTimeFormat('en-US', { timeZone: 'America/Chicago', weekday: 'short', hour: 'numeric', hour12: false }).formatToParts(new Date()); + const get = t2 => (p.find(x => x.type === t2) || {}).value; + return { dow: ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'].indexOf(get('weekday')), hour: Number(get('hour')) % 24 }; +} +async function digestFor(memberId) { + const d = await fetchMember(memberId); + if (!d || !d.registered) return null; + const st = d.subtree || {}; + const links = load(); + const prev = (links.stats || {})[String(memberId)] || {}; + const delta = prev.count != null ? st.downCount - prev.count : null; + links.stats = links.stats || {}; + links.stats[String(memberId)] = { count: st.downCount, at: Date.now() }; + save(links); + const week = Math.floor(Date.now() / (7 * 864e5)); + const angle = ANGLE_ROTATION[week % 4]; + const c = d.coach || {}; + const mover = (c.rollForward || [])[0] || (c.atRisk || []).filter(r => r.qualified)[0]; + const quiet = (c.oneAway || [])[0] || (c.atRisk || []).filter(r => !r.qualified)[0]; + const lines = [`๐Ÿ” Your 20 minutes, #${memberId} โ€” the field report:\n`]; + lines.push(`๐Ÿ“Š READ: ${st.downCount != null ? st.downCount : 'โ€”'} in your org${delta != null ? ` (${delta >= 0 ? '+' + delta : delta} this week)` : ''} ยท ${Math.round(st.downPol || 0).toLocaleString()} POL earned below you`); + lines.push(mover ? `๐Ÿ”ฅ LIFT ONE: #${mover.id} โ€” ${mover.ascensusCost ? 'their next step is already covered by catches; one message converts it' : 'money is forming below them; a heads-up now beats a pass-up later'}. Try: msg ${mover.id} ` : `๐Ÿ”ฅ LIFT ONE: nobody flagged this week โ€” congratulate your newest member instead.`); + lines.push(quiet ? `๐Ÿ•ฏ๏ธ CHECK ONE: #${quiet.id} โ€” one kind line, no pressure: msg ${quiet.id} Thinking of you โ€” no rush on anything, here if you need me.` : `๐Ÿ•ฏ๏ธ CHECK ONE: your quiet list is empty. Enjoy that.`); + lines.push(`๐Ÿ“ค SHARE ONE: this week's angle is "${angle}" โ€” your matched link:\nhttps://rmcircle.team/join/${memberId}?v=${angle}`); + lines.push(`\nThen close the tab. 20 every week beats 2 hours twice. (Lesson 10: ${lessonLink(10)})\nrhythm off to stop these ยท rhythm now to rerun anytime`); + return lines.join('\n'); +} +async function runDigests() { + try { + const d = load(); + const now = chicagoNow(); + const weekTag = Math.floor(Date.now() / (7 * 864e5)); + for (const [mid, chatId] of Object.entries(d.members)) { + const pref = (d.rhythm || {})[mid] || {}; + if (pref.off) continue; + const dow = pref.dow != null ? pref.dow : 6, hour = pref.hour != null ? pref.hour : 10; + if (now.dow !== dow || now.hour !== hour) continue; + if (pref.lastWeek === weekTag) continue; + const text = await digestFor(Number(mid)); + if (text) { + await dm(chatId, text); + const dd = load(); dd.rhythm = dd.rhythm || {}; dd.rhythm[mid] = Object.assign({}, dd.rhythm[mid], { lastWeek: weekTag, dow, hour }); save(dd); + } + } + } catch (e) { console.error('tgbot digests', e.message); } +} + // --- member commands -------------------------------------------------------- function linksText(id) { const b = 'https://rmcircle.team'; @@ -137,7 +228,7 @@ async function handleUpdate(update) { d.members[String(rec.id)] = chatId; d.chats[String(chatId)] = rec.id; save(d); - await dm(chatId, `โœ… Linked to position #${rec.id}!\n\nFrom now on:\n๐Ÿ’ฐ You get a DM the moment your position catches a payment\n๐Ÿ“จ Team messages reach you here โ€” reply to answer\n๐ŸŽ‰ You're pinged when someone joins on your link\n\nTry: links โ€” or: msg `); + await dm(chatId, `โœ… Linked to position #${rec.id}!\n\nFrom now on:\n๐Ÿ’ฐ You get a DM the moment your position catches a payment\n๐Ÿ“จ Team messages reach you here โ€” reply to answer\n๐ŸŽ‰ You're pinged when someone joins on your link\n\nTry: coach ยท team ยท links โ€” or: msg \nYour weekly 20-minute digest arrives Saturdays 10am Central (rhythm to adjust).`); return; } await dm(chatId, `That link code is expired or already used. Get a fresh one from the Messages panel on your dashboard: https://rmcircle.team/my`); @@ -150,7 +241,25 @@ async function handleUpdate(update) { if (!linked) { await dm(chatId, `You're not linked yet. Open https://rmcircle.team/my โ†’ Messages โ†’ "Connect Telegram".`); return; } if (/^\/?links$/i.test(text)) { await dm(chatId, linksText(linked)); return; } - if (/^\/?help$/i.test(text)) { await dm(chatId, `Commands:\nlinks โ€” your invite + angle links\nmsg โ€” message someone on your team line\nReply to any ๐Ÿ“จ message to answer it.\nEverything else lives on your dashboard: https://rmcircle.team/my/${linked}`); return; } + if (/^\/?help$/i.test(text)) { await dm(chatId, `Commands:\nlinks โ€” your invite + angle links\ncoach โ€” who to help + which lesson to send\nteam โ€” your org numbers\nmsg โ€” message a teammate\nlesson <1-10> โ€” grab any Circle Method lesson link\nrhythm โ€” your weekly 20-minute digest (rhythm now / rhythm sat 9 / rhythm off)\nReply to any ๐Ÿ“จ message to answer it.\nDashboard: https://rmcircle.team/my/${linked}`); return; } + + if (/^\/?coach$/i.test(text)) { const d = await fetchMember(linked); await dm(chatId, coachText(d)); return; } + if (/^\/?team$/i.test(text)) { const d = await fetchMember(linked); await dm(chatId, teamText(d)); return; } + const lm = text.match(/^\/?lesson\s+(\d{1,2})$/i); + if (lm) { const n = Number(lm[1]); if (LESSONS[n]) { await dm(chatId, `๐ŸŽ“ Lesson ${n} โ€” ${LESSONS[n]}:\n${lessonLink(n)}\n\nForward this to whoever needs it. The answer to every question is a lesson.`); } else { await dm(chatId, 'Lessons run 1โ€“10. Try: lesson 7'); } return; } + const rm = text.match(/^\/?rhythm(?:\s+(\S+))?(?:\s+(\d{1,2}))?$/i); + if (rm) { + const d2 = load(); d2.rhythm = d2.rhythm || {}; const key = String(linked); + const sub = (rm[1] || '').toLowerCase(); + if (sub === 'off') { d2.rhythm[key] = Object.assign({}, d2.rhythm[key], { off: true }); save(d2); await dm(chatId, 'Weekly digest off. rhythm on brings it back.'); return; } + if (sub === 'now') { const dd = await digestFor(linked); await dm(chatId, dd || 'Data still indexing โ€” try again shortly.'); return; } + const days = { sun: 0, mon: 1, tue: 2, wed: 3, thu: 4, fri: 5, sat: 6 }; + if (sub in days) { const hour = Math.min(23, Math.max(0, Number(rm[2] != null ? rm[2] : 10))); d2.rhythm[key] = { dow: days[sub], hour }; save(d2); await dm(chatId, `Weekly digest set: every ${sub.toUpperCase()} at ${hour}:00 US Central.`); return; } + d2.rhythm[key] = Object.assign({}, d2.rhythm[key], { off: false }); save(d2); + const cur = d2.rhythm[key]; + await dm(chatId, `Weekly digest is ON โ€” ${['Sun','Mon','Tue','Wed','Thu','Fri','Sat'][cur.dow != null ? cur.dow : 6]} at ${cur.hour != null ? cur.hour : 10}:00 US Central.\nChange it: rhythm sat 9 ยท run now: rhythm now ยท stop: rhythm off`); + return; + } const m = text.match(/^\/?msg\s+#?(\d+)\s+([\s\S]+)/i); if (m) { await sendAsMember(linked, Number(m[1]), m[2].trim(), chatId); return; }