← back to Dw Marketing Reels
scripts/publish-social.mjs
171 lines
#!/usr/bin/env node
// Auto-publish the newest reel to Instagram (via Norma's instagram-agent :9810
// /api/skill/reel) and TikTok (via the DW tiktok skill). Config-driven and
// FAIL-SAFE: if a channel's creds aren't wired yet it records 'pending-creds' /
// 'pending-reconnect' and posts NOTHING — it never hard-fails the nightly job,
// and it flips to live automatically once creds land. Runs on Mac3 (where the
// agents live), then the status is pushed to the marketing.dw console.
//
// Enable with SOCIAL_AUTOPOST=1. Channels via SOCIAL_CHANNELS=instagram,tiktok.
// IG: IG_AGENT_URL (default http://127.0.0.1:9810) + IG_AGENT_AUTH (Basic/Bearer header value)
// Media: PUBLIC_BASE (default https://marketing.designerwallcoverings.com). Reels under /reels/*.mp4
// are now PUBLICLY fetchable (unauthenticated 200/206 — the console/APIs stay Basic-Auth
// gated), verified 2026-07-13, so Meta/TikTok can pull the media URL. The media blocker is
// cleared; the only remaining go-live step is the deliberate SOCIAL_LIVE_ARMED=1 arm.
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { publishReelToTikTok, hasTikTokCreds } from './tiktok-post.mjs';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
const MAN = join(ROOT, 'data', 'reels.json');
const AUTOPOST = process.env.SOCIAL_AUTOPOST === '1';
// ARMED gate (Steve's standing pattern): even WITH valid creds, no real post fires until
// SOCIAL_LIVE_ARMED=1 — so the first live post is a deliberate, supervised act, never an
// accident of cred-landing. Until armed, a cred'd channel reports 'ready-pending-arm'.
const ARMED = process.env.SOCIAL_LIVE_ARMED === '1';
const CHANNELS = (process.env.SOCIAL_CHANNELS || 'instagram,tiktok').split(',').map(s => s.trim());
const IG_URL = process.env.IG_AGENT_URL || 'http://127.0.0.1:9810';
const IG_AUTH = process.env.IG_AGENT_AUTH || '';
const PUBLIC_BASE = process.env.PUBLIC_BASE || 'https://marketing.designerwallcoverings.com';
const now = () => new Date().toISOString();
// Content-claims gate (comms-compliance Fix 1, 2026-07-27): brand-promo reels carry factual
// advertising claims ("For 25 years…", "As seen in AD/Elle Decor/WSJ") that are FTC §5 exposure
// until substantiated. Default = HOLD any claim-bearing reel; release only after Steve confirms
// the claims are true by setting CLAIMS_SUBSTANTIATED=1. Mirrors the fail-safe ARM/leak-gate pattern.
const CLAIMS_OK = process.env.CLAIMS_SUBSTANTIATED === '1';
const CLAIM_RE = /\bas seen in\b|\bfor \d+\s*years\b|\bfeatured in\b|architectural digest|elle decor|\bwsj\b|wall street journal/i;
const claimsHold = reel => !CLAIMS_OK && CLAIM_RE.test([reel.caption, reel.title].filter(Boolean).join(' '));
async function withTimeout(p, ms = 12000) {
return Promise.race([p, new Promise((_, r) => setTimeout(() => r(new Error('timeout')), ms))]);
}
// Multi-account: each reel carries igAccount (set in the console; default 'dw').
// Per-account auth via IG_AGENT_AUTH_<ID> (uppercased, dashes→underscores),
// falling back to IG_AGENT_AUTH for the main DW account only.
function loadAccounts() {
try { return JSON.parse(readFileSync(join(ROOT, 'data', 'ig-accounts.json'), 'utf8')); } catch { return []; }
}
function accountFor(reel) {
const id = reel.igAccount || 'dw';
return loadAccounts().find(a => a.id === id) || { id, label: id, status: 'pending-creds', agentUrl: null };
}
function authFor(acc) {
const key = `IG_AGENT_AUTH_${acc.id.toUpperCase().replace(/-/g, '_')}`;
return process.env[key] || (acc.id === 'dw' ? IG_AUTH : '');
}
async function postInstagram(reel) {
const acc = accountFor(reel);
const auth = authFor(acc);
const tag = `${acc.label}${acc.handle ? ' @' + acc.handle : ''}`;
if (acc.status !== 'ready') return { status: 'pending-creds', account: acc.id, note: `${tag} not connected — wire creds + set status "ready" in ig-accounts.json`, at: now() };
if (!auth) return { status: 'pending-creds', account: acc.id, note: `${tag}: IG_AGENT_AUTH${acc.id === 'dw' ? '' : '_' + acc.id.toUpperCase().replace(/-/g, '_')} not set (route via secrets skill)`, at: now() };
if (!ARMED) return { status: 'ready-pending-arm', account: acc.id, note: `${tag} creds present; set SOCIAL_LIVE_ARMED=1 (Steve arm) to enable the live post`, at: now() };
const mediaUrl = `${PUBLIC_BASE}/reels/${encodeURIComponent(reel.file)}`;
try {
const res = await withTimeout(fetch(`${acc.agentUrl || IG_URL}/api/skill/reel`, {
method: 'POST',
headers: { 'content-type': 'application/json', authorization: auth },
// Norma's reel skill contract (skills/reel.js): video_url + caption. `mediaUrl` was a
// silent field-name mismatch — the live path would have 500'd on first arm (contrarian find).
body: JSON.stringify({ video_url: mediaUrl, caption: reel.caption, account: acc.id, source: 'dw-marketing-reels' }),
}), 90000); // IG reel publish is async (container→poll→publish, ~30-60s); the 12s default timed out MID-POST on the first live fire (2026-07-27, post still landed).
const txt = await res.text().catch(() => '');
let body = {}; try { body = JSON.parse(txt); } catch {}
if (!res.ok) return { status: res.status === 401 ? 'pending-creds' : 'error', account: acc.id, http: res.status, at: now() };
// Norma wraps the skill output as { success, skill, result } — the real status/ids
// live on .result, NOT the envelope. Reading the envelope reported a false 'posted'
// for a no-creds account (contrarian-caught). Unwrap, then honor the skill's verdict.
const r = body.result || body;
if (r.status === 'no-creds-for-account') return { status: 'no-creds-for-account', account: acc.id, note: r.note, at: now() };
// r.simulated is an explicit boolean from Norma's reel skill — read it directly.
// (The old /simulat/i text-regex falsely matched `"simulated":false`, mislabeling
// a real live post as 'simulated' — contrarian-adjacent bug, fixed with the unwrap.)
const simulated = r.simulated === true;
return { status: simulated ? 'simulated' : 'posted', account: acc.id, postId: r.media_id || r.id || r.postId || null, at: now() };
} catch (e) {
// A timeout means the request was already SENT to Norma — the post may have SUCCEEDED
// (IG's async publish outran the client timeout on the first live fire, 2026-07-27). NEVER
// blind-retry a timeout as a fresh post; mark 'posted-unverified' (the dup-guard skips it) so
// the nightly cron can't double-post. Reconcile to 'posted' out-of-band via Graph /media.
if (/timeout/i.test(e.message)) return { status: 'posted-unverified', account: acc.id, note: 'client timeout after request sent — verify on IG before assuming failure', at: now() };
return { status: 'error', account: acc.id, note: e.message, at: now() };
}
}
async function postTikTok(reel) {
if (!hasTikTokCreds()) return { status: 'pending-reconnect', note: 'TikTok creds absent (need TIKTOK_CLIENT_KEY/SECRET/REFRESH_TOKEN)', at: now() };
const DRY = process.env.TIKTOK_DRY_RUN === '1';
// TikTok has its OWN arm (TIKTOK_LIVE_ARMED), deliberately DECOUPLED from IG's SOCIAL_LIVE_ARMED:
// the DW production app is UNAUDITED, so TikTok only allows SELF_ONLY (private) posts to a private
// account until the audit passes. Keep TikTok dormant on the nightly cron until Steve arms it
// (post-audit) — so it never spams errors or posts privately-useless videos alongside the live IG feed.
// TIKTOK_DRY_RUN=1 is the ESCAPE HATCH: it exercises the real wiring (token refresh + creator_info +
// init-body assembly) but publishes NOTHING, so it's safe to run un-armed and un-audited.
if (!DRY && process.env.TIKTOK_LIVE_ARMED !== '1') {
return { status: 'ready-pending-tiktok-arm', note: 'uploader built (scripts/tiktok-post.mjs); set TIKTOK_DRY_RUN=1 to verify the wiring without posting, or TIKTOK_LIVE_ARMED=1 AFTER TikTok approves the production-app audit — until then only SELF_ONLY/private is allowed', at: now() };
}
const localPath = join(ROOT, 'reels', reel.file);
if (!existsSync(localPath)) return { status: 'error', note: `local reel missing: ${localPath}`, at: now() };
try {
const r = await publishReelToTikTok({ localPath, caption: reel.caption });
// Dry-run returns a rich wiring-verification object; pass its fields through unchanged (never a live post).
if (r.status === 'dry-run') return { ...r, at: now() };
return { status: r.status, publishId: r.publishId || null, privacy: process.env.TIKTOK_PRIVACY || 'SELF_ONLY', at: now() };
} catch (e) {
// Audit/permission blocks are expected pre-approval — surface as 'pending-audit', not a hard error.
if (/unaudited|not permitted|scope_not_authorized/i.test(e.message)) return { status: 'pending-audit', note: e.message, at: now() };
return { status: 'error', note: e.message, at: now() };
}
}
async function main() {
if (!existsSync(MAN)) { console.log('no reels.json — nothing to publish'); return; }
const reels = JSON.parse(readFileSync(MAN, 'utf8'));
const reel = reels[0];
if (!reel) { console.log('no reels'); return; }
if (!AUTOPOST) {
console.log('SOCIAL_AUTOPOST!=1 → dry mode; not publishing. Newest reel:', reel.file);
return;
}
reel.publish = reel.publish || {};
// Content-claims HOLD (Fix 1): never auto-post a reel with an unsubstantiated factual claim.
if (claimsHold(reel)) {
const held = { status: 'held-claims-review', note: 'unsubstantiated advertising claim (FTC §5) — set CLAIMS_SUBSTANTIATED=1 after Steve confirms the claim is true', at: now() };
if (CHANNELS.includes('instagram')) reel.publish.instagram = { ...held };
if (CHANNELS.includes('tiktok')) reel.publish.tiktok = { ...held };
writeFileSync(MAN, JSON.stringify(reels, null, 2));
console.log(`HELD ${reel.file}: unsubstantiated claim in caption — not posting until CLAIMS_SUBSTANTIATED=1`);
return;
}
// Duplicate-post guard (comms-compliance find, 2026-07-27): main() always takes reels[0].
// If a nightly build fails to prepend a new reel but this job still runs, an already-'posted'
// reel would be resubmitted to the Graph API — a duplicate public post (Meta prohibits
// inauthentic duplicated content). Only a genuine successful post is skipped; every non-terminal
// state (pending-creds / ready-pending-arm / error / simulated) is still (re)attempted.
const done = st => st && (st.status === 'posted' || st.status === 'already-posted' || st.status === 'posted-unverified');
if (CHANNELS.includes('instagram')) {
reel.publish.instagram = done(reel.publish.instagram)
? { ...reel.publish.instagram, status: 'already-posted', skipped: true, at: now() }
: await postInstagram(reel);
}
if (CHANNELS.includes('tiktok')) {
reel.publish.tiktok = done(reel.publish.tiktok)
? { ...reel.publish.tiktok, status: 'already-posted', skipped: true, at: now() }
: await postTikTok(reel);
}
writeFileSync(MAN, JSON.stringify(reels, null, 2));
console.log(`publish ${reel.file}:`);
for (const [ch, st] of Object.entries(reel.publish)) console.log(` ${ch}: ${st.status}${st.note ? ' — ' + st.note : ''}`);
}
main().catch(e => { console.error('publish error:', e.message); process.exit(0); }); // never hard-fail the nightly job