metatroncubeswdev 21e49b442f Inbox: Google reviews (read and reply) and YouTube comments
- 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>
2026-10-23 10:15:11 -04:00

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;
}
}