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