← back to Dw Marketing Reels

scripts/publish-ig.mjs

99 lines

#!/usr/bin/env node
// Self-contained "publish a reel to Instagram from the marketing project."
// Uses the Facebook-Login Graph flow (graph.facebook.com) with the durable
// page token — no dependency on Norma's agent being live. Proven path
// (2026-07-14, reel Dax4CTpmw51).
//
// SAFETY: gated by SOCIAL_LIVE_ARMED=1. Without it, runs a DRY check that
// resolves creds + account + media URL and reports readiness, posting NOTHING.
//
// Usage:
//   node scripts/publish-ig.mjs                 # newest reel, DRY (unless armed)
//   node scripts/publish-ig.mjs --file X.mp4    # a specific reel
//   SOCIAL_LIVE_ARMED=1 node scripts/publish-ig.mjs --file X.mp4   # real post
//
// Creds (env first, else the co-located Norma agent .env on this machine):
//   IG_USER_ID, IG_ACCESS_TOKEN  (page token, durable)
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { homedir } from 'node:os';

const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
const MAN = join(ROOT, 'data', 'reels.json');
const ARMED = process.env.SOCIAL_LIVE_ARMED === '1';
const PUBLIC_BASE = process.env.PUBLIC_BASE || 'https://marketing.designerwallcoverings.com';
const V = 'https://graph.facebook.com/v21.0';
const argFile = (process.argv.find(a => a.startsWith('--file=')) || '').split('=')[1]
  || (process.argv.includes('--file') ? process.argv[process.argv.indexOf('--file') + 1] : '');

// resolve creds: env → co-located Norma agent .env (same machine)
function creds() {
  let uid = process.env.IG_USER_ID, tok = process.env.IG_ACCESS_TOKEN;
  if (!uid || !tok) {
    const p = join(homedir(), 'Projects/Norma/agents/instagram-agent/.env');
    if (existsSync(p)) {
      for (const line of readFileSync(p, 'utf8').split('\n')) {
        const m = line.match(/^(IG_USER_ID|IG_ACCESS_TOKEN)=(.+)$/);
        if (m) { if (m[1] === 'IG_USER_ID') uid ||= m[2]; else tok ||= m[2]; }
      }
    }
  }
  return { uid, tok };
}
const j = async (u, o) => { const r = await fetch(u, o); const t = await r.text(); let d; try { d = JSON.parse(t); } catch { d = { raw: t }; } return { ok: r.ok, d }; };
const die = m => { console.error('✗', m); process.exit(1); };

const reels = existsSync(MAN) ? JSON.parse(readFileSync(MAN, 'utf8')) : [];
const list = Array.isArray(reels) ? reels : (reels.reels || []);
const reel = argFile ? list.find(r => r.file === argFile) : list[0];
if (!reel) die(argFile ? `reel not found: ${argFile}` : 'no reels in manifest');

const { uid, tok } = creds();
if (!uid || !tok) die('IG_USER_ID / IG_ACCESS_TOKEN not found (env or Norma agent .env)');
const mediaUrl = `${PUBLIC_BASE}/reels/${encodeURIComponent(reel.file)}`;

console.log(`reel:    ${reel.file}`);
console.log(`media:   ${mediaUrl}`);
console.log(`account: ig_user_id ${uid}  token …${tok.slice(-4)}`);

// verify the public media is fetchable (Meta must be able to pull it)
{ const r = await fetch(mediaUrl, { method: 'HEAD' }); console.log(`media reachable: ${r.status}`); if (r.status !== 200) die('public media not 200 — deploy the reel first'); }

if (!ARMED) {
  console.log('\n⏸  DRY (SOCIAL_LIVE_ARMED != 1) — everything resolves; nothing posted.');
  console.log('   To post for real:  SOCIAL_LIVE_ARMED=1 node scripts/publish-ig.mjs --file ' + reel.file);
  process.exit(0);
}

// live publish: container → poll → media_publish → permalink
console.log('\n→ creating reel container…');
let r = await j(`${V}/${uid}/media`, { method: 'POST', headers: { 'content-type': 'application/json' },
  body: JSON.stringify({ media_type: 'REELS', video_url: mediaUrl, caption: reel.caption, access_token: tok }) });
if (!r.ok || !r.d.id) die('container create failed: ' + JSON.stringify(r.d));
const cid = r.d.id;
let status = '';
for (let i = 0; i < 40; i++) {
  await new Promise(s => setTimeout(s, 5000));
  const s = await j(`${V}/${cid}?fields=status_code&access_token=${tok}`);
  status = s.d.status_code || '';
  process.stdout.write(`  [${i}] ${status}\n`);
  if (status === 'FINISHED') break;
  if (status === 'ERROR' || status === 'EXPIRED') die('processing ' + status + ': ' + JSON.stringify(s.d));
}
if (status !== 'FINISHED') die('not FINISHED (last: ' + status + ')');
console.log('→ publishing…');
r = await j(`${V}/${uid}/media_publish`, { method: 'POST', headers: { 'content-type': 'application/json' },
  body: JSON.stringify({ creation_id: cid, access_token: tok }) });
if (!r.ok || !r.d.id) die('media_publish failed: ' + JSON.stringify(r.d));
const mediaId = r.d.id;
r = await j(`${V}/${mediaId}?fields=permalink&access_token=${tok}`);
const permalink = r.d.permalink || '(pending)';
console.log(`\n🎉 PUBLISHED: ${permalink}  (media ${mediaId})`);

// record status in the manifest
reel.publish = reel.publish || {};
reel.publish.instagram = { status: 'posted', mediaId, permalink, at: new Date().toISOString() };
writeFileSync(MAN, JSON.stringify(reels, null, 2));
console.log('   manifest updated (publish.instagram.status = posted)');