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)

This commit is contained in:
martbost
2026-08-22 20:28:36 -05:00
parent 1f95025587
commit 2a666d4063
+111 -2
View File
@@ -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} <your note> · 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} <heads-up> · 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} <your note>` : `🔥 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 <id> <your message>`);
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 <id> <your message>\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 <id> <text> — 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 <id> <text> — 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; }