metatroncubeswdev c309581df0 Initial commit: Quorla social media manager
Dashboard (content library, publishing queue with approvals, inbox, team
management, AI runs), Meta connector, branded PDF reports, agent skills,
and Docker packaging (Dockerfile, docker-compose with Caddy HTTPS,
backup and path-relocation scripts).

Client data, live data and secrets are excluded via .gitignore.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 13:02:29 -04:00

445 lines
22 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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"));
}
accountCache = out;
return out;
}
/** 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" });
// 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,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 } = {}) {
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 } = {}) {
const r = await graph(
`${account.page_id}/posts`,
{ fields: "id,message,created_time,permalink_url,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,
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 }) {
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 = 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) ----------
export async function listConversations(account, { platform = "instagram", limit = 20 } = {}) {
const r = await graph(`${account.page_id}/conversations`, {
platform, limit, fields: "id,updated_time,participants,messages.limit(3){id,message,from,created_time}",
}, { token: account.page_token });
return r.data || [];
}
export async function sendMessage(account, recipientId, text, { platform = "instagram" } = {}) {
return graph(`${account.page_id}/messages`, {
recipient: JSON.stringify({ id: recipientId }), message: JSON.stringify({ text }), messaging_type: "RESPONSE",
...(platform === "instagram" ? { platform: "instagram" } : {}),
}, { method: "POST", token: account.page_token });
}
// ---------- Media hosting helpers ----------
/** Turn a local image into a public URL by uploading it to the client's Page as an unpublished photo. */
export async function hostImage(account, filePath) {
const up = await graphUpload(`${account.page_id}/photos`, { published: false }, filePath, { token: account.page_token });
const info = await graph(up.id, { fields: "images" }, { token: account.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 } = {}) {
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) {
const r = await graph(`${account.page_id}/scheduled_posts`, { fields: "id,message,scheduled_publish_time,created_time" }, { token: account.page_token });
return r.data || [];
}