← back to Norma

agents/instagram-agent/server.js

244 lines

/**
 * Norma Instagram Agent — Port 9809
 *
 * Creates feed posts, reels, and stories on Instagram via Meta Graph API
 * (Instagram Content Publishing API).
 * Skills: discover, post, reel, story, monitor, report
 *
 * Runs in simulation mode until IG_USER_ID and IG_ACCESS_TOKEN are set.
 *
 * Note: agent-base auto-wires routes for discover, post, reply, monitor, report.
 * We register reel and story as custom routes since they are Instagram-specific.
 */

require('dotenv').config();
const { createAgentServer } = require('../shared/agent-base');

// Import skills
const discover = require('./skills/discover');
const post = require('./skills/post');
const reel = require('./skills/reel');
const story = require('./skills/story');
const monitor = require('./skills/monitor');
const report = require('./skills/report');

const PORT = 9810;
const AGENT_NAME = 'instagram-agent';

// ──────────────────────────────────────
// Dashboard data source (for GET / in agent-base).
// Cache-backed: cron runs persist latest results to data/*.json; the
// dashboard READS the cache and never triggers a Meta Graph API call from a
// page view. In simulation mode (free/local) it computes on-the-fly if the
// cache is empty. (DTD verdict, 2026-07-10.)
// ──────────────────────────────────────
const fs = require('fs');
const path = require('path');
const DATA_DIR = path.join(__dirname, 'data');
try { fs.mkdirSync(DATA_DIR, { recursive: true }); } catch { /* ignore */ }
const cachePath = (k) => path.join(DATA_DIR, `latest-${k}.json`);
function writeCache(k, v) {
  try { fs.writeFileSync(cachePath(k), JSON.stringify({ at: new Date().toISOString(), data: v })); }
  catch (e) { console.error(`[${AGENT_NAME}] cache write ${k} failed:`, e.message); }
}
function readCache(k) {
  try { return JSON.parse(fs.readFileSync(cachePath(k), 'utf8')); } catch { return null; }
}
const liveMode = () => !!(process.env.IG_USER_ID && process.env.IG_ACCESS_TOKEN);

async function dashboard() {
  const mode = liveMode() ? 'live' : 'simulation';
  let mon = readCache('monitor');
  let disc = readCache('discover');
  if (mode === 'simulation') {
    if (!mon) { const r = await monitor({ push_to_pulse: false }); writeCache('monitor', r); mon = readCache('monitor'); }
    if (!disc) { const r = await discover({ limit: 9, push_to_pulse: false }); writeCache('discover', r); disc = readCache('discover'); }
  }

  const num = (n) => (typeof n === 'number' ? n.toLocaleString() : (n == null ? '—' : String(n)));
  const ins = (mon && mon.data && mon.data.insights) || {};
  const acct = (mon && mon.data && mon.data.account) || {};
  const metrics = mon ? [
    { label: 'Followers', value: num(acct.followers_count ?? ins.follower_count) },
    { label: 'Reach', value: num(ins.reach), sub: mon.data.period ? `per ${mon.data.period}` : '' },
    { label: 'Impressions', value: num(ins.impressions) },
    { label: 'Profile views', value: num(ins.profile_views) },
    { label: 'Posts', value: num(acct.media_count) },
    { label: 'Website clicks', value: num(ins.website_clicks) },
  ] : [];

  const rawMedia = (disc && disc.data && Array.isArray(disc.data.media)) ? disc.data.media : [];
  const media = rawMedia.slice(0, 9).map((m) => ({
    // simulation media_url is a non-loading example.com stub → empty thumb → placeholder tile
    thumb: (m.media_url && !/example\.com/.test(m.media_url)) ? m.media_url : '',
    caption: m.caption,
    permalink: m.permalink,
    type: m.media_type,
    when: m.timestamp,
  }));

  let note = '';
  if (mode === 'simulation') {
    note = 'Simulation mode — set IG_USER_ID + IG_ACCESS_TOKEN for live data. Figures and media are representative samples.';
  } else if (!mon && !disc) {
    note = 'Live mode — awaiting next scheduled refresh (monitor every 30m · discover every 3h).';
  }
  const checkedAt = mon && (mon.data.checked_at || mon.at);
  if (checkedAt) note += (note ? '  ·  ' : '') + 'Insights as of ' + new Date(checkedAt).toLocaleString();
  return { mode, metrics, media, note };
}

