← 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 };