- Public page /l/<address>: Instagram name, picture and bio (or custom), link buttons, latest 6 posts - Every click counted per link per day through /l/<address>/go/<id>; optional UTM tags for Google Analytics - Client page → Link in bio: address, links (reorder with buttons), colours, clicks in the last 30 days - Snapshots now keep the Instagram profile picture Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
492 lines
25 KiB
JavaScript
492 lines
25 KiB
JavaScript
// 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:<id>" 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 || [];
|
||
}
|