← 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
M .env.exampleM modules/social/index.js
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 →