← back to Norma

agents/instagram-agent/post-to.js

315 lines

#!/usr/bin/env node
/**
 * post-to.js — post to ANY of Steve's Instagram accounts from one command.
 *
 * Every IG account is addressed by handle; credentials are resolved from
 * accounts.json (regenerate with `node build-registry.js`) and the shared
 * never-expiring META_ACCESS_TOKEN.
 *
 * SAFETY GATE: publishing to a live account is outward-facing, so this tool
 * runs a DRY RUN by default — it resolves the account, validates inputs, and
 * (for images) creates the media container, but STOPS before /media_publish.
 * Pass --confirm to actually publish.
 *
 * Usage:
 *   node post-to.js list
 *   node post-to.js <account> --image <public_url> --caption "text"          # dry run
 *   node post-to.js <account> --image <public_url> --caption "text" --confirm # LIVE
 *   node post-to.js <account> --reel  <public_mp4_url> --caption "text" --confirm
 *   node post-to.js <account> --story <public_url> --confirm
 *   node post-to.js --all     --image <public_url> --caption "text" --confirm # every account
 *
 * <account> = handle (@velvetwallpaper / velvetwallpaper), page name, or ig_user_id.
 */

const accounts = require('./accounts');
const content = require('./content');
const jewelryGate = require('./jewelry-gate');

// JEWELRY-COUNTER EXCLUSION (Steve, 2026-09-22): no image showing a jewelry
// display case may be published. Classify every candidate image URL at post
// time and drop jewelry:true (and any the classifier can't decide — fail-closed).
// Room-setting images are NEVER dropped. Returns the filtered URL list; throws
// if nothing postable survives. Set JEWELRY_GATE=0 only for an explicit,
// deliberate bypass (never in the cadence).
async function jewelryFilter(urls, handle) {
  if (process.env.JEWELRY_GATE === '0') return urls;
  const { postable, blocked } = await jewelryGate.gateUrls(urls);
  if (blocked.length) {
    for (const b of blocked) console.log(`  ⊘ @${handle} — excluded ${b.reason} image (${b.scene || ''}): ${b.url}`);
  }
  if (!postable.length) {
    throw new Error(`all ${urls.length} candidate image(s) excluded by jewelry gate (${blocked.map((b) => b.reason).join(',')}) — nothing to post`);
  }
  return postable;
}

function parseArgs(argv) {
  const a = { _: [] };
  for (let i = 0; i < argv.length; i++) {
    const t = argv[i];
    if (t.startsWith('--')) {
      const key = t.slice(2);
      const next = argv[i + 1];
      if (next === undefined || next.startsWith('--')) { a[key] = true; }
      else { a[key] = next; i++; }
    } else { a._.push(t); }
  }
  return a;
}

async function graph(host, version, pathPart, params) {
  const url = `${host}/${version}/${pathPart}`;
  const r = await fetch(url, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(params),
  });
  const j = await r.json();
  if (j.error) throw new Error(j.error.message);
  return j;
}

async function graphGet(host, version, pathPart, token, fields) {
  const url = `${host}/${version}/${pathPart}?fields=${encodeURIComponent(fields)}&access_token=${encodeURIComponent(token)}`;
  const r = await fetch(url);
  const j = await r.json();
  if (j.error) throw new Error(j.error.message);
  return j;
}

/** Poll a container until FINISHED (throws on ERROR / timeout). */
async function waitFinished(host, ver, containerId, token, { tries = 30, delay = 3000 } = {}) {
  for (let i = 0; i < tries; i++) {
    const s = await fetch(`${host}/${ver}/${containerId}?fields=status_code&access_token=${encodeURIComponent(token)}`);
    const sj = await s.json();
    if (sj.status_code === 'FINISHED') return;
    if (sj.status_code === 'ERROR') throw new Error('container processing error');
    await new Promise((res) => setTimeout(res, delay));
  }
  throw new Error('container never reached FINISHED');
}

/** Confirm the token can actually reach an account (read its profile fields). */
async function verifyOne(acct) {
  const { ig_user_id: id, access_token: token, graph_host: host, graph_version: ver } = acct;
  const p = await graphGet(host, ver, id, token, 'username,followers_count,media_count');
  return { handle: acct.handle, ig_user_id: id, username: p.username,
    followers: p.followers_count, posts: p.media_count, reachable: true };
}

