← back to Marketing Command Center

modules/ig-activity/index.js

121 lines

// IG Posts — show every Instagram post the DW fleet has published (from the
// gen-ig-activity snapshot at public/ig-activity.json) and delete one.
//
// DELETE REALITY (do not "fix" this into a Graph call): the Instagram Graph API
// CANNOT delete a published post. The only working delete is Norma's proven
// openclaw real-Chrome flow (open post → ··· → Delete → verify 404), which is
// PACED + SERIALIZED because concurrent deletes are Meta's #1 fleet-ban trigger.
// So this module NEVER deletes directly — it PROXIES to Norma's own gated
// endpoint (POST /api/posts/delete on :9810), which already enforces:
//   • live:false (default) → tombstone only: drop from the ledger, IG untouched.
//   • live:true            → real delete, but gated behind IG_LIVE_DELETE=1 on
//                            Norma AND serialized (one at a time / 409 if busy),
//                            tombstoned ONLY on a verified 404.
// We keep a local tombstone mirror so a removed post disappears from THIS board
// immediately, even before the next snapshot regen.
//
// Self-contained per the MODULE CONTRACT. Norma auth uses the SAME ordered-credential
// helper as follow-counts (lib/norma-auth.js: secrets-manager IG_AGENT_AUTH, then the
// instagram-agent .env, then NORMA_IG_PASS; 401/403 falls through). The old hardcoded
// NORMA_IG_PASS went stale on Norma's password rotation and 403'd every call (TK-12342).
const fs = require('fs');
const path = require('path');
const { normaBase, normaFetch } = require('../../lib/norma-auth.js');

const ACTIVITY = path.join(__dirname, '..', '..', 'public', 'ig-activity.json');
const TOMBSTONES = path.join(__dirname, '..', '..', 'data', 'ig-activity-tombstones.json');


function loadActivity() {
  try { return JSON.parse(fs.readFileSync(ACTIVITY, 'utf8')); }
  catch { return { posts: [], total_posts: 0, accounts_touched: 0, last_post_at: null, generated_at: null }; }
}
function loadTombstones() {
  try { return new Set(JSON.parse(fs.readFileSync(TOMBSTONES, 'utf8'))); }
  catch { return new Set(); }
}
function saveTombstones(set) {
  fs.mkdirSync(path.dirname(TOMBSTONES), { recursive: true });
  fs.writeFileSync(TOMBSTONES, JSON.stringify([...set], null, 2));
}
// A post's stable identity — permalink first (what Norma deletes on), media_id fallback.
const postKey = p => (p && (p.permalink || p.media_id)) || '';

module.exports = {
  id: 'ig-activity',
  title: 'IG Posts',
  icon: '📸',
  mount(router) {
    // All posts (minus anything locally tombstoned), newest snapshot metadata.
    router.get('/posts', (_req, res) => {
      const j = loadActivity();
      const gone = loadTombstones();
      const posts = (j.posts || []).filter(p => !gone.has(postKey(p)));
      res.json({
        posts,
        total_posts: posts.length,
        removed: gone.size,
        accounts_touched: new Set(posts.map(p => p.handle)).size,
        last_post_at: j.last_post_at || null,
        generated_at: j.generated_at || null,
      });
    });

    // Is Norma's real-Chrome session logged in + is live delete armed? (advisory
    // for the UI so it can tell the user whether a "real" delete will fire or 403.)
    router.get('/delete-status', async (_req, res) => {
      const norma = normaBase();
      try {
        const a = await normaFetch('/api/posts/oc-status', {}, 20000, { tag: 'ig-activity' }); // oc-status probes openclaw: 5-14s
        // Auth rejected / non-2xx is a FAILURE, never ok:true+loggedIn:false (that read as
        // "just logged out" and hid the stale-credential 403 — TK-12342).
        if (!a.ok) return res.status(502).json({ ok: false, loggedIn: false, error: a.error, norma });
        if (!a.res.ok) {
          return res.status(502).json({ ok: false, loggedIn: false,
            error: a.json.error || `Norma oc-status HTTP ${a.res.status}`, norma });
        }
        res.json({ ok: true, loggedIn: !!a.json.loggedIn, norma, auth: a.source });
      } catch (e) {
        res.status(502).json({ ok: false, loggedIn: false, error: `Norma unreachable: ${e.message}`, norma });
      }
    });

    // Delete a post. Proxies to Norma's gated/serialized endpoint. Default is
    // tombstone (live:false) — safe, never touches Instagram. live:true asks for
    // the real delete, which Norma itself gates (IG_LIVE_DELETE) + serializes.
    router.post('/delete', async (req, res) => {
      const { permalink = '', media_id = '', handle = '', live = false } = req.body || {};
      const id = permalink || media_id;
      if (!id) return res.status(400).json({ ok: false, error: 'permalink or media_id required' });
      try {
        const a = await normaFetch('/api/posts/delete', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ permalink, media_id, handle, live: !!live }),
        }, live ? 120000 : 15000, { tag: 'ig-activity' }); // a real openclaw delete is slow; tombstone is fast
        if (!a.ok) return res.status(502).json({ ok: false, error: a.error });
        const r = a.res, j = a.json;
        // Mirror to our local tombstone so it leaves THIS board immediately, on any
        // outcome Norma treats as removed (tombstone mode, or verified live delete).
        if (r.ok && j.ok && (j.mode === 'tombstone' || j.verified)) {
          const set = loadTombstones(); set.add(permalink || media_id); saveTombstones(set);
        }
        return res.status(r.ok ? 200 : r.status).json(j);
      } catch (e) {
        return res.status(502).json({ ok: false, error: `Norma delete failed: ${e.message}` });
      }
    });

    // Undo a local tombstone (does NOT resurrect on Instagram — only un-hides from
    // this board; only meaningful for tombstone-mode removals).
    router.post('/restore', (req, res) => {
      const { permalink = '', media_id = '' } = req.body || {};
      const key = permalink || media_id;
      if (!key) return res.status(400).json({ ok: false, error: 'permalink or media_id required' });
      const set = loadTombstones();
      const had = set.delete(key); saveTombstones(set);
      res.json({ ok: true, restored: had });
    });
  },
};