← back to Norma Platform
IG multi-account posting: registry + resolver + post-to CLI over 35 linked accounts (shared META token, dry-run-gated)
6b3ec2486f04476b9bbfb293386a034303ccebf6 · 2026-08-10 11:53:05 -0700 · Steve
Files touched
A agents/instagram-agent/accounts.jsA agents/instagram-agent/accounts.jsonA agents/instagram-agent/build-registry.jsA agents/instagram-agent/post-to.jsM agents/instagram-agent/skills/post.js
Diff
commit 6b3ec2486f04476b9bbfb293386a034303ccebf6
Author: Steve <steve@designerwallcoverings.com>
Date: Mon Aug 10 11:53:05 2026 -0700
IG multi-account posting: registry + resolver + post-to CLI over 35 linked accounts (shared META token, dry-run-gated)
---
agents/instagram-agent/accounts.js | 81 ++++++++
agents/instagram-agent/accounts.json | 335 +++++++++++++++++++++++++++++++
agents/instagram-agent/build-registry.js | 100 +++++++++
agents/instagram-agent/post-to.js | 150 ++++++++++++++
agents/instagram-agent/skills/post.js | 17 +-
5 files changed, 680 insertions(+), 3 deletions(-)
diff --git a/agents/instagram-agent/accounts.js b/agents/instagram-agent/accounts.js
new file mode 100644
index 0000000..3e1720d
--- /dev/null
+++ b/agents/instagram-agent/accounts.js
@@ -0,0 +1,81 @@
+/**
+ * accounts.js — multi-account resolver for the Instagram posting layer.
+ *
+ * Maps an account name/handle → the credentials needed to publish to it:
+ * { ig_user_id, access_token, graph_host, graph_version, handle, page_name }
+ *
+ * All 35 IG-linked accounts share ONE never-expiring token (META_ACCESS_TOKEN),
+ * so posting to any of them is just "resolve the ig_user_id, reuse the token."
+ * A per-account `token` field in accounts.json overrides the shared token if a
+ * given account ever needs its own (e.g. an Instagram-Login flow account).
+ *
+ * Resolution order for the token:
+ * 1. account.token in accounts.json (per-account override)
+ * 2. process.env.META_ACCESS_TOKEN
+ * 3. ~/Projects/secrets-manager/.env META_ACCESS_TOKEN
+ * 4. process.env.IG_ACCESS_TOKEN (legacy single-account fallback)
+ */
+
+const fs = require('fs');
+const path = require('path');
+const os = require('os');
+
+const REGISTRY_PATH = path.join(__dirname, 'accounts.json');
+
+function sharedToken() {
+ if (process.env.META_ACCESS_TOKEN) return process.env.META_ACCESS_TOKEN;
+ try {
+ const envPath = path.join(os.homedir(), 'Projects/secrets-manager/.env');
+ const line = fs.readFileSync(envPath, 'utf8')
+ .split('\n').find((l) => l.startsWith('META_ACCESS_TOKEN='));
+ if (line) return line.slice('META_ACCESS_TOKEN='.length).trim();
+ } catch { /* ignore */ }
+ return process.env.IG_ACCESS_TOKEN || '';
+}
+
+function loadRegistry() {
+ try { return JSON.parse(fs.readFileSync(REGISTRY_PATH, 'utf8')); }
+ catch { return { accounts: {} }; }
+}
+
+/** Normalize "@Velvet Wallpaper" / "velvetwallpaper" / an ig_user_id → a lookup key. */
+function normalize(name) {
+ return String(name || '').trim().replace(/^@/, '').toLowerCase();
+}
+
+/** List all postable account handles. */
+function list() {
+ return Object.values(loadRegistry().accounts)
+ .map((a) => ({ handle: a.handle, ig_user_id: a.ig_user_id, page_name: a.page_name }));
+}
+
+/**
+ * Resolve one account → publishing credentials, or null if unknown.
+ * Accepts a handle (@x / x), a page_name, or a raw ig_user_id.
+ */
+function resolve(name) {
+ const reg = loadRegistry();
+ const key = normalize(name);
+
+ let acct = reg.accounts[key];
+ if (!acct) {
+ // fall back to matching by page_name or by ig_user_id
+ acct = Object.values(reg.accounts).find(
+ (a) => normalize(a.page_name) === key || a.ig_user_id === String(name).trim(),
+ );
+ }
+ if (!acct) return null;
+
+ const token = acct.token || sharedToken();
+ return {
+ handle: acct.handle,
+ page_name: acct.page_name,
+ ig_user_id: acct.ig_user_id,
+ access_token: token,
+ graph_host: acct.graph_host || 'https://graph.facebook.com',
+ graph_version: acct.graph_version || 'v21.0',
+ has_token: !!token,
+ };
+}
+
+module.exports = { resolve, list, normalize, loadRegistry };
diff --git a/agents/instagram-agent/accounts.json b/agents/instagram-agent/accounts.json
new file mode 100644
index 0000000..912189b
--- /dev/null
+++ b/agents/instagram-agent/accounts.json
@@ -0,0 +1,335 @@
+{
+ "generated_at": "2026-08-10T18:51:21.454Z",
+ "token_source": "META_ACCESS_TOKEN (shared, never-expiring)",
+ "pages_total": 80,
+ "accounts_postable": 35,
+ "pages_without_ig": [
+ "1800s Wallpaper",
+ "1890s Wallpaper",
+ "All News Daily",
+ "All Weather Wallpaper",
+ "As Seen in Movies",
+ "Bamboo Wallcoverings",
+ "Black Wallcovering",
+ "Brazilliance Walls & Fabrics",
+ "Bubbe ",
+ "Charleston Wallpaper Store",
+ "Chicago Wallpaper Store",
+ "DSGN TV",
+ "Design Trade Wallpaper",
+ "Designer Fabrics",
+ "DesignerWallpaperForum.com",
+ "Designers Chat",
+ "FFE Purchasing Group",
+ "Faux News Live",
+ "Flocked Wallpaper",
+ "FlockedWallpaper.com",
+ "Glass Beaded Wallcoverings",
+ "Glass Beaded Wallpaper",
+ "Glitter Walls",
+ "Grasscloth Wallpaper",
+ "Green Wallpapers",
+ "Hand Blocked Wallpaper",
+ "Handcrafted Wallpaper",
+ "Interior Designer Showroom",
+ "LAFires.org - Los Angeles Fires",
+ "Local Design Firms",
+ "Local Designer Directory",
+ "Los Angeles Fires",
+ "Madagascar Wallpaper",
+ "NOTVRequired.com",
+ "Peel and Stick Wallpaper",
+ "Raffia Wallpaper",
+ "Silk Wallpaper",
+ "To The Trade Daily",
+ "Trade Only Fabrics",
+ "Trade Only Textiles",
+ "Traditional Whimsy Home",
+ "Versace Wallpaper",
+ "Wallpaper Design Competition",
+ "WallpaperWeekly.com",
+ "Why Legalize - Marijuana"
+ ],
+ "accounts": {
+ "asseeninhotels": {
+ "handle": "asseeninhotels",
+ "ig_user_id": "17841409119139761",
+ "page_id": "453810901296460",
+ "page_name": "As Seen in Hotels",
+ "graph_host": "https://graph.facebook.com",
+ "graph_version": "v21.0"
+ },
+ "beverlyhillsvideos": {
+ "handle": "beverlyhillsvideos",
+ "ig_user_id": "17841408681887144",
+ "page_id": "249173031283",
+ "page_name": "Designer Wallcoverings",
+ "graph_host": "https://graph.facebook.com",
+ "graph_version": "v21.0"
+ },
+ "borninbeverlyhills": {
+ "handle": "borninbeverlyhills",
+ "ig_user_id": "17841408772995866",
+ "page_id": "2548395878518829",
+ "page_name": "Born in Beverly Hills",
+ "graph_host": "https://graph.facebook.com",
+ "graph_version": "v21.0"
+ },
+ "brazilliancewallpaper": {
+ "handle": "brazilliancewallpaper",
+ "ig_user_id": "17841407025318686",
+ "page_id": "2009198999106954",
+ "page_name": "Brazilliance Wallpaper and Fabric",
+ "graph_host": "https://graph.facebook.com",
+ "graph_version": "v21.0"
+ },
+ "chinoiseriewallpaper": {
+ "handle": "chinoiseriewallpaper",
+ "ig_user_id": "17841407176278551",
+ "page_id": "530286787358585",
+ "page_name": "Et Cie Chinoiserie Wall Murals",
+ "graph_host": "https://graph.facebook.com",
+ "graph_version": "v21.0"
+ },
+ "commercialwallcoverings": {
+ "handle": "commercialwallcoverings",
+ "ig_user_id": "17841408775091102",
+ "page_id": "101723721568114",
+ "page_name": "Commercial Wallcoverings",
+ "graph_host": "https://graph.facebook.com",
+ "graph_version": "v21.0"
+ },
+ "customwallcoverings": {
+ "handle": "customwallcoverings",
+ "ig_user_id": "17841408685240867",
+ "page_id": "128212609930",
+ "page_name": "Custom Wall Papers and Wallcoverings",
+ "graph_host": "https://graph.facebook.com",
+ "graph_version": "v21.0"
+ },
+ "designerlaboratory": {
+ "handle": "designerlaboratory",
+ "ig_user_id": "17841405610219019",
+ "page_id": "224684317687182",
+ "page_name": "Designer Laboratory",
+ "graph_host": "https://graph.facebook.com",
+ "graph_version": "v21.0"
+ },
+ "designerwallcoverings": {
+ "handle": "designerwallcoverings",
+ "ig_user_id": "17841400781263605",
+ "page_id": "92675029719",
+ "page_name": "DesignerWallcoverings.com",
+ "graph_host": "https://graph.facebook.com",
+ "graph_version": "v21.0"
+ },
+ "fabric_fridays": {
+ "handle": "fabric_fridays",
+ "ig_user_id": "17841434885415391",
+ "page_id": "105187997852752",
+ "page_name": "Fabric Fridays",
+ "graph_host": "https://graph.facebook.com",
+ "graph_version": "v21.0"
+ },
+ "ffepurchasing": {
+ "handle": "ffepurchasing",
+ "ig_user_id": "17841407058348607",
+ "page_id": "2054055348200975",
+ "page_name": "FFE Purchasing",
+ "graph_host": "https://graph.facebook.com",
+ "graph_version": "v21.0"
+ },
+ "goldleafwallpaper": {
+ "handle": "goldleafwallpaper",
+ "ig_user_id": "17841408856660866",
+ "page_id": "143671216006539",
+ "page_name": "Gold Leaf Wallpaper",
+ "graph_host": "https://graph.facebook.com",
+ "graph_version": "v21.0"
+ },
+ "grassclothwallpaper": {
+ "handle": "grassclothwallpaper",
+ "ig_user_id": "17841408868631110",
+ "page_id": "139542627030",
+ "page_name": "Wallpaper Weekly",
+ "graph_host": "https://graph.facebook.com",
+ "graph_version": "v21.0"
+ },
+ "hollywoodwallcoverings": {
+ "handle": "hollywoodwallcoverings",
+ "ig_user_id": "17841406772943912",
+ "page_id": "310236955763638",
+ "page_name": "Hollywood Wallcoverings",
+ "graph_host": "https://graph.facebook.com",
+ "graph_version": "v21.0"
+ },
+ "hospitalitywallcoverings": {
+ "handle": "hospitalitywallcoverings",
+ "ig_user_id": "17841407188578748",
+ "page_id": "112549388778107",
+ "page_name": "Hospitality Wallpaper",
+ "graph_host": "https://graph.facebook.com",
+ "graph_version": "v21.0"
+ },
+ "interiordesignervideos": {
+ "handle": "interiordesignervideos",
+ "ig_user_id": "17841408790905574",
+ "page_id": "683918978285923",
+ "page_name": "Interior Designer Videos",
+ "graph_host": "https://graph.facebook.com",
+ "graph_version": "v21.0"
+ },
+ "linenwallpaper": {
+ "handle": "linenwallpaper",
+ "ig_user_id": "17841408700186822",
+ "page_id": "258674951458459",
+ "page_name": "Linen Wallpaper",
+ "graph_host": "https://graph.facebook.com",
+ "graph_version": "v21.0"
+ },
+ "metallicwallpaper": {
+ "handle": "metallicwallpaper",
+ "ig_user_id": "17841408654197157",
+ "page_id": "329043417920690",
+ "page_name": "Metallic Wallpaper",
+ "graph_host": "https://graph.facebook.com",
+ "graph_version": "v21.0"
+ },
+ "papelestapiz": {
+ "handle": "papelestapiz",
+ "ig_user_id": "17841407051208925",
+ "page_id": "1640547612676556",
+ "page_name": "Papel Tapiz",
+ "graph_host": "https://graph.facebook.com",
+ "graph_version": "v21.0"
+ },
+ "patterndesignlab": {
+ "handle": "patterndesignlab",
+ "ig_user_id": "17841405752089037",
+ "page_id": "1714086251943136",
+ "page_name": "Pattern Design Lab",
+ "graph_host": "https://graph.facebook.com",
+ "graph_version": "v21.0"
+ },
+ "philliperomanodesigns": {
+ "handle": "philliperomanodesigns",
+ "ig_user_id": "17841406249858383",
+ "page_id": "1642942779256637",
+ "page_name": "Phillipe Romano",
+ "graph_host": "https://graph.facebook.com",
+ "graph_version": "v21.0"
+ },
+ "restorationwallpaper": {
+ "handle": "restorationwallpaper",
+ "ig_user_id": "17841408840359842",
+ "page_id": "723158284513545",
+ "page_name": "Restoration Wallpaper",
+ "graph_host": "https://graph.facebook.com",
+ "graph_version": "v21.0"
+ },
+ "retrowalls": {
+ "handle": "retrowalls",
+ "ig_user_id": "17841406464478261",
+ "page_id": "241532263064171",
+ "page_name": "Retro Walls",
+ "graph_host": "https://graph.facebook.com",
+ "graph_version": "v21.0"
+ },
+ "roomsettings": {
+ "handle": "roomsettings",
+ "ig_user_id": "17841408698848037",
+ "page_id": "563643740756932",
+ "page_name": "Room Settings",
+ "graph_host": "https://graph.facebook.com",
+ "graph_version": "v21.0"
+ },
+ "screenprintedwallpaper": {
+ "handle": "screenprintedwallpaper",
+ "ig_user_id": "17841408660869742",
+ "page_id": "1965321333759540",
+ "page_name": "Screen Printed Wallpaper",
+ "graph_host": "https://graph.facebook.com",
+ "graph_version": "v21.0"
+ },
+ "sheltermagazines": {
+ "handle": "sheltermagazines",
+ "ig_user_id": "17841406881694118",
+ "page_id": "1383580575209054",
+ "page_name": "Shelter Magazines",
+ "graph_host": "https://graph.facebook.com",
+ "graph_version": "v21.0"
+ },
+ "suedewallpaper": {
+ "handle": "suedewallpaper",
+ "ig_user_id": "17841408648437136",
+ "page_id": "320648328748209",
+ "page_name": "Suede Wallpaper",
+ "graph_host": "https://graph.facebook.com",
+ "graph_version": "v21.0"
+ },
+ "textilewallpaper": {
+ "handle": "textilewallpaper",
+ "ig_user_id": "17841408813467105",
+ "page_id": "242997016372360",
+ "page_name": "Textile Wallpaper",
+ "graph_host": "https://graph.facebook.com",
+ "graph_version": "v21.0"
+ },
+ "thedesignerlibrary": {
+ "handle": "thedesignerlibrary",
+ "ig_user_id": "17841408572952750",
+ "page_id": "303486766522573",
+ "page_name": "Designer Library",
+ "graph_host": "https://graph.facebook.com",
+ "graph_version": "v21.0"
+ },
+ "thesetdecorator": {
+ "handle": "thesetdecorator",
+ "ig_user_id": "17841406774743877",
+ "page_id": "500577630006739",
+ "page_name": "The Set Decorator",
+ "graph_host": "https://graph.facebook.com",
+ "graph_version": "v21.0"
+ },
+ "traditionalwhimsy": {
+ "handle": "traditionalwhimsy",
+ "ig_user_id": "17841408645959795",
+ "page_id": "188355165382826",
+ "page_name": "Traditional Whimsy",
+ "graph_host": "https://graph.facebook.com",
+ "graph_version": "v21.0"
+ },
+ "velvetwallpaper": {
+ "handle": "velvetwallpaper",
+ "ig_user_id": "17841408844140138",
+ "page_id": "349839162257610",
+ "page_name": "Velvet Wallpaper",
+ "graph_host": "https://graph.facebook.com",
+ "graph_version": "v21.0"
+ },
+ "wallpaperhistory": {
+ "handle": "wallpaperhistory",
+ "ig_user_id": "17841408790217103",
+ "page_id": "330587311005484",
+ "page_name": "Wallpaper History",
+ "graph_host": "https://graph.facebook.com",
+ "graph_version": "v21.0"
+ },
+ "wallpaperinstallers": {
+ "handle": "wallpaperinstallers",
+ "ig_user_id": "17841414900195265",
+ "page_id": "102819675952474",
+ "page_name": "Wallpaper Installers",
+ "graph_host": "https://graph.facebook.com",
+ "graph_version": "v21.0"
+ },
+ "wallpaperwednesdays": {
+ "handle": "wallpaperwednesdays",
+ "ig_user_id": "17841409211532596",
+ "page_id": "2223776031217340",
+ "page_name": "Wallpaper Wednesdays",
+ "graph_host": "https://graph.facebook.com",
+ "graph_version": "v21.0"
+ }
+ }
+}
\ No newline at end of file
diff --git a/agents/instagram-agent/build-registry.js b/agents/instagram-agent/build-registry.js
new file mode 100644
index 0000000..76f8b65
--- /dev/null
+++ b/agents/instagram-agent/build-registry.js
@@ -0,0 +1,100 @@
+#!/usr/bin/env node
+/**
+ * build-registry.js — regenerate accounts.json from the live Meta token.
+ *
+ * Walks GET /me/accounts (all Facebook Pages the token administers), keeps the
+ * ones with a linked Instagram business account, and writes a registry mapping
+ * handle → { ig_user_id, page_id, page_name }. Every entry is postable with the
+ * shared never-expiring META_ACCESS_TOKEN + graph.facebook.com (Facebook-Login
+ * flow), which is what Norma's _ig-api already uses when IG_GRAPH_HOST is set.
+ *
+ * This is SELF-HEALING: link a new IG account to its Page in Meta Business
+ * Suite, re-run this, and the new handle appears automatically.
+ *
+ * Usage: node build-registry.js # writes accounts.json
+ * node build-registry.js --print # print, don't write
+ *
+ * Token resolution (first hit wins):
+ * process.env.META_ACCESS_TOKEN → ~/Projects/secrets-manager/.env
+ */
+
+const fs = require('fs');
+const path = require('path');
+const os = require('os');
+
+const GRAPH = process.env.IG_GRAPH_HOST || 'https://graph.facebook.com';
+const VERSION = process.env.IG_GRAPH_VERSION || 'v21.0';
+
+function readSecretsToken() {
+ if (process.env.META_ACCESS_TOKEN) return process.env.META_ACCESS_TOKEN;
+ try {
+ const envPath = path.join(os.homedir(), 'Projects/secrets-manager/.env');
+ const line = fs.readFileSync(envPath, 'utf8')
+ .split('\n').find((l) => l.startsWith('META_ACCESS_TOKEN='));
+ if (line) return line.slice('META_ACCESS_TOKEN='.length).trim();
+ } catch { /* ignore */ }
+ return '';
+}
+
+async function main() {
+ const token = readSecretsToken();
+ if (!token) { console.error('No META_ACCESS_TOKEN found (env or secrets-manager/.env).'); process.exit(1); }
+
+ let url = `${GRAPH}/${VERSION}/me/accounts`
+ + `?fields=name,instagram_business_account{id,username}&limit=100&access_token=${encodeURIComponent(token)}`;
+ const linked = [];
+ const unlinked = [];
+ let pages = 0;
+
+ while (url) {
+ const r = await fetch(url);
+ const j = await r.json();
+ if (j.error) throw new Error(`me/accounts failed: ${j.error.message}`);
+ for (const p of j.data || []) {
+ pages += 1;
+ const ig = p.instagram_business_account;
+ if (ig && ig.id) {
+ linked.push({ username: ig.username || null, ig_user_id: ig.id, page_id: p.id, page_name: p.name });
+ } else {
+ unlinked.push(p.name);
+ }
+ }
+ url = (j.paging && j.paging.next) || null;
+ }
+
+ // registry: keyed by handle (lowercased, no @) → account record
+ const accounts = {};
+ for (const a of linked.sort((x, y) => (x.username || '').localeCompare(y.username || ''))) {
+ const key = (a.username || a.page_id).toLowerCase();
+ accounts[key] = {
+ handle: a.username,
+ ig_user_id: a.ig_user_id,
+ page_id: a.page_id,
+ page_name: a.page_name,
+ graph_host: GRAPH,
+ graph_version: VERSION,
+ // token omitted → resolver uses the shared META_ACCESS_TOKEN
+ };
+ }
+
+ const registry = {
+ generated_at: new Date().toISOString(),
+ token_source: 'META_ACCESS_TOKEN (shared, never-expiring)',
+ pages_total: pages,
+ accounts_postable: Object.keys(accounts).length,
+ pages_without_ig: unlinked.sort(),
+ accounts,
+ };
+
+ if (process.argv.includes('--print')) {
+ console.log(JSON.stringify(registry, null, 2));
+ return;
+ }
+ const out = path.join(__dirname, 'accounts.json');
+ fs.writeFileSync(out, JSON.stringify(registry, null, 2));
+ console.log(`Wrote ${out}`);
+ console.log(` postable IG accounts: ${registry.accounts_postable}`);
+ console.log(` pages without linked IG: ${registry.pages_without_ig.length}`);
+}
+
+main().catch((e) => { console.error('FAILED:', e.message); process.exit(1); });
diff --git a/agents/instagram-agent/post-to.js b/agents/instagram-agent/post-to.js
new file mode 100644
index 0000000..16d5d77
--- /dev/null
+++ b/agents/instagram-agent/post-to.js
@@ -0,0 +1,150 @@
+#!/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');
+
+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 publishOne(acct, opts) {
+ const { ig_user_id: id, access_token: token, graph_host: host, graph_version: ver } = acct;
+ const dry = !opts.confirm;
+ const kind = opts.reel ? 'REELS' : opts.story ? 'STORIES' : 'IMAGE';
+
+ // 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: video kinds need processing time before publish
+ if (kind !== 'IMAGE') {
+ for (let i = 0; i < 30; 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') break;
+ if (sj.status_code === 'ERROR') throw new Error(`container processing error for @${acct.handle}`);
+ await new Promise((res) => setTimeout(res, 4000));
+ }
+ }
+ // 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();
+ console.log(`${all.length} postable Instagram accounts:\n`);
+ for (const a of all) console.log(` @${(a.handle || '').padEnd(28)} ${a.page_name}`);
+ return;
+ }
+
+ if (!args.image && !args.reel && !args.story) {
+ console.error('Nothing to post. Provide --image <url> | --reel <mp4Url> | --story <url>.');
+ console.error('Run `node post-to.js list` to see accounts.');
+ 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 (const t of targets) {
+ try {
+ const r = await publishOne(t, args);
+ results.push(r);
+ 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}`);
+ }
+ }
+ 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.');
+}
+
+main().catch((e) => { console.error('FAILED:', e.message); process.exit(1); });
diff --git a/agents/instagram-agent/skills/post.js b/agents/instagram-agent/skills/post.js
index 408dc52..e62e917 100644
--- a/agents/instagram-agent/skills/post.js
+++ b/agents/instagram-agent/skills/post.js
@@ -17,11 +17,22 @@
const { loadCredentials } = require('../../shared/credentials');
const ig = require('./_ig-api');
+const accounts = require('../accounts');
const AGENT = 'instagram-agent';
const PLATFORM = 'instagram';
-async function getCredentials() {
+/**
+ * Resolve credentials. If `account` is given (a handle / page name / ig id),
+ * use the multi-account registry + shared META_ACCESS_TOKEN; otherwise fall
+ * back to the legacy single-account creds (DB / env).
+ */
+async function getCredentials(account) {
+ if (account) {
+ const a = accounts.resolve(account);
+ if (!a) throw new Error(`Unknown Instagram account "${account}" (run build-registry.js / see accounts.json)`);
+ return { has: a.has_token, userId: a.ig_user_id, accessToken: a.access_token, handle: a.handle };
+ }
const creds = await loadCredentials('instagram');
return {
has: !!(creds?.IG_USER_ID && creds?.IG_ACCESS_TOKEN),
@@ -41,10 +52,10 @@ async function getCredentials() {
module.exports = async function post(params) {
const imageUrl = params.image_url || '';
const caption = params.caption || params.text || '';
- const { has: hasCreds, userId, accessToken } = await getCredentials();
+ const { has: hasCreds, userId, accessToken, handle } = await getCredentials(params.account);
const simulated = !hasCreds;
- console.log(`[${AGENT}] Post skill invoked — simulation=${simulated}`);
+ console.log(`[${AGENT}] Post skill invoked — account=${handle || params.account || 'default'} simulation=${simulated}`);
console.log(`[${AGENT}] Caption: ${caption.substring(0, 100)}${caption.length > 100 ? '...' : ''}`);
if (imageUrl) console.log(`[${AGENT}] Image URL: ${imageUrl}`);
← 564be77 TK-10395: cap reel container poll at 12x4s so 3x-retry worst
·
back to Norma Platform
·
IG poster: poll container to FINISHED for images too (fixes 57ebb5d →