/** Carousel (2–10 images): child containers → parent CAROUSEL container → publish. */
async function publishCarousel(acct, imageUrls, caption, dry) {
  const { ig_user_id: id, access_token: token, graph_host: host, graph_version: ver } = acct;
  if (imageUrls.length < 2 || imageUrls.length > 10) {
    throw new Error(`carousel needs 2–10 images (got ${imageUrls.length})`);
  }
  if (dry) {
    return { handle: acct.handle, ig_user_id: id, kind: 'CAROUSEL', dry_run: true,
      would_post: { children: imageUrls.length, caption } };
  }
  // Step 1: a child container per image (is_carousel_item)
  const childIds = [];
  for (const url of imageUrls) {
    const c = await graph(host, ver, `${id}/media`, { image_url: url, is_carousel_item: true, access_token: token });
    await waitFinished(host, ver, c.id, token, { tries: 15, delay: 2000 });
    childIds.push(c.id);
  }
  // Step 2: parent CAROUSEL container
  const parent = await graph(host, ver, `${id}/media`, {
    media_type: 'CAROUSEL', children: childIds.join(','), caption, access_token: token,
  });
  await waitFinished(host, ver, parent.id, token, { tries: 15, delay: 2000 });
  // Step 3: publish
  const pub = await graph(host, ver, `${id}/media_publish`, { creation_id: parent.id, access_token: token });
  let permalink = null;
  try { permalink = (await graphGet(host, ver, pub.id, token, 'permalink')).permalink || null; } catch { /* ignore */ }
  return { handle: acct.handle, ig_user_id: id, kind: 'CAROUSEL', children: childIds.length,
    media_id: pub.id, permalink, posted: true };
}

async function publishOne(acct, opts) {
  const dry = !opts.confirm;

  // Carousel: --images "url1,url2,..." (2–10 images)
  if (opts.images) {
    let urls = String(opts.images).split(',').map((s) => s.trim()).filter(Boolean);
    urls = await jewelryFilter(urls, acct.handle);   // drop any jewelry-counter slide
    // A carousel needs ≥2 images; if the gate leaves exactly one, post it as a
    // single image rather than failing the whole post.
    if (urls.length === 1) return publishOne(acct, { ...opts, images: undefined, image: urls[0] });
    return publishCarousel(acct, urls, opts.caption || '', dry);
  }

  const { ig_user_id: id, access_token: token, graph_host: host, graph_version: ver } = acct;
  const kind = opts.reel ? 'REELS' : opts.story ? 'STORIES' : 'IMAGE';

  // Gate single still-image posts (feed image, or a photo story). Video kinds
  // (--reel, an .mp4 story) are gated in the reel pipeline (publish-social.mjs).
  if (opts.image) { [opts.image] = await jewelryFilter([opts.image], acct.handle); }
  else if (opts.story && !/\.mp4($|\?)/i.test(opts.story)) { [opts.story] = await jewelryFilter([opts.story], acct.handle); }

  // Build the media-container params for the requested kind
  const container = { access_token: token };
  if (opts.reel) { container.media_type = 'REELS'; container.video_url = opts.reel; }
  else if (opts.story) {
    container.media_type = 'STORIES';
    if (/\.mp4($|\?)/i.test(opts.story)) container.video_url = opts.story;
    else container.image_url = opts.story;
  } else { container.image_url = opts.image; }
  if (opts.caption && kind !== 'STORIES') container.caption = opts.caption;

  if (dry) {
    return { handle: acct.handle, ig_user_id: id, kind, dry_run: true,
      would_post: { ...container, access_token: '***' } };
  }

  // Step 1: create container
  const c = await graph(host, ver, `${id}/media`, container);
  // Step 2: ALL kinds need to reach FINISHED before publish — images fetch the
  // remote URL first (IN_PROGRESS for ~1 poll); video kinds transcode (longer).
  // Publishing early yields "Media ID is not available".
  {
    const tries = kind === 'IMAGE' ? 15 : 45;
    const delay = kind === 'IMAGE' ? 2000 : 4000;
    let finished = false;
    for (let i = 0; i < tries; i++) {
      const s = await fetch(`${host}/${ver}/${c.id}?fields=status_code&access_token=${encodeURIComponent(token)}`);
      const sj = await s.json();
      if (sj.status_code === 'FINISHED') { finished = true; break; }
      if (sj.status_code === 'ERROR') throw new Error(`container processing error for @${acct.handle}`);
      await new Promise((res) => setTimeout(res, delay));
    }
    if (!finished) throw new Error(`container never reached FINISHED for @${acct.handle}`);
  }
  // Step 3: publish
  const pub = await graph(host, ver, `${id}/media_publish`, { creation_id: c.id, access_token: token });
  // permalink (best effort)
  let permalink = null;
  try {
    const p = await fetch(`${host}/${ver}/${pub.id}?fields=permalink&access_token=${encodeURIComponent(token)}`);
    permalink = (await p.json()).permalink || null;
  } catch { /* ignore */ }
  return { handle: acct.handle, ig_user_id: id, kind, media_id: pub.id, permalink, posted: true };
}

