← back to Norma

agents/instagram-agent/posts-api.js

187 lines

/**
 * posts-api.js — the auto-posting viewer + inline delete for norma-instagram (:9810).
 *
 * Surfaces every post Norma auto-published (data/post-ledger.jsonl, ~275 rows across
 * 29 accounts) as a browsable, per-account grid at GET /posts, and supports deleting
 * any post INLINE from that grid in two honest modes:
 *
 *   mode="tombstone" (default, safe):  removes the post from Norma's viewer + ledger by
 *      appending it to data/deleted-posts.jsonl. Instagram is NOT touched. Zero Meta
 *      automation-ban risk. Use to curate what the viewer shows / what Norma tracks.
 *
 *   mode="live" (destructive):  actually deletes the post ON Instagram. The Graph API
 *      cannot delete a published IG post (see delete-originals.js), so this drives the
 *      logged-in real Chrome via openclaw (··· → Delete → confirm → verify gone), then
 *      tombstones it. Requires openclaw Chrome logged in as the OWNING account; if the
 *      Delete affordance isn't reachable it fails loudly and does NOT tombstone. Live
 *      deletes are SERIALIZED (one at a time) — rapid fleet-wide browser deletes are
 *      Meta's #1 automation-ban trigger.
 *
 * Routes registered on the caller's express app (before start()'s 404 catch-all):
 *   GET  /posts                 → the viewer HTML (public/posts-viewer.html)
 *   GET  /api/posts             → { accounts:[...], posts:[...] } (tombstones filtered out)
 *   GET  /api/posts/oc-status   → { ok, loggedIn } openclaw/Instagram login probe (for Live mode)
 *   POST /api/posts/delete      → { permalink, media_id, live } → delete inline
 */
const fs = require('fs');
const path = require('path');
const { execSync } = require('child_process');

const DIR = __dirname;
const DATA = path.join(DIR, 'data');
const LEDGER = path.join(DATA, 'post-ledger.jsonl');
const TOMBS = path.join(DATA, 'deleted-posts.jsonl');
const VIEWER = path.join(DIR, 'public', 'posts-viewer.html');

const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

// Live (real Instagram) delete is OFF by default — the viewer is tombstone-only until
// Steve explicitly enables it with IG_LIVE_DELETE=1 in this agent's .env. Everything for
// live delete is built and tested; this flag is the safety gate on the irreversible path.
const liveEnabled = () => /^(1|true|yes|on)$/i.test(process.env.IG_LIVE_DELETE || '');

