← back to Norma
agents/instagram-agent/server.js
172 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: '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 });
}
});
start();