// Platform registry: everything beyond Facebook/Instagram (which use the Meta system user). // Each client connects its own account per platform (OAuth); tokens live encrypted in data/connections.json. import linkedin from "./linkedin.js"; import youtube from "./youtube.js"; import gbp from "./gbp.js"; import tiktok from "./tiktok.js"; import pinterest from "./pinterest.js"; import threads from "./threads.js"; import reddit from "./reddit.js"; import * as vault from "./vault.js"; import * as oauth from "./oauth.js"; import * as quota from "./quota.js"; export const ADAPTERS = { linkedin, gbp, youtube, tiktok, pinterest, threads, reddit }; export const PLATFORM_IDS = Object.keys(ADAPTERS); export { vault, quota }; const err = (m, status = 400) => Object.assign(new Error(m), { status }); const FORMAT_WORD = { TEXT: "text", IMAGE: "image", CAROUSEL: "multi-image", REELS: "video", STORY: "story" }; export function adapter(id) { const a = ADAPTERS[id]; if (!a) throw err(`Unknown platform: ${id}`, 404); return a; } /** Public description of every platform for the Connections page (no secrets). */ export function describePlatforms() { return PLATFORM_IDS.map((id) => { const a = ADAPTERS[id]; const app = vault.appCredentials(id); return { id, name: a.name, color: a.color, setup: a.setup, formats: Object.keys(a.formats), fields: a.fields || [], targetLabel: a.targetLabel || null, hasTargets: Boolean(a.targets), limits: a.limits || {}, defaultScopes: a.auth.scopes, app: app ? { configured: true, source: app.source, clientId: app.clientId, scopes: app.scopes || [], updatedAt: app.updatedAt || null } : { configured: false }, }; }); } // ---------- sign-in ---------- export function startConnect({ platform, client, redirectUri, user }) { const a = adapter(platform); const app = vault.appCredentials(platform); if (!app) throw err(`${a.name} isn't set up yet. An admin adds the app's client ID and secret first.`); const p = a.auth.pkce ? oauth.pkce() : {}; const state = vault.createState({ platform, client, redirectUri, verifier: p.verifier, userId: user.id, by: user.name || user.email }); return oauth.authorizeUrl(a, app, { redirectUri, state, challenge: p.challenge }); } export async function finishConnect({ platform, code, state, user }) { const s = vault.takeState(state); if (s.platform !== platform) throw err("This sign-in doesn't match the platform. Start again."); if (s.userId !== user.id) throw err("Finish the sign-in in the same Quorla session that started it."); const a = adapter(platform); const app = vault.appCredentials(platform); const tokens = await oauth.exchangeCode(a, app, { code, redirectUri: s.redirectUri, verifier: s.verifier }); const account = await a.identify(tokens); if (platform === "youtube") quota.record("youtube", null, "read"); const conn = vault.saveConnection({ client: s.client, platform, account, tokens, scopes: (tokens.scope || "").split(/[\s,]+/).filter(Boolean), by: s.by }); // Pick the only target automatically (e.g. one business location). if (a.targets) { try { const t = await a.targets(tokens, account); if (t.length === 1) vault.updateConnection(conn.id, { target: t[0] }); } catch { /* chosen later on the Connections page */ } } return { ...vault.getConnection(conn.id), client: s.client }; } /** Fresh tokens for a connection, refreshing (and saving) them when they expire within 5 minutes. */ export async function tokensFor(connId) { const conn = vault.getConnection(connId, { withTokens: true }); if (!conn.tokens) throw err(`${adapter(conn.platform).name}: the stored sign-in can't be read (the server secret changed). Reconnect it.`); const soon = conn.tokens.expiresAt && new Date(conn.tokens.expiresAt) - Date.now() < 5 * 60000; if (!soon) return { conn, tokens: conn.tokens }; try { const tokens = await oauth.refreshTokens(adapter(conn.platform), vault.appCredentials(conn.platform), conn.tokens); vault.updateConnection(conn.id, { tokens, status: "connected", lastError: null }); return { conn, tokens }; } catch (e) { vault.updateConnection(conn.id, { status: "reconnect", lastError: e.message }); throw err(`${adapter(conn.platform).name}: the sign-in expired. Reconnect it on the Connections page. (${e.message})`); } } export async function targetsFor(connId) { const { conn, tokens } = await tokensFor(connId); const a = adapter(conn.platform); return a.targets ? a.targets(tokens, conn.account) : []; } export async function setTarget(connId, targetId) { const list = await targetsFor(connId); const t = list.find((x) => x.id === targetId); if (!t) throw err("That option isn't available for this account any more."); return vault.updateConnection(connId, { target: t }); } /** Per-post numbers for platforms whose adapter can read them (YouTube today). ids: the platform's post ids. */ export async function postStats(platform, client, ids) { const a = adapter(platform); if (!a.stats || !ids.length) return {}; const conn = vault.connectionFor(client, platform); if (!conn) return {}; const { tokens } = await tokensFor(conn.id); if (platform === "youtube") quota.ensure("youtube", null, "read"); const out = await a.stats(tokens, ids); if (platform === "youtube") quota.record("youtube", null, "read"); return out; } /** Google reviews for a client's Business Profile location. */ export async function gbpReviews(client) { const conn = vault.connectionFor(client, "gbp"); if (!conn) throw err("Google Business Profile isn't connected for this client. Connect it on the Connections page."); if (!conn.target) throw err("Choose the business location on the Connections page first."); const { tokens } = await tokensFor(conn.id); return { location: conn.target.name, ...(await adapter("gbp").reviews(tokens, conn.target)) }; } export async function gbpReply(client, reviewId, text) { const conn = vault.connectionFor(client, "gbp"); if (!conn?.target || !String(reviewId).startsWith(`${conn.target.id}/reviews/`)) throw err("That review isn't on this client's location."); const { tokens } = await tokensFor(conn.id); return adapter("gbp").replyReview(tokens, reviewId, text); } /** Latest YouTube comments on the client's channel. */ export async function youtubeComments(client) { const conn = vault.connectionFor(client, "youtube"); if (!conn) throw err("YouTube isn't connected for this client."); const { tokens } = await tokensFor(conn.id); quota.ensure("youtube", null, "read"); const out = await adapter("youtube").comments(tokens, conn.account); quota.record("youtube", null, "read"); return { channel: conn.account?.name, comments: out }; } /** Re-read the account (name, followers) to check the connection still works. */ export async function checkConnection(connId) { const { conn, tokens } = await tokensFor(connId); try { if (conn.platform === "youtube") quota.ensure("youtube", null, "read"); const account = await adapter(conn.platform).identify(tokens); if (conn.platform === "youtube") quota.record("youtube", null, "read"); return vault.updateConnection(connId, { account, status: "connected", lastError: null }); } catch (e) { vault.updateConnection(connId, { status: "error", lastError: e.message }); throw e; } } /** Platform-specific live account settings shown on the post screen (TikTok's creator info). */ export async function creatorInfo(connId) { const { conn, tokens } = await tokensFor(connId); const a = adapter(conn.platform); if (!a.creatorInfo) throw err(`${a.name} has no creator settings.`); return a.creatorInfo(tokens); } // ---------- posting ---------- /** Problems that stop a post going to this platform (checked when the draft is saved). */ export function validate(platform, client, post) { const a = adapter(platform); const conn = vault.connectionFor(client, platform); if (!conn) throw err(`${a.name} isn't connected for this client. Connect it on the Connections page.`); if (!a.formats[post.type]) throw err(`${a.name} doesn't take ${FORMAT_WORD[post.type] || post.type.toLowerCase()} posts. It takes: ${Object.keys(a.formats).map((f) => FORMAT_WORD[f]).join(", ")}.`); if (a.targetLabel && a.targets && !conn.target && platform !== "linkedin") throw err(`${a.name}: choose the ${a.targetLabel.toLowerCase()} on the Connections page first.`); const opts = post.options?.[platform] || {}; for (const f of a.fields || []) { if (f.onlyFor && !f.onlyFor.includes(post.type)) continue; if (f.required && (opts[f.key] === undefined || opts[f.key] === "")) throw err(`${a.name}: ${f.label.toLowerCase()} is required.`); if (f.max && String(opts[f.key] || "").length > f.max) throw err(`${a.name}: ${f.label.toLowerCase()} is longer than ${f.max} characters.`); } if (a.checkOptions) a.checkOptions(opts); const cap = a.limits?.caption; const caption = opts.caption ?? post.caption ?? ""; if (cap && caption.length > cap) throw err(`${a.name}: the caption is ${caption.length} characters; the limit is ${cap}. Shorten it or add a ${a.name} caption.`); } export async function publish(platform, client, post) { const a = adapter(platform); const c = vault.connectionFor(client, platform); if (!c) throw err(`${a.name} isn't connected for this client.`); const { conn, tokens } = await tokensFor(c.id); const opts = { ...Object.fromEntries((a.fields || []).filter((f) => f.default !== undefined).map((f) => [f.key, f.default])), ...(post.options?.[platform] || {}) }; quota.ensure(platform, conn.account?.id); try { const result = await a.publish({ tokens, conn, post, opts, caption: opts.caption ?? post.caption ?? "" }); quota.record(platform, conn.account?.id); return result; } catch (e) { throw quota.fromPlatformError(platform, e) || e; } }