- Inbox tabs: Instagram & Facebook / Google reviews / YouTube comments - Google reviews: average rating, needs-reply filter, reply or edit reply (public on Google), only on the client's location - YouTube comments: latest on the channel, open the comment to reply on YouTube (reply needs a broader scope) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
195 lines
9.7 KiB
JavaScript
195 lines
9.7 KiB
JavaScript
// 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;
|
|
}
|
|
}
|