← back to Norma
IG poster hardening: carousel (2-10 img) posts, verify/health-sweep, --all throttle, follower-ranked list, account param wired into reel+story (registry-first, legacy env preserved), POSTING.md docs
5dd450b1a436d9edda1ba7059f262867fe49bedc · 2026-08-10 12:18:56 -0700 · Steve
Files touched
A agents/instagram-agent/POSTING.mdM agents/instagram-agent/accounts.jsM agents/instagram-agent/accounts.jsonM agents/instagram-agent/build-registry.jsM agents/instagram-agent/post-to.jsM agents/instagram-agent/skills/reel.jsM agents/instagram-agent/skills/story.js
Diff
commit 5dd450b1a436d9edda1ba7059f262867fe49bedc
Author: Steve <steve@designerwallcoverings.com>
Date: Mon Aug 10 12:18:56 2026 -0700
IG poster hardening: carousel (2-10 img) posts, verify/health-sweep, --all throttle, follower-ranked list, account param wired into reel+story (registry-first, legacy env preserved), POSTING.md docs
---
agents/instagram-agent/POSTING.md | 64 ++++++++++++++
agents/instagram-agent/accounts.js | 34 +++++++-
agents/instagram-agent/accounts.json | 142 +++++++++++++++++++++++--------
agents/instagram-agent/build-registry.js | 13 +++
agents/instagram-agent/post-to.js | 102 ++++++++++++++++++++--
agents/instagram-agent/skills/reel.js | 26 ++----
agents/instagram-agent/skills/story.js | 25 ++++--
7 files changed, 337 insertions(+), 69 deletions(-)
diff --git a/agents/instagram-agent/POSTING.md b/agents/instagram-agent/POSTING.md
new file mode 100644
index 0000000..5dd1361
--- /dev/null
+++ b/agents/instagram-agent/POSTING.md
@@ -0,0 +1,64 @@
+# Instagram posting — multi-account (35 accounts, one command)
+
+All 35 IG-linked accounts post through **one never-expiring token**
+(`META_ACCESS_TOKEN` in `~/Projects/secrets-manager/.env`) via the Facebook Graph
+Content Publishing API. No per-account tokens, no refresh cron.
+
+## CLI — `post-to.js`
+
+```bash
+cd ~/Projects/Norma/agents/instagram-agent
+
+node post-to.js list # all 35, ranked by followers
+node post-to.js verify <account> # read-only: prove the token reaches it
+node post-to.js verify --verify-all # health-sweep every account
+
+# POST (dry-run by default — add --confirm to actually publish)
+node post-to.js <account> --image <public_url> --caption "…" --confirm # single image
+node post-to.js <account> --images "u1,u2,u3" --caption "…" --confirm # carousel (2–10)
+node post-to.js <account> --reel <public_mp4> --caption "…" --confirm # reel
+node post-to.js <account> --story <public_url> --confirm # story
+node post-to.js --all --image <public_url> --caption "…" --confirm # fan out to ALL 35
+```
+
+`<account>` = handle (`velvetwallpaper` / `@velvetwallpaper`), page name
+(`"Velvet Wallpaper"`), or the raw `ig_user_id`.
+
+### Safety
+- **Dry-run is the default.** Nothing publishes without `--confirm` — the
+ outward-facing gate is built into the tool.
+- `--all` throttles 1.5s/account (each account has its own ~50-posts/24h API limit).
+- Media URLs must be **public** (Meta fetches them server-side). Shopify CDN URLs work.
+
+## HTTP — `:9810` (agent-base, Basic-Auth)
+
+The `post`, `reel`, and `story` skills accept an `account` field, so any fleet
+agent can publish to a specific handle:
+
+```bash
+curl -s -u admin:$PASS -X POST http://127.0.0.1:9810/api/skill/post \
+ -H 'Content-Type: application/json' \
+ -d '{"account":"velvetwallpaper","image_url":"https://…","caption":"…"}'
+```
+
+Account resolution is **registry-first, env-fallback**:
+1. handle in `accounts.json` → shared `META_ACCESS_TOKEN`
+2. `dw` → `IG_USER_ID` + `IG_ACCESS_TOKEN` (legacy)
+3. `phillipe-romano` etc. → `IG_USER_ID_<SUFFIX>` + `IG_ACCESS_TOKEN_<SUFFIX>`
+ (the dw-marketing-reels convention — unchanged)
+
+## Registry — `accounts.json`
+
+Regenerate from the live token (self-healing — link a new IG in Meta Business
+Suite and it appears):
+
+```bash
+node build-registry.js # refresh handles/ids
+node build-registry.js --stats # also pull follower/post counts (35 extra GETs)
+```
+
+## Not yet postable
+- The 45 Facebook Pages with **no linked IG account** (see
+ `UNLINKED-PAGES-CHECKLIST.md`) — they'd need IG accounts *created* first.
+- `@hospitalityfabrics` — exists (360 followers, 177 posts) but isn't connected
+ to a Page, so the API can't reach it. Parked on a Page decision.
diff --git a/agents/instagram-agent/accounts.js b/agents/instagram-agent/accounts.js
index 3e1720d..3069df1 100644
--- a/agents/instagram-agent/accounts.js
+++ b/agents/instagram-agent/accounts.js
@@ -43,10 +43,11 @@ function normalize(name) {
return String(name || '').trim().replace(/^@/, '').toLowerCase();
}
-/** List all postable account handles. */
+/** List all postable account handles (with counts when the registry has them). */
function list() {
return Object.values(loadRegistry().accounts)
- .map((a) => ({ handle: a.handle, ig_user_id: a.ig_user_id, page_name: a.page_name }));
+ .map((a) => ({ handle: a.handle, ig_user_id: a.ig_user_id, page_name: a.page_name,
+ followers: a.followers_count, posts: a.media_count }));
}
/**
@@ -78,4 +79,31 @@ function resolve(name) {
};
}
-module.exports = { resolve, list, normalize, loadRegistry };
+/**
+ * Skill-facing resolver used by post/reel/story. Merges TWO addressing schemes:
+ * 1. handle-registry (this file's accounts.json) → shared META_ACCESS_TOKEN
+ * 2. legacy env-suffix scheme used by dw-marketing-reels:
+ * 'dw' → IG_USER_ID + IG_ACCESS_TOKEN
+ * 'phillipe-romano' → IG_USER_ID_PHILLIPE_ROMANO + IG_ACCESS_TOKEN_PHILLIPE_ROMANO
+ * Registry wins first; the env scheme is the fallback so existing reel callers
+ * keep working unchanged. Returns { account, userId, accessToken, source }.
+ */
+function resolveSkillAccount(account) {
+ const acct = (account || 'dw').toString().trim() || 'dw';
+ const reg = resolve(acct);
+ if (reg && reg.has_token) {
+ return { account: reg.handle || acct, userId: reg.ig_user_id, accessToken: reg.access_token, source: 'registry' };
+ }
+ if (acct === 'dw') {
+ return { account: 'dw', userId: process.env.IG_USER_ID, accessToken: process.env.IG_ACCESS_TOKEN, source: 'env' };
+ }
+ const suffix = acct.toUpperCase().replace(/-/g, '_');
+ return {
+ account: acct,
+ userId: process.env[`IG_USER_ID_${suffix}`],
+ accessToken: process.env[`IG_ACCESS_TOKEN_${suffix}`],
+ source: 'env',
+ };
+}
+
+module.exports = { resolve, resolveSkillAccount, list, normalize, loadRegistry };
diff --git a/agents/instagram-agent/accounts.json b/agents/instagram-agent/accounts.json
index 912189b..d1913f2 100644
--- a/agents/instagram-agent/accounts.json
+++ b/agents/instagram-agent/accounts.json
@@ -1,5 +1,5 @@
{
- "generated_at": "2026-08-10T18:51:21.454Z",
+ "generated_at": "2026-08-10T19:18:25.518Z",
"token_source": "META_ACCESS_TOKEN (shared, never-expiring)",
"pages_total": 80,
"accounts_postable": 35,
@@ -57,7 +57,9 @@
"page_id": "453810901296460",
"page_name": "As Seen in Hotels",
"graph_host": "https://graph.facebook.com",
- "graph_version": "v21.0"
+ "graph_version": "v21.0",
+ "followers_count": 642,
+ "media_count": 668
},
"beverlyhillsvideos": {
"handle": "beverlyhillsvideos",
@@ -65,7 +67,9 @@
"page_id": "249173031283",
"page_name": "Designer Wallcoverings",
"graph_host": "https://graph.facebook.com",
- "graph_version": "v21.0"
+ "graph_version": "v21.0",
+ "followers_count": 10,
+ "media_count": 23
},
"borninbeverlyhills": {
"handle": "borninbeverlyhills",
@@ -73,7 +77,9 @@
"page_id": "2548395878518829",
"page_name": "Born in Beverly Hills",
"graph_host": "https://graph.facebook.com",
- "graph_version": "v21.0"
+ "graph_version": "v21.0",
+ "followers_count": 51,
+ "media_count": 25
},
"brazilliancewallpaper": {
"handle": "brazilliancewallpaper",
@@ -81,7 +87,9 @@
"page_id": "2009198999106954",
"page_name": "Brazilliance Wallpaper and Fabric",
"graph_host": "https://graph.facebook.com",
- "graph_version": "v21.0"
+ "graph_version": "v21.0",
+ "followers_count": 216,
+ "media_count": 69
},
"chinoiseriewallpaper": {
"handle": "chinoiseriewallpaper",
@@ -89,7 +97,9 @@
"page_id": "530286787358585",
"page_name": "Et Cie Chinoiserie Wall Murals",
"graph_host": "https://graph.facebook.com",
- "graph_version": "v21.0"
+ "graph_version": "v21.0",
+ "followers_count": 534,
+ "media_count": 128
},
"commercialwallcoverings": {
"handle": "commercialwallcoverings",
@@ -97,7 +107,9 @@
"page_id": "101723721568114",
"page_name": "Commercial Wallcoverings",
"graph_host": "https://graph.facebook.com",
- "graph_version": "v21.0"
+ "graph_version": "v21.0",
+ "followers_count": 183,
+ "media_count": 690
},
"customwallcoverings": {
"handle": "customwallcoverings",
@@ -105,7 +117,9 @@
"page_id": "128212609930",
"page_name": "Custom Wall Papers and Wallcoverings",
"graph_host": "https://graph.facebook.com",
- "graph_version": "v21.0"
+ "graph_version": "v21.0",
+ "followers_count": 135,
+ "media_count": 710
},
"designerlaboratory": {
"handle": "designerlaboratory",
@@ -113,7 +127,9 @@
"page_id": "224684317687182",
"page_name": "Designer Laboratory",
"graph_host": "https://graph.facebook.com",
- "graph_version": "v21.0"
+ "graph_version": "v21.0",
+ "followers_count": 146,
+ "media_count": 531
},
"designerwallcoverings": {
"handle": "designerwallcoverings",
@@ -121,7 +137,9 @@
"page_id": "92675029719",
"page_name": "DesignerWallcoverings.com",
"graph_host": "https://graph.facebook.com",
- "graph_version": "v21.0"
+ "graph_version": "v21.0",
+ "followers_count": 14929,
+ "media_count": 5456
},
"fabric_fridays": {
"handle": "fabric_fridays",
@@ -129,7 +147,9 @@
"page_id": "105187997852752",
"page_name": "Fabric Fridays",
"graph_host": "https://graph.facebook.com",
- "graph_version": "v21.0"
+ "graph_version": "v21.0",
+ "followers_count": 16,
+ "media_count": 74
},
"ffepurchasing": {
"handle": "ffepurchasing",
@@ -137,7 +157,9 @@
"page_id": "2054055348200975",
"page_name": "FFE Purchasing",
"graph_host": "https://graph.facebook.com",
- "graph_version": "v21.0"
+ "graph_version": "v21.0",
+ "followers_count": 152,
+ "media_count": 127
},
"goldleafwallpaper": {
"handle": "goldleafwallpaper",
@@ -145,7 +167,9 @@
"page_id": "143671216006539",
"page_name": "Gold Leaf Wallpaper",
"graph_host": "https://graph.facebook.com",
- "graph_version": "v21.0"
+ "graph_version": "v21.0",
+ "followers_count": 178,
+ "media_count": 74
},
"grassclothwallpaper": {
"handle": "grassclothwallpaper",
@@ -153,7 +177,9 @@
"page_id": "139542627030",
"page_name": "Wallpaper Weekly",
"graph_host": "https://graph.facebook.com",
- "graph_version": "v21.0"
+ "graph_version": "v21.0",
+ "followers_count": 386,
+ "media_count": 18
},
"hollywoodwallcoverings": {
"handle": "hollywoodwallcoverings",
@@ -161,7 +187,9 @@
"page_id": "310236955763638",
"page_name": "Hollywood Wallcoverings",
"graph_host": "https://graph.facebook.com",
- "graph_version": "v21.0"
+ "graph_version": "v21.0",
+ "followers_count": 107,
+ "media_count": 489
},
"hospitalitywallcoverings": {
"handle": "hospitalitywallcoverings",
@@ -169,7 +197,9 @@
"page_id": "112549388778107",
"page_name": "Hospitality Wallpaper",
"graph_host": "https://graph.facebook.com",
- "graph_version": "v21.0"
+ "graph_version": "v21.0",
+ "followers_count": 1248,
+ "media_count": 1130
},
"interiordesignervideos": {
"handle": "interiordesignervideos",
@@ -177,7 +207,9 @@
"page_id": "683918978285923",
"page_name": "Interior Designer Videos",
"graph_host": "https://graph.facebook.com",
- "graph_version": "v21.0"
+ "graph_version": "v21.0",
+ "followers_count": 172,
+ "media_count": 25
},
"linenwallpaper": {
"handle": "linenwallpaper",
@@ -185,7 +217,9 @@
"page_id": "258674951458459",
"page_name": "Linen Wallpaper",
"graph_host": "https://graph.facebook.com",
- "graph_version": "v21.0"
+ "graph_version": "v21.0",
+ "followers_count": 190,
+ "media_count": 57
},
"metallicwallpaper": {
"handle": "metallicwallpaper",
@@ -193,7 +227,9 @@
"page_id": "329043417920690",
"page_name": "Metallic Wallpaper",
"graph_host": "https://graph.facebook.com",
- "graph_version": "v21.0"
+ "graph_version": "v21.0",
+ "followers_count": 11,
+ "media_count": 4
},
"papelestapiz": {
"handle": "papelestapiz",
@@ -201,7 +237,9 @@
"page_id": "1640547612676556",
"page_name": "Papel Tapiz",
"graph_host": "https://graph.facebook.com",
- "graph_version": "v21.0"
+ "graph_version": "v21.0",
+ "followers_count": 92,
+ "media_count": 737
},
"patterndesignlab": {
"handle": "patterndesignlab",
@@ -209,7 +247,9 @@
"page_id": "1714086251943136",
"page_name": "Pattern Design Lab",
"graph_host": "https://graph.facebook.com",
- "graph_version": "v21.0"
+ "graph_version": "v21.0",
+ "followers_count": 284,
+ "media_count": 336
},
"philliperomanodesigns": {
"handle": "philliperomanodesigns",
@@ -217,7 +257,9 @@
"page_id": "1642942779256637",
"page_name": "Phillipe Romano",
"graph_host": "https://graph.facebook.com",
- "graph_version": "v21.0"
+ "graph_version": "v21.0",
+ "followers_count": 130,
+ "media_count": 262
},
"restorationwallpaper": {
"handle": "restorationwallpaper",
@@ -225,7 +267,9 @@
"page_id": "723158284513545",
"page_name": "Restoration Wallpaper",
"graph_host": "https://graph.facebook.com",
- "graph_version": "v21.0"
+ "graph_version": "v21.0",
+ "followers_count": 223,
+ "media_count": 188
},
"retrowalls": {
"handle": "retrowalls",
@@ -233,7 +277,9 @@
"page_id": "241532263064171",
"page_name": "Retro Walls",
"graph_host": "https://graph.facebook.com",
- "graph_version": "v21.0"
+ "graph_version": "v21.0",
+ "followers_count": 172,
+ "media_count": 631
},
"roomsettings": {
"handle": "roomsettings",
@@ -241,7 +287,9 @@
"page_id": "563643740756932",
"page_name": "Room Settings",
"graph_host": "https://graph.facebook.com",
- "graph_version": "v21.0"
+ "graph_version": "v21.0",
+ "followers_count": 1558,
+ "media_count": 671
},
"screenprintedwallpaper": {
"handle": "screenprintedwallpaper",
@@ -249,7 +297,9 @@
"page_id": "1965321333759540",
"page_name": "Screen Printed Wallpaper",
"graph_host": "https://graph.facebook.com",
- "graph_version": "v21.0"
+ "graph_version": "v21.0",
+ "followers_count": 59,
+ "media_count": 22
},
"sheltermagazines": {
"handle": "sheltermagazines",
@@ -257,7 +307,9 @@
"page_id": "1383580575209054",
"page_name": "Shelter Magazines",
"graph_host": "https://graph.facebook.com",
- "graph_version": "v21.0"
+ "graph_version": "v21.0",
+ "followers_count": 732,
+ "media_count": 2213
},
"suedewallpaper": {
"handle": "suedewallpaper",
@@ -265,7 +317,9 @@
"page_id": "320648328748209",
"page_name": "Suede Wallpaper",
"graph_host": "https://graph.facebook.com",
- "graph_version": "v21.0"
+ "graph_version": "v21.0",
+ "followers_count": 41,
+ "media_count": 6
},
"textilewallpaper": {
"handle": "textilewallpaper",
@@ -273,7 +327,9 @@
"page_id": "242997016372360",
"page_name": "Textile Wallpaper",
"graph_host": "https://graph.facebook.com",
- "graph_version": "v21.0"
+ "graph_version": "v21.0",
+ "followers_count": 149,
+ "media_count": 165
},
"thedesignerlibrary": {
"handle": "thedesignerlibrary",
@@ -281,7 +337,9 @@
"page_id": "303486766522573",
"page_name": "Designer Library",
"graph_host": "https://graph.facebook.com",
- "graph_version": "v21.0"
+ "graph_version": "v21.0",
+ "followers_count": 194,
+ "media_count": 954
},
"thesetdecorator": {
"handle": "thesetdecorator",
@@ -289,7 +347,9 @@
"page_id": "500577630006739",
"page_name": "The Set Decorator",
"graph_host": "https://graph.facebook.com",
- "graph_version": "v21.0"
+ "graph_version": "v21.0",
+ "followers_count": 1540,
+ "media_count": 1116
},
"traditionalwhimsy": {
"handle": "traditionalwhimsy",
@@ -297,7 +357,9 @@
"page_id": "188355165382826",
"page_name": "Traditional Whimsy",
"graph_host": "https://graph.facebook.com",
- "graph_version": "v21.0"
+ "graph_version": "v21.0",
+ "followers_count": 3,
+ "media_count": 360
},
"velvetwallpaper": {
"handle": "velvetwallpaper",
@@ -305,7 +367,9 @@
"page_id": "349839162257610",
"page_name": "Velvet Wallpaper",
"graph_host": "https://graph.facebook.com",
- "graph_version": "v21.0"
+ "graph_version": "v21.0",
+ "followers_count": 9,
+ "media_count": 9
},
"wallpaperhistory": {
"handle": "wallpaperhistory",
@@ -313,7 +377,9 @@
"page_id": "330587311005484",
"page_name": "Wallpaper History",
"graph_host": "https://graph.facebook.com",
- "graph_version": "v21.0"
+ "graph_version": "v21.0",
+ "followers_count": 53,
+ "media_count": 658
},
"wallpaperinstallers": {
"handle": "wallpaperinstallers",
@@ -321,7 +387,9 @@
"page_id": "102819675952474",
"page_name": "Wallpaper Installers",
"graph_host": "https://graph.facebook.com",
- "graph_version": "v21.0"
+ "graph_version": "v21.0",
+ "followers_count": 1675,
+ "media_count": 632
},
"wallpaperwednesdays": {
"handle": "wallpaperwednesdays",
@@ -329,7 +397,9 @@
"page_id": "2223776031217340",
"page_name": "Wallpaper Wednesdays",
"graph_host": "https://graph.facebook.com",
- "graph_version": "v21.0"
+ "graph_version": "v21.0",
+ "followers_count": 1083,
+ "media_count": 1324
}
}
}
\ No newline at end of file
diff --git a/agents/instagram-agent/build-registry.js b/agents/instagram-agent/build-registry.js
index 76f8b65..d6e9daa 100644
--- a/agents/instagram-agent/build-registry.js
+++ b/agents/instagram-agent/build-registry.js
@@ -62,6 +62,18 @@ async function main() {
url = (j.paging && j.paging.next) || null;
}
+ // Optional: enrich each account with live follower/post counts (--stats).
+ // Costs one Graph GET per account, so it's opt-in, not on every rebuild.
+ const withStats = process.argv.includes('--stats');
+ const statsFor = async (igId) => {
+ if (!withStats) return {};
+ try {
+ const r = await fetch(`${GRAPH}/${VERSION}/${igId}?fields=followers_count,media_count&access_token=${encodeURIComponent(token)}`);
+ const j = await r.json();
+ return j.error ? {} : { followers_count: j.followers_count, media_count: j.media_count };
+ } catch { return {}; }
+ };
+
// registry: keyed by handle (lowercased, no @) → account record
const accounts = {};
for (const a of linked.sort((x, y) => (x.username || '').localeCompare(y.username || ''))) {
@@ -73,6 +85,7 @@ async function main() {
page_name: a.page_name,
graph_host: GRAPH,
graph_version: VERSION,
+ ...(await statsFor(a.ig_user_id)),
// token omitted → resolver uses the shared META_ACCESS_TOKEN
};
}
diff --git a/agents/instagram-agent/post-to.js b/agents/instagram-agent/post-to.js
index ce5bf4f..84f3824 100644
--- a/agents/instagram-agent/post-to.js
+++ b/agents/instagram-agent/post-to.js
@@ -50,9 +50,74 @@ async function graph(host, version, pathPart, params) {
return j;
}
-async function publishOne(acct, opts) {
+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) {
+ const urls = String(opts.images).split(',').map((s) => s.trim()).filter(Boolean);
+ 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';
// Build the media-container params for the requested kind
@@ -104,15 +169,34 @@ async function main() {
const cmd = args._[0];
if (cmd === 'list' || args.list) {
- const all = accounts.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) console.log(` @${(a.handle || '').padEnd(28)} ${a.page_name}`);
+ 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;
}
- 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.');
+ if (!args.image && !args.images && !args.reel && !args.story) {
+ console.error('Nothing to post. Provide --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);
}
@@ -136,7 +220,8 @@ async function main() {
: `LIVE POST → ${targets.length} account(s).\n`);
const results = [];
- for (const t of targets) {
+ for (let ti = 0; ti < targets.length; ti++) {
+ const t = targets[ti];
try {
const r = await publishOne(t, args);
results.push(r);
@@ -147,6 +232,9 @@ async function main() {
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'}.`);
diff --git a/agents/instagram-agent/skills/reel.js b/agents/instagram-agent/skills/reel.js
index 1b1e5d3..312b7a6 100644
--- a/agents/instagram-agent/skills/reel.js
+++ b/agents/instagram-agent/skills/reel.js
@@ -10,6 +10,7 @@
*/
const ig = require('./_ig-api');
+const accounts = require('../accounts');
const AGENT = 'instagram-agent';
const PLATFORM = 'instagram';
@@ -17,29 +18,20 @@ const PLATFORM = 'instagram';
/**
* Resolve the IG credential pair for a given account id.
*
- * The dw-marketing-reels publisher (scripts/publish-social.mjs) tags every
- * reel with an `account` id ('dw', 'phillipe-romano', …) and names its own
- * per-account auth header env as IG_AGENT_AUTH_<ID> where
- * <ID> = account.toUpperCase().replace(/-/g, '_'). We MIRROR that exact
- * naming here for the IG token pair so the two sides agree:
+ * Delegates to the shared resolver (accounts.resolveSkillAccount), which tries
+ * the handle-registry first (35 accounts on the shared META_ACCESS_TOKEN) and
+ * falls back to the dw-marketing-reels env-suffix scheme:
*
- * account 'dw' → IG_USER_ID + IG_ACCESS_TOKEN (existing)
- * account 'phillipe-romano' → IG_USER_ID_PHILLIPE_ROMANO + IG_ACCESS_TOKEN_PHILLIPE_ROMANO
+ * account 'velvetwallpaper' → registry → shared META token (NEW)
+ * account 'dw' → IG_USER_ID + IG_ACCESS_TOKEN (legacy)
+ * account 'phillipe-romano' → IG_USER_ID_PHILLIPE_ROMANO + IG_ACCESS_TOKEN_PHILLIPE_ROMANO
*
* Returns { account, userId, accessToken } — userId/accessToken are undefined
* when that account has no token configured (caller returns no-creds-for-account).
*/
function resolveAccount(account) {
- const acct = (account || 'dw').toString().trim() || 'dw';
- if (acct === 'dw') {
- return { account: 'dw', userId: process.env.IG_USER_ID, accessToken: process.env.IG_ACCESS_TOKEN };
- }
- const suffix = acct.toUpperCase().replace(/-/g, '_');
- return {
- account: acct,
- userId: process.env[`IG_USER_ID_${suffix}`],
- accessToken: process.env[`IG_ACCESS_TOKEN_${suffix}`],
- };
+ const r = accounts.resolveSkillAccount(account);
+ return { account: r.account, userId: r.userId, accessToken: r.accessToken };
}
/**
diff --git a/agents/instagram-agent/skills/story.js b/agents/instagram-agent/skills/story.js
index 1eebe65..3f72696 100644
--- a/agents/instagram-agent/skills/story.js
+++ b/agents/instagram-agent/skills/story.js
@@ -10,6 +10,7 @@
*/
const ig = require('./_ig-api');
+const accounts = require('../accounts');
const AGENT = 'instagram-agent';
const PLATFORM = 'instagram';
@@ -18,6 +19,7 @@ const PLATFORM = 'instagram';
* @param {Object} params - Request body
* @param {string} [params.image_url] - Public URL of the image (for image stories)
* @param {string} [params.video_url] - Public URL of the video (for video stories)
+ * @param {string} [params.account] - target account (handle / 'dw' / 'phillipe-romano')
* @param {string} [params.pipeline_id] - Pipeline entry ID to link
* @returns {Promise<Object>}
*/
@@ -25,9 +27,23 @@ module.exports = async function story(params) {
const imageUrl = params.image_url || '';
const videoUrl = params.video_url || '';
const mediaSource = videoUrl ? 'video' : 'image';
- const simulated = !ig.hasCredentials();
+ // Resolve account (registry-first, env fallback). Cross-brand-leak gate: a
+ // NON-'dw' account with no token must not fall through to DW's token.
+ const resolved = accounts.resolveSkillAccount(params.account);
+ const account = resolved.account;
+ const igUserId = resolved.userId;
+ const accessToken = resolved.accessToken;
+ const hasCreds = !!(igUserId && accessToken);
+ const simulated = !hasCreds;
- console.log(`[${AGENT}] Story skill invoked — simulation=${simulated}, source=${mediaSource}`);
+ if (account !== 'dw' && !hasCreds) {
+ console.log(`[${AGENT}] No credentials for account '${account}' — refusing to fall through to DW.`);
+ return { status: 'no-creds-for-account', account, posted: false, simulated: false, platform: PLATFORM,
+ image_url: imageUrl || null, video_url: videoUrl || null, pipeline_id: params.pipeline_id || null,
+ created_at: new Date().toISOString() };
+ }
+
+ console.log(`[${AGENT}] Story skill invoked — account=${account} simulation=${simulated}, source=${mediaSource}`);
if (imageUrl) console.log(`[${AGENT}] Image URL: ${imageUrl}`);
if (videoUrl) console.log(`[${AGENT}] Video URL: ${videoUrl}`);
@@ -57,14 +73,11 @@ module.exports = async function story(params) {
};
}
- // --- Real API call (Instagram API with Instagram Login) ---
+ // --- Real API call --- (igUserId/accessToken resolved above for `account`)
if (!videoUrl && !imageUrl) {
throw new Error('Either image_url or video_url is required for a story');
}
- const igUserId = process.env.IG_USER_ID;
- const accessToken = process.env.IG_ACCESS_TOKEN;
-
// Step 1: create the story container
const containerBody = { media_type: 'STORIES' };
if (videoUrl) containerBody.video_url = videoUrl;
← dfc3f02 IG: verified inside Business Suite — 35 = complete existing
·
back to Norma
·
IG poster: --product <handle> pulls a real DW product (live 8a9959f →