← 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
M agents/instagram-agent/live-media.jsM agents/instagram-agent/public/live-viewer.html
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 →