← back to Norma Platform
agents/instagram-agent/server.js
228 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();