← back to AbramsOS
lib/reviews/executors.js
116 lines
'use strict';
/**
* Post/send executors — THE GATED EDGE. Ported from AbramsEgo (TK-11433).
*
* HARD RAILS: nothing fires unless ALL of —
* (1) the draft is per-item status='approved',
* (2) its content_hash SEAL still matches the current content (anti stale-replay),
* (3) env REVIEWS_EXECUTORS_LIVE=1, and
* (4) the caller passes { live:true, confirm:<draft.id> }.
* Miss any → returns the PLAN, fires nothing (gated:true).
* Reviews NEVER auto-submit: openclaw opens + snapshots the page and returns
* needs_verify for a human to click submit. Only seller_email actually sends
* (via George), and only when a resolved to-address exists. No bulk path.
*/
const { execFile } = require('child_process');
const db = require('../db');
const model = require('./model');
const OPENCLAW_BIN = process.env.OPENCLAW_BIN || 'openclaw';
const GEORGE_URL = process.env.GEORGE_URL || 'http://127.0.0.1:9850';
const DEFAULT_FROM = process.env.REVIEWS_FROM || 'steve@designerwallcoverings.com';
function georgeAuth() {
// AbramsOS stores the George secret as GEORGE_BASIC_AUTH (base64 of admin:<pass>).
const b = process.env.GEORGE_BASIC_AUTH;
if (b) return b.startsWith('Basic ') ? b : 'Basic ' + b;
const a = process.env.GEORGE_AUTH;
if (a && a.includes(':')) return 'Basic ' + Buffer.from(a).toString('base64');
if (a) return a.startsWith('Basic ') ? a : 'Basic ' + a;
const u = process.env.GEORGE_USER || 'admin';
const p = process.env.GEORGE_PASS || 'DW2024!';
return 'Basic ' + Buffer.from(`${u}:${p}`).toString('base64');
}
function liveEnabled() { return process.env.REVIEWS_EXECUTORS_LIVE === '1'; }
function openclaw(args, timeoutMs = 45000) {
return new Promise((resolve) => {
execFile(OPENCLAW_BIN, args, { timeout: timeoutMs, maxBuffer: 4 * 1024 * 1024 }, (err, stdout, stderr) => {
resolve({ ok: !err, stdout: stdout || '', stderr: stderr || (err ? err.message : '') });
});
});
}
function planFor(row) {
if (row.target === 'seller_email') {
return { target: row.target, channel: 'George (Gmail :9850 /api/send)', cost: 0, costLabel: '$0 (local)',
request: { to: row.seller_email_to || null, from: DEFAULT_FROM, subject: row.draft_title, body: row.draft_text },
note: 'needs a resolved seller/brand address before it can send' };
}
const q = encodeURIComponent(row.product_name || '');
const searchUrl = row.target === 'amazon_review'
? `https://www.amazon.com/s?k=${q}`
: `https://www.google.com/maps/search/?api=1&query=${q}`;
return { target: row.target, channel: 'openclaw (real Chrome)', cost: 0, costLabel: '$0 (local)',
steps: [
`openclaw browser open "${searchUrl}" # email receipts carry no ASIN — resolve by product name`,
'openclaw browser snapshot # locate the product / business, open its review page',
'openclaw browser click --ref <5-star>',
'openclaw browser fill --fields-file <title+text>',
'openclaw browser click --ref <submit> # a human confirms this step (needs_verify)',
'openclaw browser screenshot',
],
review: { title: row.draft_title, text: row.draft_text, rating: row.rating } };
}
async function fireOpenclawReview(row) {
const plan = planFor(row);
const q = encodeURIComponent(row.product_name || '');
const url = row.target === 'amazon_review'
? `https://www.amazon.com/s?k=${q}` : `https://www.google.com/maps/search/?api=1&query=${q}`;
const open = await openclaw(['browser', 'open', url]);
if (!open.ok) return { ok: false, status: 'error', reason: 'openclaw could not open the resolve page', detail: open.stderr, plan };
const snap = await openclaw(['browser', 'snapshot']);
return { ok: false, status: 'needs_verify',
reason: 'search page opened for the product; find the item, open its review page, and submit in Chrome before marking posted',
snapshotBytes: (snap.stdout || '').length, plan };
}
async function fireSellerEmail(row) {
const plan = planFor(row);
if (!plan.request.to) return { ok: false, status: 'error', reason: 'no seller/brand email address resolved yet', plan };
try {
const r = await fetch(GEORGE_URL + '/api/send', {
method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: georgeAuth() },
body: JSON.stringify({ to: plan.request.to, subject: plan.request.subject, body: plan.request.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 }; }
}
// Single guarded entry point. userId-scoped. Returns {gated:true, plan} unless every gate passes.
async function executeDraft(userId, id, opts = {}) {
const row = await model.getDraft(userId, id);
if (!row) return { ok: false, error: 'not found' };
const plan = planFor(row);
// GATE 1: approved
if (row.status !== 'approved') return { ok: false, gated: true, reason: 'draft not per-item approved', plan };
// GATE 2: seal intact
if (!row.content_hash || row.content_hash !== model.contentHash(row)) {
await model.setStatus(userId, id, 'draft');
return { ok: false, gated: true, reason: 'content changed since approval — re-approve before sending', plan };
}
// GATE 3 + 4: live env AND matching confirm token
if (!liveEnabled() || !(opts.live === true && opts.confirm === id)) {
return { ok: false, gated: true,
reason: liveEnabled() ? 'approved — awaiting explicit {live:true, confirm:<id>}' : 'approved — REVIEWS_EXECUTORS_LIVE not set (gated to Steve)',
plan };
}
const res = row.target === 'seller_email' ? await fireSellerEmail(row) : await fireOpenclawReview(row);
if (res.status === 'posted') await db.query(`UPDATE review_draft SET status='posted', updated_at=now() WHERE id=$1`, [id]);
return res;
}
module.exports = { executeDraft, planFor, liveEnabled };