// Circle Suite — Founder Desk (level 8, Corona). // // Every tier below this produces ONE thing at a time: a post, a page, a // sequence, an ad. That is right for someone still learning what to say. At the // top of the ladder the constraint is different — these are people running // organisations who do not have an afternoon to spend clicking one tool at a // time. So the Founder Desk does the whole week in a single pass, across every // channel, and hands it over as one pack. // // Second half of the tool is API access: a personal key so a founder can pull // their own position, their organisation triage and generated copy into // whatever they already run — a spreadsheet, a bot, their own site. 'use strict'; const fs = require('fs'); const path = require('path'); const crypto = require('crypto'); const suiteAi = require('./suite-ai'); const suiteEmail = require('./suite-email'); const suiteTextAds = require('./suite-textads'); const MIN_LEVEL = 8; let DATA_DIR = null; function init(opts) { DATA_DIR = opts.dataDir; try { fs.mkdirSync(packDir(), { recursive: true }); } catch (e) {} } function packDir() { return path.join(DATA_DIR, 'founder'); } function packFile(id) { return path.join(packDir(), String(Number(id)) + '.json'); } function keysFile() { return path.join(DATA_DIR, 'suite-api-keys.json'); } // ── API keys ──────────────────────────────────────────────────────────────── // Stored hashed. We show the key exactly once, at creation, the way every other // API does it — if it leaks later we cannot un-leak it, but at least the file // on disk is not a list of live credentials. function readKeys() { try { return JSON.parse(fs.readFileSync(keysFile(), 'utf8')); } catch (e) { return {}; } } function writeKeys(v) { try { fs.writeFileSync(keysFile(), JSON.stringify(v), { mode: 0o600 }); } catch (e) {} } function hashKey(k) { return crypto.createHash('sha256').update(String(k)).digest('hex'); } function keyInfo(memberId) { const all = readKeys(); const rec = all[String(memberId)]; if (!rec) return { exists: false }; return { exists: true, createdAt: rec.createdAt, lastUsedAt: rec.lastUsedAt || null, hint: rec.hint }; } function issueKey(memberId) { const raw = 'rmc_' + crypto.randomBytes(24).toString('hex'); const all = readKeys(); all[String(memberId)] = { hash: hashKey(raw), createdAt: new Date().toISOString(), hint: raw.slice(0, 8) + '…' + raw.slice(-4) }; writeKeys(all); return raw; // the only time this is ever returned } function revokeKey(memberId) { const all = readKeys(); delete all[String(memberId)]; writeKeys(all); return true; } // Constant-time compare against every stored hash. The key set is small (only // level-8 positions), so a linear scan is fine and keeps the key itself // unguessable from the file. function memberForKey(raw) { if (!raw || typeof raw !== 'string' || raw.length < 16) return null; const h = Buffer.from(hashKey(raw), 'hex'); const all = readKeys(); let found = null; Object.keys(all).forEach(function (id) { const stored = Buffer.from(String(all[id].hash || ''), 'hex'); if (stored.length === h.length && crypto.timingSafeEqual(stored, h)) found = Number(id); }); if (found != null) { all[String(found)].lastUsedAt = new Date().toISOString(); writeKeys(all); } return found; } // ── The weekly campaign pack ──────────────────────────────────────────────── function loadPack(id) { try { return JSON.parse(fs.readFileSync(packFile(id), 'utf8')); } catch (e) { return null; } } function savePack(id, pack) { try { fs.writeFileSync(packFile(id), JSON.stringify(pack)); } catch (e) {} } // Angles rotate through the hooks the rest of the site already uses, so the // week has variety built in rather than seven versions of the same post. const WEEK_ANGLES = [ { key: 'two', brief: 'the whole job is two people and helping them get their two' }, { key: 'graveyard', brief: 'why this one does not disappear like every other program — public contract, holds nobody\'s money' }, { key: 'pocket', brief: 'the entry is small and one-time, not a subscription that bleeds you every month' }, { key: 'verify', brief: 'do not trust anyone including me — read the contract yourself before you spend a cent' }, { key: 'phone', brief: 'it runs entirely from a phone; no laptop, no stock, no shipping' } ]; function splitNumbered(text, want) { // Tolerant split on "1." / "POST 1" / "---" style separators. const parts = String(text || '') .split(/(?:^|\n)\s*(?:[-*#]{3,}|(?:POST|ITEM)?\s*\d+[.)]?\s*)(?=\n|\s)/i) .map(function (x) { return x.replace(/^[\s*#>-]+/, '').trim(); }) .filter(function (x) { return x.length > 25; }); return parts.slice(0, want); } async function buildPack(opts) { const id = Number(opts.id); const link = 'https://rmcircle.team/join/' + id; const member = { link: link, id: id, voice: opts.voice || '' }; const pack = { id: id, at: new Date().toISOString(), posts: [], messages: [], emails: [], textAds: [] }; const problems = []; // 1) A week of social posts, one per angle. try { const postInstruction = 'Write 5 separate social media posts for a member of the RM Circle team build, one for each of these angles:\n' + WEEK_ANGLES.map(function (a, i) { return (i + 1) + '. ' + a.brief; }).join('\n') + '\n\n' + 'Each post: 40-90 words, punchy opening line, no hashtags, at most one emoji, ending with a soft invitation to look rather than a hard sell, then this exact link on its own final line: ' + link + '\n\n' + 'ABSOLUTE RULES: never promise, guarantee, project or imply income, earnings or returns. Never quote dollar values — POL quantities only. Never mention the Standard tier. Be honest that this is real cryptocurrency with real risk. Do not invent numbers.\n\n' + (opts.voice ? opts.voice + '\n\n' : '') + 'Separate each post with a line containing only ---. Output nothing else: no headings, no commentary, no numbering.'; const raw = await suiteAi.generateRaw(postInstruction); pack.posts = splitNumbered(raw, 5).map(function (t, i) { return { angle: WEEK_ANGLES[i] ? WEEK_ANGLES[i].key : 'general', text: t }; }); if (!pack.posts.length) problems.push('posts'); } catch (e) { problems.push('posts'); } // 2) Outreach messages — a first approach, a follow-up, an objection reply. const msgKinds = [ { kind: 'dm', label: 'First message', brief: 'reaching out to someone you know but have not spoken to in a while' }, { kind: 'followup', label: 'Follow-up', brief: 'they looked at your link a few days ago and went quiet' }, { kind: 'objection', label: 'Objection reply', brief: 'they said this sounds like a pyramid scheme' } ]; for (const mk of msgKinds) { try { const t = await suiteAi.generate(mk.kind, mk.brief, member); pack.messages.push({ label: mk.label, kind: mk.kind, text: t }); } catch (e) { problems.push(mk.kind); } } // 3) An email the founder can drop into their autoresponder. try { pack.emails = await suiteEmail.generate('broadcast', 'a weekly update to your list about the team build', member); } catch (e) { problems.push('email'); } // 4) Text ads ready for the Traffic Desk. try { const t = await suiteTextAds.generate({ angle: 'general', count: 3 }); pack.textAds = t.variants; } catch (e) { problems.push('text ads'); } pack.problems = problems; const produced = pack.posts.length + pack.messages.length + pack.emails.length + pack.textAds.length; if (!produced) throw new Error('The engine did not come back with anything usable — try again in a moment.'); savePack(id, pack); return pack; } module.exports = { init, MIN_LEVEL, buildPack, loadPack, keyInfo, issueKey, revokeKey, memberForKey, WEEK_ANGLES };