[object Object]

← back to Norma Platform

instagram-agent /live: account dropdown — switch between all 35 IG accounts (one shared META token), per-account cache, whitelist-guarded, auto-loads an empty account on select

e25f2707d8d937ad91cad1854b1c5b9b367a3b04 · 2026-08-18 12:03:53 -0700 · Steve Abrams

Files touched

Diff

commit e25f2707d8d937ad91cad1854b1c5b9b367a3b04
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Tue Aug 18 12:03:53 2026 -0700

    instagram-agent /live: account dropdown — switch between all 35 IG accounts (one shared META token), per-account cache, whitelist-guarded, auto-loads an empty account on select
---
 agents/instagram-agent/live-media.js           | 167 +++++++++++++++----------
 agents/instagram-agent/public/live-viewer.html |  45 +++++--
 2 files changed, 137 insertions(+), 75 deletions(-)

diff --git a/agents/instagram-agent/live-media.js b/agents/instagram-agent/live-media.js
index fc59de2..6326bd7 100644
--- a/agents/instagram-agent/live-media.js
+++ b/agents/instagram-agent/live-media.js
@@ -1,23 +1,23 @@
 /**
  * live-media.js — "all the daily Instagram posts" board for norma-instagram (:9810).
  *
- * The /posts board (posts-api.js) only shows posts NORMA auto-published (the ledger).
- * This board shows the DW account's REAL feed straight from the Instagram Graph API
- * (@designerwallcoverings, ~5,500 posts), grouped by day, with delete-any.
+ * MULTI-ACCOUNT: Norma manages 35 IG business accounts, all reachable with ONE shared
+ * never-expiring META token (accounts.json → token_source META_ACCESS_TOKEN). The board
+ * has an account dropdown; each account's REAL feed comes straight from the Graph /media
+ * API, is cached per-account, grouped by day, with delete-any.
  *
  * Doctrine (matches server.js): a PAGE VIEW never calls the Graph API. A cron + an
- * explicit "Refresh" button paginate /media into data/live-media.json; the viewer and
- * /api/live/posts only READ that cache.
+ * explicit "Refresh" button paginate /media into data/live-media[-<igid>].json; the
+ * viewer and /api/live/posts only READ that cache.
  *
- * Delete reuses the proven /api/posts/delete route in posts-api.js — same two honest
- * modes (tombstone = hide from board only; live = openclaw really deletes on IG, gated
- * by IG_LIVE_DELETE). Live media items carry a real permalink, so the same path works.
+ * Security: only accounts present in accounts.json (+ the .env default) can be queried —
+ * an arbitrary ig_user_id is rejected. Delete reuses posts-api's /api/posts/delete.
  *
- * Routes registered on the caller's express app (before start()'s 404 catch-all):
- *   GET  /live                     → the viewer HTML (public/live-viewer.html)
- *   GET  /api/live/posts           → { account, cached_at, total, days:[{date,label,posts:[…]}] }
- *   POST /api/live/refresh         → paginate newest-first into the cache ({full:true} = whole history)
- *   (delete is POST /api/posts/delete, already registered by posts-api.js)
+ * Routes (registered before start()'s 404 catch-all):
+ *   GET  /live                        → viewer HTML (public/live-viewer.html)
+ *   GET  /api/live/accounts           → { accounts:[{handle,ig_user_id,page_name}], default }
+ *   GET  /api/live/posts?account=&days= → { account, days:[{date,label,posts}], … }
+ *   POST /api/live/refresh {account,full} → paginate that account newest-first into cache
  */
 const fs = require('fs');
 const path = require('path');
@@ -25,14 +25,41 @@ const https = require('https');
 
 const DIR = __dirname;
 const DATA = path.join(DIR, 'data');