// ── ledger + tombstone helpers ───────────────────────────────────────────────
function readJsonl(file) {
  if (!fs.existsSync(file)) return [];
  return fs.readFileSync(file, 'utf8').trim().split('\n').filter(Boolean)
    .map((l) => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
}

// Stable identity for a post: permalink is unique per IG post; fall back to media_id or a
// handle+ts synthetic so rows with neither can still be tombstoned.
function postId(p) { return p.permalink || p.media_id || `${p.handle || '?'}::${p.ts || ''}`; }

function readTombstones() {
  const s = new Set();
  for (const t of readJsonl(TOMBS)) { if (t.id) s.add(t.id); }
  return s;
}

function getPosts() {
  const tombs = readTombstones();
  const seen = new Set();
  const posts = [];
  // newest first; dedupe by id (a permalink can only meaningfully appear once)
  for (const p of readJsonl(LEDGER).reverse()) {
    const id = postId(p);
    if (tombs.has(id) || seen.has(id)) continue;
    seen.add(id);
    posts.push({
      id,
      ts: p.ts || null,
      handle: p.handle || '?',
      page_name: p.page_name || p.handle || '?',
      title: (p.product_title || '').split('|')[0].trim() || p.product_handle || '(untitled)',
      image: p.image_url || (typeof p.images === 'string' ? p.images.split(',')[0] : '') || '',
      permalink: p.permalink || '',
      media_id: p.media_id || '',
      kind: p.kind || 'IMAGE',
      product_handle: p.product_handle || '',
    });
  }
  const byAcct = {};
  for (const p of posts) {
    (byAcct[p.handle] ||= { handle: p.handle, page_name: p.page_name, count: 0 });
    byAcct[p.handle].count++;
  }
  const accounts = Object.values(byAcct).sort((a, b) => a.handle.localeCompare(b.handle));
  return { accounts, posts, total: posts.length };
}

function tombstone(entry, extra = {}) {
  fs.appendFileSync(TOMBS, JSON.stringify({
    id: entry.id, permalink: entry.permalink || '', media_id: entry.media_id || '',
    handle: entry.handle || '', ts: new Date().toISOString(), ...extra,
  }) + '\n');
}

// ── openclaw live-delete (mirrors delete-originals.js; the only way to delete on IG) ──
function oc(cmd) {
  return execSync(`openclaw browser ${cmd}`, { encoding: 'utf8', timeout: 60000, stdio: ['ignore', 'pipe', 'pipe'] });
}
let TAB = null;
function ocOpen(url) { const o = oc(`open ${JSON.stringify(url)} --timeout 30000`); TAB = (o.match(/id:\s*([A-F0-9]+)/i) || [])[1] || TAB; return TAB; }
function ocNav(url) { if (!TAB) return ocOpen(url); oc(`navigate ${JSON.stringify(url)} --target-id ${TAB}`); }
function ocSnap() { try { return oc(`snapshot --format ai --limit 800 ${TAB ? `--target-id ${TAB}` : ''}`); } catch { return ''; } }
function ocFindRef(snap, re) {
  for (const line of String(snap).split('\n')) {
    if (re.test(line)) { const m = line.match(/\[ref=([A-Za-z0-9_]+)\]/); if (m) return m[1]; }
  }
  return null;
}
function ocClick(ref) { oc(`click ${ref} --target-id ${TAB}`); }

let LIVE_LOCK = false; // serialize live deletes — never run two browser deletes at once

async function ocLoggedIn() {
  try {
    ocOpen('https://www.instagram.com/'); await sleep(2500);
    const s = ocSnap();
    if (/Log in|Log In|Phone number, username/i.test(s) && !/Home|Search|Profile/i.test(s)) return false;
    return true;
  } catch { return false; }
}

async function liveDelete(permalink) {
  if (!permalink) throw new Error('no permalink to delete');
  ocNav(permalink); await sleep(2500);
  const more = ocFindRef(ocSnap(), /More options|More$/i);
  if (!more) throw new Error('no ··· menu — Chrome is not logged in as this account');
  ocClick(more); await sleep(1200);
  const del = ocFindRef(ocSnap(), /\bDelete\b/);
  if (!del) throw new Error('no Delete item — logged-in account does not own this post');
  ocClick(del); await sleep(1200);
  const confirm = ocFindRef(ocSnap(), /\bDelete\b/);
  if (!confirm) throw new Error('no confirm Delete button appeared');
  ocClick(confirm); await sleep(3000);
  ocNav(permalink); await sleep(2500);
  return /isn't available|Sorry, this page|Page Not Found/i.test(ocSnap());
}

// ── route registration ───────────────────────────────────────────────────────
function registerPostRoutes(app, agentName = 'instagram-agent') {
  app.get('/posts', (req, res) => {
    if (!fs.existsSync(VIEWER)) return res.status(404).send('posts-viewer.html missing');
    res.type('html').send(fs.readFileSync(VIEWER, 'utf8'));
  });

  app.get('/api/posts', (req, res) => {
    try { res.json({ ...getPosts(), liveEnabled: liveEnabled() }); }
    catch (e) { res.status(500).json({ error: e.message }); }
  });

  app.get('/api/posts/oc-status', async (req, res) => {
    try { res.json({ ok: true, loggedIn: await ocLoggedIn() }); }
    catch (e) { res.json({ ok: false, loggedIn: false, error: e.message }); }
  });

  app.post('/api/posts/delete', async (req, res) => {
    const { permalink = '', media_id = '', live = false } = req.body || {};
    const id = permalink || media_id;
    if (!id) return res.status(400).json({ ok: false, error: 'permalink or media_id required' });
    const entry = { id, permalink, media_id, handle: (req.body && req.body.handle) || '' };

    if (!live) {
      // tombstone only — remove from viewer/ledger, never touch Instagram
      try { tombstone(entry, { mode: 'tombstone' }); return res.json({ ok: true, mode: 'tombstone' }); }
      catch (e) { return res.status(500).json({ ok: false, error: e.message }); }
    }

    // live delete — gated OFF by default until IG_LIVE_DELETE=1
    if (!liveEnabled()) return res.status(403).json({ ok: false, error: 'Live delete is disabled (tombstone-only). Set IG_LIVE_DELETE=1 to enable.' });
    // serialized, verified, tombstone ONLY on confirmed removal
    if (LIVE_LOCK) return res.status(409).json({ ok: false, error: 'a live delete is already running — one at a time' });
    LIVE_LOCK = true;
    try {
      const gone = await liveDelete(permalink);
      if (gone) { tombstone(entry, { mode: 'live', verified: true }); return res.json({ ok: true, mode: 'live', verified: true }); }
      return res.json({ ok: false, mode: 'live', verified: false, error: 'clicked Delete but post still resolves — not tombstoned' });
    } catch (e) {
      console.error(`[${agentName}] live delete failed: ${e.message}`);
      return res.status(502).json({ ok: false, mode: 'live', error: e.message });
    } finally { LIVE_LOCK = false; }
  });
}

module.exports = { registerPostRoutes, getPosts };