[object Object]

← back to Marketing Command Center

social: add free Meta Graph (IG+FB) publish backend behind ayrsharePost abstraction

0f9d5967e71aada0f16abf1063abe962322a4237 · 2026-06-12 10:36:05 -0700 · SteveStudio2

- backendName() picks ayrshare (paid) > meta (free) > null (stage-only)
- metaPost(): IG 2-step container+publish, FB /photos|/feed, native Graph API
- publish() dispatcher routes /publish route + scheduler to active backend
- /connection reports backend + liveChannels; all confirm/approved gates intact
- .env.example documents META_ACCESS_TOKEN/IG_USER_ID/FB_PAGE_ID (free path)

Files touched

Diff

commit 0f9d5967e71aada0f16abf1063abe962322a4237
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date:   Fri Jun 12 10:36:05 2026 -0700

    social: add free Meta Graph (IG+FB) publish backend behind ayrsharePost abstraction
    
    - backendName() picks ayrshare (paid) > meta (free) > null (stage-only)
    - metaPost(): IG 2-step container+publish, FB /photos|/feed, native Graph API
    - publish() dispatcher routes /publish route + scheduler to active backend
    - /connection reports backend + liveChannels; all confirm/approved gates intact
    - .env.example documents META_ACCESS_TOKEN/IG_USER_ID/FB_PAGE_ID (free path)
---
 .env.example            |  18 ++++++--
 modules/social/index.js | 115 +++++++++++++++++++++++++++++++++++++++++++-----
 2 files changed, 120 insertions(+), 13 deletions(-)

diff --git a/.env.example b/.env.example
index d237e8f..740d369 100644
--- a/.env.example
+++ b/.env.example
@@ -7,10 +7,22 @@ CTCT_ACCESS_TOKEN=
 CTCT_REFRESH_TOKEN=
 # LLM for suggested copy (optional; falls back to local templates if absent)
 ANTHROPIC_API_KEY=
-# Social live posting (Ayrshare — one key posts to IG/TikTok/Pinterest/FB).
-# Without it the Social module stages only and sends nothing. A post still
-# requires confirm:true + approved:true to ever go live.
+# ── Social live posting — pick ONE backend (or neither = stage only) ─────────
+# A post ALWAYS requires confirm:true + approved:true to ever go live, on
+# either backend. With no backend configured the module stages and sends nothing.
+#
+# Option A — Ayrshare (paid, ~$149/mo): one key → IG/TikTok/Pinterest/FB.
 AYRSHARE_API_KEY=
+#
+# Option B — Meta Graph API (FREE): Instagram + Facebook only. Needs a
+# long-lived Page access token + the IG Business account id and/or FB Page id.
+# (Get these from a Meta app: Page → linked IG Business/Creator account.)
+# Ayrshare takes priority if both are set.
+META_ACCESS_TOKEN=
+META_IG_USER_ID=
+META_FB_PAGE_ID=
+# Graph API version (optional; defaults to v21.0)
+META_GRAPH_VERSION=
 # Set to 'true' ONLY when you want the scheduler to auto-post due+approved posts.
 # Leave unset/false to keep the scheduler in dry mode (logs, never sends).
 SOCIAL_SCHEDULER_LIVE=
diff --git a/modules/social/index.js b/modules/social/index.js
index 10184df..ccb0aa6 100644
--- a/modules/social/index.js
+++ b/modules/social/index.js
@@ -22,7 +22,34 @@ const STATUSES = ['idea', 'drafted', 'scheduled', 'posted'];
 
 // Ayrshare uses the same lowercase platform names we already use as CHANNELS.
 const AYRSHARE_API = 'https://api.ayrshare.com/api/post';
