// Thin Meta Graph API client for Facebook Pages + Instagram (via Facebook Login / System User token). import { loadEnv } from "./env.js"; loadEnv(); const VERSION = process.env.META_API_VERSION || "v23.0"; const BASE = `https://graph.facebook.com/${VERSION}`; function systemToken() { const t = process.env.META_SYSTEM_USER_TOKEN; if (!t || t.startsWith("PASTE")) { throw new Error("META_SYSTEM_USER_TOKEN is missing. Put it in the .env file in the project root."); } return t; } export async function graph(pathname, params = {}, { method = "GET", token } = {}) { const url = new URL(`${BASE}/${pathname.replace(/^\//, "")}`); const body = new URLSearchParams(); const all = { ...params, access_token: token || systemToken() }; for (const [k, v] of Object.entries(all)) { if (v === undefined || v === null) continue; (method === "GET" ? url.searchParams : body).set(k, String(v)); } const res = await fetch(url, method === "GET" ? {} : { method, body }); const json = await res.json().catch(() => ({})); if (!res.ok || json.error) { const e = json.error || {}; const err = new Error(`Meta API error on ${pathname}: ${e.message || res.statusText} (code ${e.code ?? res.status}${e.error_subcode ? "/" + e.error_subcode : ""})`); err.meta = e; throw err; } return json; } // ---------- Accounts ---------- let accountCache = null; /** All Pages the system user can access, with page tokens and linked Instagram accounts. */ export async function listAccounts({ refresh = false } = {}) { if (accountCache && !refresh) return accountCache; const out = []; let next = "me/accounts"; let params = { fields: "id,name,category,access_token,tasks,instagram_business_account{id,username,name,followers_count,media_count}", limit: 100, }; while (next) { const page = await graph(next, params); for (const p of page.data || []) { out.push({ page_id: p.id, page_name: p.name, category: p.category, tasks: p.tasks, page_token: p.access_token, instagram: p.instagram_business_account || null, }); } const nextUrl = page.paging?.next; if (!nextUrl) break; const u = new URL(nextUrl); next = u.pathname.replace(`/${VERSION}/`, ""); params = Object.fromEntries([...u.searchParams].filter(([k]) => k !== "access_token")); } // Instagram accounts assigned to the system user directly, with no Facebook Page. try { const onPages = new Set(out.map((a) => a.instagram?.id).filter(Boolean)); const ig = await graph("me/assigned_instagram_accounts", { fields: "id,username,name,followers_count,media_count", limit: 100 }); for (const i of ig.data || []) { if (onPages.has(i.id)) continue; out.push({ page_id: null, page_name: i.name || i.username, category: "Instagram only", tasks: null, page_token: null, instagram: i, ig_only: true }); } } catch { /* older setups: Pages only */ } accountCache = out; return out; } /** Stable id for a connected account: the Page id, or "ig:" for an Instagram account with no Facebook Page. */ export const accountKey = (a) => a.page_id || `ig:${a.instagram?.id}`; /** The connected account a client's link (client.json) points at: by Page, or by Instagram id when there's no Page. */ export async function accountForLink({ pageId, igId } = {}) { const pick = (list) => (pageId ? list.find((a) => a.page_id === pageId) : igId ? list.find((a) => a.instagram?.id === igId) : null); return pick(await listAccounts()) || pick(await listAccounts({ refresh: true })) || null; } function needPage(account) { if (!account.page_id) throw Object.assign(new Error(`${account.page_name} is Instagram-only (no Facebook Page), so Facebook features aren't available for it.`), { status: 400 }); } /** Find an account by page id, IG id, page name or IG username (case-insensitive, partial ok). */ export async function findAccount(query) { const q = String(query).toLowerCase().replace(/^@/, ""); const accts = await listAccounts(); return ( accts.find((a) => a.page_id === q || a.instagram?.id === q) || accts.find((a) => a.instagram?.username?.toLowerCase() === q || a.page_name.toLowerCase() === q) || accts.find((a) => a.page_name.toLowerCase().includes(q) || a.instagram?.username?.toLowerCase().includes(q)) || null ); } export function publicAccount(a) { const { page_token, ...rest } = a; return rest; } // Request metrics one by one so a deprecated/unsupported metric doesn't kill the whole call. async function metricsOneByOne(objectId, metrics, extra, token) { const results = {}; const errors = {}; for (const m of metrics) { try { const r = await graph(`${objectId}/insights`, { metric: m, ...extra }, { token }); results[m] = r.data?.[0] ?? null; } catch (e) { errors[m] = e.message; } } return { results, errors }; } const days = (n) => Math.floor(Date.now() / 1000) - n * 86400; // ---------- Instagram ---------- export const IG_ACCOUNT_METRICS = ["reach", "views", "accounts_engaged", "total_interactions", "likes", "comments", "shares", "saves", "follows_and_unfollows", "profile_links_taps"]; export async function igAccountInsights(igId, { sinceDays = 28, metrics = IG_ACCOUNT_METRICS } = {}) { const profile = await graph(igId, { fields: "id,username,name,biography,followers_count,follows_count,media_count,website,profile_picture_url" }); // total_value metrics allow max 30-day windows const window = { period: "day", metric_type: "total_value", since: days(Math.min(sinceDays, 30)), until: days(0) }; const plain = metrics.filter((m) => m !== "follows_and_unfollows"); const { results, errors } = await metricsOneByOne(igId, plain, window); const totals = Object.fromEntries(Object.entries(results).map(([k, v]) => [k, v?.total_value?.value ?? null])); if (metrics.includes("follows_and_unfollows")) { // This metric only returns data with a follow_type breakdown const f = await metricsOneByOne(igId, ["follows_and_unfollows"], { ...window, breakdown: "follow_type" }); const rows = f.results.follows_and_unfollows?.total_value?.breakdowns?.[0]?.results || []; const get = (t) => rows.find((r) => r.dimension_values?.[0] === t)?.value ?? 0; if (f.results.follows_and_unfollows) { totals.new_follows = get("FOLLOWER"); totals.unfollows = get("NON_FOLLOWER"); } Object.assign(errors, f.errors); } return { profile, window_days: Math.min(sinceDays, 30), totals, errors }; } export async function igRecentMedia(igId, { limit = 12, withInsights = true } = {}) { const r = await graph(`${igId}/media`, { fields: "id,caption,media_type,media_product_type,media_url,thumbnail_url,permalink,timestamp,like_count,comments_count", limit, }); const media = r.data || []; if (withInsights) { for (const m of media) { const metrics = ["reach", "views", "saved", "shares", "total_interactions"]; const { results, errors } = await metricsOneByOne(m.id, metrics, {}); m.insights = Object.fromEntries(Object.entries(results).map(([k, v]) => [k, v?.values?.[0]?.value ?? null])); if (Object.keys(errors).length) m.insight_errors = errors; } } return media; } export async function igComments(mediaId, { limit = 50 } = {}) { const r = await graph(`${mediaId}/comments`, { fields: "id,text,username,timestamp,like_count,replies{id,text,username,timestamp}", limit }); return r.data || []; } /** Publish an image or reel to Instagram (2-step container flow). media_url must be a public URL. */ export async function igPublish(igId, { mediaUrl, caption, type = "IMAGE" }) { const params = { caption }; if (type === "REELS") Object.assign(params, { media_type: "REELS", video_url: mediaUrl }); else params.image_url = mediaUrl; const container = await graph(`${igId}/media`, params, { method: "POST" }); // Wait for video processing if (type === "REELS") { for (let i = 0; i < 30; i++) { const s = await graph(container.id, { fields: "status_code" }); if (s.status_code === "FINISHED") break; if (s.status_code === "ERROR") throw new Error("Instagram could not process the video."); await new Promise((r) => setTimeout(r, 5000)); } } return graph(`${igId}/media_publish`, { creation_id: container.id }, { method: "POST" }); } // ---------- Facebook Page ---------- export const PAGE_METRICS = ["page_post_engagements", "page_views_total", "page_daily_follows_unique", "page_media_view", "page_total_media_view_unique"]; export async function pageInsights(account, { sinceDays = 28, metrics = PAGE_METRICS } = {}) { needPage(account); const info = await graph(account.page_id, { fields: "id,name,fan_count,followers_count,link" }, { token: account.page_token }); const window = { period: "day", since: days(Math.min(sinceDays, 90)), until: days(0) }; const { results, errors } = await metricsOneByOne(account.page_id, metrics, window, account.page_token); const totals = Object.fromEntries( Object.entries(results).map(([k, v]) => [k, (v?.values || []).reduce((s, x) => s + (typeof x.value === "number" ? x.value : 0), 0)]) ); return { page: info, window_days: Math.min(sinceDays, 90), totals, errors }; } export async function pageRecentPosts(account, { limit = 10 } = {}) { needPage(account); const r = await graph( `${account.page_id}/posts`, { fields: "id,message,created_time,permalink_url,full_picture,shares,reactions.summary(total_count).limit(0),comments.summary(total_count).limit(0)", limit }, { token: account.page_token } ); return (r.data || []).map((p) => ({ id: p.id, created_time: p.created_time, permalink_url: p.permalink_url, message: p.message, picture: p.full_picture || null, reactions: p.reactions?.summary?.total_count ?? 0, comments: p.comments?.summary?.total_count ?? 0, shares: p.shares?.count ?? 0, })); } export async function pagePublish(account, { message, imageUrl, link }) { needPage(account); if (imageUrl) return graph(`${account.page_id}/photos`, { url: imageUrl, caption: message }, { method: "POST", token: account.page_token }); return graph(`${account.page_id}/feed`, { message, link }, { method: "POST", token: account.page_token }); } // ===================================================================== // Added 2026-10-17: engagement, discovery, audience, richer publishing // ===================================================================== import fsSync from "node:fs"; import pathMod from "node:path"; const GRAPH_VIDEO = `https://graph-video.facebook.com/${VERSION}`; /** Multipart POST (file uploads). `file` = local path. */ async function graphUpload(pathname, fields, filePath, { token, fileField = "source", base = BASE } = {}) { const form = new FormData(); for (const [k, v] of Object.entries(fields || {})) if (v !== undefined && v !== null) form.set(k, String(v)); form.set("access_token", token || systemToken()); const buf = fsSync.readFileSync(filePath); form.set(fileField, new Blob([buf]), pathMod.basename(filePath)); const res = await fetch(`${base}/${pathname.replace(/^\//, "")}`, { method: "POST", body: form }); const json = await res.json().catch(() => ({})); if (!res.ok || json.error) throw new Error(`Meta upload error on ${pathname}: ${json.error?.message || res.statusText}`); return json; } const isUrl = (s) => /^https?:\/\//i.test(String(s || "")); const isVideo = (s) => /\.(mp4|mov|m4v)$/i.test(String(s || "").split("?")[0]); // ---------- Comments (Instagram + Facebook) ---------- export async function igReplyComment(commentId, message) { return graph(`${commentId}/replies`, { message }, { method: "POST" }); } export async function igHideComment(commentId, hide = true) { return graph(commentId, { hide }, { method: "POST" }); } export async function fbPostComments(account, postId, { limit = 50 } = {}) { const r = await graph(`${postId}/comments`, { fields: "id,message,from{id,name},created_time,comment_count,is_hidden,comments.limit(5){id,message,from{id,name},created_time}", filter: "toplevel", order: "reverse_chronological", limit, }, { token: account.page_token }); return r.data || []; } export async function fbReplyComment(account, commentId, message) { return graph(`${commentId}/comments`, { message }, { method: "POST", token: account.page_token }); } export async function fbHideComment(account, commentId, hide = true) { return graph(commentId, { is_hidden: hide }, { method: "POST", token: account.page_token }); } /** Unified inbox: recent comments on recent posts, newest first, flagged if nobody from the account replied. */ export async function commentInbox(account, { days = 14, posts = 12 } = {}) { const since = Date.now() - days * 86400000; const out = []; if (account.instagram) { const igUser = account.instagram.username; const media = (await graph(`${account.instagram.id}/media`, { fields: "id,caption,permalink,timestamp,comments_count", limit: posts })).data || []; for (const m of media.filter((x) => x.comments_count > 0)) { const cs = await graph(`${m.id}/comments`, { fields: "id,text,username,timestamp,hidden,replies{id,text,username,timestamp}", limit: 50 }).then((r) => r.data || []).catch(() => []); for (const c of cs) { if (new Date(c.timestamp) < since || c.username === igUser) continue; const replied = (c.replies?.data || []).some((r) => r.username === igUser); out.push({ platform: "instagram", id: c.id, from: c.username, text: c.text, at: c.timestamp, hidden: !!c.hidden, replied, post: { id: m.id, permalink: m.permalink, caption: (m.caption || "").slice(0, 80) } }); } } } const fbPosts = account.page_id ? await pageRecentPosts(account, { limit: posts }).catch(() => []) : []; for (const p of fbPosts.filter((x) => x.comments > 0)) { const cs = await fbPostComments(account, p.id).catch(() => []); for (const c of cs) { if (new Date(c.created_time) < since || c.from?.id === account.page_id) continue; const replied = (c.comments?.data || []).some((r) => r.from?.id === account.page_id); out.push({ platform: "facebook", id: c.id, from: c.from?.name || "Facebook user", text: c.message, at: c.created_time, hidden: !!c.is_hidden, replied, post: { id: p.id, permalink: p.permalink_url, caption: (p.message || "").slice(0, 80) } }); } } return out.sort((a, b) => b.at.localeCompare(a.at)); } // ---------- Mentions, competitors, audience ---------- /** Posts where other accounts tagged this Instagram account. */ export async function igMentions(igId, { limit = 25 } = {}) { const r = await graph(`${igId}/tags`, { fields: "id,caption,media_type,permalink,timestamp,username,like_count,comments_count", limit }); return r.data || []; } /** Public stats and recent posts of any other Instagram business/creator account (competitor research). */ export async function igBusinessDiscovery(igId, username, { posts = 12 } = {}) { const u = String(username).replace(/^@/, ""); const r = await graph(igId, { fields: `business_discovery.username(${u}){username,name,biography,website,followers_count,follows_count,media_count,media.limit(${posts}){id,caption,like_count,comments_count,media_product_type,media_type,timestamp,permalink}}`, }); const b = r.business_discovery; const media = b?.media?.data || []; const avg = (k) => (media.length ? Math.round(media.reduce((s, m) => s + (m[k] || 0), 0) / media.length) : 0); return { ...b, media, avg_likes: avg("like_count"), avg_comments: avg("comments_count") }; } /** Follower demographics: top cities, countries, age and gender. */ export async function igAudience(igId) { const out = {}; const errors = {}; for (const breakdown of ["city", "country", "age", "gender"]) { let r = null; for (const extra of [{}, { timeframe: "this_month" }, { timeframe: "last_30_days" }]) { try { r = await graph(`${igId}/insights`, { metric: "follower_demographics", period: "lifetime", metric_type: "total_value", breakdown, ...extra }); break; } catch (e) { errors[breakdown] = e.message; } } const rows = r?.data?.[0]?.total_value?.breakdowns?.[0]?.results || []; if (rows.length) { delete errors[breakdown]; out[breakdown] = rows.map((x) => ({ key: x.dimension_values?.join(" / "), value: x.value })).sort((a, b) => b.value - a.value).slice(0, 10); } } // Hours when followers are online (may be unavailable on newer API versions). try { const r = await graph(`${igId}/insights`, { metric: "online_followers", period: "lifetime" }); const v = r.data?.[0]?.values?.slice(-1)[0]?.value; if (v) out.online_hours_utc = v; } catch (e) { errors.online_followers = e.message; } return { ...out, errors }; } // ---------- Direct messages (needs instagram_manage_messages / pages_messaging) ---------- // Instagram-only accounts (no Page) go through the Instagram account itself with the system user token. export async function listConversations(account, { platform = "instagram", limit = 20 } = {}) { if (!account.page_id && platform !== "instagram") needPage(account); const node = account.page_id || account.instagram?.id; const r = await graph(`${node}/conversations`, { platform, limit, fields: "id,updated_time,participants,messages.limit(3){id,message,from,created_time}", }, { token: account.page_token || undefined }); return r.data || []; } export async function sendMessage(account, recipientId, text, { platform = "instagram" } = {}) { if (!account.page_id && platform !== "instagram") needPage(account); const node = account.page_id || account.instagram?.id; return graph(`${node}/messages`, { recipient: JSON.stringify({ id: recipientId }), message: JSON.stringify({ text }), messaging_type: "RESPONSE", ...(platform === "instagram" ? { platform: "instagram" } : {}), }, { method: "POST", token: account.page_token || undefined }); } // ---------- Media hosting helpers ---------- /** * Turn a local image into a public URL by uploading it as an UNPUBLISHED photo (never shown on the Page). * Uses the client's own Page; Instagram-only clients use the agency's own Page (META_MEDIA_PAGE_ID in .env). * Never another client's Page: their admins would see the upload in their media library. */ async function mediaPageFor(account) { if (account.page_token) return account; const id = process.env.META_MEDIA_PAGE_ID; if (!id) throw new Error("Instagram-only clients need META_MEDIA_PAGE_ID in .env (the agency's own Facebook Page ID) to post images."); const host = (await listAccounts()).find((a) => a.page_id === id && a.page_token); if (!host) throw new Error("META_MEDIA_PAGE_ID doesn't match a Facebook Page the agency's system user can manage."); return host; } export async function hostImage(account, filePath) { const host = await mediaPageFor(account); const up = await graphUpload(`${host.page_id}/photos`, { published: false }, filePath, { token: host.page_token }); const info = await graph(up.id, { fields: "images" }, { token: host.page_token }); const best = (info.images || []).sort((a, b) => b.width - a.width)[0]; if (!best?.source) throw new Error("Couldn't get a public URL for the uploaded image."); return best.source; } async function waitForContainer(id, { tries = 60, every = 5000 } = {}) { for (let i = 0; i < tries; i++) { const s = await graph(id, { fields: "status_code,status" }); if (s.status_code === "FINISHED") return; if (s.status_code === "ERROR" || s.status_code === "EXPIRED") throw new Error(`Instagram couldn't process the media: ${s.status || s.status_code}`); await new Promise((r) => setTimeout(r, every)); } throw new Error("Instagram is still processing the video after 5 minutes. Try again later."); } /** Upload a local video straight to Instagram (resumable upload). Returns a container id. */ async function igUploadLocalVideo(igId, filePath, params) { const c = await graph(`${igId}/media`, { ...params, upload_type: "resumable" }, { method: "POST" }); const size = fsSync.statSync(filePath).size; const res = await fetch(c.uri, { method: "POST", headers: { Authorization: `OAuth ${systemToken()}`, offset: "0", file_size: String(size) }, body: fsSync.readFileSync(filePath), }); const j = await res.json().catch(() => ({})); if (!res.ok || j.success === false) throw new Error(`Video upload failed: ${j.debug_info?.message || j.error?.message || res.statusText}`); return c.id; } /** * Publish anything to Instagram. * type: IMAGE | REELS | CAROUSEL | STORY. media: array of local paths or public URLs. * collaborators: array of usernames (Collab post invites). */ export async function igPublishAny(account, { type = "IMAGE", media = [], caption = "", collaborators = [], coverUrl, shareToFeed = true }) { const igId = account.instagram?.id; if (!igId) throw new Error(`${account.page_name} has no Instagram account linked.`); const collab = collaborators.length ? JSON.stringify(collaborators.map((c) => String(c).replace(/^@/, ""))) : undefined; const asUrl = async (m) => (isUrl(m) ? m : isVideo(m) ? null : hostImage(account, m)); let creationId; if (type === "CAROUSEL") { if (media.length < 2 || media.length > 10) throw new Error("A carousel needs 2–10 items."); const children = []; for (const m of media) { if (isVideo(m)) { const id = isUrl(m) ? (await graph(`${igId}/media`, { media_type: "VIDEO", video_url: m, is_carousel_item: true }, { method: "POST" })).id : await igUploadLocalVideo(igId, m, { media_type: "VIDEO", is_carousel_item: true }); await waitForContainer(id); children.push(id); } else { children.push((await graph(`${igId}/media`, { image_url: await asUrl(m), is_carousel_item: true }, { method: "POST" })).id); } } creationId = (await graph(`${igId}/media`, { media_type: "CAROUSEL", children: children.join(","), caption, collaborators: collab }, { method: "POST" })).id; } else if (type === "REELS" || (type === "STORY" && isVideo(media[0]))) { const base = type === "REELS" ? { media_type: "REELS", caption, share_to_feed: shareToFeed, collaborators: collab, cover_url: coverUrl } : { media_type: "STORIES" }; creationId = isUrl(media[0]) ? (await graph(`${igId}/media`, { ...base, video_url: media[0] }, { method: "POST" })).id : await igUploadLocalVideo(igId, media[0], base); await waitForContainer(creationId); } else if (type === "STORY") { creationId = (await graph(`${igId}/media`, { media_type: "STORIES", image_url: await asUrl(media[0]) }, { method: "POST" })).id; } else { creationId = (await graph(`${igId}/media`, { image_url: await asUrl(media[0]), caption, collaborators: collab }, { method: "POST" })).id; } const pub = await graph(`${igId}/media_publish`, { creation_id: creationId }, { method: "POST" }); const info = await graph(pub.id, { fields: "permalink" }).catch(() => ({})); return { id: pub.id, permalink: info.permalink }; } /** * Publish (or natively schedule) on a Facebook Page. * scheduledAt: Date — if given and 10+ minutes ahead, Facebook holds and publishes it (shows in Meta Planner). */ export async function fbPublishAny(account, { message = "", media = [], link, scheduledAt } = {}) { needPage(account); const sched = scheduledAt && scheduledAt - Date.now() > 10 * 60000 ? { published: false, scheduled_publish_time: Math.floor(scheduledAt / 1000) } : {}; const tok = { token: account.page_token }; const first = media[0]; if (first && isVideo(first)) { const fields = { description: message, ...sched }; return isUrl(first) ? graph(`${account.page_id}/videos`, { ...fields, file_url: first }, { method: "POST", ...tok }) : graphUpload(`${account.page_id}/videos`, fields, first, { ...tok, base: GRAPH_VIDEO }); } if (media.length > 1) { const ids = []; for (const m of media) { const up = isUrl(m) ? await graph(`${account.page_id}/photos`, { url: m, published: false, temporary: Boolean(sched.scheduled_publish_time) }, { method: "POST", ...tok }) : await graphUpload(`${account.page_id}/photos`, { published: false, temporary: Boolean(sched.scheduled_publish_time) }, m, tok); ids.push({ media_fbid: up.id }); } const f = { message, ...sched }; ids.forEach((x, i) => (f[`attached_media[${i}]`] = JSON.stringify(x))); return graph(`${account.page_id}/feed`, f, { method: "POST", ...tok }); } if (first) { const fields = { caption: message, ...sched }; return isUrl(first) ? graph(`${account.page_id}/photos`, { ...fields, url: first }, { method: "POST", ...tok }) : graphUpload(`${account.page_id}/photos`, fields, first, tok); } return graph(`${account.page_id}/feed`, { message, link, ...sched }, { method: "POST", ...tok }); } /** Facebook posts already scheduled natively (Meta Planner). */ export async function fbScheduledPosts(account) { needPage(account); const r = await graph(`${account.page_id}/scheduled_posts`, { fields: "id,message,scheduled_publish_time,created_time" }, { token: account.page_token }); return r.data || []; }