- quota.js: per-platform limits (YouTube 10,000 units/day Pacific per project, Instagram 100 / Threads 250 / TikTok 15 posts per 24 h per account, ...), usage log, .env overrides (QUOTA_<PLATFORM>) - Publishing checks the budget first and records usage; when a platform is out of quota (or answers quota exceeded / 429) the post stays approved and the queue retries after the reset, without re-posting finished platforms - Drafts that exceed a day's limit get a warning - Connections page shows used/limit and when it frees up - Tests for windows (incl. DST), shared vs per-account limits and the wait-and-retry queue flow; docs for quotas and the Google app setup Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
157 lines
7.8 KiB
JavaScript
157 lines
7.8 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 });
|
|
}
|
|
|
|
/** 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;
|
|
}
|
|
}
|