-const liveEnabled = () => !!process.env.AYRSHARE_API_KEY;
+
+// ── live-posting backend selection ───────────────────────────────────────────
+// Two ways to actually post:
+//   • ayrshare — one paid key ($149/mo) → IG/TikTok/Pinterest/FB.
+//   • meta     — FREE native Graph API → Instagram + Facebook only.
+// Ayrshare wins if both are configured (it covers more channels).
+const META_GRAPH_VERSION = () => process.env.META_GRAPH_VERSION || 'v21.0';
+const metaConfigured = () =>
+  !!process.env.META_ACCESS_TOKEN &&
+  (!!process.env.META_IG_USER_ID || !!process.env.META_FB_PAGE_ID);
+function backendName() {
+  if (process.env.AYRSHARE_API_KEY) return 'ayrshare';
+  if (metaConfigured()) return 'meta';
+  return null;
+}
+// Channels the active backend can actually reach (Meta = IG+FB only).
+function backendChannels() {
+  const b = backendName();
+  if (b === 'ayrshare') return CHANNELS;
+  if (b === 'meta') {
+    const c = [];
+    if (process.env.META_IG_USER_ID) c.push('instagram');
+    if (process.env.META_FB_PAGE_ID) c.push('facebook');
+    return c;
+  }
+  return [];
+}
+const liveEnabled = () => backendName() !== null;
 
 // ── tiny JSON persistence helpers ────────────────────────────────────────────
 function readJson(file, fallback) {
@@ -114,6 +141,69 @@ function ayrsharePost({ platforms, caption, hashtags, imageUrl, scheduleDate })
   });
 }
 
+// ── Meta Graph publisher (FREE — Instagram + Facebook only) ──────────────────
+// Uses the official Graph API. Requires a long-lived Page access token plus the
+// IG Business account id and/or the FB Page id. Posts nothing for tiktok/pinterest
+// (returns a clear per-channel error — those need Ayrshare). NEVER throws.
+function graphPost(pathPart, params) {
+  return new Promise((resolve) => {
+    const body = new URLSearchParams(params).toString();
+    const req = https.request(`https://graph.facebook.com/${META_GRAPH_VERSION()}/${pathPart}`, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'Content-Length': Buffer.byteLength(body) },
+    }, r => { let d = ''; r.on('data', c => d += c); r.on('end', () => {
+      let json; try { json = JSON.parse(d); } catch { json = { raw: d.slice(0, 500) }; }
+      resolve({ status: r.statusCode, json });
+    }); });
+    req.on('error', e => resolve({ status: 0, json: { error: { message: String(e).slice(0, 200) } } }));
+    req.write(body); req.end();
+  });
+}
+async function metaPost({ platforms, caption, hashtags, imageUrl }) {
+  const token = process.env.META_ACCESS_TOKEN;
+  const igUser = process.env.META_IG_USER_ID;
+  const fbPage = process.env.META_FB_PAGE_ID;
+  const text = [caption, (hashtags || []).join(' ')].filter(Boolean).join('\n\n').trim();
+  const results = {};
+  for (const pl of platforms) {
+    if (pl === 'instagram') {
+      if (!igUser) { results.instagram = { ok: false, error: 'META_IG_USER_ID not set' }; continue; }
+      if (!imageUrl) { results.instagram = { ok: false, error: 'Instagram requires a public imageUrl (Graph IG publishing is media-only)' }; continue; }
+      // Two-step: create media container, then publish it.
+      const c = await graphPost(`${igUser}/media`, { image_url: imageUrl, caption: text, access_token: token });
+      if (!c.json.id) { results.instagram = { ok: false, status: c.status, error: (c.json.error && c.json.error.message) || 'container create failed' }; continue; }
+      const pub = await graphPost(`${igUser}/media_publish`, { creation_id: c.json.id, access_token: token });
+      results.instagram = pub.json.id
+        ? { ok: true, id: pub.json.id }
+        : { ok: false, status: pub.status, error: (pub.json.error && pub.json.error.message) || 'media_publish failed' };
+    } else if (pl === 'facebook') {
+      if (!fbPage) { results.facebook = { ok: false, error: 'META_FB_PAGE_ID not set' }; continue; }
+      const r = imageUrl
+        ? await graphPost(`${fbPage}/photos`, { url: imageUrl, caption: text, access_token: token })
+        : await graphPost(`${fbPage}/feed`, { message: text, access_token: token });
+      const id = r.json.id || r.json.post_id;
+      results.facebook = id
+        ? { ok: true, id }
+        : { ok: false, status: r.status, error: (r.json.error && r.json.error.message) || 'page post failed' };
+    } else {
+      results[pl] = { ok: false, error: `Meta backend supports only instagram + facebook — use Ayrshare for ${pl}` };
+    }
+  }
+  const vals = Object.values(results);
+  const ok = vals.length > 0 && vals.every(r => r.ok);
+  return { ok, live: true, backend: 'meta', response: results };
+}
+
+// ── publish dispatcher ───────────────────────────────────────────────────────
+// Routes a publish to the active backend. No backend configured → mock (sends
+// nothing). The /publish route + scheduler call THIS, not a specific backend.
+function publish(opts) {
+  const b = backendName();
+  if (b === 'ayrshare') return ayrsharePost(opts);
+  if (b === 'meta') return metaPost(opts);
+  return Promise.resolve({ ok: true, live: false, mock: true, wouldSend: opts });
+}
+
 // ── scheduler ────────────────────────────────────────────────────────────────
 // Ticks every 60s. Finds posts that are status:'scheduled', approved:true, and
 // due (date+time <= now). Behaviour:
