← back to Marketing Command Center
modules/channels/index.js
998 lines
// Channels — the multi-channel PUBLISHING ENGINE. The heart of the marketing
// machine: one composer → publishes to Facebook (pages), Instagram, TikTok,
// YouTube. Each platform adapter holds the REAL publish call behind a credential
// gate; with no creds the post is STAGED to the outbox (status pending-connection)
// so the pipeline works end-to-end before any account is connected.
//
// LIVE posting is gated exactly like the CC sends: requires confirm:true and
// dryRun:false; bulk/auto never posts without that. Connecting accounts (OAuth)
// is a Steve-authorized, per-platform step done on each developer portal.
const fs = require('fs');
const path = require('path');
const mediaCache = require('../../lib/media-cache');
const OUTBOX = path.join(__dirname, '..', '..', 'data', 'channels-outbox.json');
const readOutbox = () => { try { return JSON.parse(fs.readFileSync(OUTBOX, 'utf8')); } catch { return []; } };
const writeOutbox = (a) => { fs.mkdirSync(path.dirname(OUTBOX), { recursive: true }); fs.writeFileSync(OUTBOX, JSON.stringify(a, null, 2)); };
const env = (k) => (process.env[k] || '').trim();
const GRAPH = 'https://graph.facebook.com/v23.0';
// ── Meta Pages cache (the user's 80 FB Pages + per-Page tokens + linked IG) ─────
// Discovered from /me/accounts with META_ACCESS_TOKEN (a user token). Each Page
// carries its OWN access_token (that's what you post to a Page with) + an optional
// linked Instagram business account. Cached here so the picker + per-Page posting
// don't re-hit Graph on every render. CONTAINS PAGE TOKENS → gitignored, never
// sent to the browser (the /pages route sanitizes the tokens out).
const META_PAGES = path.join(__dirname, '..', '..', 'data', 'meta-pages.json');
const readMetaPages = () => { try { return JSON.parse(fs.readFileSync(META_PAGES, 'utf8')); } catch { return { fetchedAt: null, pages: [] }; } };
const writeMetaPages = (o) => { fs.mkdirSync(path.dirname(META_PAGES), { recursive: true }); fs.writeFileSync(META_PAGES, JSON.stringify(o, null, 2)); };
async function fetchMetaPages() {
const token = env('META_ACCESS_TOKEN');
if (!token) throw new Error('META_ACCESS_TOKEN not set — paste it into .env first');
let pages = [], url = `${GRAPH}/me/accounts?fields=name,id,access_token,instagram_business_account{id,username},tasks&limit=100&access_token=${encodeURIComponent(token)}`;
let guard = 0;
while (url && guard++ < 20) {
const r = await fetch(url); const j = await r.json();
if (j.error) throw new Error(j.error.message);
for (const p of (j.data || [])) pages.push({
id: p.id, name: p.name, token: p.access_token || null,
igId: p.instagram_business_account?.id || null,
igUsername: p.instagram_business_account?.username || null,
canPost: (p.tasks || []).includes('CREATE_CONTENT'),
});
url = j.paging?.next || null;
}
// Meta stopped populating instagram_business_account in the bulk /me/accounts
// response (verified 2026-07-15 — it returns null there even with instagram_basic).
// It DOES resolve when you query the page node WITH ITS OWN PAGE TOKEN. So for any
// page still missing an IG link but holding a token, resolve it per-page (batched).
const needIg = pages.filter(p => !p.igId && p.token);
for (let i = 0; i < needIg.length; i += 10) {
await Promise.all(needIg.slice(i, i + 10).map(async (p) => {
try {
const r = await fetch(`${GRAPH}/${p.id}?fields=instagram_business_account{id,username}&access_token=${encodeURIComponent(p.token)}`);
const j = await r.json();
const ig = j.instagram_business_account;
if (ig) { p.igId = ig.id || null; p.igUsername = ig.username || null; }
} catch { /* leave this page IG-less; a single failure never aborts the fleet */ }
}));
}
pages.sort((a, b) => (a.name || '').localeCompare(b.name || ''));
const cache = { fetchedAt: new Date().toISOString(), pages };
writeMetaPages(cache);
return cache;
}
// ── OAuth connect machinery ───────────────────────────────────────────────────
// Tokens minted by the connect flow are stored here (gitignored), so a one-time
// browser "Authorize" is all it takes once the app client id/secret are set.
const TOKENS = path.join(__dirname, '..', '..', 'data', 'channels-tokens.json');
const readTokens = () => { try { return JSON.parse(fs.readFileSync(TOKENS, 'utf8')); } catch { return {}; } };
const writeTokens = (o) => { fs.mkdirSync(path.dirname(TOKENS), { recursive: true }); fs.writeFileSync(TOKENS, JSON.stringify(o, null, 2)); };
// Public base for OAuth redirect URIs — register `${base}/api/channels/oauth/<platform>/callback`
// in each app console. Defaults to the canonical live host (matches .env's
// OAUTH_REDIRECT_BASE); override with OAUTH_REDIRECT_BASE.
const redirectBase = () => env('OAUTH_REDIRECT_BASE') || 'https://marketing.agentabrams.com';
const redirectUri = (p) => `${redirectBase()}/api/channels/oauth/${p}/callback`;
// ── Self-serve setup (the Activate page) ──────────────────────────────────────
// .env is the source of truth the shell loads; we write directly to it AND update
// process.env live so the Connect button lights up without a restart.
const ENV_PATH = path.join(__dirname, '..', '..', '.env');
// Allowlist — only these may be written via the form (no arbitrary env injection).
const ALLOWED_KEYS = new Set([
'META_APP_ID', 'META_APP_SECRET', 'FB_PAGE_IDS', 'FB_PAGE_ACCESS_TOKEN', 'IG_BUSINESS_ACCOUNT_ID', 'IG_ACCESS_TOKEN',
'TIKTOK_CLIENT_KEY', 'TIKTOK_CLIENT_SECRET', 'TIKTOK_ACCESS_TOKEN',
'YOUTUBE_CLIENT_ID', 'YOUTUBE_CLIENT_SECRET', 'GOOGLE_CLIENT_ID', 'GOOGLE_CLIENT_SECRET', 'YOUTUBE_ACCESS_TOKEN', 'YOUTUBE_REFRESH_TOKEN',
'BLUESKY_HANDLE', 'BLUESKY_APP_PASSWORD',
'THREADS_ACCESS_TOKEN', 'THREADS_USER_ID',
// LinkedIn (Phase 2) — allowed so the Accounts panel's one-stop "fix" can save
// them the same way; the linkedin module reads these same keys from process.env.
'LINKEDIN_ACCESS_TOKEN', 'LINKEDIN_AUTHOR_URN', 'LINKEDIN_ORG_URN',
'OAUTH_REDIRECT_BASE',
]);
// Provider cards the front-end renders from. id is the form group only.
const SETUP_PROVIDERS = [
{
id: 'meta', title: 'Meta — Facebook + Instagram', icon: '📘',
blurb: 'One Meta app activates BOTH Facebook Pages and Instagram. In the portal: create a Business app, add "Facebook Login", request the permissions below, and register the redirect URIs.',
portal: 'https://developers.facebook.com/apps', portalLabel: 'Meta for Developers',
platforms: ['facebook', 'instagram'],
permissions: 'pages_manage_posts · pages_read_engagement · instagram_content_publish · instagram_basic · business_management',
fields: [
{ key: 'META_APP_ID', label: 'App ID', secret: false, ph: 'e.g. 1234567890123456' },
{ key: 'META_APP_SECRET', label: 'App Secret', secret: true, ph: '32-char secret' },
],
},
{
id: 'tiktok', title: 'TikTok', icon: '🎵',
blurb: 'TikTok for Developers app with the Content Posting API. Direct-post requires app audit; until approved, sandbox posts are private-only.',
portal: 'https://developers.tiktok.com', portalLabel: 'TikTok for Developers',
platforms: ['tiktok'],
permissions: 'video.publish · video.upload · user.info.basic',
fields: [
{ key: 'TIKTOK_CLIENT_KEY', label: 'Client Key', secret: false, ph: 'aw...' },
{ key: 'TIKTOK_CLIENT_SECRET', label: 'Client Secret', secret: true, ph: 'client secret' },
],
},
{
id: 'youtube', title: 'YouTube', icon: '▶️',
blurb: 'Google Cloud project with YouTube Data API v3 enabled + an OAuth client (type: Web application). OAuth connects now; the binary upload pipeline lands later, so posts stage until then.',
portal: 'https://console.cloud.google.com/apis/credentials', portalLabel: 'Google Cloud Console',
platforms: ['youtube'],
permissions: 'youtube.upload · youtube.readonly',
fields: [
{ key: 'YOUTUBE_CLIENT_ID', label: 'OAuth Client ID', secret: false, ph: '...apps.googleusercontent.com' },
{ key: 'YOUTUBE_CLIENT_SECRET', label: 'OAuth Client Secret', secret: true, ph: 'GOCSPX-...' },
],
},
{
id: 'bluesky', title: 'Bluesky', icon: '🦋', noRedirect: true,
blurb: 'No app review, no OAuth — just an App Password. In Bluesky: Settings → Privacy and security → App Passwords → Add. Paste your handle + the app password below and it posts live immediately.',
portal: 'https://bsky.app/settings/app-passwords', portalLabel: 'Bluesky App Passwords',
platforms: ['bluesky'],
permissions: 'AT Protocol app-password (full account; no granular scopes)',
fields: [
{ key: 'BLUESKY_HANDLE', label: 'Handle', secret: false, ph: 'designerwallcoverings.bsky.social' },
{ key: 'BLUESKY_APP_PASSWORD', label: 'App Password', secret: true, ph: 'xxxx-xxxx-xxxx-xxxx' },
],
},
{
id: 'threads', title: 'Threads', icon: '🧵', noRedirect: true,
blurb: 'Threads (Meta) Graph API. Needs a Threads user-access token with threads_basic + threads_content_publish (Meta App Review gates content_publish, like FB/IG). Paste the token + your Threads user id to enable live posting.',
portal: 'https://developers.facebook.com/docs/threads', portalLabel: 'Threads API docs',
platforms: ['threads'],
permissions: 'threads_basic · threads_content_publish',
fields: [
{ key: 'THREADS_USER_ID', label: 'Threads User ID', secret: false, ph: 'e.g. 17841400000000000' },
{ key: 'THREADS_ACCESS_TOKEN', label: 'Access Token', secret: true, ph: 'THQVJ...' },
],
},
];
function setEnvKeys(updates) {
let lines = [];
try { lines = fs.readFileSync(ENV_PATH, 'utf8').split('\n'); } catch { /* new file */ }
for (const [k, v] of Object.entries(updates)) {
lines = lines.filter(l => !l.startsWith(k + '='));
// Single-quote the .env value so a token containing a space / # / <> can't
// abort a `set -a` shell `source` at that line (Steve's env-unquoted-value
// sourcing bug). loadEnv() strips surrounding quotes on read, so process.env
// still gets the raw value below — round-trip stays clean.
if (v !== '' && v != null) { lines.push(`${k}='${String(v).replace(/'/g, "'\\''")}'`); process.env[k] = String(v); }
else { delete process.env[k]; }
}
const out = lines.join('\n').replace(/\n{3,}/g, '\n\n').replace(/^\n+/, '');
fs.writeFileSync(ENV_PATH, out.endsWith('\n') ? out : out + '\n', { mode: 0o600 });
}
// Per-platform OAuth config. clientId/secret come from env (Steve pastes once).
const OAUTH = {
youtube: {
clientId: () => env('YOUTUBE_CLIENT_ID') || env('GOOGLE_CLIENT_ID'),
clientSecret: () => env('YOUTUBE_CLIENT_SECRET') || env('GOOGLE_CLIENT_SECRET'),
authUrl: 'https://accounts.google.com/o/oauth2/v2/auth',
tokenUrl: 'https://oauth2.googleapis.com/token',
scope: 'https://www.googleapis.com/auth/youtube.upload https://www.googleapis.com/auth/youtube.readonly https://www.googleapis.com/auth/youtube.force-ssl',
extra: { access_type: 'offline', prompt: 'consent' },
},
tiktok: {
clientId: () => env('TIKTOK_CLIENT_KEY') || env('TIKTOK_CLIENT_ID'),
clientSecret: () => env('TIKTOK_CLIENT_SECRET'),
authUrl: 'https://www.tiktok.com/v2/auth/authorize/',
tokenUrl: 'https://open.tiktokapis.com/v2/oauth/token/',
scope: 'video.publish,video.upload,user.info.basic',
clientParam: 'client_key',
},
facebook: {
clientId: () => env('META_APP_ID'),
clientSecret: () => env('META_APP_SECRET'),
authUrl: 'https://www.facebook.com/v21.0/dialog/oauth',
tokenUrl: 'https://graph.facebook.com/v21.0/oauth/access_token',
scope: 'pages_manage_posts,pages_read_engagement,instagram_content_publish,instagram_basic,business_management',
},
};
OAUTH.instagram = OAUTH.facebook; // same Meta app/flow
function authorizeUrl(p) {
const c = OAUTH[p]; if (!c || !c.clientId()) return null;
const params = new URLSearchParams({
[c.clientParam || 'client_id']: c.clientId(),
redirect_uri: redirectUri(p),
response_type: 'code',
scope: c.scope,
state: p,
...(c.extra || {}),
});
return `${c.authUrl}?${params.toString()}`;
}
async function exchangeCode(p, code) {
const c = OAUTH[p];
const body = new URLSearchParams({
[c.clientParam || 'client_id']: c.clientId(),
client_secret: c.clientSecret(),
code, grant_type: 'authorization_code', redirect_uri: redirectUri(p),
});
const r = await fetch(c.tokenUrl, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body });
const j = await r.json().catch(() => ({}));
if (!r.ok || j.error) throw new Error(j.error_description || j.error?.message || JSON.stringify(j).slice(0, 200));
return j; // {access_token, refresh_token?, expires_in?}
}
// Upgrade a short-lived Meta user token to a long-lived (~60-day) one. Page tokens
// derived from a long-lived user token are PERMANENT (never expire) — this is the
// single step that stops the whole fleet dying in an hour. Returns the long-lived
// access_token, or throws (caller decides whether to fall back to the short one).
async function exchangeLongLivedMeta(shortToken) {
const appId = env('META_APP_ID'), secret = env('META_APP_SECRET');
if (!appId || !secret) throw new Error('META_APP_ID / META_APP_SECRET not set');
const qs = new URLSearchParams({
grant_type: 'fb_exchange_token', client_id: appId, client_secret: secret, fb_exchange_token: shortToken,
});
const r = await fetch(`${GRAPH}/oauth/access_token?${qs}`);
const j = await r.json().catch(() => ({}));
if (!r.ok || j.error || !j.access_token) throw new Error(j.error?.message || 'long-lived exchange returned no token');
return j.access_token;
}
// Real-world posting capability. `connected` (creds present) is NOT the same as
// "will actually post" — a channel can be connected yet blocked by App Review, a
// dead token, or a media-type mismatch. This encodes the KNOWN structural blocks
// (verified in production) so the status endpoint stops advertising a non-postable
// channel as ready. `postable`: can it publish anything right now? `mediaTypes`:
// what it will accept (an image caller must check this). `postableNote`: the why.
const POSTABILITY = {
bluesky: { postable: true, mediaTypes: ['image', 'text'] },
threads: { postable: true, mediaTypes: ['image', 'text'] }, // DW is a Meta tester → publishes without App Review (proven)
instagram: { postable: true, mediaTypes: ['image', 'video'] },
youtube: { postable: true, mediaTypes: ['video'],
postableNote: 'Video uploads only — image/text posts are not supported.' },
tiktok: { postable: true, mediaTypes: ['video'],
postableNote: 'Private-only (SELF_ONLY) and video-only until TikTok App Review — cannot post publicly or post an image.' },
facebook: { postable: false, mediaTypes: ['image', 'video'],
postableNote: 'Blocked: page posting needs pages_manage_posts, which requires Meta App Review — API posts fail until approved.' },
linkedin: { postable: false, mediaTypes: ['image', 'text'],
postableNote: 'Blocked: company-page posting needs a dedicated Community-Management app; the current token is unscoped/invalid.' },
};
// ── Per-platform connection status (what's wired vs what Steve must authorize) ──
function platformStatus() {
const tk = readTokens();
const cfg = (p) => !!(OAUTH[p] && OAUTH[p].clientId() && OAUTH[p].clientSecret());
const tokenConnected = (p) => !!(tk[p] && tk[p].access_token);
const base = {
facebook: {
label: 'Facebook Pages', icon: '📘',
// A user META_ACCESS_TOKEN unlocks per-Page tokens (via /me/accounts) — that's
// "connected" for the page-picker flow, independent of the legacy single-page env.
connected: !!(env('META_ACCESS_TOKEN') || (env('META_APP_ID') && env('FB_PAGE_ACCESS_TOKEN'))),
accounts: env('META_ACCESS_TOKEN') ? readMetaPages().pages.map(p => p.name || p.id)
: (env('FB_PAGE_IDS') ? env('FB_PAGE_IDS').split(',').map(s => s.trim()).filter(Boolean) : []),
needs: 'Meta user token (META_ACCESS_TOKEN) → per-Page tokens auto-discovered. Live posting needs pages_manage_posts on the token.',
scopes: ['pages_manage_posts', 'pages_read_engagement', 'business_management'],
},
instagram: {
label: 'Instagram', icon: '📸',
connected: !!(env('META_ACCESS_TOKEN') || (env('META_APP_ID') && env('IG_ACCESS_TOKEN') && env('IG_BUSINESS_ACCOUNT_ID'))),
accounts: env('META_ACCESS_TOKEN') ? readMetaPages().pages.filter(p => p.igId).map(p => p.igUsername || p.igId)
: (env('IG_BUSINESS_ACCOUNT_ID') ? [env('IG_BUSINESS_ACCOUNT_ID')] : []),
needs: 'IG Business account linked to a selected Page. Live posting needs instagram_content_publish on the token.',
scopes: ['instagram_content_publish', 'instagram_basic'],
},
tiktok: {
label: 'TikTok', icon: '🎵',
connected: !!(env('TIKTOK_ACCESS_TOKEN')),
accounts: env('TIKTOK_USERNAME') ? [env('TIKTOK_USERNAME')] : (env('TIKTOK_OPEN_ID') ? [env('TIKTOK_OPEN_ID')] : []),
needs: 'TikTok for Developers app + Content Posting API access (audited) → user OAuth → access token. Direct-post needs app review; sandbox posts to private only.',
scopes: ['video.publish', 'video.upload'],
// Surfaced at publish time so a "live" post isn't mistaken for public.
caveat: 'Posts are private-only (SELF_ONLY) until TikTok approves the app audit.',
},
youtube: {
label: 'YouTube', icon: '▶️',
connected: !!(env('YOUTUBE_ACCESS_TOKEN') || (env('GOOGLE_CLIENT_ID') && env('YOUTUBE_REFRESH_TOKEN'))),
accounts: env('YOUTUBE_CHANNEL_TITLE') ? [env('YOUTUBE_CHANNEL_TITLE')] : (env('YOUTUBE_CHANNEL_ID') ? [env('YOUTUBE_CHANNEL_ID')] : []),
needs: 'Google Cloud project + YouTube Data API v3 enabled + OAuth consent (youtube.upload scope) → channel refresh token.',
scopes: ['https://www.googleapis.com/auth/youtube.upload'],
// Resumable videos.insert upload pipeline is wired (postYouTube). Uploads land on
// the connected token's channel; defaults to privacyStatus 'private'.
caveat: 'Uploads go to the connected channel; posts default to Private (set content.privacy to publish public).',
},
bluesky: {
label: 'Bluesky', icon: '🦋',
// App-password auth: present creds = live. No OAuth, no review.
connected: !!(env('BLUESKY_HANDLE') && env('BLUESKY_APP_PASSWORD')),
accounts: env('BLUESKY_HANDLE') ? [env('BLUESKY_HANDLE')] : [],
needs: 'Bluesky handle + an App Password (Settings → App Passwords). Posts go live immediately — no review.',
scopes: ['app-password'],
},
threads: {
label: 'Threads', icon: '🧵',
connected: !!(env('THREADS_ACCESS_TOKEN') && env('THREADS_USER_ID')),
accounts: env('THREADS_USER_ID') ? [env('THREADS_USER_ID')] : [],
needs: 'Threads user id + access token with threads_content_publish.',
scopes: ['threads_basic', 'threads_content_publish'],
caveat: 'DW is a Meta tester → threads_content_publish posts live WITHOUT App Review (verified). Public rollout still needs review, but tester posting works now.',
},
linkedin: {
label: 'LinkedIn', icon: '💼',
connected: !!(env('LINKEDIN_ACCESS_TOKEN') && (env('LINKEDIN_ORG_URN') || env('LINKEDIN_AUTHOR_URN'))),
accounts: env('LINKEDIN_ORG_URN') ? [env('LINKEDIN_ORG_URN')] : (env('LINKEDIN_AUTHOR_URN') ? [env('LINKEDIN_AUTHOR_URN')] : []),
needs: 'LinkedIn access token (w_organization_social) + org/author URN — same keys the linkedin module reads.',
scopes: ['w_organization_social'],
},
};
// Merge in OAuth-flow state: a platform is `connected` if env creds OR a minted
// token exists; `configured` = app client id/secret present so a Connect button works.
for (const p of Object.keys(base)) {
// OAuth platforms: configured = app id/secret present (lights the Connect button).
// App-password / token-paste platforms (bluesky, threads): configured once their
// creds are present — there's no OAuth Connect, creds are saved directly via /config.
base[p].configured = cfg(p) || base[p].connected;
base[p].connected = base[p].connected || tokenConnected(p);
base[p].connectUrl = (cfg(p) && !base[p].connected) ? `/api/channels/connect/${p}` : null;
// postable = connected AND not structurally blocked. This is the honest
// "will it actually post" signal; `connected` alone over-promises.
const cap = POSTABILITY[p] || { postable: base[p].connected, mediaTypes: ['image', 'text'] };
base[p].postable = base[p].connected && cap.postable;
base[p].mediaTypes = cap.mediaTypes;
if (cap.postableNote) base[p].postableNote = cap.postableNote;
else if (!base[p].connected) base[p].postableNote = 'Not connected — add credentials first.';
}
return base;
}
// ── Meta token health (expiry-aware status) ───────────────────────────────────
// platformStatus() is presence-only: a PRESENT-but-EXPIRED Meta token reads as
// "connected" while every real post silently fails AND the reconnect button stays
// hidden (connectUrl only shows when !connected). This adds a cached debug_token
// check so an expired/invalid Meta token flips to needsReconnect → surfaces the
// Connect button. FAIL-SAFE: a network blip or missing app creds returns ok:null
// (unknown) and NEVER flips a possibly-good token to broken.
let _metaHealth = null; // { token, at, v }
const META_HEALTH_TTL = 10 * 60 * 1000; // 10 min — debug_token is free but rate-limited
async function metaTokenHealth() {
const token = env('META_ACCESS_TOKEN');
if (!token) return { present: false, ok: false, reason: 'no token' };
const now = Date.now();
if (_metaHealth && _metaHealth.token === token && (now - _metaHealth.at) < META_HEALTH_TTL) return _metaHealth.v;
const appId = env('META_APP_ID'), secret = env('META_APP_SECRET');
if (!appId || !secret) { // can't verify without app creds → unknown, don't flip
const v = { present: true, ok: null, reason: 'no app id/secret to verify token' };
_metaHealth = { token, at: now, v }; return v;
}
try {
const r = await fetch(`${GRAPH}/debug_token?input_token=${encodeURIComponent(token)}&access_token=${encodeURIComponent(appId + '|' + secret)}`);
const j = await r.json().catch(() => ({}));
// Graph signals rate-limits / app-token problems as a TOP-LEVEL {error} (no
// throw, no data.is_valid) — that means we COULDN'T verify, not that the user
// token is bad. Treat is_valid as three-valued: only a definitive boolean
// false flips a token; a missing/indeterminate verdict → ok:null (unknown),
// leaving the presence-based status untouched (fail-safe).
if (j.error || !j.data || typeof j.data.is_valid !== 'boolean') {
const v = { present: true, ok: null, reason: j.error?.message || 'validity indeterminate — Graph returned no verdict' };
_metaHealth = { token, at: now, v }; return v;
}
const d = j.data;
const v = {
present: true,
ok: d.is_valid,
expiresAt: d.expires_at ? d.expires_at * 1000 : null,
reason: d.is_valid ? null : (d.error?.message || 'token invalid/expired'),
};
_metaHealth = { token, at: now, v }; return v;
} catch (e) { // network failure → unknown, keep prior belief
const v = { present: true, ok: null, reason: 'validity check failed: ' + e.message };
_metaHealth = { token, at: now, v }; return v;
}
}
// Mutate the presence-based status with Meta token validity. Only acts when the
// token is DEFINITIVELY invalid (ok === false); ok:null (unknown) is left alone.
function augmentMetaHealth(st, health) {
for (const p of ['facebook', 'instagram']) {
if (!st[p] || !st[p].connected || !health || !health.present) continue;
if (health.ok === false) {
st[p].connected = false;
st[p].needsReconnect = true;
st[p].tokenError = health.reason || 'token expired — reconnect required';
st[p].connectUrl = st[p].configured ? `/api/channels/connect/${p}` : st[p].connectUrl;
} else if (health.ok === true && health.expiresAt) {
st[p].tokenExpiresAt = health.expiresAt; // lets the UI show "expires in N days"
}
}
return st;
}
// ── Real adapters (fire only when connected) ──────────────────────────────────
async function postFacebook(content, opts = {}) {
// Two modes: (a) selected-pages — post to a chosen subset using each Page's OWN
// token from the cache; (b) legacy — FB_PAGE_IDS + one FB_PAGE_ACCESS_TOKEN.
let targets = [];
if (opts.pages && opts.pages.length) {
const byId = Object.fromEntries(readMetaPages().pages.map(p => [String(p.id), p]));
for (const id of opts.pages) { const p = byId[String(id)]; if (p && p.token) targets.push({ id: p.id, token: p.token, name: p.name }); }
if (!targets.length) return [{ ok: false, error: 'selected Pages not in cache — click ↻ refresh on the Pages picker' }];
} else {
const token = env('FB_PAGE_ACCESS_TOKEN');
const ids = env('FB_PAGE_IDS');
if (!ids) return [{ ok: false, error: 'No Facebook Page selected and FB_PAGE_IDS not configured' }];
targets = ids.split(',').map(s => s.trim()).filter(Boolean).map(id => ({ id, token, name: id }));
}
const results = [];
for (const t of targets) {
// video (a selected reel) → /videos with a public file_url; photo → /photos;
// text → /feed. Video + photo posting both need pages_manage_posts on the token.
let url, body;
if (content.videoUrl) {
url = `${GRAPH}/${t.id}/videos`;
body = { file_url: content.videoUrl, description: content.caption, access_token: t.token };
} else if (content.mediaUrl) {
url = `${GRAPH}/${t.id}/photos`;
body = { url: content.mediaUrl, caption: content.caption, access_token: t.token };
} else {
url = `${GRAPH}/${t.id}/feed`;
body = { message: content.caption, access_token: t.token };
}
const r = await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) });
const j = await r.json().catch(() => ({}));
results.push({ pageId: t.id, name: t.name, ok: r.ok, id: j.id || j.post_id, error: r.ok ? null : (j.error?.message || `HTTP ${r.status}`) });
}
return results;
}
async function postInstagram(content, opts = {}) {
if (!content.mediaUrl && !content.videoUrl) return [{ ok: false, error: 'Instagram requires an image or video URL' }];
// selected-pages → post to each selected Page's LINKED IG account (Page token);
// legacy → single IG_BUSINESS_ACCOUNT_ID + IG_ACCESS_TOKEN.
let igTargets = [];
if (opts.pages && opts.pages.length) {
const byId = Object.fromEntries(readMetaPages().pages.map(p => [String(p.id), p]));
for (const id of opts.pages) { const p = byId[String(id)]; if (p && p.igId && p.token) igTargets.push({ igId: p.igId, token: p.token, name: p.igUsername || p.name }); }
if (!igTargets.length) return [{ ok: false, error: 'none of the selected Pages have a linked Instagram account' }];
} else {
igTargets = [{ igId: env('IG_BUSINESS_ACCOUNT_ID'), token: env('IG_ACCESS_TOKEN'), name: 'IG' }];
}
const results = [];
for (const t of igTargets) {
// A selected reel → REELS container (video_url); else an IMAGE container.
const createBody = content.videoUrl
? { media_type: 'REELS', video_url: content.videoUrl, caption: content.caption, access_token: t.token }
: { image_url: content.mediaUrl, caption: content.caption, access_token: t.token };
const create = await fetch(`${GRAPH}/${t.igId}/media`, {
method: 'POST', headers: { 'content-type': 'application/json' },
body: JSON.stringify(createBody),
});
const cj = await create.json().catch(() => ({}));
if (!create.ok || !cj.id) { results.push({ igId: t.igId, name: t.name, ok: false, error: cj.error?.message || 'media create failed' }); continue; }
// Meta processes the container ASYNC — publishing before it's FINISHED throws
// "Media ID is not available". Poll status_code until FINISHED (or ERROR/timeout).
// Video (REELS) transcodes slower than an image, so give it more polls.
let ready = false;
const maxPolls = content.videoUrl ? 30 : 12;
for (let i = 0; i < maxPolls; i++) {
await new Promise(r => setTimeout(r, i === 0 ? 1500 : 2500));
const s = await fetch(`${GRAPH}/${cj.id}?fields=status_code&access_token=${encodeURIComponent(t.token)}`);
const sj = await s.json().catch(() => ({}));
if (sj.status_code === 'FINISHED') { ready = true; break; }
if (sj.status_code === 'ERROR' || sj.status_code === 'EXPIRED') { break; }
}
if (!ready) { results.push({ igId: t.igId, name: t.name, ok: false, error: 'media container not ready (still processing or errored)' }); continue; }
const pub = await fetch(`${GRAPH}/${t.igId}/media_publish`, {
method: 'POST', headers: { 'content-type': 'application/json' },
body: JSON.stringify({ creation_id: cj.id, access_token: t.token }),
});
const pj = await pub.json().catch(() => ({}));
results.push({ igId: t.igId, name: t.name, ok: pub.ok, id: pj.id, error: pub.ok ? null : (pj.error?.message || 'publish failed') });
}
return results;
}
async function postTikTok(content) {
// TikTok Content Posting API (direct post). Requires audited app.
const token = env('TIKTOK_ACCESS_TOKEN');
const r = await fetch('https://open.tiktokapis.com/v2/post/publish/video/init/', {
method: 'POST', headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' },
body: JSON.stringify({ post_info: { title: content.caption, privacy_level: 'SELF_ONLY' }, source_info: { source: 'PULL_FROM_URL', video_url: content.mediaUrl } }),
});
const j = await r.json().catch(() => ({}));
return [{ ok: r.ok && !j.error?.code, id: j.data?.publish_id, error: r.ok ? null : JSON.stringify(j.error || {}) }];
}
// YouTube access tokens live ~1h, so mint a fresh one from the stored refresh token
// on every post (grant_type=refresh_token). Falls back to any stored access token.
async function youtubeAccessToken() {
const yt = readTokens().youtube || {};
const rt = yt.refresh_token || env('YOUTUBE_REFRESH_TOKEN');
const cid = env('YOUTUBE_CLIENT_ID') || env('GOOGLE_CLIENT_ID');
const cs = env('YOUTUBE_CLIENT_SECRET') || env('GOOGLE_CLIENT_SECRET');
if (rt && cid && cs) {
const body = new URLSearchParams({ client_id: cid, client_secret: cs, refresh_token: rt, grant_type: 'refresh_token' });
const r = await fetch('https://oauth2.googleapis.com/token', { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body });
const j = await r.json().catch(() => ({}));
if (j.access_token) return j.access_token;
}
return yt.access_token || env('YOUTUBE_ACCESS_TOKEN') || null;
}
// YouTube Data API videos.insert — a RESUMABLE binary upload. Unlike the URL-based
// channels, YouTube needs the actual bytes: we fetch the source video, open a
// resumable session (metadata POST → upload URL), then PUT the bytes. Posts land on
// whichever channel the connected token authorized (pick the brand channel at OAuth
// time). Defaults to privacyStatus 'private' unless content.privacy overrides.
async function postYouTube(content) {
const src = content.videoUrl || content.mediaUrl;
if (!src) return [{ ok: false, error: 'YouTube needs a video (videoUrl/mediaUrl) — it never posts an image alone' }];
const token = await youtubeAccessToken();
if (!token) return [{ ok: false, error: 'YouTube not connected (no refresh/access token)' }];
const vr = await fetch(src);
if (!vr.ok) return [{ ok: false, error: `couldn't fetch source video (${vr.status}) from ${src}` }];
const ctype = vr.headers.get('content-type') || 'video/*';
const buf = Buffer.from(await vr.arrayBuffer());
const cap = (content.caption || 'Designer Wallcoverings').trim();
const title = (content.title || cap.split('\n')[0] || 'Designer Wallcoverings').slice(0, 100);
const description = content.description || cap;
const privacyStatus = content.privacy || 'private';
// 1) open a resumable upload session
const init = await fetch('https://www.googleapis.com/upload/youtube/v3/videos?uploadType=resumable&part=snippet,status', {
method: 'POST',
headers: {
authorization: `Bearer ${token}`,
'content-type': 'application/json; charset=UTF-8',
'x-upload-content-type': ctype,
'x-upload-content-length': String(buf.length),
},
body: JSON.stringify({
snippet: { title, description, tags: content.tags || ['wallpaper', 'designerwallcoverings', 'interiordesign'], categoryId: env('YOUTUBE_CATEGORY_ID') || '22' },
status: { privacyStatus, selfDeclaredMadeForKids: false },
}),
});
if (!init.ok) { const e = await init.text().catch(() => ''); return [{ ok: false, error: `youtube init ${init.status}: ${e.slice(0, 200)}` }]; }
const uploadUrl = init.headers.get('location');
if (!uploadUrl) return [{ ok: false, error: 'youtube resumable init returned no upload URL' }];
// 2) upload the bytes in a single PUT
const put = await fetch(uploadUrl, { method: 'PUT', headers: { 'content-type': ctype, 'content-length': String(buf.length) }, body: buf });
const pj = await put.json().catch(() => ({}));
if (!put.ok || pj.error) return [{ ok: false, error: `youtube upload ${put.status}: ${JSON.stringify(pj.error || {}).slice(0, 200)}` }];
return [{ ok: true, id: pj.id, url: pj.id ? `https://youtu.be/${pj.id}` : null }];
}
// Rich-text FACETS for a Bluesky post — makes #hashtags and links clickable.
// Per the AT Protocol spec (docs.bsky.app), facet index offsets are UTF-8 BYTE
// positions, NOT JS string indices — so emoji/multibyte in the caption don't shift
// them. We measure with Buffer.byteLength on the substring up to each match.
function detectFacets(text) {
const facets = [];
const byteLen = s => Buffer.byteLength(s, 'utf8');
// #hashtags — # then a non-digit, run of non-space; strip trailing punctuation
const tagRe = /(?:^|\s)(#[^\s#]+)/g;
let m;
while ((m = tagRe.exec(text)) !== null) {
let tag = m[1].replace(/[\p{P}]+$/u, ''); // drop trailing punctuation
if (tag.length < 2 || /^#\d/.test(tag)) continue; // skip bare # / #123
const at = m.index + m[0].indexOf('#');
const byteStart = byteLen(text.slice(0, at));
facets.push({ index: { byteStart, byteEnd: byteStart + byteLen(tag) }, features: [{ $type: 'app.bsky.richtext.facet#tag', tag: tag.slice(1) }] });
}
// explicit http(s) links + bare domains (e.g. designerwallcoverings.com) → link facet
const urlRe = /(?:^|\s)(https?:\/\/[^\s]+|(?:[a-z0-9-]+\.)+[a-z]{2,}(?:\/[^\s]*)?)/gi;
while ((m = urlRe.exec(text)) !== null) {
let token = m[1].replace(/[.,;:!?)]+$/, '');
const uri = /^https?:\/\//i.test(token) ? token : 'https://' + token;
const at = m.index + m[0].indexOf(m[1]);
const byteStart = byteLen(text.slice(0, at));
facets.push({ index: { byteStart, byteEnd: byteStart + byteLen(token) }, features: [{ $type: 'app.bsky.richtext.facet#link', uri }] });
}
return facets.sort((a, b) => a.index.byteStart - b.index.byteStart);
}
async function postBluesky(content) {
// AT Protocol: createSession (handle + app password) → optional uploadBlob for the
// image → createRecord (app.bsky.feed.post). No OAuth, no review. 300-char limit.
const handle = env('BLUESKY_HANDLE'), pw = env('BLUESKY_APP_PASSWORD');
if (!handle || !pw) return [{ ok: false, error: 'Bluesky not configured (handle + app password)' }];
const PDS = 'https://bsky.social/xrpc';
const s = await fetch(`${PDS}/com.atproto.server.createSession`, {
method: 'POST', headers: { 'content-type': 'application/json' },
body: JSON.stringify({ identifier: handle, password: pw }),
});
const sj = await s.json().catch(() => ({}));
if (!s.ok || !sj.accessJwt) return [{ ok: false, error: sj.message || sj.error || 'Bluesky auth failed' }];
const jwt = sj.accessJwt, did = sj.did;
let embed;
if (content.mediaUrl) {
// HARD RULE (Steve 2026-07-17): DW never posts text-only. If a mediaUrl was
// supplied, the image MUST embed — if the fetch or uploadBlob fails, ABORT the
// post instead of silently falling back to text-only. The route-level text-only
// guard can't catch that fallback because content.mediaUrl was truthy upstream,
// so this is the only place it can be enforced. Fail LOUD, never post naked.
try {
const img = await fetch(content.mediaUrl);
if (!img.ok) return [{ ok: false, error: `Bluesky image fetch failed (${img.status}) — post aborted; DW never posts text-only` }];
const buf = Buffer.from(await img.arrayBuffer());
const ct = img.headers.get('content-type') || 'image/jpeg';
const up = await fetch(`${PDS}/com.atproto.repo.uploadBlob`, {
method: 'POST', headers: { 'content-type': ct, authorization: `Bearer ${jwt}` }, body: buf,
});
const uj = await up.json().catch(() => ({}));
if (!up.ok || !uj.blob) return [{ ok: false, error: `Bluesky image upload failed (${up.status}) — post aborted; DW never posts text-only` }];
embed = { $type: 'app.bsky.embed.images', images: [{ alt: (content.caption || '').slice(0, 280), image: uj.blob }] };
} catch (e) {
return [{ ok: false, error: `Bluesky image upload error: ${e.message} — post aborted; DW never posts text-only` }];
}
}
const text = (content.caption || '').slice(0, 300);
const record = { $type: 'app.bsky.feed.post', text, createdAt: new Date().toISOString() };
const facets = detectFacets(text); // clickable #hashtags + links (docs.bsky.app)
if (facets.length) record.facets = facets;
if (embed) record.embed = embed;
const r = await fetch(`${PDS}/com.atproto.repo.createRecord`, {
method: 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${jwt}` },
body: JSON.stringify({ repo: did, collection: 'app.bsky.feed.post', record }),
});
const rj = await r.json().catch(() => ({}));
return [{ ok: r.ok, id: rj.uri, error: r.ok ? null : (rj.message || rj.error || 'post failed') }];
}
async function postThreads(content) {
// Threads Graph API: create a media container (TEXT or IMAGE) → publish it.
// threads_content_publish is App-Review-gated, so this fails cleanly until approved.
const token = env('THREADS_ACCESS_TOKEN'), uid = env('THREADS_USER_ID');
if (!token || !uid) return [{ ok: false, error: 'Threads not configured (user id + access token)' }];
const base = `https://graph.threads.net/v1.0/${uid}`;
const cp = new URLSearchParams({ access_token: token });
if (content.mediaUrl) { cp.set('media_type', 'IMAGE'); cp.set('image_url', content.mediaUrl); } else { cp.set('media_type', 'TEXT'); }
if (content.caption) cp.set('text', content.caption.slice(0, 500));
const c = await fetch(`${base}/threads`, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: cp });
const cj = await c.json().catch(() => ({}));
if (!c.ok || !cj.id) return [{ ok: false, error: cj.error?.message || 'Threads container create failed' }];
const pub = await fetch(`${base}/threads_publish`, {
method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ creation_id: cj.id, access_token: token }),
});
const pj = await pub.json().catch(() => ({}));
return [{ ok: pub.ok, id: pj.id, error: pub.ok ? null : (pj.error?.message || 'Threads publish failed') }];
}
async function postLinkedIn(content) {
// LinkedIn Community Management API. DW posts always carry media, so this is the
// full image flow: initializeUpload → PUT the bytes → create the post with the
// image URN. Text+link ride in `commentary`. Version pinned to match liPost.
const token = env('LINKEDIN_ACCESS_TOKEN');
const author = env('LINKEDIN_ORG_URN') || env('LINKEDIN_AUTHOR_URN');
if (!token || !author) return [{ ok: false, error: 'LinkedIn not configured (token + org/author URN)' }];
const H = { Authorization: `Bearer ${token}`, 'LinkedIn-Version': '202401', 'X-Restli-Protocol-Version': '2.0.0' };
let mediaId = null;
const src = content.mediaUrl || content.videoUrl;
if (src) {
const init = await fetch('https://api.linkedin.com/rest/images?action=initializeUpload', {
method: 'POST', headers: { ...H, 'Content-Type': 'application/json' },
body: JSON.stringify({ initializeUploadRequest: { owner: author } }),
});
const ij = await init.json().catch(() => ({}));
const up = ij.value && ij.value.uploadUrl; mediaId = ij.value && ij.value.image;
if (!init.ok || !up) return [{ ok: false, error: `image init ${init.status}: ${JSON.stringify(ij).slice(0, 150)}` }];
const img = await fetch(src);
if (!img.ok) return [{ ok: false, error: `couldn't fetch media (${img.status})` }];
const buf = Buffer.from(await img.arrayBuffer());
const put = await fetch(up, { method: 'PUT', headers: { Authorization: `Bearer ${token}` }, body: buf });
if (!put.ok) return [{ ok: false, error: `image upload ${put.status}` }];
}
const body = {
author, commentary: content.caption || '', visibility: 'PUBLIC',
distribution: { feedDistribution: 'MAIN_FEED', targetEntities: [], thirdPartyDistributionChannels: [] },
lifecycleState: 'PUBLISHED', isReshareDisabledByAuthor: false,
};
if (mediaId) body.content = { media: { id: mediaId } };
const r = await fetch('https://api.linkedin.com/rest/posts', {
method: 'POST', headers: { ...H, 'Content-Type': 'application/json' }, body: JSON.stringify(body),
});
const id = r.headers.get('x-restli-id') || r.headers.get('x-linkedin-id');
let j = null; try { j = await r.json(); } catch { /* create returns empty body */ }
return [{ ok: r.ok, id, error: r.ok ? null : ((j && j.message) || `HTTP ${r.status}`) }];
}
const ADAPTERS = { facebook: postFacebook, instagram: postInstagram, tiktok: postTikTok, youtube: postYouTube, bluesky: postBluesky, threads: postThreads, linkedin: postLinkedIn };
module.exports = {
id: 'channels',
title: 'Channels & Publish',
icon: '📡',
mount(router) {
router.get('/status', async (_req, res) => {
const st = augmentMetaHealth(platformStatus(), await metaTokenHealth());
const connected = Object.values(st).filter(p => p.connected).length;
res.json({ platforms: st, connectedCount: connected, total: Object.keys(st).length, outbox: readOutbox().length });
});
// Localize expiring IG/fbcdn media to durable local copies before serving
// (see lib/media-cache): cached→local path synchronously, background-fetch
// any missing so the next load self-heals. Never blocks the response.
router.get('/outbox', (_req, res) => res.json({ items: mediaCache.localizeItems(readOutbox().slice(-100).reverse()) }));
// IG media re-pull health: the board shows last-run time + result at a glance
// (see scripts/ig-media-repull.js, run daily by com.steve.mcc-ig-repull).
const REPULL_STATUS = path.join(__dirname, '..', '..', 'data', 'ig-repull-status.json');
router.get('/ig-repull-status', (_req, res) => {
try { res.json(JSON.parse(fs.readFileSync(REPULL_STATUS, 'utf8'))); }
catch { res.json({ ranAt: null }); } // never run yet
});
// "Run now" — the fix-it affordance. Triggers a --apply re-pull as a detached
// child (idempotent, backs up the outbox, only touches at-risk images). Guarded
// against concurrent runs; returns immediately, the status file reflects the result.
let repullRunning = false;
router.post('/ig-repull-run', (_req, res) => {
if (repullRunning) return res.status(409).json({ ok: false, error: 'a re-pull is already running' });
repullRunning = true;
const { spawn } = require('child_process');
const child = spawn(process.execPath, [path.join(__dirname, '..', '..', 'scripts', 'ig-media-repull.js'), '--apply'], { cwd: path.join(__dirname, '..', '..'), detached: false });
let out = '';
child.stdout.on('data', d => { out += d; });
child.stderr.on('data', d => { out += d; });
child.on('close', code => { repullRunning = false; });
child.on('error', () => { repullRunning = false; });
res.json({ ok: true, started: true });
});
// Clear outbox cards. Body: { scope } — 'staged' (default) drops everything that
// never went live (staged / pending-connection / failed), keeping real 'posted'
// history; 'all' wipes everything; { channel } limits it to one channel. Lets the
// board dismiss stale seed/staged cards (e.g. old pending-connection demo posts).
router.post('/outbox/clear', (req, res) => {
const { scope = 'staged', channel } = req.body || {};
const keptPosted = it => it.status === 'posted';
let items = readOutbox();
const before = items.length;
items = items.filter(it => {
if (channel && (it.channel || '').toLowerCase() !== String(channel).toLowerCase()) return true; // untouched
if (scope === 'all') return false;
return keptPosted(it); // 'staged' scope → keep only posted
});
writeOutbox(items);
res.json({ ok: true, removed: before - items.length, remaining: items.length });
});
// The Page picker's data source: the user's FB Pages (+ linked IG), discovered
// from /me/accounts. Tokens are STRIPPED before sending to the browser. Pass
// ?refresh=1 to re-fetch from Graph; otherwise serves the cache.
router.get('/pages', async (req, res) => {
try {
let cache = readMetaPages();
if (req.query.refresh === '1' || !cache.pages.length) cache = await fetchMetaPages();
const pages = cache.pages.map(p => ({ id: p.id, name: p.name, igUsername: p.igUsername, hasIG: !!p.igId, canPost: !!p.canPost }));
res.json({ fetchedAt: cache.fetchedAt, count: pages.length, igCount: pages.filter(p => p.hasIG).length, hasToken: !!env('META_ACCESS_TOKEN'), pages });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// Activate page data: provider cards + which keys are already set (booleans only,
// never the values) + the redirect URIs to register in each portal.
router.get('/setup', async (_req, res) => {
const st = augmentMetaHealth(platformStatus(), await metaTokenHealth());
const providers = SETUP_PROVIDERS.map(p => ({
id: p.id, title: p.title, icon: p.icon, blurb: p.blurb,
portal: p.portal, portalLabel: p.portalLabel, permissions: p.permissions,
redirectUris: p.noRedirect ? [] : p.platforms.map(pl => ({ platform: pl, uri: redirectUri(pl) })),
fields: p.fields.map(f => ({ key: f.key, label: f.label, secret: f.secret, ph: f.ph, set: !!env(f.key) })),
platforms: p.platforms.map(pl => ({
id: pl, label: st[pl].label, connected: st[pl].connected,
configured: st[pl].configured, connectUrl: st[pl].connectUrl,
caveat: st[pl].caveat || null,
needsReconnect: st[pl].needsReconnect || false,
tokenError: st[pl].tokenError || null,
tokenExpiresAt: st[pl].tokenExpiresAt || null,
})),
}));
res.json({ redirectBase: redirectBase(), providers });
});
// Save app credentials → .env (allowlisted keys only). Blank value = keep
// existing (we simply skip it) so re-saving one field doesn't wipe the rest.
router.post('/config', (req, res) => {
const keys = (req.body && req.body.keys) || {};
const updates = {};
for (const [k, v] of Object.entries(keys)) {
if (!ALLOWED_KEYS.has(k)) return res.status(400).json({ error: `key not allowed: ${k}` });
if (typeof v !== 'string') return res.status(400).json({ error: `bad value for ${k}` });
const t = v.trim();
if (t === '') continue; // blank = leave as-is
updates[k] = t;
}
if (!Object.keys(updates).length) return res.status(400).json({ error: 'nothing to save' });
try { setEnvKeys(updates); } catch (e) { return res.status(500).json({ error: e.message }); }
res.json({ ok: true, saved: Object.keys(updates), status: platformStatus() });
});
// Deep IG audit — find EVERY Instagram account this Meta user can reach, not
// just the page-linked ones. Three sources, deduped by IG id:
// 1. page-linked IG (from /me/accounts → instagram_business_account, per-page)
// 2. Business-Manager OWNED igs (/{business}/owned_instagram_accounts)
// 3. Business-Manager CLIENT igs (/{business}/client_instagram_accounts)
// Personal (non-Business/Creator) IG accounts are invisible to the Graph API and
// cannot appear here — that gap is reported so the count is honest.
router.get('/ig-audit', async (_req, res) => {
const token = env('META_ACCESS_TOKEN');
if (!token) return res.status(400).json({ error: 'META_ACCESS_TOKEN not set' });
const seen = new Map(); // igId → {username, id, via}
const add = (ig, via) => { if (ig && ig.id && !seen.has(ig.id)) seen.set(ig.id, { id: ig.id, username: ig.username || null, via }); };
const getJSON = async (u) => { try { const r = await fetch(u); return await r.json(); } catch { return {}; } };
try {
// 1. page-linked (reuse the fresh per-page resolver)
const cache = await fetchMetaPages();
for (const p of cache.pages) if (p.igId) add({ id: p.igId, username: p.igUsername }, 'page');
// 2 + 3. businesses → owned + client IG accounts
const biz = await getJSON(`${GRAPH}/me/businesses?fields=id,name&limit=100&access_token=${encodeURIComponent(token)}`);
const businesses = (biz.data || []);
for (const b of businesses) {
for (const edge of ['owned_instagram_accounts', 'client_instagram_accounts', 'instagram_business_accounts']) {
let url = `${GRAPH}/${b.id}/${edge}?fields=username,id&limit=200&access_token=${encodeURIComponent(token)}`, guard = 0;
while (url && guard++ < 20) {
const j = await getJSON(url);
for (const ig of (j.data || [])) add(ig, edge.replace('_instagram_accounts', ''));
url = j.paging?.next || null;
}
}
}
const all = [...seen.values()].sort((a, b) => (a.username || '').localeCompare(b.username || ''));
res.json({ total: all.length, businesses: businesses.map(b => b.name), byVia: all.reduce((m, a) => (m[a.via] = (m[a.via] || 0) + 1, m), {}), accounts: all });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// OAuth: start the connect flow → redirect the browser to the platform consent.
router.get('/connect/:platform', (req, res) => {
const url = authorizeUrl(req.params.platform);
if (!url) return res.status(400).send(`Channel '${req.params.platform}' has no app client id/secret configured yet. Paste them into .env first.`);
res.redirect(url);
});
// OAuth callback: exchange the code, store the token, mark connected.
router.get('/oauth/:platform/callback', async (req, res) => {
const p = req.params.platform;
if (req.query.error) return res.status(400).send(`Authorization declined: ${req.query.error_description || req.query.error}`);
if (!req.query.code) return res.status(400).send('No authorization code returned.');
try {
const tok = await exchangeCode(p, req.query.code);
const all = readTokens();
all[p] = { ...tok, connectedAt: new Date().toISOString() };
if (p === 'facebook' || p === 'instagram') { all.facebook = all[p]; all.instagram = all[p]; }
writeTokens(all);
// TikTok: platformStatus() connected-check AND postTikTok() both read
// env('TIKTOK_ACCESS_TOKEN'), but the callback above only persisted the token to
// channels-tokens.json — so a live post would send `Bearer undefined`. Mirror the
// freshly-minted token into the live env so posting sends a real Bearer.
if (p === 'tiktok' && tok && tok.access_token) { try { setEnvKeys({ TIKTOK_ACCESS_TOKEN: tok.access_token }); } catch { /* non-fatal */ } }
// Meta short-lived → long-lived, then re-cache ALL Pages. A short-lived
// user token (and any Page tokens derived from it) dies in ~1-2h; Page
// tokens derived from a LONG-LIVED user token never expire. So we upgrade
// once here, persist it as META_ACCESS_TOKEN, and fetch /me/accounts so
// the whole 80-Page / IG fleet comes back permanent — not just for an hour.
let fleetNote = '';
if (p === 'facebook' || p === 'instagram') {
try {
const longLived = await exchangeLongLivedMeta(tok.access_token);
setEnvKeys({ META_ACCESS_TOKEN: longLived }); // process.env updated live
const cache = await fetchMetaPages(); // re-cache all Pages + IG links
const igCount = cache.pages.filter(x => x.igId).length;
fleetNote = `<p>Restored <b>${cache.pages.length} Pages</b> (${igCount} with Instagram) — long-lived, won’t expire.</p>`;
} catch (e) {
fleetNote = `<p style="color:#b00">Connected, but fleet refresh failed: ${e.message}. Click “↻ refresh Pages” on the Pages picker.</p>`;
}
}
res.send(`<body style="font-family:system-ui;padding:50px;text-align:center"><h2>✓ ${p} connected</h2>${fleetNote}<p>You can close this tab and return to the Marketing Command Center.</p><a href="/#channels">← back to Channels</a></body>`);
} catch (e) { res.status(500).send(`Token exchange failed for ${p}: ${e.message}`); }
});
// Paste-a-token ingest — the fallback when the OAuth redirect is blocked (e.g.
// App Domains / redirect-URI not registered in the Meta app). Steve generates a
// user token in Graph API Explorer and pastes it; we upgrade it to long-lived,
// persist it as META_ACCESS_TOKEN, and re-cache the whole Page/IG fleet. Same end
// state as a successful OAuth callback, zero redirect config required.
router.post('/meta-token', async (req, res) => {
const raw = (req.body && (req.body.token || req.body.access_token) || '').trim();
if (!raw) return res.status(400).json({ error: 'no token provided' });
try {
let longLived = raw;
try { longLived = await exchangeLongLivedMeta(raw); } catch { /* already long-lived / exchange n/a → use as-is */ }
setEnvKeys({ META_ACCESS_TOKEN: longLived }); // process.env updated live
const cache = await fetchMetaPages(); // re-cache all Pages + IG (per-page resolve)
const igCount = cache.pages.filter(p => p.igId).length;
res.json({ ok: true, pages: cache.pages.length, ig: igCount, igAccounts: cache.pages.filter(p => p.igId).map(p => p.igUsername || p.igId) });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// The publish pipeline. Stages by default; only fires live with confirm + !dryRun
// AND a connected platform. Unconnected platforms always stage.
router.post('/publish', async (req, res) => {
try {
const d = req.body || {};
const channels = Array.isArray(d.channels) ? d.channels.filter(c => ADAPTERS[c]) : [];
const selectedPages = Array.isArray(d.pages) ? d.pages.map(String).filter(Boolean) : [];
const content = { caption: (d.caption || '').slice(0, 2200), mediaUrl: d.mediaUrl || '', videoUrl: d.videoUrl || '' };
if (!channels.length) return res.status(400).json({ error: 'no channels selected' });
if (!content.caption && !content.mediaUrl) return res.status(400).json({ error: 'empty post' });
// HARD RULE (Steve 2026-07-17): every DW social post MUST carry a photo/video/reel —
// NEVER text-only. Block a text-only publish server-side (dry-run staging too).
if (!content.mediaUrl && !content.videoUrl) {
return res.status(400).json({ error: 'A photo, video, or reel is required — DW never posts text-only. Attach an image and try again.' });
}
const st = platformStatus();
const live = d.confirm === true && d.dryRun === false;
const isVideo = !!content.videoUrl;
const isImage = !!content.mediaUrl && !content.videoUrl;
// Why a channel can't actually post right now (null = clear to fire).
// Respects the honest `postable` signal + media-type fit so a full fan-out
// silently SKIPS doomed attempts — Facebook (App Review), LinkedIn (dead
// token), and video-only TikTok/YouTube given an image — instead of firing
// them and collecting guaranteed errors in the outbox.
const blockedReason = (ch) => {
const s = st[ch] || {};
if (!s.connected) return 'not connected';
if (!s.postable) return s.postableNote || 'not postable';
const mt = s.mediaTypes || ['image', 'text'];
if (isVideo && !mt.includes('video')) return 'accepts image/text only — no video';
if (isImage && !mt.includes('image')) return `requires video — image not accepted (accepts: ${mt.join('/')})`;
return null;
};
const outbox = readOutbox();
const results = [];
for (const ch of channels) {
const block = blockedReason(ch);
if (live && !block) {
const r = await ADAPTERS[ch](content, { pages: selectedPages }).catch(e => [{ ok: false, error: e.message }]);
const ok = r.length > 0 && r.every(x => x.ok);
outbox.push({ id: 'pub-' + ch + '-' + outbox.length, channel: ch, caption: content.caption, mediaUrl: content.mediaUrl, pages: selectedPages.length || undefined, status: ok ? 'posted' : 'failed', detail: r, at: new Date().toISOString() });
results.push({ channel: ch, live: true, ok, detail: r });
} else {
const reason = block ? `skipped — ${block}`
: (d.dryRun !== false ? 'staged (dry-run)' : 'staged (needs confirm)');
outbox.push({ id: 'stg-' + ch + '-' + outbox.length, channel: ch, caption: content.caption, mediaUrl: content.mediaUrl, status: reason, at: new Date().toISOString() });
results.push({ channel: ch, live: false, skipped: !!block, staged: true, reason });
}
}
writeOutbox(outbox);
res.json({ ok: true, live, results, note: live ? 'Live posts fired to connected channels; unconnected staged.' : 'Staged only — connect channels + confirm with dryRun:false to post live.' });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// Flip a YouTube video's visibility (private ↔ unlisted ↔ public) via videos.update.
// Lets us post Private, review, then publish Public — customer-facing, so gated.
router.post('/youtube/visibility', async (req, res) => {
try {
const { videoId, privacy = 'public' } = req.body || {};
if (!videoId) return res.status(400).json({ error: 'videoId required' });
if (!['private', 'unlisted', 'public'].includes(privacy)) return res.status(400).json({ error: 'privacy must be private|unlisted|public' });
const token = await youtubeAccessToken();
if (!token) return res.status(400).json({ error: 'YouTube not connected' });
const r = await fetch('https://www.googleapis.com/youtube/v3/videos?part=status', {
method: 'PUT',
headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' },
body: JSON.stringify({ id: videoId, status: { privacyStatus: privacy } }),
});
const j = await r.json().catch(() => ({}));
if (!r.ok || j.error) return res.status(500).json({ error: JSON.stringify(j.error || {}).slice(0, 200) });
res.json({ ok: true, id: videoId, privacy: j.status?.privacyStatus, url: `https://youtu.be/${videoId}` });
} catch (e) { res.status(500).json({ error: e.message }); }
});
},
};
// ── Shared read-only exports for the Accounts panel ───────────────────────────
// The Accounts rail is a VIEW over the same credential truth this module owns.
// Rather than duplicate the env checks (and risk drift), expose the authoritative
// helpers so `modules/accounts` can aggregate without re-deriving. Additive — the
// server only reads id/title/icon/mount, so these extra props are inert to it.
module.exports.platformStatus = platformStatus;
module.exports.setupProviders = () => SETUP_PROVIDERS;
module.exports.redirectUriFor = redirectUri;
// Live token-health so the accounts rail can show the SAME truth as /channels/status
// (a dead Meta token flips connected:false instead of showing green on env-presence).
module.exports.metaTokenHealth = metaTokenHealth;
module.exports.augmentMetaHealth = augmentMetaHealth;
// Programmatic live publish for the engine scheduler. Approval in the Engine
// panel IS the confirm; this enforces the same rails as /publish.
module.exports.postLive = async function postLive(channel, content, opts = {}) {
if (!ADAPTERS[channel]) return { ok: false, error: 'unknown channel' };
if (!content.mediaUrl && !content.videoUrl)
return { ok: false, error: 'media required — DW never posts text-only' };
const st = platformStatus();
if (!st[channel] || !st[channel].connected) return { ok: false, error: 'not connected' };
const r = await ADAPTERS[channel](content, opts).catch(e => [{ ok: false, error: e.message }]);
const ok = Array.isArray(r) && r.length > 0 && r.every(x => x.ok);
const outbox = readOutbox();
const entry = { id: 'eng-' + channel + '-' + Date.now().toString(36), channel,
caption: content.caption, mediaUrl: content.mediaUrl, status: ok ? 'posted' : 'failed',
detail: r, via: 'engine', at: new Date().toISOString() };
outbox.push(entry); writeOutbox(outbox);
return { ok, detail: r, outboxId: entry.id };
};