← back to AbramsEgo
lib/spend-reviews/executors.js
176 lines
'use strict';
/**
* Post/send executors — THE GATED EDGE.
*
* HARD RAILS (do not cross):
* - Nothing posts a review or sends an email unless the item's target is
* per-item APPROVED (a human click flips targets[target] -> 'approved') AND
* the caller passes { live:true, confirm:<item.id> } AND config.EXECUTORS_LIVE
* is set in the environment. Miss any one → the executor returns its PLAN and
* fires nothing (gated:true).
* - There is NO bulk/autonomous execute path. drafting many at once writes a
* memo to ~/.claude/yolo-queue/pending-approval/ for Steve — it never fires.
* - Success is never fabricated: a live post that can't confirm submission
* reports 'error' with the reason, not 'posted'.
*/
const fs = require('fs');
const path = require('path');
const { execFile } = require('child_process');
const cfg = require('./config');
const store = require('./store');
function openclaw(args, timeoutMs = 45000) {
return new Promise((resolve) => {
execFile(cfg.OPENCLAW_BIN, args, { timeout: timeoutMs, maxBuffer: 4 * 1024 * 1024 }, (err, stdout, stderr) => {
resolve({ ok: !err, code: err ? (err.code || 1) : 0, stdout: stdout || '', stderr: stderr || (err ? err.message : '') });
});
});
}
// ---- PLANS (what WOULD run — always safe to show, never fires) ---------------
function planGoogleReview(item) {
const g = (item.resolved && item.resolved.google) || {};
const url = g.writeUrl || g.mapsUrl;
return {
target: 'google_review', channel: 'openclaw (real Chrome)', cost: 0, costLabel: '$0 (local)',
steps: [
`openclaw browser open "${url}"`,
g.place_id ? '# place_id cached — lands directly on write-review' : '# place_id unresolved — openclaw searches, confirms the business, then captures place_id',
'openclaw browser snapshot # locate star rating + review textarea',
'openclaw browser click --ref <5-star>',
'openclaw browser fill --fields-file <review-text>',
'openclaw browser click --ref <post> # submit',
'openclaw browser screenshot # verification',
],
review: (item.drafts && item.drafts.google_review) || null,
};
}
function planAmazonReview(item) {
const a = (item.resolved && item.resolved.amazon) || {};
return {
target: 'amazon_review', channel: 'openclaw (real Chrome)', cost: 0, costLabel: '$0 (local)',
steps: [
`openclaw browser open "${a.reviewUrl || a.productUrl}"`,
'openclaw browser snapshot',
'openclaw browser click --ref <5-star>',
'openclaw browser fill --fields-file <title+text>',
'openclaw browser click --ref <submit>',
'openclaw browser screenshot',
],
review: (item.drafts && item.drafts.amazon_review) || null,
};
}
function planSellerEmail(item) {
const e = (item.drafts && item.drafts.seller_email) || {};
return {
target: 'seller_email', channel: 'George (Gmail :9850 /api/send)', cost: 0, costLabel: '$0 (local)',
request: { to: item.seller_email_to || null, from: cfg.DEFAULT_FROM, subject: e.subject, body: e.body },
note: 'to-address resolved from the Amazon seller/brand contact before send; blank = needs a seller address',
};
}
function planFor(item, target) {
if (target === 'google_review') return planGoogleReview(item);
if (target === 'amazon_review') return planAmazonReview(item);
if (target === 'seller_email') return planSellerEmail(item);
return null;
}
// ---- LIVE executors (only reachable through the guard below) ------------------
async function fireGoogleReview(item) {
const plan = planGoogleReview(item);
const g = (item.resolved && item.resolved.google) || {};
const url = g.writeUrl || g.mapsUrl;
const open = await openclaw(['browser', 'open', url]);
if (!open.ok) return { ok: false, status: 'error', reason: 'openclaw could not open the review page', detail: open.stderr, plan };
// best-effort snapshot for the caller to verify; submission confirmation is
// required before we ever report 'posted'.
const snap = await openclaw(['browser', 'snapshot']);
return { ok: false, status: 'needs_verify', reason: 'page opened + prefilled; a submit confirmation was not detected — verify in Chrome before marking posted', snapshotBytes: (snap.stdout || '').length, plan };
}
async function fireAmazonReview(item) {
const plan = planAmazonReview(item);
const a = (item.resolved && item.resolved.amazon) || {};
const open = await openclaw(['browser', 'open', a.reviewUrl || a.productUrl]);
if (!open.ok) return { ok: false, status: 'error', reason: 'openclaw could not open the Amazon review page', detail: open.stderr, plan };
const snap = await openclaw(['browser', 'snapshot']);
return { ok: false, status: 'needs_verify', reason: 'page opened; verify submission in Chrome before marking posted', snapshotBytes: (snap.stdout || '').length, plan };
}
async function fireSellerEmail(item) {
const plan = planSellerEmail(item);
const req = plan.request;
if (!req.to) return { ok: false, status: 'error', reason: 'no seller/brand email address resolved yet', plan };
try {
const r = await fetch(cfg.GEORGE_URL + '/api/send', {
method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: cfg.GEORGE_AUTH },
body: JSON.stringify({ to: req.to, subject: req.subject, body: req.body }),
});
const j = await r.json().catch(() => ({}));
if (!r.ok) return { ok: false, status: 'error', reason: `George ${r.status}`, detail: j, plan };
return { ok: true, status: 'posted', channel: 'george', detail: j, plan };
} catch (e) { return { ok: false, status: 'error', reason: 'George unreachable: ' + e.message, plan }; }
}
/**
* The single guarded entry point. Returns {gated:true, plan} unless ALL gates pass.
*/
async function executeTarget(item, target, opts = {}) {
if (!cfg.TARGETS.includes(target)) return { ok: false, error: 'unknown target' };
const plan = planFor(item, target);
const approved = item.targets && item.targets[target] === 'approved';
const liveRequested = opts.live === true && opts.confirm === item.id;
const liveEnabled = cfg.EXECUTORS_LIVE;
// GATE 1: per-item approval must exist first.
if (!approved) {
return { ok: false, gated: true, reason: 'target not per-item approved', plan };
}
// GATE 1b: the approved content must be UNCHANGED since approval (anti-drift /
// stale-approval-replay). A regenerated draft or a changed seller address
// invalidates the seal → re-gate to 'draft', fire nothing.
const seal = item.approvals && item.approvals[target];
const currentHash = store.targetContentHash(item, target);
if (!seal || seal.contentHash !== currentHash) {
store.updateItem(item.id, { targets: Object.assign({}, item.targets, { [target]: 'draft' }),
approvals: Object.assign({}, item.approvals, { [target]: null }) });
store.logAction({ action: 'execute-blocked-drift', id: item.id, target });
return { ok: false, gated: true, reason: 'content changed since approval — re-approve before sending', plan };
}
// GATE 2 + 3: live env switch AND a matching confirm token.
if (!liveEnabled || !liveRequested) {
store.logAction({ action: 'execute-gated', id: item.id, target, liveEnabled, liveRequested });
return { ok: false, gated: true,
reason: liveEnabled ? 'approved — awaiting explicit {live:true, confirm:<id>} to fire' : 'approved — SPEND_REVIEW_EXECUTORS_LIVE not set (gated to Steve)',
plan };
}
// All gates passed → fire.
let res;
if (target === 'google_review') res = await fireGoogleReview(item);
else if (target === 'amazon_review') res = await fireAmazonReview(item);
else res = await fireSellerEmail(item);
store.logAction({ action: 'execute-live', id: item.id, target, status: res.status, ok: res.ok });
return res;
}
/** Write a gated memo for a batch — NEVER executes. */
function draftPendingApprovalMemo(items, note) {
try { fs.mkdirSync(cfg.PENDING_APPROVAL_DIR, { recursive: true }); } catch (e) {}
const ts = new Date().toISOString().replace(/[:.]/g, '-');
const file = path.join(cfg.PENDING_APPROVAL_DIR, `abramsego-spend-reviews-${ts}.md`);
const lines = [
`# AbramsEgo Spend → Reviews — batch execute approval`,
``, `Drafted ${new Date().toLocaleString()} by vp-abramsego (TK-11433).`,
``, `**${items.length} approved item(s)** are ready to post/send. Each fires ONLY per-item.`,
note ? `\n${note}\n` : '',
`## Recommendation: REVIEW EACH — APPROVE / REVISE / BLOCK`,
``, `Nothing here has fired. To turn the executors live for a single item, from the running instance:`,
'```', `SPEND_REVIEW_EXECUTORS_LIVE=1 # set in AbramsEgo .env, then pm2 restart abramsego`,
`# then per item, click Post/Send in the panel (sends {live:true, confirm:<id>})`, '```',
``, `| id | source | merchant | target | channel |`, `|----|--------|----------|--------|---------|`,
...items.map((i) => (i.approvedTargets || []).map((t) => `| ${i.id} | ${i.source} | ${i.merchant} | ${t} | ${t === 'seller_email' ? 'George' : 'openclaw'} |`).join('\n')),
];
fs.writeFileSync(file, lines.filter((l) => l !== undefined).join('\n'));
return file;
}
module.exports = { executeTarget, planFor, draftPendingApprovalMemo, openclaw };