diff --git a/.env.example b/.env.example index 3dc6a48..8cbb4a1 100644 --- a/.env.example +++ b/.env.example @@ -4,6 +4,10 @@ META_SYSTEM_USER_TOKEN=PASTE_TOKEN_HERE META_API_VERSION=v23.0 +# Your agency's OWN Facebook Page ID. Images for Instagram-only clients (no Page) are uploaded there +# unpublished to get a link Instagram can fetch. Never use a client's Page here. +META_MEDIA_PAGE_ID= + # Keep false until you are ready. When true, the agent can publish, but only with confirm=true after human approval. ALLOW_PUBLISH=false diff --git a/connector/src/meta.js b/connector/src/meta.js index 6e2d5a5..8bfac9d 100644 --- a/connector/src/meta.js +++ b/connector/src/meta.js @@ -62,10 +62,32 @@ export async function listAccounts({ refresh = false } = {}) { 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(/^@/, ""); @@ -168,6 +190,7 @@ export async function igPublish(igId, { mediaUrl, caption, type = "IMAGE" }) { 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); @@ -178,6 +201,7 @@ export async function pageInsights(account, { sinceDays = 28, metrics = PAGE_MET } export async function pageRecentPosts(account, { limit = 10 } = {}) { + needPage(account); 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 }, @@ -195,6 +219,7 @@ export async function pageRecentPosts(account, { limit = 10 } = {}) { } 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 }); } @@ -260,7 +285,7 @@ export async function commentInbox(account, { days = 14, posts = 12 } = {}) { } } } - const fbPosts = await pageRecentPosts(account, { limit: posts }).catch(() => []); + 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) { @@ -313,24 +338,43 @@ export async function igAudience(igId) { } // ---------- 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 } = {}) { - const r = await graph(`${account.page_id}/conversations`, { + 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 }); + }, { token: account.page_token || undefined }); return r.data || []; } export async function sendMessage(account, recipientId, text, { platform = "instagram" } = {}) { - return graph(`${account.page_id}/messages`, { + 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 }); + }, { method: "POST", token: account.page_token || undefined }); } // ---------- Media hosting helpers ---------- -/** Turn a local image into a public URL by uploading it to the client's Page as an unpublished photo. */ +/** + * 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 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 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; @@ -407,6 +451,7 @@ export async function igPublishAny(account, { type = "IMAGE", media = [], captio * 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 }; @@ -439,6 +484,7 @@ export async function fbPublishAny(account, { message = "", media = [], link, sc /** 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 || []; } diff --git a/connector/src/schedule.js b/connector/src/schedule.js index cdcf802..c6114d1 100644 --- a/connector/src/schedule.js +++ b/connector/src/schedule.js @@ -43,7 +43,7 @@ export function clientInfo(slug) { if (!/^[a-z0-9][a-z0-9-]*$/.test(String(slug || ""))) throw err("Unknown client.", 404); const file = path.join(CLIENTS, slug, "client.json"); let link = {}; try { link = JSON.parse(fs.readFileSync(file, "utf8")); } catch { /* not linked */ } - return { slug, name: link.name || slug, pageId: link.pageId || null, timezone: link.timezone || "America/Toronto", market: link.market || null }; + return { slug, name: link.name || slug, pageId: link.pageId || null, igId: link.igId || null, timezone: link.timezone || "America/Toronto", market: link.market || null }; } /** Good default posting slots by market, in the client's local time (adjust per client from insights). */ @@ -55,9 +55,9 @@ export function suggestedTimes(tz) { async function accountFor(slug) { const c = clientInfo(slug); - if (!c.pageId) throw err(`${c.name} isn't linked to a Facebook Page yet.`); - const a = (await meta.listAccounts()).find((x) => x.page_id === c.pageId) || (await meta.listAccounts({ refresh: true })).find((x) => x.page_id === c.pageId); - if (!a) throw err(`The agency can't see ${c.name}'s Page. Assign it to the social-agent system user.`); + if (!c.pageId && !c.igId) throw err(`${c.name} isn't linked to a Facebook Page or Instagram account yet.`); + const a = await meta.accountForLink(c); + if (!a) throw err(`The agency can't see ${c.name}'s account. Assign it to the social-agent system user.`); return a; } @@ -82,6 +82,11 @@ export function createPost({ client, platforms = ["instagram"], type = "IMAGE", if (!TYPES.includes(type)) throw err(`Type must be one of ${TYPES.join(", ")}.`); platforms = [...new Set(platforms)].filter((p) => p === "instagram" || p === "facebook"); if (!platforms.length) throw err("Pick Instagram, Facebook or both."); + // Instagram-only clients (no Facebook Page): drop Facebook when Instagram is also picked. + if (!c.pageId && c.igId) { + if (!platforms.includes("instagram")) throw err(`${c.name} is Instagram-only (no Facebook Page). Post to Instagram instead.`); + platforms = ["instagram"]; + } if (type === "TEXT" && platforms.includes("instagram")) throw err("Instagram needs an image or video. Use Facebook only for text posts."); if (type !== "TEXT" && !media.length) throw err("Add at least one image or video (file path or public URL)."); for (const m of media) if (!/^https?:\/\//.test(m) && !fs.existsSync(m)) throw err(`File not found: ${m}`); diff --git a/dashboard/server/clients.js b/dashboard/server/clients.js index 75a497f..b37919c 100644 --- a/dashboard/server/clients.js +++ b/dashboard/server/clients.js @@ -89,7 +89,8 @@ export function listClients() { igUsername: link.igUsername || null, category: link.category || null, odooProject: link.odooProject || null, - linked: Boolean(link.pageId), + linked: Boolean(link.pageId || link.igId), + igOnly: Boolean(!link.pageId && link.igId), setup: setupStatus(dir), lastAuditAt: lastAudit?.updatedAt || null, lastReportAt: lastReport?.updatedAt || null, @@ -114,18 +115,19 @@ export function saveLink(slug, patch) { /** Meta accounts the agency can manage that no client folder is linked to yet. */ export async function unlinkedAccounts({ refresh = false } = {}) { const accounts = await meta.listAccounts({ refresh }); - const linked = new Set(listClients().map((c) => c.pageId).filter(Boolean)); - return accounts.filter((a) => !linked.has(a.page_id)).map(meta.publicAccount); + const linked = new Set(listClients().flatMap((c) => [c.pageId, c.igId && `ig:${c.igId}`]).filter(Boolean)); + return accounts + .filter((a) => !linked.has(meta.accountKey(a)) && !(a.instagram?.id && linked.has(`ig:${a.instagram.id}`))) + .map((a) => ({ ...meta.publicAccount(a), account_id: meta.accountKey(a) })); } /** Link an existing client folder to a Meta Page the agency manages (keeps the folder's name and files). */ -export async function linkToAccount(slug, pageId) { +export async function linkToAccount(slug, accountId) { const client = getClient(slug); if (!client) throw Object.assign(new Error("Client not found."), { status: 404 }); - const account = (await meta.listAccounts({ refresh: true })).find((a) => a.page_id === String(pageId)); - if (!account) throw Object.assign(new Error("That Page isn't connected to the agency's Meta account."), { status: 400 }); - const other = listClients().find((c) => c.pageId === account.page_id && c.slug !== slug); - if (other) throw Object.assign(new Error(`That Page is already linked to ${other.name}.`), { status: 409 }); + const account = await findConnected(accountId); + const other = clientUsing(account, slug); + if (other) throw Object.assign(new Error(`That account is already linked to ${other.name}.`), { status: 409 }); return saveLink(slug, { ...linkFields(account), name: client.name || account.page_name }); } @@ -148,6 +150,18 @@ export async function autoLink() { return results; } +/** A connected account by its id: a Page id, or "ig:" for Instagram-only accounts. */ +async function findConnected(accountId) { + const id = String(accountId || ""); + const account = (await meta.listAccounts({ refresh: true })).find((a) => meta.accountKey(a) === id); + if (!account) throw Object.assign(new Error("That account isn't connected to the agency's Meta account."), { status: 400 }); + return account; +} + +/** Another client already linked to this account (same Page, or same Instagram account). */ +const clientUsing = (account, exceptSlug) => + listClients().find((c) => c.slug !== exceptSlug && ((account.page_id && c.pageId === account.page_id) || (account.instagram?.id && c.igId === account.instagram.id))); + const linkFields = (a) => ({ name: a.page_name, pageId: a.page_id, @@ -158,13 +172,13 @@ const linkFields = (a) => ({ }); /** "Add business": create clients// from the template for a connected Meta account. */ -export async function addClientFromAccount(pageId, { name } = {}) { - const account = (await meta.listAccounts({ refresh: true })).find((a) => a.page_id === String(pageId)); - if (!account) throw Object.assign(new Error("That Page isn't connected to the agency's Meta account."), { status: 400 }); - if (listClients().some((c) => c.pageId === account.page_id)) throw Object.assign(new Error("This business already has a client folder."), { status: 409 }); +export async function addClientFromAccount(accountId, { name } = {}) { + const account = await findConnected(accountId); + if (clientUsing(account)) throw Object.assign(new Error("This business already has a client folder."), { status: 409 }); const displayName = (name || account.page_name).trim(); - let slug = slugify(displayName) || `client-${account.page_id}`; - if (fs.existsSync(path.join(CLIENTS_DIR, slug))) slug = `${slug}-${account.page_id.slice(-4)}`; + const idTail = (account.page_id || account.instagram.id).slice(-4); + let slug = slugify(displayName) || `client-${idTail}`; + if (fs.existsSync(path.join(CLIENTS_DIR, slug))) slug = `${slug}-${idTail}`; const dir = path.join(CLIENTS_DIR, slug); fs.cpSync(TEMPLATE_DIR, dir, { recursive: true }); for (const sub of Object.values(DOC_DIRS)) fs.mkdirSync(path.join(dir, sub), { recursive: true }); @@ -175,7 +189,7 @@ export async function addClientFromAccount(pageId, { name } = {}) { let text = fs.readFileSync(file, "utf8").replace(/^#\s*.*?(—|-)\s*/m, `# ${displayName} — `); if (f === "brand.md") { const ig = account.instagram?.username ? `Instagram @${account.instagram.username}` : "Instagram …"; - text = text.replace(/^\*\*Handles:\*\*.*$/m, `**Handles:** ${ig} · Facebook: ${account.page_name} (page ${account.page_id}) · YouTube … · LinkedIn …`); + text = text.replace(/^\*\*Handles:\*\*.*$/m, `**Handles:** ${ig} · Facebook: ${account.page_id ? `${account.page_name} (page ${account.page_id})` : "none (Instagram only)"} · YouTube … · LinkedIn …`); if (account.category) text = text.replace(/^\*\*Business type:\*\*.*$/m, `**Business type:** ${account.category}`); } fs.writeFileSync(file, text); diff --git a/dashboard/server/index.js b/dashboard/server/index.js index fc273f9..cccc01b 100644 --- a/dashboard/server/index.js +++ b/dashboard/server/index.js @@ -128,7 +128,7 @@ app.get("/api/clients/:slug", h(async (req) => { app.post("/api/clients/:slug/refresh", h(async (req) => { clients.safeSlug(req.params.slug); - if (!clients.getClient(req.params.slug)?.linked) throw Object.assign(new Error("This client isn't linked to a Facebook Page yet, so there are no numbers to fetch. Link a Page first."), { status: 400 }); + if (!clients.getClient(req.params.slug)?.linked) throw Object.assign(new Error("This client isn't linked to a Facebook Page or Instagram account yet, so there are no numbers to fetch. Link an account first."), { status: 400 }); const wait = req.query.wait === "1"; const p = metrics.refreshSnapshot(req.params.slug); if (wait) return { snapshot: await p }; @@ -143,14 +143,14 @@ app.patch("/api/clients/:slug/link", requireAdmin, h(async (req) => { })); app.post("/api/clients/:slug/link-page", requireAdmin, h(async (req) => { - const link = await clients.linkToAccount(req.params.slug, req.body?.pageId); + const link = await clients.linkToAccount(req.params.slug, req.body?.accountId || req.body?.pageId); metrics.refreshSnapshot(req.params.slug).catch((e) => console.error("first refresh", req.params.slug, e.message)); return { link }; })); app.get("/api/accounts/unlinked", h(async (req) => ({ accounts: await clients.unlinkedAccounts({ refresh: req.query.refresh === "1" }) }))); app.post("/api/clients", requireAdmin, h(async (req) => { - const client = await clients.addClientFromAccount(req.body?.pageId, { name: req.body?.name }); + const client = await clients.addClientFromAccount(req.body?.accountId || req.body?.pageId, { name: req.body?.name }); metrics.refreshSnapshot(client.slug).catch((e) => console.error("first refresh", client.slug, e.message)); if (req.body?.runAudit) jobs.createJob({ type: "account-audit", slug: client.slug }, req.user); return { client }; @@ -196,9 +196,9 @@ const MEDIA_DIR = path.join(path.dirname(_DD), "schedule", "media"); fs.mkdirSync(MEDIA_DIR, { recursive: true }); const clientAccount = async (slug) => { const c = clients.getClient(slug); - if (!c?.pageId) throw Object.assign(new Error("This client isn't linked to a Facebook Page yet."), { status: 400 }); - const a = (await meta.listAccounts()).find((x) => x.page_id === c.pageId); - if (!a) throw Object.assign(new Error("The agency can't see this client's Page."), { status: 400 }); + if (!c?.linked) throw Object.assign(new Error("This client isn't linked to a Facebook Page or Instagram account yet."), { status: 400 }); + const a = await meta.accountForLink(c); + if (!a) throw Object.assign(new Error("The agency can't see this client's account."), { status: 400 }); return a; }; const needPublishing = () => { if (process.env.ALLOW_PUBLISH !== "true") throw Object.assign(new Error("Publishing is switched off. Set ALLOW_PUBLISH=true in .env and restart."), { status: 403 }); }; diff --git a/dashboard/server/metrics.js b/dashboard/server/metrics.js index 6a5b02c..f604cc5 100644 --- a/dashboard/server/metrics.js +++ b/dashboard/server/metrics.js @@ -57,8 +57,8 @@ function derive(posts) { } async function fetchSnapshot(client) { - const account = await meta.findAccount(client.pageId); - if (!account) throw new Error("The linked Facebook Page is no longer connected to the agency's Meta account."); + const account = await meta.accountForLink(client); + if (!account) throw new Error("The linked account is no longer connected to the agency's Meta account."); const snap = { slug: client.slug, fetchedAt: new Date().toISOString(), ig: null, fb: null, errors: [] }; if (account.instagram?.id) { @@ -79,7 +79,7 @@ async function fetchSnapshot(client) { snap.errors.push(`Instagram: ${e.message}`); } } - try { + if (account.page_id) try { const page = await meta.pageInsights(account, { sinceDays: 28 }); const posts = await meta.pageRecentPosts(account, { limit: 10 }); snap.fb = { page: page.page, totals: page.totals, metricErrors: page.errors, posts: posts.map((p) => ({ ...p, message: (p.message || "").slice(0, 600) })) }; diff --git a/dashboard/web/src/pages/AddClientDialog.jsx b/dashboard/web/src/pages/AddClientDialog.jsx index 01218ea..9cc3e01 100644 --- a/dashboard/web/src/pages/AddClientDialog.jsx +++ b/dashboard/web/src/pages/AddClientDialog.jsx @@ -25,7 +25,7 @@ export default function AddClientDialog({ open, onClose, onAdded }) { setBusy(true); setError(null); try { - const r = await api("/api/clients", { method: "POST", body: { pageId: picked.page_id, name, runAudit } }); + const r = await api("/api/clients", { method: "POST", body: { accountId: picked.account_id, name, runAudit } }); onClose(); onAdded?.(r.client); } catch (e) { @@ -63,16 +63,16 @@ export default function AddClientDialog({ open, onClose, onAdded }) { ) : (
    {accounts?.map((a) => ( -
  • +
  • ) : isAdmin && ( - + )} @@ -104,8 +104,8 @@ export default function Client() { ? : ( - setLinkOpen(true)}>Link a Page}> - Numbers, publishing and the inbox need this client's Facebook Page (and its Instagram). If the Page isn't in the list, + setLinkOpen(true)}>Link an account}> + Numbers, publishing and the inbox need this client's Facebook Page or Instagram account. If it isn't in the list, assign it to the agency's system user in Meta Business Settings → System users → Assign assets, then check again. @@ -134,28 +134,28 @@ function LinkPageDialog({ client, onClose, onLinked }) { const link = async () => { setBusy(true); setError(null); - try { await api(`/api/clients/${client.slug}/link-page`, { method: "POST", body: { pageId: picked.page_id } }); onLinked(); } + try { await api(`/api/clients/${client.slug}/link-page`, { method: "POST", body: { accountId: picked.account_id } }); onLinked(); } catch (e) { setError(e); setBusy(false); } }; return ( - }> -

    Pages the agency's Meta account manages that no client is linked to yet. The client keeps its name and files.

    +

    Facebook Pages and Instagram accounts the agency's Meta account manages that no client is linked to yet. The client keeps its name and files.

    {!accounts && !error ? : accounts?.length === 0 ? ( - Assign {client.name}'s Page and Instagram to the agency's system user in Meta Business Settings → System users → Assign assets, then press Check Meta again. + Assign {client.name}'s Page or Instagram account to the agency's system user in Meta Business Settings → System users → Assign assets, then press Check Meta again. ) : (
      {accounts?.map((a) => ( -
    • -