@@ -134,9 +224,9 @@ async function schedulerTick() {
   if (!due.length) return;
   const live = process.env.SOCIAL_SCHEDULER_LIVE === 'true' && liveEnabled();
   for (const p of due) {
-    if (!live) { appendLog({ kind: 'scheduler-dry', id: p.id, channel: p.channel, note: 'due+approved; SOCIAL_SCHEDULER_LIVE off or no key — not sent' }); continue; }
-    const r = await ayrsharePost({ platforms: [p.channel], caption: p.caption, hashtags: p.hashtags, imageUrl: p.imageUrl });
-    appendLog({ kind: 'scheduler-publish', id: p.id, channel: p.channel, ok: r.ok, result: r.response || r.error });
+    if (!live) { appendLog({ kind: 'scheduler-dry', id: p.id, channel: p.channel, note: 'due+approved; SOCIAL_SCHEDULER_LIVE off or no backend — not sent' }); continue; }
+    const r = await publish({ platforms: [p.channel], caption: p.caption, hashtags: p.hashtags, imageUrl: p.imageUrl });
+    appendLog({ kind: 'scheduler-publish', id: p.id, channel: p.channel, backend: backendName(), ok: r.ok, result: r.response || r.error });
     if (r.ok) { const fresh = loadQueue(); const i = fresh.findIndex(x => x.id === p.id); if (i >= 0) { fresh[i].status = 'posted'; fresh[i].postedAt = new Date().toISOString(); saveQueue(fresh); } }
   }
 }
@@ -265,23 +355,28 @@ module.exports = {
         });
       }
 
-      // Key present + confirmed + approved → real publish via Ayrshare.
+      // Backend configured + confirmed + approved → real publish (Ayrshare or Meta).
       (async () => {
-        const r = await ayrsharePost({ platforms: [post.channel], caption: post.caption, hashtags: post.hashtags, imageUrl: post.imageUrl });
-        appendLog({ kind: 'publish-live', id: post.id, channel: post.channel, ok: r.ok, result: r.response || r.error });
+        const r = await publish({ platforms: [post.channel], caption: post.caption, hashtags: post.hashtags, imageUrl: post.imageUrl });
+        appendLog({ kind: 'publish-live', id: post.id, channel: post.channel, backend: backendName(), ok: r.ok, result: r.response || r.error });
         if (r.ok) { const fresh = loadQueue(); const i = fresh.findIndex(x => x.id === post.id); if (i >= 0) { fresh[i].status = 'posted'; fresh[i].postedAt = new Date().toISOString(); saveQueue(fresh); } }
         res.status(r.ok ? 200 : 502).json({ ok: r.ok, dryRun: false, live: true, staged: false, would, result: r.response || r.error });
       })();
     });
 
-    // GET /connection — is live posting wired? (no secret values leaked)
+    // GET /connection — is live posting wired? which backend? (no secret values leaked)
     router.get('/connection', (req, res) => {
+      const backend = backendName();
       res.json({
-        provider: 'ayrshare',
+        provider: backend || 'none',
+        backend,                                  // 'ayrshare' | 'meta' | null
         keyPresent: liveEnabled(),
         schedulerLive: process.env.SOCIAL_SCHEDULER_LIVE === 'true',
-        mode: liveEnabled() ? (process.env.SOCIAL_SCHEDULER_LIVE === 'true' ? 'LIVE' : 'manual-only (scheduler dry)') : 'staging (no key)',
+        mode: backend
+          ? (process.env.SOCIAL_SCHEDULER_LIVE === 'true' ? 'LIVE' : 'manual-only (scheduler dry)')
+          : 'staging (no backend)',
         channels: CHANNELS,
+        liveChannels: backendChannels(),          // which channels the active backend can actually post to
       });
     });
 

← 751cc47 gitignore transient social-publish-log.json  ·  back to Marketing Command Center  ·  social: add validated meta-go-live.sh (Graph-verify token → c6baf4f →