async function main() {
  const args = parseArgs(process.argv.slice(2));
  const cmd = args._[0];

  if (cmd === 'list' || args.list) {
    const all = accounts.list().sort((a, b) => (b.followers || 0) - (a.followers || 0));
    console.log(`${all.length} postable Instagram accounts:\n`);
    for (const a of all) {
      const stat = a.followers != null ? `  ${String(a.followers).padStart(6)} followers` : '';
      console.log(`  @${(a.handle || '').padEnd(28)}${stat}  ${a.page_name}`);
    }
    return;
  }

  // verify — prove the token can reach an account (or all of them). Read-only.
  if (cmd === 'verify' || args.verify || args['verify-all']) {
    const targets = (args['verify-all'] || args.all || (cmd === 'verify' && !args._[1]))
      ? accounts.list().map((a) => accounts.resolve(a.handle))
      : [accounts.resolve(args._[1] || cmd)].filter(Boolean);
    if (!targets.length) { console.error('Unknown account. Try: node post-to.js list'); process.exit(1); }
    console.log(`Verifying ${targets.length} account(s) — read-only:\n`);
    let ok = 0;
    for (const t of targets) {
      try { const v = await verifyOne(t); ok++; console.log(`  ✓ @${(v.handle||'').padEnd(26)} followers=${v.followers} posts=${v.posts}`); }
      catch (e) { console.log(`  ✗ @${(t.handle||'').padEnd(26)} ${e.message}`); }
    }
    console.log(`\n${ok}/${targets.length} reachable with the shared token.`);
    return;
  }

  // --product <handle-or-url>: pull a real DW product → fills --image + --caption
  if (args.product) {
    try {
      const prod = await content.resolveProduct(args.product);
      // Carousel when a room-setting image exists (room first, product swatch second); else single image.
      if (!args.image && !args.images && prod.images && prod.images.length > 1) {
        args.images = prod.images.join(',');
        console.log(`Product: ${prod.title}\n  carousel: ${prod.images.length} slides (room setting → product)\n`);
      } else {
        args.image = args.image || prod.image_url;
        console.log(`Product: ${prod.title}\n  image: ${prod.image_url}\n`);
      }
      if (!args.caption) args.caption = prod.caption;
      args._productTitle = prod.title;
      args._productHandle = String(args.product).replace(/^@/, '');
      args._imageUrl = prod.image_url;
    } catch (e) { console.error(`--product failed: ${e.message}`); process.exit(1); }
  }

  if (!args.image && !args.images && !args.reel && !args.story) {
    console.error('Nothing to post. Provide --product <handle> | --image <url> | --images "u1,u2,..." | --reel <mp4Url> | --story <url>.');
    console.error('Run `node post-to.js list` to see accounts, or `verify <account>` to health-check.');
    process.exit(1);
  }

  // Resolve target accounts
  let targets;
  if (args.all) {
    targets = accounts.list().map((a) => accounts.resolve(a.handle));
  } else {
    if (!cmd) { console.error('Specify an account (or --all).'); process.exit(1); }
    const one = accounts.resolve(cmd);
    if (!one) { console.error(`Unknown account "${cmd}". Try: node post-to.js list`); process.exit(1); }
    targets = [one];
  }

  const missing = targets.filter((t) => !t.has_token);
  if (missing.length) { console.error(`No token resolved for: ${missing.map((m) => m.handle).join(', ')}`); process.exit(1); }

  const dry = !args.confirm;
  console.log(dry
    ? `DRY RUN (no --confirm) → ${targets.length} account(s). Nothing will be published.\n`
    : `LIVE POST → ${targets.length} account(s).\n`);

  const results = [];
  for (let ti = 0; ti < targets.length; ti++) {
    const t = targets[ti];
    try {
      const r = await publishOne(t, args);
      results.push(r);
      if (!dry) {
        try {
          const fs = require('fs'), path = require('path');
          const dir = path.join(__dirname, 'data');
          if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
          fs.appendFileSync(path.join(dir, 'post-ledger.jsonl'), JSON.stringify({
            ts: new Date().toISOString(), handle: t.handle, page_name: t.page_name || null,
            product_handle: args._productHandle || null, product_title: args._productTitle || null,
            image_url: args._imageUrl || args.image || null,
            // ALL published slides (carousel = room + swatch), so the cadence's image-dedup guard
            // sees the room photo too — not just the swatch (Cody gate, /yoloforever cycle 1).
            images: args.images || args._imageUrl || args.image || null,
            permalink: r.permalink || null, media_id: r.media_id || null, kind: r.kind || null,
          }) + '\n');
        } catch (_) { /* ledger is best-effort; never block a post */ }
      }
      console.log(dry
        ? `  ✓ @${t.handle} — dry-run OK (${r.kind})`
        : `  ✓ @${t.handle} — posted ${r.kind} ${r.permalink || r.media_id}`);
    } catch (e) {
      results.push({ handle: t.handle, error: e.message });
      console.log(`  ✗ @${t.handle} — ${e.message}`);
    }
    // Throttle live fan-out so a --all sweep doesn't hammer the Graph API.
    // (Each account has its own ~50-posts/24h publishing limit; 1/account is safe.)
    if (!dry && ti < targets.length - 1) await new Promise((res) => setTimeout(res, 1500));
  }
  const ok = results.filter((r) => !r.error).length;
  console.log(`\n${ok}/${results.length} ${dry ? 'validated' : 'posted'}.`);
  if (dry) console.log('Add --confirm to publish for real.');
  // Refresh the marketing.dw "IG Network Activity" feed after any live post.
  if (!dry && ok) {
    try {
      require('child_process').execFileSync(process.execPath, [require('path').join(__dirname, 'gen-ig-activity.js')], { stdio: 'ignore' });
    } catch (_) { /* feed refresh is best-effort */ }
  }
}

main().catch((e) => { console.error('FAILED:', e.message); process.exit(1); });