← back to Norma Platform
agents/instagram-agent/accounts.js
110 lines
/**
* 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 (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,
followers: a.followers_count, posts: a.media_count }));
}
/**
* 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,
};
}
/**
* 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 };