const { app, start, scheduler } = createAgentServer({
  name: AGENT_NAME,
  port: PORT,
  dashboard,
  skills: {
    discover,
    post,
    monitor,
    report,
  },
  cronJobs: [
    {
      name: 'monitor-account',
      schedule: '*/30 * * * *', // every 30 minutes
      fn: async () => {
        console.log(`[${AGENT_NAME}] Cron: monitoring account insights...`);
        try {
          const result = await monitor({ push_to_pulse: true });
          writeCache('monitor', result);
          console.log(`[${AGENT_NAME}] Cron monitor complete: reach=${result.insights.reach}`);
        } catch (err) {
          console.error(`[${AGENT_NAME}] Cron monitor error:`, err.message);
        }
      },
    },
    {
      name: 'discover-media',
      schedule: '15 */3 * * *', // every 3 hours at :15
      fn: async () => {
        console.log(`[${AGENT_NAME}] Cron: discovering recent media...`);
        try {
          const result = await discover({ limit: 10, push_to_pulse: true });
          writeCache('discover', result);
          console.log(`[${AGENT_NAME}] Cron discover complete: ${result.count} media items`);
        } catch (err) {
          console.error(`[${AGENT_NAME}] Cron discover error:`, err.message);
        }
      },
    },
    {
      name: 'live-media-refresh',
      schedule: '7 * * * *', // hourly at :07 — pull new posts into the /live + /calendar caches
      fn: async () => {
        if (!liveMode()) return;
        try {
          // Sweep ALL accounts (not just DW) so the cross-account calendar stays LIVE for the
          // whole roster. Incremental: each account stops early on already-known posts → cheap.
          const { refreshAllAccounts } = require('./live-media');
          const r = await refreshAllAccounts({ full: false });
          console.log(`[${AGENT_NAME}] Cron live-media refresh: +${r.added} new across ${r.accounts} accounts (errors ${r.errors})`);
        } catch (err) {
          console.error(`[${AGENT_NAME}] Cron live-media refresh error:`, err.message);
        }
      },
    },
    {
      name: 'live-media-backfill-sweep',
      schedule: '40 4 * * *', // daily 04:40 — safety net: fully backfill any account not yet complete
      fn: async () => {
        if (!liveMode()) return;
        try {
          const { refreshAllAccounts } = require('./live-media');
          // Only touch accounts still marked incomplete, a few per run, so a big backfill drains
          // over several nights without hammering the shared Graph token. No-ops once all complete.
          const r = await refreshAllAccounts({ full: true, onlyIncomplete: true, max: 5, delayMs: 1200 });
          console.log(`[${AGENT_NAME}] Cron backfill sweep: backfilled ${r.accounts} incomplete account(s), +${r.added} posts (errors ${r.errors})`);
        } catch (err) {
          console.error(`[${AGENT_NAME}] Cron backfill sweep error:`, err.message);
        }
      },
    },
    {
      name: 'report-to-pulse',
      schedule: '0 */4 * * *', // every 4 hours
      fn: async () => {
        console.log(`[${AGENT_NAME}] Cron: reporting to Pulse...`);
        try {
          const result = await report({ hours_back: 4 });
          console.log(`[${AGENT_NAME}] Cron report complete`);
        } catch (err) {
          console.error(`[${AGENT_NAME}] Cron report error:`, err.message);
        }
      },
    },
  ],
});

// ──────────────────────────────────────
// Custom skill routes for Instagram-specific features
// (agent-base only auto-wires: discover, post, reply, monitor, report)
// ──────────────────────────────────────

app.post('/api/skill/reel', async (req, res) => {
  try {
    const result = await reel(req.body, req);
    res.json({ success: true, skill: 'reel', result });
  } catch (err) {
    console.error(`[${new Date().toISOString()}] [${AGENT_NAME}] Skill "reel" error:`, err.message);
    res.status(500).json({ success: false, skill: 'reel', error: err.message });
  }
});

app.post('/api/skill/story', async (req, res) => {
  try {
    const result = await story(req.body, req);
    res.json({ success: true, skill: 'story', result });
  } catch (err) {
    console.error(`[${new Date().toISOString()}] [${AGENT_NAME}] Skill "story" error:`, err.message);
    res.status(500).json({ success: false, skill: 'story', error: err.message });
  }
});

// ──────────────────────────────────────
// Auto-posting viewer + inline delete (GET /posts). Reads data/post-ledger.jsonl,
// deletes inline in "tombstone" (remove from viewer/ledger) or "live" (openclaw
// deletes on Instagram + verifies) mode. Registered BEFORE start()'s 404 catch-all.
// ──────────────────────────────────────
require('./posts-api').registerPostRoutes(app, AGENT_NAME);

// Focused Spoonflower-purge viewer (GET /spoonflower). The flagged posts are reshares,
// not in post-ledger.jsonl, so /posts can't show them — this board can, with live status.
require('./spoonflower-api').registerSpoonflowerRoutes(app);

// "All the daily Instagram posts" board (GET /live) — the DW account's REAL feed from the
// Graph API, grouped by day, delete-any (reuses posts-api's /api/posts/delete path).
// Page views read the cache only; a cron + the Refresh button paginate /media into it.
require('./live-media').registerLiveRoutes(app, AGENT_NAME);

// Cross-account post CALENDAR (GET /calendar) — the day-major transpose of /live: every account's
// posts grouped by calendar day, with an all/select-accounts filter. Read-only over the same caches.
require('./calendar').registerCalendarRoutes(app);

// Per-account "what to post next from DW Shopify" planning notes (GET /notes). Human-authored brief
// per account, stored separately from the refreshed caches so a refresh never clobbers it.
require('./account-notes').registerNotesRoutes(app);

start();

// ──────────────────────────────────────
// Loopback co-listener (TK-12012). BIND_HOST=100.82.17.107 pins the PRIMARY
// bind (in agent-base start()) to the Tailscale IP for remote fleet ops, but a
// single-IP bind DROPS 127.0.0.1 — so every LOCAL consumer (dw-marketing-reels
// nightly IG publish, dw-follow-counts) hitting http://127.0.0.1:9810 got
// connection-refused since 09-21. Add a SECOND loopback-only listener serving
// the SAME Express `app` so local callers reach it again. Only when BIND_HOST
// is set to a non-loopback IP (guards against double-binding loopback →
// EADDRINUSE). NEVER 0.0.0.0 — Basic Auth is the only perimeter. Reversible:
// delete this block.
if ((process.env.BIND_HOST || '127.0.0.1') !== '127.0.0.1') {
  app.listen(PORT, '127.0.0.1', () => {
    console.log(`[${new Date().toISOString()}] [${AGENT_NAME}] loopback co-listener on 127.0.0.1:${PORT}`);
  });
}