f05d5a2746
Completes the ladder — no tier is a placeholder now. Every tier below builds one thing at a time, which is right for someone still finding their words. At the top the constraint is different: these are people running organisations who don't have an afternoon to spend clicking. So one pass produces five social posts across five different angles, three outreach messages (first approach, follow-up, pyramid-scheme answer), an email, and three text ads — all carrying their link, all in the member's own voice profile if they have one. Partial failures are reported rather than hidden: if the engine can't finish a section the pack says which, instead of quietly handing over a short week. API access is real, not a label: a personal key over GET /me, GET /team, POST /generate. Keys are stored hashed and shown exactly once, compared in constant time, and every call goes through the same meters and compliance rules as the web tools. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
168 lines
7.9 KiB
JavaScript
168 lines
7.9 KiB
JavaScript
// 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
|
|
};
|