-const CACHE = path.join(DATA, 'live-media.json');
 const TOMBS = path.join(DATA, 'deleted-posts.jsonl');
 const VIEWER = path.join(DIR, 'public', 'live-viewer.html');
 
 const HOST = (process.env.IG_GRAPH_HOST || 'https://graph.facebook.com').replace(/^https?:\/\//, '');
 const VER = process.env.IG_GRAPH_VERSION || 'v21.0';
 const FIELDS = 'id,caption,media_type,media_url,thumbnail_url,permalink,timestamp,like_count,comments_count';
-const hasCreds = () => !!(process.env.IG_USER_ID && process.env.IG_ACCESS_TOKEN);
+const TOKEN = () => process.env.IG_ACCESS_TOKEN || ''; // the shared META token
+const DEFAULT_ID = () => process.env.IG_USER_ID || '';
+
+// ── account roster (accounts.json: {accounts:[{handle,ig_user_id,page_name,…}]}) ──
+function listAccounts() {
+  let roster = [];
+  try {
+    const a = JSON.parse(fs.readFileSync(path.join(DIR, 'accounts.json'), 'utf8'));
+    const raw = Array.isArray(a.accounts) ? a.accounts : (a.accounts ? Object.values(a.accounts) : []);
+    roster = raw
+      .filter((x) => x && x.ig_user_id)
+      .map((x) => ({ handle: x.handle || x.username || x.ig_user_id, ig_user_id: String(x.ig_user_id), page_name: x.page_name || x.name || '' }));
+  } catch { /* fall through to env-only */ }
+  // ensure the .env default (DW) is present + pinned first
+  const def = DEFAULT_ID();
+  if (def && !roster.some((r) => r.ig_user_id === def)) {
+    roster.unshift({ handle: 'designerwallcoverings', ig_user_id: def, page_name: 'Designer Wallcoverings' });
+  }
+  // dedupe by id, sort by handle but keep DW first
+  const seen = new Set();
+  const uniq = roster.filter((r) => (seen.has(r.ig_user_id) ? false : seen.add(r.ig_user_id)));
+  uniq.sort((a, b) => (a.ig_user_id === def ? -1 : b.ig_user_id === def ? 1 : a.handle.localeCompare(b.handle)));
+  return uniq;
+}
+function resolveAccount(igUserId) {
+  const roster = listAccounts();
+  const id = igUserId ? String(igUserId) : DEFAULT_ID();
+  return roster.find((r) => r.ig_user_id === id) || null; // null = not whitelisted → reject
+}
 
 // ── tiny GET-JSON (no deps) ───────────────────────────────────────────────────
 function getJSON(url) {
@@ -55,38 +82,41 @@ function readTombstones() {
   return s;
 }
 
-// ── cache ─────────────────────────────────────────────────────────────────────
-function readCache() {
-  try { return JSON.parse(fs.readFileSync(CACHE, 'utf8')); }
-  catch { return { account: null, cached_at: null, complete: false, byId: {} }; }
+// ── per-account cache (DW default keeps the original live-media.json) ─────────
+function cachePathFor(igUserId) {
+  return igUserId === DEFAULT_ID()
+    ? path.join(DATA, 'live-media.json')
+    : path.join(DATA, `live-media-${igUserId}.json`);
 }
-function writeCache(c) {
-  fs.writeFileSync(CACHE, JSON.stringify(c));
+function readCache(igUserId) {
+  try { return JSON.parse(fs.readFileSync(cachePathFor(igUserId), 'utf8')); }
+  catch { return { account: null, cached_at: null, complete: false, byId: {} }; }
 }
+function writeCache(igUserId, c) { fs.writeFileSync(cachePathFor(igUserId), JSON.stringify(c)); }
 
-let REFRESH_LOCK = false;
+const REFRESH_LOCKS = new Set(); // per-account refresh lock
 
 /**
- * Paginate the account's /media feed newest-first into the cache.
- *  - default (incremental): walk pages until we hit an id we already have, or maxPages.
+ * Paginate one account's /media feed newest-first into its cache.
+ *  - default (incremental): stop when a whole page is already-known, or maxPages.
  *  - { full:true }: walk the entire history (cap 260 pages ≈ 6,500 posts).
- * Returns { added, scanned, complete }.
  */
-async function refreshLiveMedia({ full = false, maxPages = full ? 260 : 24 } = {}) {
-  if (!hasCreds()) throw new Error('IG_USER_ID + IG_ACCESS_TOKEN not set (simulation mode)');
-  if (REFRESH_LOCK) throw new Error('a refresh is already running');
-  REFRESH_LOCK = true;
-  const token = process.env.IG_ACCESS_TOKEN;
-  const uid = process.env.IG_USER_ID;
-  const cache = readCache();
+async function refreshLiveMedia({ igUserId, full = false, maxPages = full ? 260 : 24 } = {}) {
+  if (!TOKEN()) throw new Error('IG_ACCESS_TOKEN not set (simulation mode)');
+  const acct = resolveAccount(igUserId);
+  if (!acct) throw new Error('unknown account (not in accounts.json)');
+  const id = acct.ig_user_id;
+  if (REFRESH_LOCKS.has(id)) throw new Error('a refresh is already running for this account');
+  REFRESH_LOCKS.add(id);
+  const token = TOKEN();
+  const cache = readCache(id);
   const known = new Set(Object.keys(cache.byId));
   let added = 0, scanned = 0, pages = 0, hitKnown = false;
-  let url = `https://${HOST}/${VER}/${uid}/media?fields=${encodeURIComponent(FIELDS)}&limit=50&access_token=${token}`;
+  let url = `https://${HOST}/${VER}/${id}/media?fields=${encodeURIComponent(FIELDS)}&limit=50&access_token=${token}`;
   try {
-    // account identity (cheap, refreshes counts)
     try {
-      const a = await getJSON(`https://${HOST}/${VER}/${uid}?fields=username,name,media_count,followers_count&access_token=${token}`);
-      if (a && !a.error) cache.account = { username: a.username, name: a.name, media_count: a.media_count, followers_count: a.followers_count };
+      const a = await getJSON(`https://${HOST}/${VER}/${id}?fields=username,name,media_count,followers_count&access_token=${token}`);
+      if (a && !a.error) cache.account = { ig_user_id: id, handle: acct.handle, username: a.username, name: a.name, media_count: a.media_count, followers_count: a.followers_count };
     } catch { /* keep prior */ }
 
     while (url && pages < maxPages) {
@@ -98,25 +128,19 @@ async function refreshLiveMedia({ full = false, maxPages = full ? 260 : 24 } = {
         scanned++;
         if (!cache.byId[m.id]) added++;
         cache.byId[m.id] = {
-          id: m.id,
-          caption: m.caption || '',
-          type: m.media_type || 'IMAGE',
-          thumb: m.thumbnail_url || m.media_url || '',
-          permalink: m.permalink || '',
-          ts: m.timestamp || '',
-          likes: m.like_count ?? null,
-          comments: m.comments_count ?? null,
+          id: m.id, caption: m.caption || '', type: m.media_type || 'IMAGE',
+          thumb: m.thumbnail_url || m.media_url || '', permalink: m.permalink || '',
+          ts: m.timestamp || '', likes: m.like_count ?? null, comments: m.comments_count ?? null,
         };
       }
-      // incremental early-stop: this page was entirely posts we already had
       if (!full && rows.length && rows.every((m) => known.has(m.id))) { hitKnown = true; break; }
       url = (j.paging && j.paging.next) || '';
     }
     cache.cached_at = new Date().toISOString();
-    if (full && !url) cache.complete = true; // reached the end of history
-    writeCache(cache);
-    return { added, scanned, pages, complete: !!cache.complete, hitKnown };
-  } finally { REFRESH_LOCK = false; }
+    if (full && !url) cache.complete = true;
+    writeCache(id, cache);
+    return { account: acct.handle, ig_user_id: id, added, scanned, pages, complete: !!cache.complete, hitKnown };
+  } finally { REFRESH_LOCKS.delete(id); }
 }
 
 // ── grouping for the viewer ─────────────────────────────────────────────────
@@ -132,17 +156,18 @@ function dayLabel(iso, today) {
   return { key, label };
 }
 
-function getLivePosts({ days = 0, limit = 0 } = {}) {
-  const cache = readCache();
+function getLivePosts({ igUserId, days = 0, limit = 0 } = {}) {
+  const acct = resolveAccount(igUserId);
+  if (!acct) throw new Error('unknown account (not in accounts.json)');
+  const cache = readCache(acct.ig_user_id);
   const tombs = readTombstones();
   const all = Object.values(cache.byId)
     .filter((p) => p.permalink && !tombs.has(p.permalink) && !tombs.has(p.id))
     .sort((a, b) => (b.ts || '').localeCompare(a.ts || ''));
-  const now = new Date();
-  const cutoff = days > 0 ? now.getTime() - days * 86400000 : 0;
+  const cutoff = days > 0 ? Date.now() - days * 86400000 : 0;
   let rows = cutoff ? all.filter((p) => new Date(p.ts).getTime() >= cutoff) : all;
   if (limit > 0) rows = rows.slice(0, limit);
-  // group by day
+  const now = new Date();
   const groups = new Map();
   for (const p of rows) {
     const { key, label } = dayLabel(p.ts, now);
@@ -150,7 +175,8 @@ function getLivePosts({ days = 0, limit = 0 } = {}) {
     groups.get(key).posts.push(p);
   }
   return {
-    account: cache.account,
+    account: cache.account || { ig_user_id: acct.ig_user_id, handle: acct.handle, username: acct.handle, name: acct.page_name },
+    selected: acct.ig_user_id,
     cached_at: cache.cached_at,
     complete: !!cache.complete,
     cached_total: Object.keys(cache.byId).length,
@@ -167,24 +193,37 @@ function registerLiveRoutes(app, agentName = 'instagram-agent') {
     res.type('html').send(fs.readFileSync(VIEWER, 'utf8'));
   });
 
-  app.get('/api/live/posts', (req, res) => {
+  app.get('/api/live/accounts', (req, res) => {
     try {
-      const days = parseInt(req.query.days, 10) || 0;
-      const limit = parseInt(req.query.limit, 10) || 0;
-      res.json(getLivePosts({ days, limit }));
+      const accounts = listAccounts().map((a) => {
+        const c = readCache(a.ig_user_id);
+        return { ...a, cached_total: Object.keys(c.byId || {}).length, complete: !!c.complete };
+      });
+      res.json({ accounts, default: DEFAULT_ID() });
     } catch (e) { res.status(500).json({ error: e.message }); }
   });
 
+  app.get('/api/live/posts', (req, res) => {
+    try {
+      res.json(getLivePosts({
+        igUserId: req.query.account || '',
+        days: parseInt(req.query.days, 10) || 0,
+        limit: parseInt(req.query.limit, 10) || 0,
+      }));
+    } catch (e) { res.status(/unknown account/.test(e.message) ? 400 : 500).json({ error: e.message }); }
+  });
+
   app.post('/api/live/refresh', async (req, res) => {
     try {
-      const full = /^(1|true|yes|on)$/i.test(String((req.body && req.body.full) || req.query.full || ''));
-      const r = await refreshLiveMedia({ full });
+      const b = req.body || {};
+      const full = /^(1|true|yes|on)$/i.test(String(b.full || req.query.full || ''));
+      const r = await refreshLiveMedia({ igUserId: b.account || req.query.account || '', full });
       res.json({ ok: true, ...r, cached_at: new Date().toISOString() });
     } catch (e) {
-      const busy = /already running/.test(e.message);
-      res.status(busy ? 409 : 500).json({ ok: false, error: e.message });
+      const code = /already running/.test(e.message) ? 409 : /unknown account/.test(e.message) ? 400 : 500;
+      res.status(code).json({ ok: false, error: e.message });
     }
   });
 }
 
-module.exports = { registerLiveRoutes, refreshLiveMedia, getLivePosts };
+module.exports = { registerLiveRoutes, refreshLiveMedia, getLivePosts, listAccounts };
diff --git a/agents/instagram-agent/public/live-viewer.html b/agents/instagram-agent/public/live-viewer.html
index 8854e2c..a9da149 100644
--- a/agents/instagram-agent/public/live-viewer.html
+++ b/agents/instagram-agent/public/live-viewer.html
@@ -53,7 +53,8 @@ main{padding:14px 16px 80px}
 <body>
 <header>
   <div class="row1">
-    <h1>📸 Designer Wallcoverings — Instagram</h1>
+    <h1>📸 Instagram</h1>
+    <select id="account" title="Choose which Instagram account to load" style="min-width:190px;font-weight:600"></select>
     <span class="sub" id="acct">loading…</span>
     <span class="grow"></span>
     <button class="btn" id="refresh" title="Fetch new posts since last cache">↻ Refresh</button>
@@ -91,7 +92,7 @@ main{padding:14px 16px 80px}
 
 <script>
 const $=s=>document.querySelector(s), $$=s=>[...document.querySelectorAll(s)];
-let STATE={days:1,sort:'new',q:'',live:0,data:null};
+let STATE={days:1,sort:'new',q:'',live:0,account:'',handle:'',data:null,accounts:[]};
 
 // persist controls
 try{const p=JSON.parse(localStorage.getItem('iglive')||'{}');Object.assign(STATE,p);}catch{}
@@ -99,7 +100,18 @@ $('#density').value = localStorage.getItem('igdensity')||210;
 document.documentElement.style.setProperty('--card', ($('#density').value)+'px');
 
 function toast(msg,err){const t=$('#toast');t.textContent=msg;t.className='toast show'+(err?' err':'');clearTimeout(t._t);t._t=setTimeout(()=>t.className='toast',3200);}
-function save(){localStorage.setItem('iglive',JSON.stringify({days:STATE.days,sort:STATE.sort,live:STATE.live}));}
+function save(){localStorage.setItem('iglive',JSON.stringify({days:STATE.days,sort:STATE.sort,live:STATE.live,account:STATE.account}));}
+
+async function loadAccounts(){
+  const r=await fetch('/api/live/accounts'); const j=await r.json();
+  STATE.accounts=j.accounts||[];
+  if(!STATE.account || !STATE.accounts.some(a=>a.ig_user_id===STATE.account)) STATE.account=j.default||(STATE.accounts[0]&&STATE.accounts[0].ig_user_id)||'';
+  const sel=$('#account');
+  sel.innerHTML=STATE.accounts.map(a=>`<option value="${a.ig_user_id}">@${a.handle}${a.cached_total?` (${a.cached_total.toLocaleString()})`:' — empty'}</option>`).join('');
+  sel.value=STATE.account;
+  const cur=STATE.accounts.find(a=>a.ig_user_id===STATE.account);
+  STATE.handle=cur?cur.handle:'';
+}
 
 function applyMode(){
   $$('#mode button').forEach(b=>b.classList.toggle('on', +b.dataset.live===STATE.live));
@@ -109,11 +121,18 @@ function applyMode(){
 }
 
 async function load(){
-  const r=await fetch('/api/live/posts?days='+STATE.days);
+  const r=await fetch('/api/live/posts?days='+STATE.days+'&account='+encodeURIComponent(STATE.account));
   STATE.data=await r.json();
   const d=STATE.data;
-  if(d.account){$('#acct').innerHTML=`@${d.account.username} · ${(d.account.media_count||0).toLocaleString()} posts · ${(d.account.followers_count||0).toLocaleString()} followers · <span class="sub">cache: ${d.cached_total.toLocaleString()}${d.complete?'':' (partial — Backfill all)'}${d.cached_at?' · '+new Date(d.cached_at).toLocaleString():''}</span>`;}
+  const cur=STATE.accounts.find(a=>a.ig_user_id===STATE.account); STATE.handle=cur?cur.handle:(d.account&&d.account.handle)||'';
+  if(d.account){$('#acct').innerHTML=`@${d.account.username||d.account.handle} · ${(d.account.media_count||0).toLocaleString()} posts · ${(d.account.followers_count||0).toLocaleString()} followers · <span class="sub">cache: ${(d.cached_total||0).toLocaleString()}${d.complete?'':' (partial — Backfill all)'}${d.cached_at?' · '+new Date(d.cached_at).toLocaleString():''}</span>`;}
   if(STATE.live===1 && !d.liveEnabled){toast('Delete-on-IG is turned off server-side (IG_LIVE_DELETE). Falling back to safe mode.'); STATE.live=0;}
+  // auto-populate an account that's never been cached (once per account — no loop if it has 0 posts)
+  if((d.cached_total||0)===0 && !autoRefreshed.has(STATE.account)){
+    autoRefreshed.add(STATE.account);
+    $('#out').className='empty';$('#out').innerHTML='No posts cached for @'+STATE.handle+' yet — loading…';
+    await refresh(false);return;
+  }
   applyMode(); render();
 }
 
@@ -155,7 +174,7 @@ async function del(btn){
   btn.disabled=true; btn.textContent=live?'deleting…':'hiding…';
   try{
     const r=await fetch('/api/posts/delete',{method:'POST',headers:{'Content-Type':'application/json'},
-      body:JSON.stringify({permalink,media_id:id,live,handle:'designerwallcoverings'})});
+      body:JSON.stringify({permalink,media_id:id,live,handle:STATE.handle||'designerwallcoverings'})});
     const j=await r.json();
     if(j.ok){cardEl.classList.add('gone');setTimeout(()=>cardEl.remove(),260);toast(live?'Deleted on Instagram ✓':'Hidden from board ✓');}
     else{btn.disabled=false;btn.textContent='Delete';toast(j.error||'delete failed',true);}
@@ -168,15 +187,19 @@ $('#sort').value=STATE.sort; $('#sort').onchange=e=>{STATE.sort=e.target.value;s
 $('#density').oninput=e=>{document.documentElement.style.setProperty('--card',e.target.value+'px');localStorage.setItem('igdensity',e.target.value);};
 $$('#range button').forEach(b=>{b.classList.toggle('on',+b.dataset.days===STATE.days);b.onclick=()=>{STATE.days=+b.dataset.days;$$('#range button').forEach(x=>x.classList.remove('on'));b.classList.add('on');save();load();};});
 $$('#mode button').forEach(b=>b.onclick=()=>{STATE.live=+b.dataset.live;save();applyMode();});
+let autoRefreshed=new Set();
 async function refresh(full){const btn=full?$('#backfill'):$('#refresh');const o=btn.textContent;btn.textContent=full?'backfilling…':'refreshing…';btn.disabled=true;
-  try{const r=await fetch('/api/live/refresh',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({full})});const j=await r.json();
-    if(j.ok){toast(`+${j.added} new · scanned ${j.scanned}${j.complete?' · full history':''}`);await load();}else{toast(j.error||'refresh failed',true);}}
+  try{const r=await fetch('/api/live/refresh',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({full,account:STATE.account})});const j=await r.json();
+    if(j.ok){toast(`@${j.account}: +${j.added} new · scanned ${j.scanned}${j.complete?' · full history':''}`);await load();}else{toast(j.error||'refresh failed',true);}}
   catch(e){toast(e.message,true);}finally{btn.textContent=o;btn.disabled=false;}}
 $('#refresh').onclick=()=>refresh(false);
-$('#backfill').onclick=()=>{if(confirm('Paginate the ENTIRE ~5,500-post history into the cache? (~110 API calls, one-time)'))refresh(true);};
+$('#backfill').onclick=()=>{if(confirm('Paginate this account\'s ENTIRE post history into the cache? (one-time, up to a few hundred API calls)'))refresh(true);};
+
+// account dropdown → switch feeds
+$('#account').onchange=e=>{STATE.account=e.target.value;const cur=STATE.accounts.find(a=>a.ig_user_id===STATE.account);STATE.handle=cur?cur.handle:'';save();load();};
 
-// init day-seg from state
+// init: day-seg from state, then accounts, then posts
 $$('#range button').forEach(b=>b.classList.toggle('on',+b.dataset.days===STATE.days));
-load();
+(async()=>{ await loadAccounts(); await load(); })();
 </script>
 </body></html>

← 5b9bed4 auto-data-snapshot: 2026-08-18T11:49:58 (1 data files) — age  ·  back to Norma Platform  ·  auto-data-snapshot: 2026-08-18T12:24:13 (1 data files) — age 8510437 →