Add LinkedIn, Google Business Profile, YouTube, TikTok, Pinterest, Threads, Reddit
- Connections page: admins set up each platform's developer app (keys stored encrypted), connect a client's account via OAuth, choose company Page / business location, check or disconnect - Adapters per platform with sign-in, identity, targets and publishing; token refresh with "reconnect needed" when a sign-in expires - Publishing and Content library: "Post to" lists the client's connected platforms with their own fields and optional per-platform caption - Queue validates drafts per platform and publishes through the adapters; Facebook/Instagram unchanged - Signed, expiring media links (/m/...) for platforms that fetch media by URL - Agent: list_connections tool; schedule tools accept all platforms + options - Offline test suite with simulated platform APIs (15 tests) - docs/platforms.md: setup, approvals per platform, what is and isn't tested Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
3cbc76e7f1
commit
f24e6ada4b
14
.env.example
14
.env.example
@ -26,6 +26,20 @@ APP_DOMAIN=app.quorlasocial.com
|
|||||||
# Let's Encrypt sends certificate notices here
|
# Let's Encrypt sends certificate notices here
|
||||||
ACME_EMAIL=support@quorlasocial.com
|
ACME_EMAIL=support@quorlasocial.com
|
||||||
|
|
||||||
|
# ---------- Other platforms (LinkedIn, Google, TikTok, Pinterest, Threads, Reddit) ----------
|
||||||
|
# App keys are normally entered in the dashboard (Connections → Set up) and stored encrypted in
|
||||||
|
# data/connections.json, using DASHBOARD_SECRET as the key (or QUORLA_SECRET_KEY if set).
|
||||||
|
# Changing that secret means every platform must be reconnected.
|
||||||
|
# Public address of this install, used for sign-in redirects and media links (TikTok photos, Threads,
|
||||||
|
# Google Business Profile). Defaults to https://APP_DOMAIN when APP_DOMAIN is set.
|
||||||
|
PUBLIC_URL=
|
||||||
|
# LinkedIn API version (YYYYMM). LinkedIn retires versions after about a year.
|
||||||
|
LINKEDIN_API_VERSION=202606
|
||||||
|
# true while the Pinterest app only has Trial access (Pins go to Pinterest's sandbox)
|
||||||
|
PINTEREST_SANDBOX=false
|
||||||
|
# Reddit requires a descriptive User-Agent: web:<app id>:<version> (by /u/<reddit username>)
|
||||||
|
REDDIT_USER_AGENT=web:com.quorlasocial.app:1.0 (by /u/YOUR_REDDIT_USERNAME)
|
||||||
|
|
||||||
# ---------- Dashboard ----------
|
# ---------- Dashboard ----------
|
||||||
DASHBOARD_PORT=4310
|
DASHBOARD_PORT=4310
|
||||||
# 127.0.0.1 behind nginx (recommended); 0.0.0.0 to listen on all interfaces
|
# 127.0.0.1 behind nginx (recommended); 0.0.0.0 to listen on all interfaces
|
||||||
|
|||||||
@ -32,6 +32,7 @@ You run many client accounts at once. Every client is different: never reuse one
|
|||||||
- DMs: `direct_messages` reads; `send_direct_message` only replies to someone who messaged first (Meta's 24-hour window), with approval.
|
- DMs: `direct_messages` reads; `send_direct_message` only replies to someone who messaged first (Meta's 24-hour window), with approval.
|
||||||
- Research: `competitor_lookup` (any public business/creator IG account), `audience_demographics`, `instagram_mentions`.
|
- Research: `competitor_lookup` (any public business/creator IG account), `audience_demographics`, `instagram_mentions`.
|
||||||
- Every write is logged to `data/publish-log.jsonl`.
|
- Every write is logged to `data/publish-log.jsonl`.
|
||||||
|
- **Other platforms** (LinkedIn, Google Business Profile, YouTube, TikTok, Pinterest, Threads, Reddit): an admin connects each client's account in the dashboard (Connections). Call `list_connections` first; only plan posts for platforms that are connected, and pass each platform's required fields in `options` (e.g. YouTube `title`, Pinterest `boardId`, TikTok `privacy`, Reddit `subreddit` + `title`). Write a platform-specific `caption` in `options` when the main caption doesn't fit (LinkedIn: professional; Threads: ≤500 chars; GBP: local offer + button). Same approval rules apply. Reddit: only where the subreddit's rules allow it; never disguise promotion.
|
||||||
|
|
||||||
## Reports & branding
|
## Reports & branding
|
||||||
- Every report we hand over (audits, weekly reports) is exported as a Metatroncube-branded PDF: `node tools/report-pdf/build.mjs <file.md>` (first time: `cd tools/report-pdf && npm install`). Logos and styles live in `branding/`.
|
- Every report we hand over (audits, weekly reports) is exported as a Metatroncube-branded PDF: `node tools/report-pdf/build.mjs <file.md>` (first time: `cd tools/report-pdf && npm install`). Logos and styles live in `branding/`.
|
||||||
|
|||||||
@ -98,11 +98,11 @@ export function archiveItem(id, by = "team") {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Turn an approved item into a DRAFT post in the queue (still needs approval there to publish). */
|
/** Turn an approved item into a DRAFT post in the queue (still needs approval there to publish). */
|
||||||
export function scheduleItem(id, { platforms = ["instagram", "facebook"], localTime, caption, fbCaption, collaborators = [], type }, by = "team") {
|
export function scheduleItem(id, { platforms = ["instagram", "facebook"], localTime, caption, fbCaption, collaborators = [], options = {}, type }, by = "team") {
|
||||||
const i = getItem(id);
|
const i = getItem(id);
|
||||||
if (!["approved", "scheduled"].includes(i.status)) throw err("Only approved work can be scheduled. Review it first.");
|
if (!["approved", "scheduled"].includes(i.status)) throw err("Only approved work can be scheduled. Review it first.");
|
||||||
const post = sched.createPost({
|
const post = sched.createPost({
|
||||||
client: i.client, platforms, type: type || i.format, media: i.files, caption: caption ?? i.captionIdea, fbCaption, collaborators, localTime,
|
client: i.client, platforms, type: type || i.format, media: i.files, caption: caption ?? i.captionIdea, fbCaption, collaborators, options, localTime,
|
||||||
createdBy: by, notes: `From library item ${i.id} "${i.title}" by ${i.uploadedBy}`,
|
createdBy: by, notes: `From library item ${i.id} "${i.title}" by ${i.uploadedBy}`,
|
||||||
});
|
});
|
||||||
mutate(id, (x) => { x.status = "scheduled"; x.postIds.push(post.id); x.history.push({ at: now(), by, action: `scheduled ${post.localTime}`, postId: post.id }); });
|
mutate(id, (x) => { x.status = "scheduled"; x.postIds.push(post.id); x.history.push({ at: now(), by, action: `scheduled ${post.localTime}`, postId: post.id }); });
|
||||||
|
|||||||
63
connector/src/platforms/gbp.js
Normal file
63
connector/src/platforms/gbp.js
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
// Google Business Profile: "updates" (local posts) on a business location, with an optional button.
|
||||||
|
// Access to the Business Profile APIs must be requested from Google (approval form) before calls work.
|
||||||
|
import { request, isVideo } from "./http.js";
|
||||||
|
import { googleAuth, googleSetup } from "./google.js";
|
||||||
|
import { publicMediaUrl } from "./media.js";
|
||||||
|
|
||||||
|
const CTA = [["", "No button"], ["LEARN_MORE", "Learn more"], ["BOOK", "Book"], ["ORDER", "Order online"], ["SHOP", "Buy"], ["SIGN_UP", "Sign up"], ["CALL", "Call now"]];
|
||||||
|
|
||||||
|
export default {
|
||||||
|
id: "gbp",
|
||||||
|
name: "Google Business Profile",
|
||||||
|
color: "#4285f4",
|
||||||
|
setup: googleSetup("Business Profile API access is approved separately: request it with Google's “GBP API access” form for the same project.", [
|
||||||
|
"APIs & Services → Library → enable “My Business Account Management API”, “My Business Business Information API” and “Google My Business API”.",
|
||||||
|
"Request Business Profile API access (developers.google.com/my-business → Request access). Calls fail with a quota of 0 until Google approves.",
|
||||||
|
]),
|
||||||
|
auth: googleAuth(["openid", "email", "https://www.googleapis.com/auth/business.manage"]),
|
||||||
|
formats: { TEXT: true, IMAGE: true },
|
||||||
|
limits: { caption: 1500 },
|
||||||
|
targetLabel: "Business location",
|
||||||
|
fields: [
|
||||||
|
{ key: "cta", label: "Button", type: "select", options: CTA, default: "" },
|
||||||
|
{ key: "ctaUrl", label: "Button link", type: "text", hint: "Needed for every button except Call now. e.g. booking page." },
|
||||||
|
],
|
||||||
|
|
||||||
|
async identify(tokens) {
|
||||||
|
const { data } = await request("Google", "https://openidconnect.googleapis.com/v1/userinfo", { token: tokens.accessToken });
|
||||||
|
return { id: data.sub, name: data.email || data.name, username: null, avatar: data.picture || null, url: null };
|
||||||
|
},
|
||||||
|
|
||||||
|
async targets(tokens) {
|
||||||
|
const out = [];
|
||||||
|
const { data } = await request("Google Business Profile", "https://mybusinessaccountmanagement.googleapis.com/v1/accounts", { token: tokens.accessToken });
|
||||||
|
for (const acc of data.accounts || []) {
|
||||||
|
let pageToken;
|
||||||
|
do {
|
||||||
|
const r = await request("Google Business Profile", `https://mybusinessbusinessinformation.googleapis.com/v1/${acc.name}/locations`, {
|
||||||
|
token: tokens.accessToken, query: { readMask: "name,title,storefrontAddress", pageSize: 100, pageToken },
|
||||||
|
});
|
||||||
|
for (const loc of r.data.locations || []) {
|
||||||
|
const addr = loc.storefrontAddress ? [loc.storefrontAddress.addressLines?.[0], loc.storefrontAddress.locality].filter(Boolean).join(", ") : "";
|
||||||
|
out.push({ id: `${acc.name}/${loc.name}`, name: addr ? `${loc.title} · ${addr}` : loc.title });
|
||||||
|
}
|
||||||
|
pageToken = r.data.nextPageToken;
|
||||||
|
} while (pageToken);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
},
|
||||||
|
|
||||||
|
async publish({ tokens, conn, post, opts, caption }) {
|
||||||
|
const location = conn.target?.id;
|
||||||
|
if (!location) throw new Error("Google Business Profile: pick the business location on the Connections page first.");
|
||||||
|
const body = { languageCode: "en", summary: String(caption || "").slice(0, 1500), topicType: "STANDARD" };
|
||||||
|
if (opts.cta) {
|
||||||
|
if (opts.cta !== "CALL" && !opts.ctaUrl) throw new Error("Google Business Profile: add a link for the button.");
|
||||||
|
body.callToAction = { actionType: opts.cta, ...(opts.cta !== "CALL" ? { url: opts.ctaUrl } : {}) };
|
||||||
|
}
|
||||||
|
const img = (post.media || []).find((m) => !isVideo(m));
|
||||||
|
if (img) body.media = [{ mediaFormat: "PHOTO", sourceUrl: /^https?:/.test(img) ? img : publicMediaUrl(img) }];
|
||||||
|
const { data } = await request("Google Business Profile", `https://mybusiness.googleapis.com/v4/${location}/localPosts`, { token: tokens.accessToken, method: "POST", json: body });
|
||||||
|
return { id: data.name, permalink: data.searchUrl || null, state: data.state };
|
||||||
|
},
|
||||||
|
};
|
||||||
20
connector/src/platforms/google.js
Normal file
20
connector/src/platforms/google.js
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
// Shared Google sign-in for YouTube and Google Business Profile (one Google Cloud OAuth client covers both).
|
||||||
|
export const googleAuth = (scopes) => ({
|
||||||
|
authorizeUrl: "https://accounts.google.com/o/oauth2/v2/auth",
|
||||||
|
tokenUrl: "https://oauth2.googleapis.com/token",
|
||||||
|
scopes,
|
||||||
|
pkce: true,
|
||||||
|
extraAuthParams: { access_type: "offline", prompt: "consent", include_granted_scopes: "true" },
|
||||||
|
});
|
||||||
|
|
||||||
|
export const googleSetup = (product, extraSteps = []) => ({
|
||||||
|
console: "https://console.cloud.google.com/apis/credentials",
|
||||||
|
steps: [
|
||||||
|
"In Google Cloud Console create (or pick) a project for Quorla.",
|
||||||
|
...extraSteps,
|
||||||
|
"APIs & Services → OAuth consent screen: External, app name Quorla, your support email, and add the scopes Quorla asks for.",
|
||||||
|
"Credentials → Create credentials → OAuth client ID → Web application. Add the Redirect URI shown below.",
|
||||||
|
`Copy the Client ID and Client secret here. YouTube and Google Business Profile can share the same client: enter it under each.`,
|
||||||
|
],
|
||||||
|
note: `Until Google verifies the consent screen, add each client's Google account as a Test user (max 100). ${product}`,
|
||||||
|
});
|
||||||
48
connector/src/platforms/http.js
Normal file
48
connector/src/platforms/http.js
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
// Small HTTP helpers shared by the platform adapters.
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
export class PlatformError extends Error {
|
||||||
|
constructor(platform, message, { status, body } = {}) {
|
||||||
|
super(`${platform}: ${message}`);
|
||||||
|
this.platform = platform; this.status = status; this.body = body;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** fetch → JSON, throwing a readable PlatformError on HTTP or API errors. */
|
||||||
|
export async function request(platform, url, { method = "GET", headers = {}, json, form, body, token, query } = {}) {
|
||||||
|
const u = new URL(url);
|
||||||
|
for (const [k, v] of Object.entries(query || {})) if (v !== undefined && v !== null) u.searchParams.set(k, String(v));
|
||||||
|
const h = { Accept: "application/json", ...headers };
|
||||||
|
if (token) h.Authorization = `Bearer ${token}`;
|
||||||
|
let payload = body;
|
||||||
|
if (json !== undefined) { h["Content-Type"] = "application/json"; payload = JSON.stringify(json); }
|
||||||
|
if (form !== undefined) { h["Content-Type"] = "application/x-www-form-urlencoded"; payload = new URLSearchParams(Object.entries(form).filter(([, v]) => v !== undefined && v !== null).map(([k, v]) => [k, String(v)])).toString(); }
|
||||||
|
const res = await fetch(u, { method, headers: h, body: payload });
|
||||||
|
const text = await res.text();
|
||||||
|
let data = null;
|
||||||
|
try { data = text ? JSON.parse(text) : null; } catch { data = text; }
|
||||||
|
const apiError = data && typeof data === "object" && (data.error?.message || data.error_description || (typeof data.error === "string" && data.error) || data.message && !res.ok && data.message);
|
||||||
|
if (!res.ok || (data && typeof data === "object" && data.error && data.error.code && data.error.code !== "ok")) {
|
||||||
|
throw new PlatformError(platform, apiError || `${res.status} ${res.statusText}`, { status: res.status, body: data });
|
||||||
|
}
|
||||||
|
return { data, headers: res.headers, status: res.status };
|
||||||
|
}
|
||||||
|
|
||||||
|
export const fileName = (p) => path.basename(String(p)).replace(/^\d+-[a-z0-9]{4}-/i, "");
|
||||||
|
export const isUrl = (s) => /^https?:\/\//i.test(String(s || ""));
|
||||||
|
export const isVideo = (s) => /\.(mp4|mov|m4v|webm)$/i.test(String(s || "").split("?")[0]);
|
||||||
|
export const mimeOf = (p) => ({ ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png", ".gif": "image/gif", ".webp": "image/webp", ".mp4": "video/mp4", ".mov": "video/quicktime", ".m4v": "video/mp4", ".webm": "video/webm" })[path.extname(String(p).split("?")[0]).toLowerCase()] || "application/octet-stream";
|
||||||
|
|
||||||
|
/** Bytes of a local file or a URL. */
|
||||||
|
export async function bytesOf(src) {
|
||||||
|
if (isUrl(src)) {
|
||||||
|
const r = await fetch(src);
|
||||||
|
if (!r.ok) throw new Error(`Couldn't download ${src}: ${r.status}`);
|
||||||
|
return Buffer.from(await r.arrayBuffer());
|
||||||
|
}
|
||||||
|
return fs.readFileSync(src);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const sizeOf = async (src) => (isUrl(src) ? (await bytesOf(src)).length : fs.statSync(src).size);
|
||||||
|
export const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||||
136
connector/src/platforms/index.js
Normal file
136
connector/src/platforms/index.js
Normal file
@ -0,0 +1,136 @@
|
|||||||
|
// 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";
|
||||||
|
|
||||||
|
export const ADAPTERS = { linkedin, gbp, youtube, tiktok, pinterest, threads, reddit };
|
||||||
|
export const PLATFORM_IDS = Object.keys(ADAPTERS);
|
||||||
|
export { vault };
|
||||||
|
|
||||||
|
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);
|
||||||
|
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 {
|
||||||
|
const account = await adapter(conn.platform).identify(tokens);
|
||||||
|
return vault.updateConnection(connId, { account, status: "connected", lastError: null });
|
||||||
|
} catch (e) {
|
||||||
|
vault.updateConnection(connId, { status: "error", lastError: e.message });
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 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.`);
|
||||||
|
}
|
||||||
|
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] || {}) };
|
||||||
|
return a.publish({ tokens, conn, post, opts, caption: opts.caption ?? post.caption ?? "" });
|
||||||
|
}
|
||||||
113
connector/src/platforms/linkedin.js
Normal file
113
connector/src/platforms/linkedin.js
Normal file
@ -0,0 +1,113 @@
|
|||||||
|
// LinkedIn: post to a person's profile or to a company Page they administer.
|
||||||
|
// Company Pages need the Community Management API product on the LinkedIn app (approval required).
|
||||||
|
import { request, bytesOf, isVideo, sizeOf } from "./http.js";
|
||||||
|
|
||||||
|
const API = "https://api.linkedin.com";
|
||||||
|
const version = () => process.env.LINKEDIN_API_VERSION || "202606";
|
||||||
|
const rest = (token) => ({ token, headers: { "LinkedIn-Version": version(), "X-Restli-Protocol-Version": "2.0.0" } });
|
||||||
|
|
||||||
|
// LinkedIn's "little text" format: reserved characters must be escaped; hashtags use a template.
|
||||||
|
export function littleText(s) {
|
||||||
|
const text = String(s || "");
|
||||||
|
const esc = (t) => t.replace(/[\\|{}@[\]()<>#*_~]/g, (c) => `\\${c}`);
|
||||||
|
const re = /(^|\s)#([\p{L}\p{N}_]+)/gu;
|
||||||
|
let out = "", last = 0, m;
|
||||||
|
while ((m = re.exec(text))) {
|
||||||
|
out += esc(text.slice(last, m.index)) + m[1] + `{hashtag|\\#|${m[2]}}`;
|
||||||
|
last = m.index + m[0].length;
|
||||||
|
}
|
||||||
|
return out + esc(text.slice(last));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadImage(token, owner, src) {
|
||||||
|
const { data } = await request("LinkedIn", `${API}/rest/images?action=initializeUpload`, { ...rest(token), method: "POST", json: { initializeUploadRequest: { owner } } });
|
||||||
|
const { uploadUrl, image } = data.value;
|
||||||
|
const put = await fetch(uploadUrl, { method: "PUT", headers: { Authorization: `Bearer ${token}` }, body: await bytesOf(src) });
|
||||||
|
if (!put.ok) throw new Error(`LinkedIn: image upload failed (${put.status}).`);
|
||||||
|
return image;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadVideo(token, owner, src) {
|
||||||
|
const bytes = await bytesOf(src);
|
||||||
|
const { data } = await request("LinkedIn", `${API}/rest/videos?action=initializeUpload`, {
|
||||||
|
...rest(token), method: "POST",
|
||||||
|
json: { initializeUploadRequest: { owner, fileSizeBytes: bytes.length, uploadCaptions: false, uploadThumbnail: false } },
|
||||||
|
});
|
||||||
|
const { video, uploadInstructions, uploadToken } = data.value;
|
||||||
|
const etags = [];
|
||||||
|
for (const part of uploadInstructions) {
|
||||||
|
const r = await fetch(part.uploadUrl, { method: "PUT", headers: { "Content-Type": "application/octet-stream" }, body: bytes.subarray(part.firstByte, part.lastByte + 1) });
|
||||||
|
if (!r.ok) throw new Error(`LinkedIn: video upload failed (${r.status}).`);
|
||||||
|
etags.push(r.headers.get("etag"));
|
||||||
|
}
|
||||||
|
await request("LinkedIn", `${API}/rest/videos?action=finalizeUpload`, {
|
||||||
|
...rest(token), method: "POST", json: { finalizeUploadRequest: { video, uploadToken: uploadToken || "", uploadedPartIds: etags } },
|
||||||
|
});
|
||||||
|
return video;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
id: "linkedin",
|
||||||
|
name: "LinkedIn",
|
||||||
|
color: "#0a66c2",
|
||||||
|
setup: {
|
||||||
|
console: "https://www.linkedin.com/developers/apps",
|
||||||
|
steps: [
|
||||||
|
"Create an app at linkedin.com/developers/apps and link it to your company's LinkedIn Page (the Page admin must verify it).",
|
||||||
|
"Products tab: add “Sign In with LinkedIn using OpenID Connect” and “Share on LinkedIn” (instant).",
|
||||||
|
"For posting to clients' company Pages, also request “Community Management API” (LinkedIn reviews it; your company Page must be verified).",
|
||||||
|
"Auth tab: add the Redirect URL shown below, then copy the Client ID and Client Secret here.",
|
||||||
|
],
|
||||||
|
note: "Until Community Management API is approved, set Scopes to “openid profile w_member_social” so you can post to personal profiles only.",
|
||||||
|
},
|
||||||
|
auth: {
|
||||||
|
authorizeUrl: "https://www.linkedin.com/oauth/v2/authorization",
|
||||||
|
tokenUrl: "https://www.linkedin.com/oauth/v2/accessToken",
|
||||||
|
scopes: ["openid", "profile", "w_member_social", "r_organization_admin", "w_organization_social", "r_organization_social"],
|
||||||
|
},
|
||||||
|
formats: { TEXT: true, IMAGE: true, CAROUSEL: true, REELS: true },
|
||||||
|
limits: { caption: 3000, images: 20 },
|
||||||
|
targetLabel: "Post as",
|
||||||
|
|
||||||
|
async identify(tokens) {
|
||||||
|
const { data } = await request("LinkedIn", `${API}/v2/userinfo`, { token: tokens.accessToken });
|
||||||
|
return { id: data.sub, name: data.name, username: null, avatar: data.picture || null, url: null };
|
||||||
|
},
|
||||||
|
|
||||||
|
/** The person, plus every company Page they administer (needs Community Management API). */
|
||||||
|
async targets(tokens, account) {
|
||||||
|
const out = [{ id: `urn:li:person:${account.id}`, name: `${account.name} (personal profile)` }];
|
||||||
|
try {
|
||||||
|
const { data } = await request("LinkedIn", `${API}/rest/organizationAcls`, { ...rest(tokens.accessToken), query: { q: "roleAssignee", role: "ADMINISTRATOR", state: "APPROVED" } });
|
||||||
|
for (const e of data.elements || []) {
|
||||||
|
const orgId = String(e.organization).split(":").pop();
|
||||||
|
let name = `Company ${orgId}`;
|
||||||
|
try { name = (await request("LinkedIn", `${API}/rest/organizations/${orgId}`, rest(tokens.accessToken))).data.localizedName || name; } catch { /* keep id */ }
|
||||||
|
out.push({ id: e.organization, name: `${name} (company Page)` });
|
||||||
|
}
|
||||||
|
} catch { /* no organization scopes: personal profile only */ }
|
||||||
|
return out;
|
||||||
|
},
|
||||||
|
|
||||||
|
async publish({ tokens, conn, post, caption }) {
|
||||||
|
const author = conn.target?.id || `urn:li:person:${conn.account.id}`;
|
||||||
|
const body = {
|
||||||
|
author, commentary: littleText(caption), visibility: "PUBLIC", lifecycleState: "PUBLISHED", isReshareDisabledByAuthor: false,
|
||||||
|
distribution: { feedDistribution: "MAIN_FEED", targetEntities: [], thirdPartyDistributionChannels: [] },
|
||||||
|
};
|
||||||
|
const media = post.media || [];
|
||||||
|
if (post.type === "REELS" || (media.length === 1 && isVideo(media[0]))) {
|
||||||
|
if ((await sizeOf(media[0])) > 5 * 1024 ** 3) throw new Error("LinkedIn: videos must be under 5 GB.");
|
||||||
|
body.content = { media: { id: await uploadVideo(tokens.accessToken, author, media[0]) } };
|
||||||
|
} else if (media.length > 1) {
|
||||||
|
const images = [];
|
||||||
|
for (const m of media.slice(0, 20)) images.push({ id: await uploadImage(tokens.accessToken, author, m) });
|
||||||
|
body.content = { multiImage: { images } };
|
||||||
|
} else if (media.length === 1) {
|
||||||
|
body.content = { media: { id: await uploadImage(tokens.accessToken, author, media[0]) } };
|
||||||
|
}
|
||||||
|
const { headers } = await request("LinkedIn", `${API}/rest/posts`, { ...rest(tokens.accessToken), method: "POST", json: body });
|
||||||
|
const id = headers.get("x-restli-id") || headers.get("x-linkedin-id");
|
||||||
|
return { id, permalink: id ? `https://www.linkedin.com/feed/update/${id}/` : null };
|
||||||
|
},
|
||||||
|
};
|
||||||
50
connector/src/platforms/media.js
Normal file
50
connector/src/platforms/media.js
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
// Short-lived signed public links to uploaded media, for platforms that fetch media from a URL
|
||||||
|
// (TikTok photos, Threads, Google Business Profile). Served by the dashboard at /m/<token>/<file>.
|
||||||
|
// Only files under data/library and data/schedule/media can be linked, and links expire.
|
||||||
|
import path from "node:path";
|
||||||
|
import crypto from "node:crypto";
|
||||||
|
import { PROJECT_ROOT, loadEnv } from "../env.js";
|
||||||
|
|
||||||
|
loadEnv();
|
||||||
|
const DATA = path.join(PROJECT_ROOT, "data");
|
||||||
|
const ROOTS = [path.join(DATA, "library"), path.join(DATA, "schedule", "media")].map((p) => path.resolve(p));
|
||||||
|
const err = (m, status = 400) => Object.assign(new Error(m), { status });
|
||||||
|
|
||||||
|
function secret() {
|
||||||
|
const s = process.env.QUORLA_SECRET_KEY || process.env.DASHBOARD_SECRET;
|
||||||
|
if (!s) throw err("Set DASHBOARD_SECRET in .env to create media links.", 500);
|
||||||
|
return crypto.createHash("sha256").update(`quorla-media:${s}`).digest();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The public HTTPS address of this install, e.g. https://app.quorlasocial.com */
|
||||||
|
export function publicBaseUrl() {
|
||||||
|
const u = process.env.PUBLIC_URL || (process.env.APP_DOMAIN ? `https://${process.env.APP_DOMAIN}` : "");
|
||||||
|
return u.replace(/\/$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
const allowed = (f) => ROOTS.some((r) => f === r || f.startsWith(r + path.sep));
|
||||||
|
|
||||||
|
/** Signed URL for a local media file, valid for `hours`. */
|
||||||
|
export function publicMediaUrl(filePath, { hours = 6 } = {}) {
|
||||||
|
const base = publicBaseUrl();
|
||||||
|
if (!base) throw err("This platform fetches media from a public link. Set PUBLIC_URL (e.g. https://app.quorlasocial.com) in .env.");
|
||||||
|
const f = path.resolve(filePath);
|
||||||
|
if (!allowed(f)) throw err("Only files from the content library or Publishing uploads can be shared with platforms.");
|
||||||
|
const rel = path.relative(DATA, f).split(path.sep).join("/");
|
||||||
|
const body = Buffer.from(JSON.stringify({ p: rel, e: Date.now() + hours * 3600000 })).toString("base64url");
|
||||||
|
const sig = crypto.createHmac("sha256", secret()).update(body).digest("base64url");
|
||||||
|
return `${base}/m/${body}.${sig}/${encodeURIComponent(path.basename(f).replace(/^\d+-[a-z0-9]{4}-/i, ""))}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Verify a token from /m/<token>/… and return the absolute file path, or null. */
|
||||||
|
export function resolveMediaToken(token) {
|
||||||
|
const [body, sig] = String(token || "").split(".");
|
||||||
|
if (!body || !sig) return null;
|
||||||
|
const expected = crypto.createHmac("sha256", secret()).update(body).digest("base64url");
|
||||||
|
if (sig.length !== expected.length || !crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) return null;
|
||||||
|
let p;
|
||||||
|
try { p = JSON.parse(Buffer.from(body, "base64url").toString()); } catch { return null; }
|
||||||
|
if (!p?.p || p.e < Date.now()) return null;
|
||||||
|
const f = path.resolve(DATA, p.p);
|
||||||
|
return allowed(f) ? f : null;
|
||||||
|
}
|
||||||
69
connector/src/platforms/oauth.js
Normal file
69
connector/src/platforms/oauth.js
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
// Generic OAuth 2.0 authorization-code flow used by every platform adapter:
|
||||||
|
// build the sign-in URL (with PKCE where the platform supports it), exchange the code, refresh tokens.
|
||||||
|
import crypto from "node:crypto";
|
||||||
|
import { request } from "./http.js";
|
||||||
|
|
||||||
|
/** PKCE verifier + challenge. TikTok's desktop flavour uses hex; everyone else uses base64url (RFC 7636). */
|
||||||
|
export function pkce(style = "S256") {
|
||||||
|
const verifier = crypto.randomBytes(48).toString("base64url");
|
||||||
|
const hash = crypto.createHash("sha256").update(verifier);
|
||||||
|
const challenge = style === "hex" ? hash.digest("hex") : hash.digest("base64url");
|
||||||
|
return { verifier, challenge };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function authorizeUrl(adapter, app, { redirectUri, state, challenge }) {
|
||||||
|
const a = adapter.auth;
|
||||||
|
const u = new URL(a.authorizeUrl);
|
||||||
|
u.searchParams.set(a.clientIdParam || "client_id", app.clientId);
|
||||||
|
u.searchParams.set("redirect_uri", redirectUri);
|
||||||
|
u.searchParams.set("response_type", "code");
|
||||||
|
u.searchParams.set("scope", (app.scopes?.length ? app.scopes : a.scopes).join(a.scopeSeparator || " "));
|
||||||
|
u.searchParams.set("state", state);
|
||||||
|
if (a.pkce && challenge) { u.searchParams.set("code_challenge", challenge); u.searchParams.set("code_challenge_method", "S256"); }
|
||||||
|
for (const [k, v] of Object.entries(a.extraAuthParams || {})) u.searchParams.set(k, v);
|
||||||
|
return u.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Normalise a token response into { accessToken, refreshToken, expiresAt, refreshExpiresAt, raw-ish extras }. */
|
||||||
|
export function normaliseTokens(t, prev = {}) {
|
||||||
|
const now = Date.now();
|
||||||
|
return {
|
||||||
|
accessToken: t.access_token,
|
||||||
|
refreshToken: t.refresh_token || prev.refreshToken || null,
|
||||||
|
tokenType: t.token_type || "bearer",
|
||||||
|
expiresAt: t.expires_in ? new Date(now + Number(t.expires_in) * 1000).toISOString() : prev.expiresAt || null,
|
||||||
|
refreshExpiresAt: t.refresh_expires_in || t.refresh_token_expires_in
|
||||||
|
? new Date(now + Number(t.refresh_expires_in || t.refresh_token_expires_in) * 1000).toISOString()
|
||||||
|
: prev.refreshExpiresAt || null,
|
||||||
|
userId: t.user_id ? String(t.user_id) : t.open_id || prev.userId || null,
|
||||||
|
scope: t.scope || prev.scope || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function tokenRequest(adapter, app, form) {
|
||||||
|
const a = adapter.auth;
|
||||||
|
const headers = {};
|
||||||
|
const body = { ...form };
|
||||||
|
if (a.clientAuth === "basic") headers.Authorization = `Basic ${Buffer.from(`${app.clientId}:${app.clientSecret}`).toString("base64")}`;
|
||||||
|
else { body[a.clientIdParam || "client_id"] = app.clientId; body.client_secret = app.clientSecret; }
|
||||||
|
if (a.tokenHeaders) Object.assign(headers, a.tokenHeaders);
|
||||||
|
const { data } = await request(adapter.name, a.tokenUrl, { method: "POST", headers, form: body });
|
||||||
|
if (!data?.access_token) throw new Error(`${adapter.name}: no access token in the response.`);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function exchangeCode(adapter, app, { code, redirectUri, verifier }) {
|
||||||
|
const raw = await tokenRequest(adapter, app, {
|
||||||
|
grant_type: "authorization_code", code, redirect_uri: redirectUri, ...(adapter.auth.pkce && verifier ? { code_verifier: verifier } : {}),
|
||||||
|
});
|
||||||
|
let tokens = normaliseTokens(raw);
|
||||||
|
if (adapter.afterExchange) tokens = await adapter.afterExchange(tokens, app);
|
||||||
|
return tokens;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function refreshTokens(adapter, app, prev) {
|
||||||
|
if (adapter.refresh) return adapter.refresh(prev, app); // platforms with their own refresh scheme (Threads)
|
||||||
|
if (!prev.refreshToken) throw new Error(`${adapter.name}: the sign-in expired and can't be renewed automatically. Reconnect it.`);
|
||||||
|
const raw = await tokenRequest(adapter, app, { grant_type: "refresh_token", refresh_token: prev.refreshToken });
|
||||||
|
return normaliseTokens(raw, prev);
|
||||||
|
}
|
||||||
87
connector/src/platforms/pinterest.js
Normal file
87
connector/src/platforms/pinterest.js
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
// Pinterest API v5: image, multi-image (carousel) and video Pins on a chosen board.
|
||||||
|
// Trial-access apps can only create Pins in Pinterest's sandbox: set PINTEREST_SANDBOX=true until upgraded.
|
||||||
|
import { request, bytesOf, mimeOf, isVideo, sleep } from "./http.js";
|
||||||
|
|
||||||
|
const api = () => (process.env.PINTEREST_SANDBOX === "true" ? "https://api-sandbox.pinterest.com/v5" : "https://api.pinterest.com/v5");
|
||||||
|
|
||||||
|
async function uploadVideo(token, src) {
|
||||||
|
const { data } = await request("Pinterest", `${api()}/media`, { token, method: "POST", json: { media_type: "video" } });
|
||||||
|
const form = new FormData();
|
||||||
|
for (const [k, v] of Object.entries(data.upload_parameters || {})) form.set(k, v);
|
||||||
|
form.set("file", new Blob([await bytesOf(src)], { type: mimeOf(src) }), "video.mp4");
|
||||||
|
const up = await fetch(data.upload_url, { method: "POST", body: form });
|
||||||
|
if (!up.ok) throw new Error(`Pinterest: video upload failed (${up.status}).`);
|
||||||
|
for (let i = 0; i < 60; i++) {
|
||||||
|
const s = (await request("Pinterest", `${api()}/media/${data.media_id}`, { token })).data;
|
||||||
|
if (s.status === "succeeded") return data.media_id;
|
||||||
|
if (s.status === "failed") throw new Error("Pinterest couldn't process the video.");
|
||||||
|
await sleep(5000);
|
||||||
|
}
|
||||||
|
throw new Error("Pinterest is still processing the video after 5 minutes.");
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
id: "pinterest",
|
||||||
|
name: "Pinterest",
|
||||||
|
color: "#e60023",
|
||||||
|
setup: {
|
||||||
|
console: "https://developers.pinterest.com/apps/",
|
||||||
|
steps: [
|
||||||
|
"Use a Pinterest Business account. Go to developers.pinterest.com → My apps → Connect app, and fill in the app details (quorlasocial.com, privacy policy).",
|
||||||
|
"Add the Redirect URI shown below in the app's settings.",
|
||||||
|
"Copy the App ID and App secret key here.",
|
||||||
|
"Request Standard access (Pinterest asks for a short demo video of the Connect and Pin flow).",
|
||||||
|
],
|
||||||
|
note: "With Trial access, Pins can only be created in Pinterest's sandbox. Set PINTEREST_SANDBOX=true in .env until Standard access is approved.",
|
||||||
|
},
|
||||||
|
auth: {
|
||||||
|
authorizeUrl: "https://www.pinterest.com/oauth/",
|
||||||
|
tokenUrl: "https://api.pinterest.com/v5/oauth/token",
|
||||||
|
clientAuth: "basic",
|
||||||
|
scopes: ["boards:read", "pins:read", "pins:write", "user_accounts:read"],
|
||||||
|
scopeSeparator: ",",
|
||||||
|
},
|
||||||
|
formats: { IMAGE: true, CAROUSEL: true, REELS: true },
|
||||||
|
limits: { caption: 500, title: 100, images: 5 },
|
||||||
|
fields: [
|
||||||
|
{ key: "boardId", label: "Board", type: "select", optionsFrom: "targets", required: true },
|
||||||
|
{ key: "title", label: "Pin title", type: "text", max: 100 },
|
||||||
|
{ key: "link", label: "Destination link", type: "text", hint: "Where the Pin leads, e.g. the product or booking page." },
|
||||||
|
],
|
||||||
|
|
||||||
|
async identify(tokens) {
|
||||||
|
const { data } = await request("Pinterest", `${api()}/user_account`, { token: tokens.accessToken });
|
||||||
|
return { id: data.id || data.username, name: data.business_name || data.username, username: data.username, avatar: data.profile_image || null, url: `https://www.pinterest.com/${data.username}/`, followers: data.follower_count ?? null };
|
||||||
|
},
|
||||||
|
|
||||||
|
async targets(tokens) {
|
||||||
|
const out = [];
|
||||||
|
let bookmark;
|
||||||
|
do {
|
||||||
|
const { data } = await request("Pinterest", `${api()}/boards`, { token: tokens.accessToken, query: { page_size: 100, bookmark } });
|
||||||
|
for (const b of data.items || []) out.push({ id: b.id, name: b.name });
|
||||||
|
bookmark = data.bookmark;
|
||||||
|
} while (bookmark);
|
||||||
|
return out;
|
||||||
|
},
|
||||||
|
|
||||||
|
async publish({ tokens, post, opts, caption }) {
|
||||||
|
if (!opts.boardId) throw new Error("Pinterest: pick a board.");
|
||||||
|
const media = post.media || [];
|
||||||
|
let media_source;
|
||||||
|
if (post.type === "REELS" || isVideo(media[0])) {
|
||||||
|
media_source = { source_type: "video_id", media_id: await uploadVideo(tokens.accessToken, media[0]), cover_image_key_frame_time: 1 };
|
||||||
|
} else if (media.length > 1) {
|
||||||
|
const items = [];
|
||||||
|
for (const m of media.slice(0, 5)) items.push({ content_type: mimeOf(m), data: (await bytesOf(m)).toString("base64") });
|
||||||
|
media_source = { source_type: "multiple_image_base64", items };
|
||||||
|
} else {
|
||||||
|
media_source = { source_type: "image_base64", content_type: mimeOf(media[0]), data: (await bytesOf(media[0])).toString("base64") };
|
||||||
|
}
|
||||||
|
const { data } = await request("Pinterest", `${api()}/pins`, {
|
||||||
|
token: tokens.accessToken, method: "POST",
|
||||||
|
json: { board_id: opts.boardId, title: opts.title ? String(opts.title).slice(0, 100) : undefined, description: String(caption || "").slice(0, 500), link: opts.link || undefined, media_source },
|
||||||
|
});
|
||||||
|
return { id: data.id, permalink: `https://www.pinterest.com/pin/${data.id}/` };
|
||||||
|
},
|
||||||
|
};
|
||||||
53
connector/src/platforms/reddit.js
Normal file
53
connector/src/platforms/reddit.js
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
// Reddit: text and link posts to a subreddit. Reddit communities often remove brand self-promotion:
|
||||||
|
// check each subreddit's rules, and prefer genuine participation over ads.
|
||||||
|
import { request } from "./http.js";
|
||||||
|
|
||||||
|
const UA = () => process.env.REDDIT_USER_AGENT || "web:com.quorlasocial.app:1.0 (by /u/quorla)";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
id: "reddit",
|
||||||
|
name: "Reddit",
|
||||||
|
color: "#ff4500",
|
||||||
|
setup: {
|
||||||
|
console: "https://www.reddit.com/prefs/apps",
|
||||||
|
steps: [
|
||||||
|
"Signed in as the agency's Reddit account, open reddit.com/prefs/apps → “create another app…” → type “web app”.",
|
||||||
|
"Set the redirect uri to the address shown below.",
|
||||||
|
"Copy the client ID (under the app name) and the secret here.",
|
||||||
|
"Commercial use of Reddit's API needs Reddit's approval: register via Reddit's Data API request form before using it for clients.",
|
||||||
|
],
|
||||||
|
note: "Set REDDIT_USER_AGENT in .env to something like web:com.quorlasocial.app:1.0 (by /u/yourusername), as Reddit requires.",
|
||||||
|
},
|
||||||
|
auth: {
|
||||||
|
authorizeUrl: "https://www.reddit.com/api/v1/authorize",
|
||||||
|
tokenUrl: "https://www.reddit.com/api/v1/access_token",
|
||||||
|
clientAuth: "basic",
|
||||||
|
scopes: ["identity", "submit", "read", "mysubreddits", "flair"],
|
||||||
|
extraAuthParams: { duration: "permanent" },
|
||||||
|
get tokenHeaders() { return { "User-Agent": UA() }; },
|
||||||
|
},
|
||||||
|
formats: { TEXT: true },
|
||||||
|
limits: { caption: 40000, title: 300 },
|
||||||
|
fields: [
|
||||||
|
{ key: "subreddit", label: "Subreddit", type: "text", required: true, hint: "Without r/, e.g. waterloo. Read the subreddit's rules first." },
|
||||||
|
{ key: "title", label: "Post title", type: "text", required: true, max: 300 },
|
||||||
|
{ key: "link", label: "Link (optional)", type: "text", hint: "Makes it a link post; the caption is then not used." },
|
||||||
|
{ key: "flairId", label: "Flair ID (optional)", type: "text", hint: "Some subreddits require a flair." },
|
||||||
|
],
|
||||||
|
|
||||||
|
async identify(tokens) {
|
||||||
|
const { data } = await request("Reddit", "https://oauth.reddit.com/api/v1/me", { token: tokens.accessToken, headers: { "User-Agent": UA() } });
|
||||||
|
return { id: data.id, name: `u/${data.name}`, username: data.name, avatar: (data.icon_img || "").split("?")[0] || null, url: `https://www.reddit.com/user/${data.name}`, followers: data.total_karma ?? null };
|
||||||
|
},
|
||||||
|
|
||||||
|
async publish({ tokens, opts, caption }) {
|
||||||
|
const sr = String(opts.subreddit || "").replace(/^\/?r\//i, "").trim();
|
||||||
|
if (!sr || !opts.title) throw new Error("Reddit: add the subreddit and a title.");
|
||||||
|
const form = { sr, title: String(opts.title).slice(0, 300), api_type: "json", resubmit: "true", ...(opts.flairId ? { flair_id: opts.flairId } : {}) };
|
||||||
|
if (opts.link) Object.assign(form, { kind: "link", url: opts.link }); else Object.assign(form, { kind: "self", text: caption || "" });
|
||||||
|
const { data } = await request("Reddit", "https://oauth.reddit.com/api/submit", { token: tokens.accessToken, method: "POST", headers: { "User-Agent": UA() }, form });
|
||||||
|
const errors = data?.json?.errors || [];
|
||||||
|
if (errors.length) throw new Error(`Reddit: ${errors.map((e) => e.slice(1).join(" ")).join("; ")}`);
|
||||||
|
return { id: data.json.data.name || data.json.data.id, permalink: data.json.data.url };
|
||||||
|
},
|
||||||
|
};
|
||||||
93
connector/src/platforms/threads.js
Normal file
93
connector/src/platforms/threads.js
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
// Threads (Meta): text, image, video and carousel posts. Media is fetched by Threads from a public link.
|
||||||
|
// Uses its own Threads app credentials (Meta app with the Threads use case), separate from the Meta system user.
|
||||||
|
import { request, isVideo, sleep } from "./http.js";
|
||||||
|
import { publicMediaUrl } from "./media.js";
|
||||||
|
|
||||||
|
const G = "https://graph.threads.net";
|
||||||
|
const url = (m) => (/^https?:/.test(m) ? m : publicMediaUrl(m));
|
||||||
|
|
||||||
|
async function waitFinished(token, id) {
|
||||||
|
for (let i = 0; i < 60; i++) {
|
||||||
|
const { data } = await request("Threads", `${G}/v1.0/${id}`, { query: { fields: "status,error_message", access_token: token } });
|
||||||
|
if (data.status === "FINISHED") return;
|
||||||
|
if (data.status === "ERROR" || data.status === "EXPIRED") throw new Error(`Threads couldn't process the media: ${data.error_message || data.status}`);
|
||||||
|
await sleep(5000);
|
||||||
|
}
|
||||||
|
throw new Error("Threads is still processing the media after 5 minutes.");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function container(token, uid, params) {
|
||||||
|
const { data } = await request("Threads", `${G}/v1.0/${uid}/threads`, { method: "POST", query: { ...params, access_token: token } });
|
||||||
|
return data.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
id: "threads",
|
||||||
|
name: "Threads",
|
||||||
|
color: "#000000",
|
||||||
|
setup: {
|
||||||
|
console: "https://developers.facebook.com/apps/",
|
||||||
|
steps: [
|
||||||
|
"In Meta for Developers, create an app with the “Access the Threads API” use case (it can be a separate app from the Facebook/Instagram one).",
|
||||||
|
"Use case → Settings: add the Redirect URI shown below as a Redirect Callback URL (also fill Uninstall and Delete callback URLs with your site).",
|
||||||
|
"Add permissions threads_basic, threads_content_publish, threads_manage_replies, threads_read_replies, threads_manage_insights.",
|
||||||
|
"App settings → Basic: copy the Threads App ID and Threads App secret here.",
|
||||||
|
],
|
||||||
|
note: "Until Meta approves the app, only Threads accounts added under App roles → Roles (as Threads testers, who accept the invite in Threads) can connect.",
|
||||||
|
},
|
||||||
|
auth: {
|
||||||
|
authorizeUrl: "https://threads.net/oauth/authorize",
|
||||||
|
tokenUrl: `${G}/oauth/access_token`,
|
||||||
|
scopes: ["threads_basic", "threads_content_publish", "threads_manage_replies", "threads_read_replies", "threads_manage_insights"],
|
||||||
|
scopeSeparator: ",",
|
||||||
|
},
|
||||||
|
formats: { TEXT: true, IMAGE: true, CAROUSEL: true, REELS: true },
|
||||||
|
limits: { caption: 500, images: 20 },
|
||||||
|
|
||||||
|
/** Swap the 1-hour token for a 60-day one. */
|
||||||
|
async afterExchange(tokens, app) {
|
||||||
|
const { data } = await request("Threads", `${G}/access_token`, { query: { grant_type: "th_exchange_token", client_secret: app.clientSecret, access_token: tokens.accessToken } });
|
||||||
|
return { ...tokens, accessToken: data.access_token, expiresAt: new Date(Date.now() + Number(data.expires_in || 5184000) * 1000).toISOString(), refreshToken: null };
|
||||||
|
},
|
||||||
|
/** Long-lived Threads tokens are refreshed with themselves (any time after 24 h, before expiry). */
|
||||||
|
async refresh(prev) {
|
||||||
|
const { data } = await request("Threads", `${G}/refresh_access_token`, { query: { grant_type: "th_refresh_token", access_token: prev.accessToken } });
|
||||||
|
return { ...prev, accessToken: data.access_token, expiresAt: new Date(Date.now() + Number(data.expires_in || 5184000) * 1000).toISOString() };
|
||||||
|
},
|
||||||
|
|
||||||
|
async identify(tokens) {
|
||||||
|
const { data } = await request("Threads", `${G}/v1.0/me`, { query: { fields: "id,username,name,threads_profile_picture_url", access_token: tokens.accessToken } });
|
||||||
|
let followers = null;
|
||||||
|
try {
|
||||||
|
const ins = await request("Threads", `${G}/v1.0/${data.id}/threads_insights`, { query: { metric: "followers_count", access_token: tokens.accessToken } });
|
||||||
|
followers = ins.data.data?.[0]?.total_value?.value ?? null;
|
||||||
|
} catch { /* insights permission not granted */ }
|
||||||
|
return { id: data.id, name: data.name || data.username, username: data.username, avatar: data.threads_profile_picture_url || null, url: `https://www.threads.net/@${data.username}`, followers };
|
||||||
|
},
|
||||||
|
|
||||||
|
async publish({ tokens, conn, post, caption }) {
|
||||||
|
const token = tokens.accessToken, uid = conn.account.id;
|
||||||
|
const text = String(caption || "").slice(0, 500);
|
||||||
|
const media = post.media || [];
|
||||||
|
let id;
|
||||||
|
if (post.type === "TEXT" || !media.length) {
|
||||||
|
id = await container(token, uid, { media_type: "TEXT", text });
|
||||||
|
} else if (media.length > 1) {
|
||||||
|
const children = [];
|
||||||
|
for (const m of media.slice(0, 20)) {
|
||||||
|
const c = await container(token, uid, isVideo(m) ? { media_type: "VIDEO", video_url: url(m), is_carousel_item: true } : { media_type: "IMAGE", image_url: url(m), is_carousel_item: true });
|
||||||
|
await waitFinished(token, c);
|
||||||
|
children.push(c);
|
||||||
|
}
|
||||||
|
id = await container(token, uid, { media_type: "CAROUSEL", children: children.join(","), text });
|
||||||
|
} else {
|
||||||
|
const m = media[0];
|
||||||
|
id = await container(token, uid, isVideo(m) ? { media_type: "VIDEO", video_url: url(m), text } : { media_type: "IMAGE", image_url: url(m), text });
|
||||||
|
}
|
||||||
|
await waitFinished(token, id);
|
||||||
|
const { data } = await request("Threads", `${G}/v1.0/${uid}/threads_publish`, { method: "POST", query: { creation_id: id, access_token: token } });
|
||||||
|
let permalink = null;
|
||||||
|
try { permalink = (await request("Threads", `${G}/v1.0/${data.id}`, { query: { fields: "permalink", access_token: token } })).data.permalink; } catch { /* optional */ }
|
||||||
|
return { id: data.id, permalink };
|
||||||
|
},
|
||||||
|
};
|
||||||
108
connector/src/platforms/tiktok.js
Normal file
108
connector/src/platforms/tiktok.js
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
// TikTok Content Posting API: direct-post videos (uploaded in chunks) and photo posts (pulled from a URL).
|
||||||
|
// Until TikTok audits the app, posts can only be private (SELF_ONLY) and the account must be set to private.
|
||||||
|
import { request, bytesOf, isVideo, sleep } from "./http.js";
|
||||||
|
import { publicMediaUrl } from "./media.js";
|
||||||
|
|
||||||
|
const API = "https://open.tiktokapis.com/v2";
|
||||||
|
const PRIVACY = [["PUBLIC_TO_EVERYONE", "Everyone"], ["MUTUAL_FOLLOW_FRIENDS", "Friends"], ["FOLLOWER_OF_CREATOR", "Followers"], ["SELF_ONLY", "Only me"]];
|
||||||
|
|
||||||
|
/** TikTok chunk rules: whole file if ≤ 64 MB, else 10 MB chunks with the remainder in the last one. */
|
||||||
|
export function chunkPlan(size) {
|
||||||
|
if (size <= 64 * 1024 * 1024) return { chunkSize: size, count: 1 };
|
||||||
|
const chunkSize = 10 * 1024 * 1024;
|
||||||
|
return { chunkSize, count: Math.floor(size / chunkSize) };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForPost(token, publishId) {
|
||||||
|
for (let i = 0; i < 60; i++) {
|
||||||
|
const { data } = await request("TikTok", `${API}/post/publish/status/fetch/`, { token, method: "POST", json: { publish_id: publishId } });
|
||||||
|
const s = data.data?.status;
|
||||||
|
if (s === "PUBLISH_COMPLETE") return data.data;
|
||||||
|
if (s === "FAILED") throw new Error(`TikTok rejected the post: ${data.data?.fail_reason || "unknown reason"}`);
|
||||||
|
await sleep(5000);
|
||||||
|
}
|
||||||
|
return { status: "PROCESSING" }; // TikTok keeps processing; it appears on the profile when done
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
id: "tiktok",
|
||||||
|
name: "TikTok",
|
||||||
|
color: "#010101",
|
||||||
|
setup: {
|
||||||
|
console: "https://developers.tiktok.com/apps",
|
||||||
|
steps: [
|
||||||
|
"Create an app at developers.tiktok.com (Manage apps → Connect an app). Add a website URL, terms and privacy policy links (quorlasocial.com).",
|
||||||
|
"Add products “Login Kit” and “Content Posting API”; in Content Posting API turn on Direct Post.",
|
||||||
|
"Login Kit → Redirect URI: add the address shown below.",
|
||||||
|
"For photo posts: URL properties → verify the domain of your Quorla address (e.g. app.quorlasocial.com).",
|
||||||
|
"Copy the Client key and Client secret here, then submit the app for review.",
|
||||||
|
],
|
||||||
|
note: "Before TikTok approves the app, posts are private (“Only me”) and only work for accounts added as Sandbox target users.",
|
||||||
|
},
|
||||||
|
auth: {
|
||||||
|
authorizeUrl: "https://www.tiktok.com/v2/auth/authorize/",
|
||||||
|
tokenUrl: `${API}/oauth/token/`,
|
||||||
|
clientIdParam: "client_key",
|
||||||
|
scopes: ["user.info.basic", "user.info.profile", "user.info.stats", "video.publish", "video.upload"],
|
||||||
|
scopeSeparator: ",",
|
||||||
|
},
|
||||||
|
formats: { REELS: true, IMAGE: true, CAROUSEL: true },
|
||||||
|
limits: { caption: 2200, images: 35 },
|
||||||
|
fields: [
|
||||||
|
{ key: "privacy", label: "Who can view", type: "select", options: PRIVACY, required: true, hint: "TikTok requires you to choose. Unaudited apps can only use “Only me”." },
|
||||||
|
{ key: "allowComments", label: "Allow comments", type: "checkbox", default: true },
|
||||||
|
{ key: "allowDuet", label: "Allow Duet", type: "checkbox", default: false, onlyFor: ["REELS"] },
|
||||||
|
{ key: "allowStitch", label: "Allow Stitch", type: "checkbox", default: false, onlyFor: ["REELS"] },
|
||||||
|
{ key: "title", label: "Photo post title", type: "text", max: 90, onlyFor: ["IMAGE", "CAROUSEL"], hint: "Optional, up to 90 characters. The caption becomes the description." },
|
||||||
|
],
|
||||||
|
|
||||||
|
async identify(tokens) {
|
||||||
|
const { data } = await request("TikTok", `${API}/user/info/`, { token: tokens.accessToken, query: { fields: "open_id,avatar_url,display_name,username,follower_count,profile_deep_link" } });
|
||||||
|
const u = data.data?.user || {};
|
||||||
|
return { id: u.open_id || tokens.userId, name: u.display_name, username: u.username || null, avatar: u.avatar_url || null, url: u.username ? `https://www.tiktok.com/@${u.username}` : u.profile_deep_link || null, followers: u.follower_count ?? null };
|
||||||
|
},
|
||||||
|
|
||||||
|
async publish({ tokens, conn, post, opts, caption }) {
|
||||||
|
const token = tokens.accessToken;
|
||||||
|
if (!opts.privacy) throw new Error("TikTok: choose who can view the post.");
|
||||||
|
const creator = (await request("TikTok", `${API}/post/publish/creator_info/query/`, { token, method: "POST", json: {} })).data.data || {};
|
||||||
|
if (creator.privacy_level_options && !creator.privacy_level_options.includes(opts.privacy)) {
|
||||||
|
throw new Error(`TikTok: this account can't post as “${opts.privacy}”. Allowed: ${creator.privacy_level_options.join(", ")}.`);
|
||||||
|
}
|
||||||
|
const media = post.media || [];
|
||||||
|
let publishId;
|
||||||
|
if (post.type === "REELS" || isVideo(media[0])) {
|
||||||
|
const bytes = await bytesOf(media[0]);
|
||||||
|
const plan = chunkPlan(bytes.length);
|
||||||
|
const { data } = await request("TikTok", `${API}/post/publish/video/init/`, {
|
||||||
|
token, method: "POST",
|
||||||
|
json: {
|
||||||
|
post_info: { title: String(caption || "").slice(0, 2200), privacy_level: opts.privacy, disable_comment: opts.allowComments === false, disable_duet: !opts.allowDuet, disable_stitch: !opts.allowStitch },
|
||||||
|
source_info: { source: "FILE_UPLOAD", video_size: bytes.length, chunk_size: plan.chunkSize, total_chunk_count: plan.count },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
publishId = data.data.publish_id;
|
||||||
|
for (let i = 0; i < plan.count; i++) {
|
||||||
|
const start = i * plan.chunkSize;
|
||||||
|
const end = i === plan.count - 1 ? bytes.length : start + plan.chunkSize;
|
||||||
|
const r = await fetch(data.data.upload_url, { method: "PUT", headers: { "Content-Type": "video/mp4", "Content-Range": `bytes ${start}-${end - 1}/${bytes.length}` }, body: bytes.subarray(start, end) });
|
||||||
|
if (!r.ok) throw new Error(`TikTok: video upload failed on part ${i + 1} (${r.status}).`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const urls = media.slice(0, 35).map((m) => (/^https?:/.test(m) ? m : publicMediaUrl(m)));
|
||||||
|
const { data } = await request("TikTok", `${API}/post/publish/content/init/`, {
|
||||||
|
token, method: "POST",
|
||||||
|
json: {
|
||||||
|
post_info: { title: String(opts.title || "").slice(0, 90), description: String(caption || "").slice(0, 4000), privacy_level: opts.privacy, disable_comment: opts.allowComments === false, auto_add_music: true },
|
||||||
|
source_info: { source: "PULL_FROM_URL", photo_cover_index: 0, photo_images: urls },
|
||||||
|
post_mode: "DIRECT_POST", media_type: "PHOTO",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
publishId = data.data.publish_id;
|
||||||
|
}
|
||||||
|
const done = await waitForPost(token, publishId);
|
||||||
|
const postId = done.publicaly_available_post_id?.[0];
|
||||||
|
const user = conn.account?.username;
|
||||||
|
return { id: postId ? String(postId) : publishId, publishId, status: done.status, permalink: postId && user ? `https://www.tiktok.com/@${user}/video/${postId}` : null };
|
||||||
|
},
|
||||||
|
};
|
||||||
152
connector/src/platforms/vault.js
Normal file
152
connector/src/platforms/vault.js
Normal file
@ -0,0 +1,152 @@
|
|||||||
|
// Encrypted store for platform app credentials (client ID/secret per platform) and each client's
|
||||||
|
// connected accounts (OAuth tokens). Lives in data/connections.json; secrets are AES-256-GCM encrypted
|
||||||
|
// with a key derived from QUORLA_SECRET_KEY (or DASHBOARD_SECRET). Tokens never leave the server.
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import crypto from "node:crypto";
|
||||||
|
import { PROJECT_ROOT, loadEnv } from "../env.js";
|
||||||
|
|
||||||
|
loadEnv();
|
||||||
|
const FILE = process.env.QUORLA_CONNECTIONS_FILE || path.join(PROJECT_ROOT, "data", "connections.json"); // override for tests
|
||||||
|
const err = (message, status = 400) => Object.assign(new Error(message), { status });
|
||||||
|
const now = () => new Date().toISOString();
|
||||||
|
|
||||||
|
function key() {
|
||||||
|
const secret = process.env.QUORLA_SECRET_KEY || process.env.DASHBOARD_SECRET;
|
||||||
|
if (!secret) throw err("Set DASHBOARD_SECRET (or QUORLA_SECRET_KEY) in .env before connecting platforms: it encrypts the stored tokens.", 500);
|
||||||
|
return crypto.createHash("sha256").update(`quorla-vault:${secret}`).digest();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function seal(value) {
|
||||||
|
const iv = crypto.randomBytes(12);
|
||||||
|
const c = crypto.createCipheriv("aes-256-gcm", key(), iv);
|
||||||
|
const body = Buffer.concat([c.update(JSON.stringify(value), "utf8"), c.final()]);
|
||||||
|
return `v1.${iv.toString("base64url")}.${c.getAuthTag().toString("base64url")}.${body.toString("base64url")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function open(sealed) {
|
||||||
|
const [v, iv, tag, body] = String(sealed || "").split(".");
|
||||||
|
if (v !== "v1") throw err("Unreadable secret.", 500);
|
||||||
|
const d = crypto.createDecipheriv("aes-256-gcm", key(), Buffer.from(iv, "base64url"));
|
||||||
|
d.setAuthTag(Buffer.from(tag, "base64url"));
|
||||||
|
return JSON.parse(Buffer.concat([d.update(Buffer.from(body, "base64url")), d.final()]).toString("utf8"));
|
||||||
|
}
|
||||||
|
|
||||||
|
function read() {
|
||||||
|
try { return JSON.parse(fs.readFileSync(FILE, "utf8")); } catch { return { apps: {}, connections: [] }; }
|
||||||
|
}
|
||||||
|
function write(db) {
|
||||||
|
fs.mkdirSync(path.dirname(FILE), { recursive: true });
|
||||||
|
const tmp = `${FILE}.tmp`;
|
||||||
|
fs.writeFileSync(tmp, JSON.stringify(db, null, 2), { mode: 0o600 });
|
||||||
|
fs.renameSync(tmp, FILE);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- platform apps (client ID / secret) ----------
|
||||||
|
/** App credentials for a platform: from the vault, else from .env (<PLATFORM>_CLIENT_ID / _CLIENT_SECRET). */
|
||||||
|
export function appCredentials(platform) {
|
||||||
|
const rec = read().apps[platform];
|
||||||
|
if (rec) {
|
||||||
|
try { return { ...open(rec.sealed), source: "settings", updatedAt: rec.updatedAt }; } catch { /* key changed */ }
|
||||||
|
}
|
||||||
|
const P = platform.toUpperCase();
|
||||||
|
const clientId = process.env[`${P}_CLIENT_ID`], clientSecret = process.env[`${P}_CLIENT_SECRET`];
|
||||||
|
return clientId ? { clientId, clientSecret, source: "env" } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Save a platform's app credentials. Leave the secret empty to keep the stored one. `scopes` overrides the defaults. */
|
||||||
|
export function saveApp(platform, { clientId, clientSecret, scopes }, by) {
|
||||||
|
if (!String(clientId || "").trim()) throw err("Enter the app's client ID (or client key).");
|
||||||
|
const db = read();
|
||||||
|
const prev = db.apps[platform] ? (() => { try { return open(db.apps[platform].sealed); } catch { return {}; } })() : {};
|
||||||
|
const secret = String(clientSecret || "").trim() || prev.clientSecret;
|
||||||
|
if (!secret) throw err("Enter the app's client secret.");
|
||||||
|
const scopeList = String(scopes ?? "").split(/[\s,]+/).filter(Boolean);
|
||||||
|
db.apps[platform] = { sealed: seal({ clientId: String(clientId).trim(), clientSecret: secret, scopes: scopeList }), updatedAt: now(), updatedBy: by };
|
||||||
|
write(db);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeApp(platform) {
|
||||||
|
const db = read(); delete db.apps[platform]; write(db);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- connected accounts ----------
|
||||||
|
const publicConn = (c) => {
|
||||||
|
const { sealed, ...rest } = c;
|
||||||
|
return rest;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function listConnections({ client, platform } = {}) {
|
||||||
|
return read().connections.filter((c) => (!client || c.client === client) && (!platform || c.platform === platform)).map(publicConn);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getConnection(id, { withTokens = false } = {}) {
|
||||||
|
const c = read().connections.find((x) => x.id === id);
|
||||||
|
if (!c) throw err("Connection not found.", 404);
|
||||||
|
if (!withTokens) return publicConn(c);
|
||||||
|
let tokens = null;
|
||||||
|
try { tokens = open(c.sealed); } catch { /* key changed: reconnect */ }
|
||||||
|
return { ...publicConn(c), tokens };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The client's connection for a platform (one account per platform per client). */
|
||||||
|
export function connectionFor(client, platform, opts) {
|
||||||
|
const c = read().connections.find((x) => x.client === client && x.platform === platform);
|
||||||
|
return c ? getConnection(c.id, opts) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Create or replace the client's connection for a platform. */
|
||||||
|
export function saveConnection({ client, platform, account, tokens, scopes, by }) {
|
||||||
|
const db = read();
|
||||||
|
const existing = db.connections.find((x) => x.client === client && x.platform === platform);
|
||||||
|
const rec = {
|
||||||
|
id: existing?.id || crypto.randomUUID().slice(0, 8),
|
||||||
|
client, platform,
|
||||||
|
account, // { id, name, username, url, avatar, followers }
|
||||||
|
target: existing && existing.account?.id === account?.id ? existing.target || null : null,
|
||||||
|
scopes: scopes || [],
|
||||||
|
expiresAt: tokens.expiresAt || null,
|
||||||
|
refreshExpiresAt: tokens.refreshExpiresAt || null,
|
||||||
|
status: "connected", lastError: null,
|
||||||
|
connectedAt: now(), connectedBy: by, updatedAt: now(),
|
||||||
|
sealed: seal(tokens),
|
||||||
|
};
|
||||||
|
db.connections = db.connections.filter((x) => x !== existing);
|
||||||
|
db.connections.push(rec);
|
||||||
|
write(db);
|
||||||
|
return publicConn(rec);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateConnection(id, patch) {
|
||||||
|
const db = read();
|
||||||
|
const c = db.connections.find((x) => x.id === id);
|
||||||
|
if (!c) throw err("Connection not found.", 404);
|
||||||
|
if (patch.tokens) { c.sealed = seal(patch.tokens); c.expiresAt = patch.tokens.expiresAt || null; c.refreshExpiresAt = patch.tokens.refreshExpiresAt || c.refreshExpiresAt || null; }
|
||||||
|
for (const k of ["target", "status", "lastError", "account"]) if (k in patch) c[k] = patch[k];
|
||||||
|
c.updatedAt = now();
|
||||||
|
write(db);
|
||||||
|
return publicConn(c);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeConnection(id) {
|
||||||
|
const db = read();
|
||||||
|
const before = db.connections.length;
|
||||||
|
db.connections = db.connections.filter((x) => x.id !== id);
|
||||||
|
if (db.connections.length === before) throw err("Connection not found.", 404);
|
||||||
|
write(db);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- pending OAuth sign-ins (state → who/what, PKCE verifier) ----------
|
||||||
|
const pending = new Map();
|
||||||
|
export function createState(data) {
|
||||||
|
const state = crypto.randomBytes(24).toString("base64url");
|
||||||
|
pending.set(state, { ...data, at: Date.now() });
|
||||||
|
for (const [k, v] of pending) if (Date.now() - v.at > 15 * 60000) pending.delete(k);
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
export function takeState(state) {
|
||||||
|
const v = pending.get(String(state || ""));
|
||||||
|
pending.delete(String(state || ""));
|
||||||
|
if (!v || Date.now() - v.at > 15 * 60000) throw err("This sign-in link expired or was already used. Start again from Connections.");
|
||||||
|
return v;
|
||||||
|
}
|
||||||
57
connector/src/platforms/youtube.js
Normal file
57
connector/src/platforms/youtube.js
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
// YouTube: upload videos (vertical videos under 3 minutes show as Shorts).
|
||||||
|
// Unverified Google apps can only upload as Private until Google audits the app.
|
||||||
|
import { request, bytesOf, mimeOf, isVideo } from "./http.js";
|
||||||
|
import { googleAuth, googleSetup } from "./google.js";
|
||||||
|
|
||||||
|
const API = "https://www.googleapis.com/youtube/v3";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
id: "youtube",
|
||||||
|
name: "YouTube",
|
||||||
|
color: "#ff0000",
|
||||||
|
setup: googleSetup("YouTube API audit (Google's form) lifts the private-only upload limit and the low daily quota.", [
|
||||||
|
"APIs & Services → Library → enable “YouTube Data API v3”.",
|
||||||
|
]),
|
||||||
|
auth: googleAuth(["https://www.googleapis.com/auth/youtube.upload", "https://www.googleapis.com/auth/youtube.readonly"]),
|
||||||
|
formats: { REELS: true },
|
||||||
|
limits: { caption: 5000, title: 100 },
|
||||||
|
fields: [
|
||||||
|
{ key: "title", label: "Video title", type: "text", required: true, max: 100, hint: "Shown on YouTube. Up to 100 characters. The caption becomes the description." },
|
||||||
|
{ key: "privacy", label: "Visibility", type: "select", options: [["public", "Public"], ["unlisted", "Unlisted"], ["private", "Private"]], default: "public" },
|
||||||
|
{ key: "madeForKids", label: "Made for kids?", type: "select", options: [["no", "No"], ["yes", "Yes"]], default: "no", hint: "Required by YouTube (COPPA)." },
|
||||||
|
],
|
||||||
|
|
||||||
|
async identify(tokens) {
|
||||||
|
const { data } = await request("YouTube", `${API}/channels`, { token: tokens.accessToken, query: { part: "snippet,statistics", mine: "true" } });
|
||||||
|
const ch = data.items?.[0];
|
||||||
|
if (!ch) throw new Error("YouTube: this Google account has no YouTube channel. Create one first, or sign in with the brand account.");
|
||||||
|
return {
|
||||||
|
id: ch.id, name: ch.snippet.title, username: ch.snippet.customUrl || null,
|
||||||
|
avatar: ch.snippet.thumbnails?.default?.url || null, url: `https://www.youtube.com/channel/${ch.id}`,
|
||||||
|
followers: Number(ch.statistics?.subscriberCount ?? 0) || null,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
async publish({ tokens, post, opts, caption }) {
|
||||||
|
const src = (post.media || [])[0];
|
||||||
|
if (!src || !isVideo(src)) throw new Error("YouTube needs one video file.");
|
||||||
|
const title = String(opts.title || "").trim();
|
||||||
|
if (!title) throw new Error("YouTube needs a video title.");
|
||||||
|
const bytes = await bytesOf(src);
|
||||||
|
const meta = {
|
||||||
|
snippet: { title: title.slice(0, 100), description: String(caption || "").slice(0, 5000), categoryId: "22" },
|
||||||
|
status: { privacyStatus: opts.privacy || "public", selfDeclaredMadeForKids: opts.madeForKids === "yes" },
|
||||||
|
};
|
||||||
|
const init = await fetch("https://www.googleapis.com/upload/youtube/v3/videos?uploadType=resumable&part=snippet,status", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { Authorization: `Bearer ${tokens.accessToken}`, "Content-Type": "application/json; charset=UTF-8", "X-Upload-Content-Type": mimeOf(src), "X-Upload-Content-Length": String(bytes.length) },
|
||||||
|
body: JSON.stringify(meta),
|
||||||
|
});
|
||||||
|
if (!init.ok) throw new Error(`YouTube: couldn't start the upload (${init.status}): ${(await init.text()).slice(0, 300)}`);
|
||||||
|
const location = init.headers.get("location");
|
||||||
|
const up = await fetch(location, { method: "PUT", headers: { "Content-Type": mimeOf(src), "Content-Length": String(bytes.length) }, body: bytes });
|
||||||
|
const video = await up.json().catch(() => ({}));
|
||||||
|
if (!up.ok || !video.id) throw new Error(`YouTube: upload failed (${up.status}): ${video.error?.message || ""}`);
|
||||||
|
return { id: video.id, permalink: `https://www.youtube.com/shorts/${video.id}`, privacy: video.status?.privacyStatus };
|
||||||
|
},
|
||||||
|
};
|
||||||
@ -6,6 +6,10 @@ import path from "node:path";
|
|||||||
import crypto from "node:crypto";
|
import crypto from "node:crypto";
|
||||||
import { PROJECT_ROOT } from "./env.js";
|
import { PROJECT_ROOT } from "./env.js";
|
||||||
import * as meta from "./meta.js";
|
import * as meta from "./meta.js";
|
||||||
|
import * as extra from "./platforms/index.js";
|
||||||
|
|
||||||
|
const META_PLATFORMS = ["instagram", "facebook"];
|
||||||
|
export const ALL_PLATFORMS = [...META_PLATFORMS, ...extra.PLATFORM_IDS];
|
||||||
|
|
||||||
const DIR = path.join(PROJECT_ROOT, "data", "schedule");
|
const DIR = path.join(PROJECT_ROOT, "data", "schedule");
|
||||||
const FILE = path.join(DIR, "queue.json");
|
const FILE = path.join(DIR, "queue.json");
|
||||||
@ -77,24 +81,27 @@ export function getPost(id) {
|
|||||||
* Add a post to the queue as a DRAFT. Nothing is published until a person approves it.
|
* Add a post to the queue as a DRAFT. Nothing is published until a person approves it.
|
||||||
* localTime is in the client's time zone, e.g. "2026-10-20 19:00".
|
* localTime is in the client's time zone, e.g. "2026-10-20 19:00".
|
||||||
*/
|
*/
|
||||||
export function createPost({ client, platforms = ["instagram"], type = "IMAGE", media = [], caption = "", fbCaption, collaborators = [], localTime, createdBy = "agent", notes } = {}) {
|
export function createPost({ client, platforms = ["instagram"], type = "IMAGE", media = [], caption = "", fbCaption, collaborators = [], options = {}, localTime, createdBy = "agent", notes } = {}) {
|
||||||
const c = clientInfo(client);
|
const c = clientInfo(client);
|
||||||
if (!TYPES.includes(type)) throw err(`Type must be one of ${TYPES.join(", ")}.`);
|
if (!TYPES.includes(type)) throw err(`Type must be one of ${TYPES.join(", ")}.`);
|
||||||
platforms = [...new Set(platforms)].filter((p) => p === "instagram" || p === "facebook");
|
platforms = [...new Set(platforms)].filter((p) => ALL_PLATFORMS.includes(p));
|
||||||
if (!platforms.length) throw err("Pick Instagram, Facebook or both.");
|
if (!platforms.length) throw err("Pick at least one platform.");
|
||||||
// Instagram-only clients (no Facebook Page): drop Facebook when Instagram is also picked.
|
// Instagram-only clients (no Facebook Page): drop Facebook when something else is also picked.
|
||||||
if (!c.pageId && c.igId) {
|
if (!c.pageId && c.igId && platforms.includes("facebook")) {
|
||||||
if (!platforms.includes("instagram")) throw err(`${c.name} is Instagram-only (no Facebook Page). Post to Instagram instead.`);
|
if (platforms.length === 1) throw err(`${c.name} is Instagram-only (no Facebook Page). Post to Instagram instead.`);
|
||||||
platforms = ["instagram"];
|
platforms = platforms.filter((p) => p !== "facebook");
|
||||||
}
|
}
|
||||||
|
if (platforms.some((p) => META_PLATFORMS.includes(p)) && !c.pageId && !c.igId) throw err(`${c.name} isn't linked to a Facebook Page or Instagram account, so it can't post there.`);
|
||||||
if (type === "TEXT" && platforms.includes("instagram")) throw err("Instagram needs an image or video. Use Facebook only for text posts.");
|
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).");
|
if (type !== "TEXT" && !media.length) throw err("Add at least one image or video (file path or public URL).");
|
||||||
|
options = Object.fromEntries(Object.entries(options || {}).filter(([k]) => platforms.includes(k)));
|
||||||
|
for (const p of platforms) if (!META_PLATFORMS.includes(p)) extra.validate(p, c.slug, { type, caption, media, options });
|
||||||
for (const m of media) if (!/^https?:\/\//.test(m) && !fs.existsSync(m)) throw err(`File not found: ${m}`);
|
for (const m of media) if (!/^https?:\/\//.test(m) && !fs.existsSync(m)) throw err(`File not found: ${m}`);
|
||||||
const publishAt = zonedToUtc(localTime, c.timezone);
|
const publishAt = zonedToUtc(localTime, c.timezone);
|
||||||
if (publishAt < Date.now() - 60000) throw err(`That time has already passed in ${c.timezone}.`);
|
if (publishAt < Date.now() - 60000) throw err(`That time has already passed in ${c.timezone}.`);
|
||||||
const post = {
|
const post = {
|
||||||
id: crypto.randomUUID().slice(0, 8), client: c.slug, clientName: c.name, platforms, type, media, caption,
|
id: crypto.randomUUID().slice(0, 8), client: c.slug, clientName: c.name, platforms, type, media, caption,
|
||||||
fbCaption: fbCaption || null, collaborators, timezone: c.timezone, localTime: formatInTz(publishAt, c.timezone),
|
fbCaption: fbCaption || null, collaborators, options, timezone: c.timezone, localTime: formatInTz(publishAt, c.timezone),
|
||||||
publishAt: publishAt.toISOString(), status: "draft", createdBy, createdAt: new Date().toISOString(), notes: notes || null,
|
publishAt: publishAt.toISOString(), status: "draft", createdBy, createdAt: new Date().toISOString(), notes: notes || null,
|
||||||
results: {}, errors: {},
|
results: {}, errors: {},
|
||||||
};
|
};
|
||||||
@ -106,7 +113,8 @@ export function updatePost(id, patch, by = "team") {
|
|||||||
const items = read(); const p = items.find((x) => x.id === id);
|
const items = read(); const p = items.find((x) => x.id === id);
|
||||||
if (!p) throw err("Post not found.", 404);
|
if (!p) throw err("Post not found.", 404);
|
||||||
if (!["draft", "failed"].includes(p.status)) throw err("Only drafts or failed posts can be edited. Cancel it first.");
|
if (!["draft", "failed"].includes(p.status)) throw err("Only drafts or failed posts can be edited. Cancel it first.");
|
||||||
for (const k of ["caption", "fbCaption", "media", "platforms", "collaborators", "type", "notes"]) if (patch[k] !== undefined) p[k] = patch[k];
|
for (const k of ["caption", "fbCaption", "media", "platforms", "collaborators", "type", "notes", "options"]) if (patch[k] !== undefined) p[k] = patch[k];
|
||||||
|
for (const pl of p.platforms) if (!META_PLATFORMS.includes(pl)) extra.validate(pl, p.client, p);
|
||||||
if (patch.localTime) { const t = zonedToUtc(patch.localTime, p.timezone); p.publishAt = t.toISOString(); p.localTime = formatInTz(t, p.timezone); }
|
if (patch.localTime) { const t = zonedToUtc(patch.localTime, p.timezone); p.publishAt = t.toISOString(); p.localTime = formatInTz(t, p.timezone); }
|
||||||
p.status = "draft"; p.updatedBy = by; p.updatedAt = new Date().toISOString();
|
p.status = "draft"; p.updatedBy = by; p.updatedAt = new Date().toISOString();
|
||||||
write(items); return p;
|
write(items); return p;
|
||||||
@ -143,13 +151,16 @@ export async function cancelPost(id, by = "team") {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function publishNow(p) {
|
async function publishNow(p) {
|
||||||
const a = await accountFor(p.client);
|
let a = null;
|
||||||
for (const platform of p.platforms) {
|
for (const platform of p.platforms) {
|
||||||
if (p.results[platform]?.id && !p.results[platform]?.cancelled) continue; // already done (e.g. FB scheduled natively)
|
if (p.results[platform]?.id && !p.results[platform]?.cancelled) continue; // already done (e.g. FB scheduled natively)
|
||||||
try {
|
try {
|
||||||
|
if (META_PLATFORMS.includes(platform)) a ||= await accountFor(p.client);
|
||||||
p.results[platform] = platform === "instagram"
|
p.results[platform] = platform === "instagram"
|
||||||
? await meta.igPublishAny(a, { type: p.type, media: p.media, caption: p.caption, collaborators: p.collaborators })
|
? await meta.igPublishAny(a, { type: p.type, media: p.media, caption: p.caption, collaborators: p.collaborators })
|
||||||
: await meta.fbPublishAny(a, { message: p.fbCaption || p.caption, media: p.media });
|
: platform === "facebook"
|
||||||
|
? await meta.fbPublishAny(a, { message: p.fbCaption || p.caption, media: p.media })
|
||||||
|
: await extra.publish(platform, p.client, p);
|
||||||
delete p.errors[platform];
|
delete p.errors[platform];
|
||||||
log({ action: "published", platform, post: p.id, client: p.client, result: p.results[platform] });
|
log({ action: "published", platform, post: p.id, client: p.client, result: p.results[platform] });
|
||||||
} catch (e) { p.errors[platform] = e.message; log({ action: "failed", platform, post: p.id, client: p.client, error: e.message }); }
|
} catch (e) { p.errors[platform] = e.message; log({ action: "failed", platform, post: p.id, client: p.client, error: e.message }); }
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import { PROJECT_ROOT } from "./env.js";
|
|||||||
import * as meta from "./meta.js";
|
import * as meta from "./meta.js";
|
||||||
import * as sched from "./schedule.js";
|
import * as sched from "./schedule.js";
|
||||||
import * as lib from "./library.js";
|
import * as lib from "./library.js";
|
||||||
|
import * as extra from "./platforms/index.js";
|
||||||
|
|
||||||
const server = new McpServer({ name: "social-manager", version: "0.1.0" });
|
const server = new McpServer({ name: "social-manager", version: "0.1.0" });
|
||||||
|
|
||||||
@ -139,11 +140,25 @@ server.tool("audience_demographics", "Follower demographics (top cities, countri
|
|||||||
|
|
||||||
// ---------- Scheduling (queue → human approval → auto-publish at the client's local time) ----------
|
// ---------- Scheduling (queue → human approval → auto-publish at the client's local time) ----------
|
||||||
const slug = z.string().describe("Client folder slug, e.g. maison-de-treats");
|
const slug = z.string().describe("Client folder slug, e.g. maison-de-treats");
|
||||||
server.tool("schedule_post", "Add a post to the content queue as a DRAFT (never publishes). localTime is in the CLIENT's time zone. media = local file paths or public URLs.",
|
const platformList = z.array(z.enum(sched.ALL_PLATFORMS)).min(1);
|
||||||
{ client: slug, platforms: z.array(z.enum(["instagram", "facebook"])).min(1), type: z.enum(["IMAGE", "REELS", "CAROUSEL", "STORY", "TEXT"]),
|
const options = z.record(z.string(), z.record(z.string(), z.any())).default({})
|
||||||
|
.describe("Per-platform settings, e.g. {\"youtube\":{\"title\":\"…\",\"privacy\":\"public\"},\"pinterest\":{\"boardId\":\"…\"},\"linkedin\":{\"caption\":\"…\"}}. Any platform can take its own \"caption\". See list_connections for each platform's fields.");
|
||||||
|
|
||||||
|
server.tool("list_connections", "Which platforms beyond Facebook/Instagram (LinkedIn, Google Business Profile, YouTube, TikTok, Pinterest, Threads, Reddit) are connected for a client, the account, and the per-post fields each platform needs.",
|
||||||
|
{ client: slug },
|
||||||
|
wrap(async ({ client }) => {
|
||||||
|
const conns = extra.vault.listConnections({ client });
|
||||||
|
return extra.describePlatforms().map((p) => {
|
||||||
|
const c = conns.find((x) => x.platform === p.id);
|
||||||
|
return { platform: p.id, name: p.name, connected: Boolean(c), status: c?.status || null, account: c?.account?.name || null, target: c?.target?.name || null, formats: p.formats, fields: p.fields.map((f) => ({ key: f.key, label: f.label, required: Boolean(f.required), options: f.options?.map((o) => o[0]), onlyFor: f.onlyFor })), captionLimit: p.limits.caption || null };
|
||||||
|
});
|
||||||
|
}));
|
||||||
|
|
||||||
|
server.tool("schedule_post", "Add a post to the content queue as a DRAFT (never publishes). localTime is in the CLIENT's time zone. media = local file paths or public URLs. Platforms other than instagram/facebook must be connected for the client first (list_connections).",
|
||||||
|
{ client: slug, platforms: platformList, type: z.enum(["IMAGE", "REELS", "CAROUSEL", "STORY", "TEXT"]), options,
|
||||||
media: z.array(z.string()).default([]), caption: z.string().default(""), fb_caption: z.string().optional(),
|
media: z.array(z.string()).default([]), caption: z.string().default(""), fb_caption: z.string().optional(),
|
||||||
collaborators: z.array(z.string()).default([]).describe("Instagram usernames to invite as Collab"), local_time: z.string().describe("YYYY-MM-DD HH:MM in the client's time zone"), notes: z.string().optional() },
|
collaborators: z.array(z.string()).default([]).describe("Instagram usernames to invite as Collab"), local_time: z.string().describe("YYYY-MM-DD HH:MM in the client's time zone"), notes: z.string().optional() },
|
||||||
wrap(async (a) => sched.createPost({ client: a.client, platforms: a.platforms, type: a.type, media: a.media, caption: a.caption, fbCaption: a.fb_caption, collaborators: a.collaborators, localTime: a.local_time, notes: a.notes, createdBy: "agent" })));
|
wrap(async (a) => sched.createPost({ client: a.client, platforms: a.platforms, type: a.type, media: a.media, caption: a.caption, fbCaption: a.fb_caption, collaborators: a.collaborators, options: a.options, localTime: a.local_time, notes: a.notes, createdBy: "agent" })));
|
||||||
|
|
||||||
server.tool("list_scheduled", "The content queue (drafts, approved, published, failed), optionally for one client.",
|
server.tool("list_scheduled", "The content queue (drafts, approved, published, failed), optionally for one client.",
|
||||||
{ client: z.string().optional(), status: z.enum(["draft", "approved", "publishing", "published", "partial", "failed", "cancelled"]).optional() },
|
{ client: z.string().optional(), status: z.enum(["draft", "approved", "publishing", "published", "partial", "failed", "cancelled"]).optional() },
|
||||||
@ -165,8 +180,8 @@ server.tool("content_library", "Finished work the team uploaded for a client (de
|
|||||||
wrap(async ({ client, status }) => lib.listItems({ client, status }).map((i) => ({ id: i.id, client: i.client, title: i.title, format: i.format, files: i.files, captionIdea: i.captionIdea, notes: i.notes, forDate: i.forDate, taskRef: i.taskRef, status: i.status, version: i.version, uploadedBy: i.uploadedBy, reviewNote: i.reviewNote, updatedAt: i.updatedAt, postIds: i.postIds }))));
|
wrap(async ({ client, status }) => lib.listItems({ client, status }).map((i) => ({ id: i.id, client: i.client, title: i.title, format: i.format, files: i.files, captionIdea: i.captionIdea, notes: i.notes, forDate: i.forDate, taskRef: i.taskRef, status: i.status, version: i.version, uploadedBy: i.uploadedBy, reviewNote: i.reviewNote, updatedAt: i.updatedAt, postIds: i.postIds }))));
|
||||||
|
|
||||||
server.tool("schedule_from_library", "Turn an APPROVED library item into a DRAFT post in the queue (never publishes; a person still approves the post). local_time is in the client's time zone. Leave caption empty to use the uploader's caption idea, but normally write a finished caption.",
|
server.tool("schedule_from_library", "Turn an APPROVED library item into a DRAFT post in the queue (never publishes; a person still approves the post). local_time is in the client's time zone. Leave caption empty to use the uploader's caption idea, but normally write a finished caption.",
|
||||||
{ id: z.string(), platforms: z.array(z.enum(["instagram", "facebook"])).min(1).default(["instagram", "facebook"]), local_time: z.string().describe("YYYY-MM-DD HH:MM in the client's time zone"),
|
{ id: z.string(), platforms: platformList.default(["instagram", "facebook"]), local_time: z.string().describe("YYYY-MM-DD HH:MM in the client's time zone"),
|
||||||
caption: z.string().optional(), fb_caption: z.string().optional(), collaborators: z.array(z.string()).default([]) },
|
caption: z.string().optional(), fb_caption: z.string().optional(), collaborators: z.array(z.string()).default([]), options },
|
||||||
wrap(async (a) => lib.scheduleItem(a.id, { platforms: a.platforms, localTime: a.local_time, caption: a.caption, fbCaption: a.fb_caption, collaborators: a.collaborators }, "agent")));
|
wrap(async (a) => lib.scheduleItem(a.id, { platforms: a.platforms, localTime: a.local_time, caption: a.caption, fbCaption: a.fb_caption, collaborators: a.collaborators, options: a.options }, "agent")));
|
||||||
|
|
||||||
await server.connect(new StdioServerTransport());
|
await server.connect(new StdioServerTransport());
|
||||||
|
|||||||
301
connector/test/platforms.test.mjs
Normal file
301
connector/test/platforms.test.mjs
Normal file
@ -0,0 +1,301 @@
|
|||||||
|
// Offline tests for the platform adapters: every platform API is simulated by a fake fetch that records
|
||||||
|
// what Quorla sends. Run: node --test connector/test/ (no network, no real accounts touched)
|
||||||
|
import { test, before, after } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "quorla-test-"));
|
||||||
|
process.env.QUORLA_CONNECTIONS_FILE = path.join(TMP, "connections.json");
|
||||||
|
process.env.DASHBOARD_SECRET = "test-secret-not-real";
|
||||||
|
process.env.PUBLIC_URL = "https://app.example.test";
|
||||||
|
|
||||||
|
const { PROJECT_ROOT } = await import("../src/env.js");
|
||||||
|
const P = await import("../src/platforms/index.js");
|
||||||
|
const { vault } = P;
|
||||||
|
const media = await import("../src/platforms/media.js");
|
||||||
|
const { littleText } = await import("../src/platforms/linkedin.js");
|
||||||
|
const { chunkPlan } = await import("../src/platforms/tiktok.js");
|
||||||
|
|
||||||
|
// Test media inside data/library (the only folders media links may expose).
|
||||||
|
const LIB = path.join(PROJECT_ROOT, "data", "library", "_quorla_test");
|
||||||
|
const IMG = path.join(LIB, "1700000000000-abcd-photo.jpg");
|
||||||
|
const IMG2 = path.join(LIB, "1700000000001-abcd-photo2.png");
|
||||||
|
const VID = path.join(LIB, "1700000000002-abcd-clip.mp4");
|
||||||
|
|
||||||
|
// ---------- fake fetch ----------
|
||||||
|
let calls = [];
|
||||||
|
let routes = [];
|
||||||
|
const realFetch = globalThis.fetch;
|
||||||
|
function route(method, match, reply) { routes.push({ method, match, reply }); }
|
||||||
|
function reset() { calls = []; routes = []; }
|
||||||
|
const json = (body, status = 200, headers = {}) => new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json", ...headers } });
|
||||||
|
async function fakeFetch(input, init = {}) {
|
||||||
|
const url = String(input instanceof URL ? input : input?.url || input);
|
||||||
|
const method = (init.method || "GET").toUpperCase();
|
||||||
|
let body = init.body;
|
||||||
|
if (body instanceof URLSearchParams) body = body.toString();
|
||||||
|
calls.push({ method, url, headers: init.headers || {}, body });
|
||||||
|
const r = routes.find((x) => x.method === method && (typeof x.match === "string" ? url.startsWith(x.match) : x.match.test(url)));
|
||||||
|
if (!r) return json({ error: { message: `no fake route for ${method} ${url}` } }, 599);
|
||||||
|
return typeof r.reply === "function" ? r.reply({ url, method, body, headers: init.headers || {} }) : r.reply.clone();
|
||||||
|
}
|
||||||
|
const sent = (re) => calls.filter((c) => re.test(c.url));
|
||||||
|
const bodyJson = (c) => JSON.parse(c.body);
|
||||||
|
const form = (c) => Object.fromEntries(new URLSearchParams(c.body));
|
||||||
|
|
||||||
|
const user = { id: "u1", name: "Tester" };
|
||||||
|
async function connect(platform, client, { tokenReply, identify }) {
|
||||||
|
vault.saveApp(platform, { clientId: `${platform}-id`, clientSecret: `${platform}-secret` }, "test");
|
||||||
|
const url = new URL(P.startConnect({ platform, client, redirectUri: `https://app.example.test/oauth/${platform}/callback`, user }));
|
||||||
|
const state = url.searchParams.get("state");
|
||||||
|
route("POST", P.adapter(platform).auth.tokenUrl, tokenReply || json({ access_token: `${platform}-at`, refresh_token: `${platform}-rt`, expires_in: 3600, scope: "a b" }));
|
||||||
|
for (const [m, match, reply] of identify) route(m, match, reply);
|
||||||
|
const conn = await P.finishConnect({ platform, code: "the-code", state, user });
|
||||||
|
return { url, conn };
|
||||||
|
}
|
||||||
|
|
||||||
|
before(() => {
|
||||||
|
fs.mkdirSync(LIB, { recursive: true });
|
||||||
|
fs.writeFileSync(IMG, Buffer.from([0xff, 0xd8, 0xff, 0xe0, 1, 2, 3]));
|
||||||
|
fs.writeFileSync(IMG2, Buffer.from([0x89, 0x50, 0x4e, 0x47, 4, 5]));
|
||||||
|
fs.writeFileSync(VID, Buffer.alloc(1024, 7));
|
||||||
|
globalThis.fetch = fakeFetch;
|
||||||
|
});
|
||||||
|
after(() => {
|
||||||
|
globalThis.fetch = realFetch;
|
||||||
|
fs.rmSync(LIB, { recursive: true, force: true });
|
||||||
|
fs.rmSync(TMP, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------- vault ----------
|
||||||
|
test("vault: secrets are encrypted at rest and never listed", () => {
|
||||||
|
vault.saveApp("linkedin", { clientId: "cid", clientSecret: "super-secret-value" }, "t");
|
||||||
|
const raw = fs.readFileSync(process.env.QUORLA_CONNECTIONS_FILE, "utf8");
|
||||||
|
assert.ok(!raw.includes("super-secret-value") && !raw.includes("cid"), "app secret must not be stored in plain text");
|
||||||
|
assert.equal(vault.appCredentials("linkedin").clientSecret, "super-secret-value");
|
||||||
|
vault.saveApp("linkedin", { clientId: "cid2", clientSecret: "" }, "t");
|
||||||
|
assert.equal(vault.appCredentials("linkedin").clientSecret, "super-secret-value", "empty secret keeps the stored one");
|
||||||
|
const c = vault.saveConnection({ client: "acme", platform: "linkedin", account: { id: "1", name: "A" }, tokens: { accessToken: "tok-123" }, by: "t" });
|
||||||
|
assert.ok(!JSON.stringify(vault.listConnections()).includes("tok-123"));
|
||||||
|
assert.ok(!fs.readFileSync(process.env.QUORLA_CONNECTIONS_FILE, "utf8").includes("tok-123"));
|
||||||
|
assert.equal(vault.getConnection(c.id, { withTokens: true }).tokens.accessToken, "tok-123");
|
||||||
|
vault.removeConnection(c.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("vault: a sign-in state works once and only for the same platform/user", () => {
|
||||||
|
const s = vault.createState({ platform: "x", userId: "u1" });
|
||||||
|
assert.equal(vault.takeState(s).platform, "x");
|
||||||
|
assert.throws(() => vault.takeState(s), /expired or was already used/);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------- media links ----------
|
||||||
|
test("media links: signed, expiring, limited to library/uploads", () => {
|
||||||
|
const u = media.publicMediaUrl(IMG);
|
||||||
|
assert.match(u, /^https:\/\/app\.example\.test\/m\/[^/]+\/photo\.jpg$/);
|
||||||
|
const token = u.split("/m/")[1].split("/")[0];
|
||||||
|
assert.equal(media.resolveMediaToken(token), path.resolve(IMG));
|
||||||
|
assert.equal(media.resolveMediaToken(token.slice(0, -2) + "xx"), null, "tampered signature rejected");
|
||||||
|
assert.throws(() => media.publicMediaUrl(path.join(PROJECT_ROOT, ".env")), /Only files from the content library/);
|
||||||
|
const old = media.publicMediaUrl(IMG, { hours: -1 }).split("/m/")[1].split("/")[0];
|
||||||
|
assert.equal(media.resolveMediaToken(old), null, "expired link rejected");
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------- helpers ----------
|
||||||
|
test("LinkedIn little-text escaping keeps hashtags as hashtags", () => {
|
||||||
|
assert.equal(littleText("Hi (team) #Waterloo_Eats, see [x]!"), "Hi \\(team\\) {hashtag|\\#|Waterloo_Eats}, see \\[x\\]!");
|
||||||
|
assert.equal(littleText("C# 100%"), "C\\# 100%");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("TikTok chunk plan follows TikTok's rules", () => {
|
||||||
|
assert.deepEqual(chunkPlan(3 * 1024 * 1024), { chunkSize: 3 * 1024 * 1024, count: 1 });
|
||||||
|
const big = 150 * 1024 * 1024;
|
||||||
|
const p = chunkPlan(big);
|
||||||
|
assert.equal(p.chunkSize, 10 * 1024 * 1024);
|
||||||
|
assert.equal(p.count, 15);
|
||||||
|
assert.ok(big - (p.count - 1) * p.chunkSize <= 128 * 1024 * 1024, "last chunk stays under 128 MB");
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------- sign-in URLs ----------
|
||||||
|
test("sign-in URLs carry the right client id, scopes and PKCE per platform", () => {
|
||||||
|
for (const id of P.PLATFORM_IDS) vault.saveApp(id, { clientId: `${id}-id`, clientSecret: "s" }, "t");
|
||||||
|
const u = (id) => new URL(P.startConnect({ platform: id, client: "acme", redirectUri: `https://app.example.test/oauth/${id}/callback`, user }));
|
||||||
|
const tt = u("tiktok");
|
||||||
|
assert.equal(tt.searchParams.get("client_key"), "tiktok-id");
|
||||||
|
assert.match(tt.searchParams.get("scope"), /video\.publish,video\.upload/);
|
||||||
|
const yt = u("youtube");
|
||||||
|
assert.equal(yt.searchParams.get("access_type"), "offline");
|
||||||
|
assert.equal(yt.searchParams.get("code_challenge_method"), "S256");
|
||||||
|
assert.ok(yt.searchParams.get("code_challenge"));
|
||||||
|
assert.equal(u("reddit").searchParams.get("duration"), "permanent");
|
||||||
|
assert.equal(u("linkedin").searchParams.get("redirect_uri"), "https://app.example.test/oauth/linkedin/callback");
|
||||||
|
vault.saveApp("linkedin", { clientId: "x", clientSecret: "s", scopes: "openid profile w_member_social" }, "t");
|
||||||
|
assert.equal(u("linkedin").searchParams.get("scope"), "openid profile w_member_social", "scope override is used");
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------- each platform: connect + publish ----------
|
||||||
|
test("LinkedIn: connect, list company Pages, post image to a company Page", async () => {
|
||||||
|
reset();
|
||||||
|
const { conn } = await connect("linkedin", "acme", { identify: [
|
||||||
|
["GET", "https://api.linkedin.com/v2/userinfo", json({ sub: "abc", name: "Pat Lee", picture: "https://p" })],
|
||||||
|
["GET", "https://api.linkedin.com/rest/organizationAcls", json({ elements: [{ organization: "urn:li:organization:42" }] })],
|
||||||
|
["GET", "https://api.linkedin.com/rest/organizations/42", json({ localizedName: "Acme Co" })],
|
||||||
|
] });
|
||||||
|
assert.equal(conn.account.name, "Pat Lee");
|
||||||
|
const targets = await P.targetsFor(conn.id);
|
||||||
|
assert.deepEqual(targets.map((t) => t.id), ["urn:li:person:abc", "urn:li:organization:42"]);
|
||||||
|
await P.setTarget(conn.id, "urn:li:organization:42");
|
||||||
|
route("POST", "https://api.linkedin.com/rest/images?action=initializeUpload", json({ value: { uploadUrl: "https://upload.linkedin.test/1", image: "urn:li:image:9" } }));
|
||||||
|
route("PUT", "https://upload.linkedin.test/1", new Response("", { status: 201 }));
|
||||||
|
route("POST", "https://api.linkedin.com/rest/posts", new Response("", { status: 201, headers: { "x-restli-id": "urn:li:share:77" } }));
|
||||||
|
const r = await P.publish("linkedin", "acme", { type: "IMAGE", media: [IMG], caption: "New menu #Waterloo" });
|
||||||
|
assert.equal(r.id, "urn:li:share:77");
|
||||||
|
const post = bodyJson(sent(/rest\/posts/)[0]);
|
||||||
|
assert.equal(post.author, "urn:li:organization:42");
|
||||||
|
assert.equal(post.content.media.id, "urn:li:image:9");
|
||||||
|
assert.match(post.commentary, /\{hashtag\|\\#\|Waterloo\}/);
|
||||||
|
assert.ok(sent(/rest\/posts/)[0].headers["LinkedIn-Version"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("YouTube: connect a channel, upload a Short with title and privacy", async () => {
|
||||||
|
reset();
|
||||||
|
const { conn } = await connect("youtube", "acme", { identify: [
|
||||||
|
["GET", "https://www.googleapis.com/youtube/v3/channels", json({ items: [{ id: "UC1", snippet: { title: "Acme TV", customUrl: "@acmetv", thumbnails: { default: { url: "t" } } }, statistics: { subscriberCount: "1200" } }] })],
|
||||||
|
] });
|
||||||
|
assert.equal(conn.account.followers, 1200);
|
||||||
|
route("POST", "https://www.googleapis.com/upload/youtube/v3/videos", new Response("", { status: 200, headers: { location: "https://upload.youtube.test/s1" } }));
|
||||||
|
route("PUT", "https://upload.youtube.test/s1", json({ id: "vid1", status: { privacyStatus: "unlisted" } }));
|
||||||
|
P.validate("youtube", "acme", { type: "REELS", media: [VID], caption: "x", options: { youtube: { title: "Behind the scenes" } } });
|
||||||
|
assert.throws(() => P.validate("youtube", "acme", { type: "REELS", media: [VID], caption: "x", options: {} }), /video title is required/);
|
||||||
|
assert.throws(() => P.validate("youtube", "acme", { type: "IMAGE", media: [IMG], caption: "x", options: { youtube: { title: "t" } } }), /doesn't take image posts/);
|
||||||
|
const r = await P.publish("youtube", "acme", { type: "REELS", media: [VID], caption: "Desc", options: { youtube: { title: "Behind the scenes", privacy: "unlisted" } } });
|
||||||
|
assert.equal(r.permalink, "https://www.youtube.com/shorts/vid1");
|
||||||
|
const meta = JSON.parse(sent(/upload\/youtube/)[0].body);
|
||||||
|
assert.equal(meta.snippet.title, "Behind the scenes");
|
||||||
|
assert.equal(meta.status.privacyStatus, "unlisted");
|
||||||
|
assert.equal(meta.status.selfDeclaredMadeForKids, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Google Business Profile: auto-picks the only location, posts with a button and photo link", async () => {
|
||||||
|
reset();
|
||||||
|
const { conn } = await connect("gbp", "acme", { identify: [
|
||||||
|
["GET", "https://openidconnect.googleapis.com/v1/userinfo", json({ sub: "g1", email: "owner@acme.test" })],
|
||||||
|
["GET", "https://mybusinessaccountmanagement.googleapis.com/v1/accounts", json({ accounts: [{ name: "accounts/1" }] })],
|
||||||
|
["GET", "https://mybusinessbusinessinformation.googleapis.com/v1/accounts/1/locations", json({ locations: [{ name: "locations/5", title: "Acme Bakery", storefrontAddress: { addressLines: ["1 King St"], locality: "Waterloo" } }] })],
|
||||||
|
] });
|
||||||
|
assert.equal(vault.getConnection(conn.id).target.id, "accounts/1/locations/5");
|
||||||
|
route("POST", "https://mybusiness.googleapis.com/v4/accounts/1/locations/5/localPosts", json({ name: "accounts/1/locations/5/localPosts/9", searchUrl: "https://g.test/p" }));
|
||||||
|
assert.throws(() => P.validate("gbp", "acme", { type: "REELS", media: [VID], caption: "x", options: {} }), /doesn't take video/);
|
||||||
|
await assert.rejects(P.publish("gbp", "acme", { type: "IMAGE", media: [IMG], caption: "Fresh bread", options: { gbp: { cta: "ORDER" } } }), /add a link for the button/);
|
||||||
|
const r = await P.publish("gbp", "acme", { type: "IMAGE", media: [IMG], caption: "Fresh bread", options: { gbp: { cta: "ORDER", ctaUrl: "https://acme.test/order" } } });
|
||||||
|
assert.equal(r.permalink, "https://g.test/p");
|
||||||
|
const b = bodyJson(sent(/localPosts/).at(-1));
|
||||||
|
assert.deepEqual(b.callToAction, { actionType: "ORDER", url: "https://acme.test/order" });
|
||||||
|
assert.match(b.media[0].sourceUrl, /^https:\/\/app\.example\.test\/m\//);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("TikTok: connect, privacy is enforced, video uploads and photo posts pull from signed links", async () => {
|
||||||
|
reset();
|
||||||
|
const { conn } = await connect("tiktok", "acme", {
|
||||||
|
tokenReply: json({ access_token: "tt-at", refresh_token: "tt-rt", expires_in: 86400, open_id: "op1", scope: "video.publish" }),
|
||||||
|
identify: [["GET", "https://open.tiktokapis.com/v2/user/info/", json({ data: { user: { open_id: "op1", display_name: "Acme", username: "acme", follower_count: 50 } }, error: { code: "ok" } })]],
|
||||||
|
});
|
||||||
|
assert.equal(conn.account.username, "acme");
|
||||||
|
assert.throws(() => P.validate("tiktok", "acme", { type: "REELS", media: [VID], caption: "x", options: {} }), /who can view is required/);
|
||||||
|
route("POST", "https://open.tiktokapis.com/v2/post/publish/creator_info/query/", json({ data: { privacy_level_options: ["SELF_ONLY"] }, error: { code: "ok" } }));
|
||||||
|
await assert.rejects(P.publish("tiktok", "acme", { type: "REELS", media: [VID], caption: "x", options: { tiktok: { privacy: "PUBLIC_TO_EVERYONE" } } }), /can't post as/);
|
||||||
|
route("POST", "https://open.tiktokapis.com/v2/post/publish/video/init/", json({ data: { publish_id: "pub1", upload_url: "https://upload.tiktok.test/u1" }, error: { code: "ok" } }));
|
||||||
|
route("PUT", "https://upload.tiktok.test/u1", new Response("", { status: 201 }));
|
||||||
|
route("POST", "https://open.tiktokapis.com/v2/post/publish/status/fetch/", json({ data: { status: "PUBLISH_COMPLETE", publicaly_available_post_id: [7001] }, error: { code: "ok" } }));
|
||||||
|
const r = await P.publish("tiktok", "acme", { type: "REELS", media: [VID], caption: "Our kitchen", options: { tiktok: { privacy: "SELF_ONLY" } } });
|
||||||
|
assert.equal(r.permalink, "https://www.tiktok.com/@acme/video/7001");
|
||||||
|
const init = bodyJson(sent(/video\/init/)[0]);
|
||||||
|
assert.equal(init.source_info.total_chunk_count, 1);
|
||||||
|
assert.equal(init.post_info.privacy_level, "SELF_ONLY");
|
||||||
|
assert.equal(sent(/upload\.tiktok/)[0].headers["Content-Range"], "bytes 0-1023/1024");
|
||||||
|
route("POST", "https://open.tiktokapis.com/v2/post/publish/content/init/", json({ data: { publish_id: "pub2" }, error: { code: "ok" } }));
|
||||||
|
await P.publish("tiktok", "acme", { type: "CAROUSEL", media: [IMG, IMG2], caption: "Menu", options: { tiktok: { privacy: "SELF_ONLY", title: "New menu" } } });
|
||||||
|
const photo = bodyJson(sent(/content\/init/)[0]);
|
||||||
|
assert.equal(photo.media_type, "PHOTO");
|
||||||
|
assert.equal(photo.source_info.photo_images.length, 2);
|
||||||
|
assert.ok(photo.source_info.photo_images.every((u) => u.startsWith("https://app.example.test/m/")));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Pinterest: boards, base64 image Pin to the chosen board", async () => {
|
||||||
|
reset();
|
||||||
|
const { conn } = await connect("pinterest", "acme", { identify: [
|
||||||
|
["GET", "https://api.pinterest.com/v5/user_account", json({ username: "acmepins", business_name: "Acme", follower_count: 300 })],
|
||||||
|
["GET", "https://api.pinterest.com/v5/boards", json({ items: [{ id: "b1", name: "Cakes" }, { id: "b2", name: "Bread" }] })],
|
||||||
|
] });
|
||||||
|
assert.equal(calls.find((c) => c.url.startsWith("https://api.pinterest.com/v5/oauth/token")).headers.Authorization.startsWith("Basic "), true, "Pinterest uses Basic auth for tokens");
|
||||||
|
assert.equal(vault.getConnection(conn.id).target, null, "two boards: nothing auto-picked");
|
||||||
|
assert.throws(() => P.validate("pinterest", "acme", { type: "IMAGE", media: [IMG], caption: "x", options: {} }), /board is required/);
|
||||||
|
route("POST", "https://api.pinterest.com/v5/pins", json({ id: "pin9" }));
|
||||||
|
const r = await P.publish("pinterest", "acme", { type: "IMAGE", media: [IMG], caption: "Birthday cakes", options: { pinterest: { boardId: "b1", title: "Cakes", link: "https://acme.test" } } });
|
||||||
|
assert.equal(r.permalink, "https://www.pinterest.com/pin/pin9/");
|
||||||
|
const pin = bodyJson(sent(/v5\/pins/)[0]);
|
||||||
|
assert.equal(pin.board_id, "b1");
|
||||||
|
assert.equal(pin.media_source.source_type, "image_base64");
|
||||||
|
assert.equal(pin.media_source.data, fs.readFileSync(IMG).toString("base64"));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Threads: long-lived token exchange, text and image posts", async () => {
|
||||||
|
reset();
|
||||||
|
route("GET", "https://graph.threads.net/access_token", json({ access_token: "th-long", expires_in: 5184000 }));
|
||||||
|
const { conn } = await connect("threads", "acme", {
|
||||||
|
tokenReply: json({ access_token: "th-short", user_id: 555 }),
|
||||||
|
identify: [
|
||||||
|
["GET", "https://graph.threads.net/v1.0/me", json({ id: "555", username: "acme" })],
|
||||||
|
["GET", "https://graph.threads.net/v1.0/555/threads_insights", json({ data: [{ total_value: { value: 88 } }] })],
|
||||||
|
],
|
||||||
|
});
|
||||||
|
assert.equal(vault.getConnection(conn.id, { withTokens: true }).tokens.accessToken, "th-long");
|
||||||
|
assert.equal(conn.account.followers, 88);
|
||||||
|
route("POST", "https://graph.threads.net/v1.0/555/threads?", json({ id: "c1" }));
|
||||||
|
route("GET", "https://graph.threads.net/v1.0/c1", json({ status: "FINISHED" }));
|
||||||
|
route("POST", "https://graph.threads.net/v1.0/555/threads_publish", json({ id: "t1" }));
|
||||||
|
route("GET", "https://graph.threads.net/v1.0/t1", json({ permalink: "https://threads.net/@acme/post/t1" }));
|
||||||
|
const r = await P.publish("threads", "acme", { type: "IMAGE", media: [IMG], caption: "Hello" });
|
||||||
|
assert.equal(r.permalink, "https://threads.net/@acme/post/t1");
|
||||||
|
const c = new URL(sent(/555\/threads\?/)[0].url);
|
||||||
|
assert.equal(c.searchParams.get("media_type"), "IMAGE");
|
||||||
|
assert.match(c.searchParams.get("image_url"), /^https:\/\/app\.example\.test\/m\//);
|
||||||
|
assert.throws(() => P.validate("threads", "acme", { type: "TEXT", media: [], caption: "x".repeat(501), options: {} }), /limit is 500/);
|
||||||
|
P.validate("threads", "acme", { type: "TEXT", media: [], caption: "x".repeat(501), options: { threads: { caption: "short" } } });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Reddit: text post needs subreddit and title, sends User-Agent", async () => {
|
||||||
|
reset();
|
||||||
|
await connect("reddit", "acme", { identify: [["GET", "https://oauth.reddit.com/api/v1/me", json({ id: "r1", name: "acme_bakery", total_karma: 12 })]] });
|
||||||
|
assert.throws(() => P.validate("reddit", "acme", { type: "TEXT", media: [], caption: "x", options: { reddit: { subreddit: "waterloo" } } }), /post title is required/);
|
||||||
|
route("POST", "https://oauth.reddit.com/api/submit", json({ json: { errors: [], data: { name: "t3_abc", url: "https://reddit.com/r/waterloo/abc" } } }));
|
||||||
|
const r = await P.publish("reddit", "acme", { type: "TEXT", media: [], caption: "We're open late", options: { reddit: { subreddit: "r/waterloo", title: "Late hours" } } });
|
||||||
|
assert.equal(r.permalink, "https://reddit.com/r/waterloo/abc");
|
||||||
|
const sub = sent(/api\/submit/)[0];
|
||||||
|
assert.deepEqual({ sr: form(sub).sr, kind: form(sub).kind, text: form(sub).text }, { sr: "waterloo", kind: "self", text: "We're open late" });
|
||||||
|
assert.ok(sub.headers["User-Agent"]);
|
||||||
|
route("POST", "https://oauth.reddit.com/api/submit", json({ json: { errors: [["SUBREDDIT_NOTALLOWED", "you aren't allowed to post there.", "sr"]] } }));
|
||||||
|
routes = routes.filter((x, i, a) => !(x.match === "https://oauth.reddit.com/api/submit" && a.findIndex((y) => y.match === x.match) === i));
|
||||||
|
await assert.rejects(P.publish("reddit", "acme", { type: "TEXT", media: [], caption: "x", options: { reddit: { subreddit: "x", title: "t" } } }), /aren't allowed to post/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("expired tokens are refreshed and saved; failed refresh asks to reconnect", async () => {
|
||||||
|
reset();
|
||||||
|
const c = vault.connectionFor("acme", "youtube");
|
||||||
|
vault.updateConnection(c.id, { tokens: { accessToken: "old", refreshToken: "yt-rt", expiresAt: new Date(Date.now() - 1000).toISOString() } });
|
||||||
|
route("POST", "https://oauth2.googleapis.com/token", json({ access_token: "new-at", expires_in: 3600 }));
|
||||||
|
const { tokens } = await P.tokensFor(c.id);
|
||||||
|
assert.equal(tokens.accessToken, "new-at");
|
||||||
|
assert.equal(tokens.refreshToken, "yt-rt", "refresh token kept when the platform doesn't send a new one");
|
||||||
|
assert.equal(form(sent(/oauth2\.googleapis/)[0]).grant_type, "refresh_token");
|
||||||
|
reset();
|
||||||
|
vault.updateConnection(c.id, { tokens: { accessToken: "old", refreshToken: "bad", expiresAt: new Date(Date.now() - 1000).toISOString() } });
|
||||||
|
route("POST", "https://oauth2.googleapis.com/token", json({ error: "invalid_grant", error_description: "Token has been revoked." }, 400));
|
||||||
|
await assert.rejects(P.tokensFor(c.id), /Reconnect it/);
|
||||||
|
assert.equal(vault.getConnection(c.id).status, "reconnect");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("posting to an unconnected platform is refused clearly", () => {
|
||||||
|
assert.throws(() => P.validate("linkedin", "nobody", { type: "TEXT", media: [], caption: "x", options: {} }), /isn't connected for this client/);
|
||||||
|
});
|
||||||
@ -13,6 +13,8 @@ import * as docs from "./docs.js";
|
|||||||
import * as meta from "../../connector/src/meta.js";
|
import * as meta from "../../connector/src/meta.js";
|
||||||
import * as sched from "../../connector/src/schedule.js";
|
import * as sched from "../../connector/src/schedule.js";
|
||||||
import * as lib from "../../connector/src/library.js";
|
import * as lib from "../../connector/src/library.js";
|
||||||
|
import * as extra from "../../connector/src/platforms/index.js";
|
||||||
|
import { publicBaseUrl, resolveMediaToken } from "../../connector/src/platforms/media.js";
|
||||||
import { DATA_DIR as _DD } from "./config.js";
|
import { DATA_DIR as _DD } from "./config.js";
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
@ -47,6 +49,31 @@ app.get("/api/auth/me", (req, res) => res.json({ user: currentUser(req), agency:
|
|||||||
app.get("/brand/logo.png", (_req, res) => res.sendFile(path.join(BRANDING_DIR, "metatroncube-logo.png")));
|
app.get("/brand/logo.png", (_req, res) => res.sendFile(path.join(BRANDING_DIR, "metatroncube-logo.png")));
|
||||||
app.get("/brand/mark.png", (_req, res) => res.sendFile(path.join(BRANDING_DIR, "metatroncube-mark.png")));
|
app.get("/brand/mark.png", (_req, res) => res.sendFile(path.join(BRANDING_DIR, "metatroncube-mark.png")));
|
||||||
|
|
||||||
|
// Signed, expiring media links that platforms (TikTok, Threads, Google Business Profile) fetch posts from.
|
||||||
|
app.get("/m/:token/:name", (req, res) => {
|
||||||
|
const f = resolveMediaToken(req.params.token);
|
||||||
|
if (!f || !fs.existsSync(f)) return res.status(404).send("Not found");
|
||||||
|
res.setHeader("Cache-Control", "private, max-age=3600");
|
||||||
|
res.sendFile(f);
|
||||||
|
});
|
||||||
|
|
||||||
|
// OAuth: the platform sends the person back here after they approve Quorla.
|
||||||
|
const baseUrl = (req) => publicBaseUrl() || `${req.protocol}://${req.get("host")}`;
|
||||||
|
const redirectUri = (req, platform) => `${baseUrl(req)}/oauth/${platform}/callback`;
|
||||||
|
app.get("/oauth/:platform/callback", async (req, res) => {
|
||||||
|
const back = (q) => res.redirect(`/connections?${new URLSearchParams(q)}`);
|
||||||
|
const user = currentUser(req);
|
||||||
|
if (!user) return back({ error: "Your Quorla session ended. Sign in and connect again." });
|
||||||
|
if (req.query.error) return back({ error: `${req.params.platform}: ${String(req.query.error_description || req.query.error).slice(0, 300)}` });
|
||||||
|
try {
|
||||||
|
const conn = await extra.finishConnect({ platform: req.params.platform, code: String(req.query.code || ""), state: String(req.query.state || ""), user });
|
||||||
|
back({ client: conn.client, connected: req.params.platform });
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[oauth]", req.params.platform, e.message);
|
||||||
|
back({ error: String(e.message).slice(0, 300) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ---------- everything below needs a session ----------
|
// ---------- everything below needs a session ----------
|
||||||
app.use("/api", requireAuth);
|
app.use("/api", requireAuth);
|
||||||
|
|
||||||
@ -58,6 +85,24 @@ app.post("/api/me/password", h(async (req, res) => {
|
|||||||
return { user };
|
return { user };
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// ---------- platforms beyond Facebook/Instagram ----------
|
||||||
|
app.get("/api/platforms", h(async (req) => ({
|
||||||
|
platforms: extra.describePlatforms().map((p) => ({ ...p, redirectUri: redirectUri(req, p.id), app: req.user.role === "admin" ? p.app : { configured: p.app.configured } })),
|
||||||
|
publicUrl: baseUrl(req), mediaLinks: Boolean(publicBaseUrl()),
|
||||||
|
})));
|
||||||
|
app.put("/api/platforms/:id/app", requireAdmin, h(async (req) => { extra.adapter(req.params.id); extra.vault.saveApp(req.params.id, req.body || {}, req.user.name || req.user.email); return { ok: true }; }));
|
||||||
|
app.delete("/api/platforms/:id/app", requireAdmin, h(async (req) => { extra.adapter(req.params.id); extra.vault.removeApp(req.params.id); return { ok: true }; }));
|
||||||
|
app.get("/api/connections", h(async (req) => ({ connections: extra.vault.listConnections({ client: req.query.client || undefined }) })));
|
||||||
|
app.get("/api/oauth/:platform/start", requireAdmin, h(async (req) => {
|
||||||
|
const client = String(req.query.client || "");
|
||||||
|
if (!clients.getClient(client)) throw Object.assign(new Error("Pick a client first."), { status: 400 });
|
||||||
|
return { url: extra.startConnect({ platform: req.params.platform, client, redirectUri: redirectUri(req, req.params.platform), user: req.user }) };
|
||||||
|
}));
|
||||||
|
app.post("/api/connections/:id/check", requireAdmin, h(async (req) => ({ connection: await extra.checkConnection(req.params.id) })));
|
||||||
|
app.get("/api/connections/:id/targets", h(async (req) => ({ targets: await extra.targetsFor(req.params.id) })));
|
||||||
|
app.patch("/api/connections/:id", requireAdmin, h(async (req) => ({ connection: await extra.setTarget(req.params.id, String(req.body?.targetId || "")) })));
|
||||||
|
app.delete("/api/connections/:id", requireAdmin, h(async (req) => { extra.vault.removeConnection(req.params.id); return { ok: true }; }));
|
||||||
|
|
||||||
// ---------- team members (admins) ----------
|
// ---------- team members (admins) ----------
|
||||||
app.get("/api/users", requireAdmin, h(async () => ({ users: users.listUsers() })));
|
app.get("/api/users", requireAdmin, h(async () => ({ users: users.listUsers() })));
|
||||||
app.post("/api/users", requireAdmin, h(async (req) => users.createUser(req.body || {}, req.user.name || req.user.email)));
|
app.post("/api/users", requireAdmin, h(async (req) => users.createUser(req.body || {}, req.user.name || req.user.email)));
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { createContext, useCallback, useContext, useEffect, useState } from "react";
|
import { createContext, useCallback, useContext, useEffect, useState } from "react";
|
||||||
import { BrowserRouter, NavLink, Navigate, Route, Routes } from "react-router-dom";
|
import { BrowserRouter, NavLink, Navigate, Route, Routes } from "react-router-dom";
|
||||||
import { LayoutGrid, Users2, Sparkles, Settings2, LogOut, CalendarClock, MessageCircle, FolderUp } from "lucide-react";
|
import { LayoutGrid, Users2, Sparkles, Settings2, LogOut, CalendarClock, MessageCircle, FolderUp, Plug } from "lucide-react";
|
||||||
import { api } from "./api.js";
|
import { api } from "./api.js";
|
||||||
import { cx, Spinner } from "./ui.jsx";
|
import { cx, Spinner } from "./ui.jsx";
|
||||||
import Login from "./pages/Login.jsx";
|
import Login from "./pages/Login.jsx";
|
||||||
@ -12,6 +12,7 @@ import Settings from "./pages/Settings.jsx";
|
|||||||
import Publishing from "./pages/Publishing.jsx";
|
import Publishing from "./pages/Publishing.jsx";
|
||||||
import Inbox from "./pages/Inbox.jsx";
|
import Inbox from "./pages/Inbox.jsx";
|
||||||
import Library from "./pages/Library.jsx";
|
import Library from "./pages/Library.jsx";
|
||||||
|
import Connections from "./pages/Connections.jsx";
|
||||||
|
|
||||||
const AuthCtx = createContext(null);
|
const AuthCtx = createContext(null);
|
||||||
export const useAuth = () => useContext(AuthCtx);
|
export const useAuth = () => useContext(AuthCtx);
|
||||||
@ -21,6 +22,7 @@ const NAV = [
|
|||||||
{ to: "/library", label: "Content library", icon: FolderUp },
|
{ to: "/library", label: "Content library", icon: FolderUp },
|
||||||
{ to: "/publishing", label: "Publishing", icon: CalendarClock },
|
{ to: "/publishing", label: "Publishing", icon: CalendarClock },
|
||||||
{ to: "/inbox", label: "Inbox", icon: MessageCircle },
|
{ to: "/inbox", label: "Inbox", icon: MessageCircle },
|
||||||
|
{ to: "/connections", label: "Connections", icon: Plug },
|
||||||
{ to: "/team", label: "Team", icon: Users2 },
|
{ to: "/team", label: "Team", icon: Users2 },
|
||||||
{ to: "/runs", label: "AI runs", icon: Sparkles },
|
{ to: "/runs", label: "AI runs", icon: Sparkles },
|
||||||
{ to: "/settings", label: "Settings", icon: Settings2 },
|
{ to: "/settings", label: "Settings", icon: Settings2 },
|
||||||
@ -117,6 +119,7 @@ export default function App() {
|
|||||||
<Route path="/library" element={<Library />} />
|
<Route path="/library" element={<Library />} />
|
||||||
<Route path="/publishing" element={<Publishing />} />
|
<Route path="/publishing" element={<Publishing />} />
|
||||||
<Route path="/inbox" element={<Inbox />} />
|
<Route path="/inbox" element={<Inbox />} />
|
||||||
|
<Route path="/connections" element={<Connections />} />
|
||||||
<Route path="/team" element={<Team />} />
|
<Route path="/team" element={<Team />} />
|
||||||
<Route path="/runs" element={<Runs />} />
|
<Route path="/runs" element={<Runs />} />
|
||||||
<Route path="/runs/:id" element={<Runs />} />
|
<Route path="/runs/:id" element={<Runs />} />
|
||||||
|
|||||||
128
dashboard/web/src/PlatformPicker.jsx
Normal file
128
dashboard/web/src/PlatformPicker.jsx
Normal file
@ -0,0 +1,128 @@
|
|||||||
|
// "Post to" picker shared by New post (Publishing) and Schedule (Content library):
|
||||||
|
// Instagram/Facebook from the Meta link, plus every platform connected for the client, each with its own fields.
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { api } from "./api.js";
|
||||||
|
import { cx, Field } from "./ui.jsx";
|
||||||
|
|
||||||
|
const FORMAT_LABEL = { TEXT: "text", IMAGE: "image", CAROUSEL: "multi-image", REELS: "video", STORY: "story" };
|
||||||
|
|
||||||
|
/** Platforms the client can post to: [{ id, name, meta, formats, fields, limits, conn }] */
|
||||||
|
export function useClientPlatforms(client) {
|
||||||
|
const [state, setState] = useState({ loading: true, list: [] });
|
||||||
|
useEffect(() => {
|
||||||
|
if (!client) { setState({ loading: false, list: [] }); return; }
|
||||||
|
let live = true;
|
||||||
|
setState((s) => ({ ...s, loading: true }));
|
||||||
|
Promise.all([api(`/api/clients/${client}`), api("/api/platforms"), api(`/api/connections?client=${encodeURIComponent(client)}`)])
|
||||||
|
.then(([c, p, k]) => {
|
||||||
|
if (!live) return;
|
||||||
|
const info = c.client;
|
||||||
|
const list = [];
|
||||||
|
if (info.igId) list.push({ id: "instagram", name: "Instagram", meta: true, formats: ["IMAGE", "REELS", "CAROUSEL", "STORY"], fields: [], limits: { caption: 2200 } });
|
||||||
|
if (info.pageId) list.push({ id: "facebook", name: "Facebook", meta: true, formats: ["IMAGE", "REELS", "CAROUSEL", "STORY", "TEXT"], fields: [], limits: {} });
|
||||||
|
for (const pl of p.platforms) {
|
||||||
|
const conn = k.connections.find((x) => x.platform === pl.id && x.status === "connected");
|
||||||
|
if (conn) list.push({ ...pl, conn });
|
||||||
|
}
|
||||||
|
setState({ loading: false, list });
|
||||||
|
})
|
||||||
|
.catch(() => live && setState({ loading: false, list: [] }));
|
||||||
|
return () => { live = false; };
|
||||||
|
}, [client]);
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Default selection for a client: its Meta platforms. */
|
||||||
|
export const defaultPlatforms = (list) => list.filter((p) => p.meta).map((p) => p.id);
|
||||||
|
|
||||||
|
function TargetSelect({ conn, value, onChange, id }) {
|
||||||
|
const [opts, setOpts] = useState(null);
|
||||||
|
useEffect(() => { api(`/api/connections/${conn.id}/targets`).then((r) => setOpts(r.targets)).catch(() => setOpts([])); }, [conn.id]);
|
||||||
|
return (
|
||||||
|
<select id={id} className="input" value={value || ""} onChange={(e) => onChange(e.target.value)}>
|
||||||
|
<option value="" disabled>{opts === null ? "Loading…" : opts.length ? "Choose…" : "None found"}</option>
|
||||||
|
{(opts || []).map((o) => <option key={o.id} value={o.id}>{o.name}</option>)}
|
||||||
|
</select>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PlatformFields({ platform, type, options, setOption, caption }) {
|
||||||
|
const fields = platform.fields.filter((f) => !f.onlyFor || f.onlyFor.includes(type));
|
||||||
|
const own = options.caption;
|
||||||
|
const limit = platform.limits?.caption;
|
||||||
|
const used = (own ?? caption ?? "").length;
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-line p-3 space-y-3">
|
||||||
|
<div className="text-[12.5px] font-semibold flex items-center gap-2">
|
||||||
|
<span className="size-2.5 rounded-full" style={{ background: platform.color }} aria-hidden="true" />{platform.name}
|
||||||
|
{platform.conn?.target && <span className="font-normal text-muted">· {platform.conn.target.name}</span>}
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
{fields.map((f) => {
|
||||||
|
const id = `opt-${platform.id}-${f.key}`;
|
||||||
|
const v = options[f.key] ?? f.default ?? "";
|
||||||
|
if (f.type === "checkbox") {
|
||||||
|
return <label key={f.key} htmlFor={id} className="flex items-center gap-2 text-[13px] col-span-1"><input id={id} type="checkbox" checked={Boolean(v)} onChange={(e) => setOption(f.key, e.target.checked)} />{f.label}</label>;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div key={f.key} className={cx(f.type === "text" && "col-span-2")}>
|
||||||
|
<Field label={`${f.label}${f.required ? "" : " (optional)"}`} hint={f.hint}>
|
||||||
|
{f.type === "select" && f.optionsFrom === "targets" ? <TargetSelect id={id} conn={platform.conn} value={v} onChange={(x) => setOption(f.key, x)} />
|
||||||
|
: f.type === "select" ? (
|
||||||
|
<select id={id} className="input" value={v} onChange={(e) => setOption(f.key, e.target.value)}>
|
||||||
|
{!f.default && f.default !== "" && <option value="" disabled>Choose…</option>}
|
||||||
|
{f.options.map(([val, label]) => <option key={val} value={val}>{label}</option>)}
|
||||||
|
</select>
|
||||||
|
) : <input id={id} className="input" value={v} maxLength={f.max} onChange={(e) => setOption(f.key, e.target.value)} />}
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<Field label={`${platform.name} caption (optional)`} hint={`Leave empty to use the main caption.${limit ? ` ${used}/${limit} characters.` : ""}`}>
|
||||||
|
<textarea id={`opt-${platform.id}-caption`} className={cx("input h-16 py-2", limit && used > limit && "border-bad")} value={own ?? ""} onChange={(e) => setOption("caption", e.target.value || undefined)} />
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* value: { platforms: string[], options: { [platform]: {...} } }, onChange(next)
|
||||||
|
* type: the post format (IMAGE, REELS, CAROUSEL, STORY, TEXT)
|
||||||
|
*/
|
||||||
|
export default function PlatformPicker({ client, type, caption, value, onChange, available }) {
|
||||||
|
const { loading, list } = available;
|
||||||
|
const byId = useMemo(() => Object.fromEntries(list.map((p) => [p.id, p])), [list]);
|
||||||
|
const toggle = (id) => onChange({ ...value, platforms: value.platforms.includes(id) ? value.platforms.filter((x) => x !== id) : [...value.platforms, id] });
|
||||||
|
const setOption = (pid) => (k, v) => onChange({ ...value, options: { ...value.options, [pid]: { ...(value.options[pid] || {}), [k]: v } } });
|
||||||
|
|
||||||
|
if (loading) return <div className="text-[13px] text-muted">Loading platforms…</div>;
|
||||||
|
if (!list.length) return <div className="text-[13px] text-muted">This client has no connected accounts yet. <Link className="text-brand-deep underline" to={`/connections?client=${client}`}>Connect one</Link>.</div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex flex-wrap gap-x-4 gap-y-2 text-[13px]">
|
||||||
|
{list.map((p) => {
|
||||||
|
const ok = p.formats.includes(type);
|
||||||
|
return (
|
||||||
|
<label key={p.id} className={cx("flex items-center gap-1.5", !ok && "text-faint")} title={ok ? undefined : `${p.name} doesn't take ${FORMAT_LABEL[type]} posts`}>
|
||||||
|
<input type="checkbox" id={`plat-${p.id}`} disabled={!ok} checked={ok && value.platforms.includes(p.id)} onChange={() => toggle(p.id)} /> {p.name}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{value.platforms.filter((id) => byId[id] && !byId[id].meta && byId[id].formats.includes(type)).map((id) => (
|
||||||
|
<PlatformFields key={id} platform={byId[id]} type={type} caption={caption} options={value.options[id] || {}} setOption={setOption(id)} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Only the platforms that accept this format, with options for those platforms. */
|
||||||
|
export function selectionFor(value, available, type) {
|
||||||
|
const ok = new Set(available.list.filter((p) => p.formats.includes(type)).map((p) => p.id));
|
||||||
|
const platforms = value.platforms.filter((p) => ok.has(p));
|
||||||
|
const options = Object.fromEntries(platforms.filter((p) => value.options[p]).map((p) => [p, value.options[p]]));
|
||||||
|
return { platforms, options };
|
||||||
|
}
|
||||||
211
dashboard/web/src/pages/Connections.jsx
Normal file
211
dashboard/web/src/pages/Connections.jsx
Normal file
@ -0,0 +1,211 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { useSearchParams } from "react-router-dom";
|
||||||
|
import { Check, CircleAlert, Copy, ExternalLink, Plug, RefreshCw, Settings2, Unplug } from "lucide-react";
|
||||||
|
import { api } from "../api.js";
|
||||||
|
import { useAuth } from "../App.jsx";
|
||||||
|
import { Badge, Button, cx, Dialog, ErrorNote, Field, fmt, Panel, Spinner, timeAgo } from "../ui.jsx";
|
||||||
|
|
||||||
|
const FORMAT_LABEL = { TEXT: "text", IMAGE: "image", CAROUSEL: "multi-image", REELS: "video", STORY: "story" };
|
||||||
|
|
||||||
|
function CopyText({ text }) {
|
||||||
|
const [done, setDone] = useState(false);
|
||||||
|
const copy = () => navigator.clipboard?.writeText(text).then(() => { setDone(true); setTimeout(() => setDone(false), 1400); }).catch(() => {});
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 rounded-md border border-line bg-canvas px-2.5 py-1.5">
|
||||||
|
<code className="font-mono text-[12.5px] break-all flex-1 min-w-0">{text}</code>
|
||||||
|
<button type="button" onClick={copy} className="p-1 rounded hover:bg-white text-muted shrink-0" aria-label="Copy">{done ? <Check className="size-4 text-ok" /> : <Copy className="size-4" />}</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Admin: enter a platform's developer-app credentials (stored encrypted on the server). */
|
||||||
|
function AppDialog({ platform, onClose, onSaved }) {
|
||||||
|
const [f, setF] = useState({ clientId: platform.app.clientId || "", clientSecret: "", scopes: (platform.app.scopes || []).join(" ") });
|
||||||
|
const [busy, setBusy] = useState(null);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const set = (k) => (e) => setF({ ...f, [k]: e.target.value });
|
||||||
|
const save = async (e) => {
|
||||||
|
e.preventDefault(); setBusy("save"); setError(null);
|
||||||
|
try { await api(`/api/platforms/${platform.id}/app`, { method: "PUT", body: f }); onSaved(); } catch (err) { setError(err); setBusy(null); }
|
||||||
|
};
|
||||||
|
const remove = async () => {
|
||||||
|
setBusy("remove"); setError(null);
|
||||||
|
try { await api(`/api/platforms/${platform.id}/app`, { method: "DELETE" }); onSaved(); } catch (err) { setError(err); setBusy(null); }
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<Dialog open onClose={onClose} wide title={`Set up ${platform.name}`}
|
||||||
|
footer={<>
|
||||||
|
{platform.app.configured && platform.app.source === "settings" && <Button variant="danger" className="mr-auto" loading={busy === "remove"} onClick={remove}>Remove app keys</Button>}
|
||||||
|
<Button variant="ghost" onClick={onClose}>Cancel</Button>
|
||||||
|
<Button variant="primary" type="submit" form="app-form" loading={busy === "save"}>Save</Button>
|
||||||
|
</>}>
|
||||||
|
<div className="space-y-4 text-[13.5px]">
|
||||||
|
<ol className="list-decimal pl-5 space-y-1.5 text-ink-2">
|
||||||
|
{platform.setup.steps.map((s) => <li key={s}>{s}</li>)}
|
||||||
|
</ol>
|
||||||
|
<a href={platform.setup.console} target="_blank" rel="noreferrer" className="inline-flex items-center gap-1 text-brand-deep font-medium">Open the {platform.name} developer console <ExternalLink className="size-3.5" /></a>
|
||||||
|
<Field label="Redirect URI (paste this into the developer console)"><CopyText text={platform.redirectUri} /></Field>
|
||||||
|
{platform.setup.note && <div className="rounded-md border border-[#f1dcb8] bg-warn-soft text-warn px-3 py-2 text-[13px]">{platform.setup.note}</div>}
|
||||||
|
<form id="app-form" onSubmit={save} className="grid grid-cols-2 gap-3">
|
||||||
|
<Field label={platform.id === "tiktok" ? "Client key" : "Client ID / App ID"}><input id="app-client-id" className="input" value={f.clientId} onChange={set("clientId")} required autoComplete="off" /></Field>
|
||||||
|
<Field label="Client secret" hint={platform.app.configured ? "Leave empty to keep the stored secret." : "Stored encrypted. Never shown again."}>
|
||||||
|
<input id="app-client-secret" className="input" type="password" value={f.clientSecret} onChange={set("clientSecret")} required={!platform.app.configured} autoComplete="new-password" />
|
||||||
|
</Field>
|
||||||
|
<div className="col-span-2">
|
||||||
|
<Field label="Scopes (optional)" hint={`Leave empty for the defaults: ${platform.defaultScopes.join(" ")}`}>
|
||||||
|
<input id="app-scopes" className="input" value={f.scopes} onChange={set("scopes")} placeholder={platform.defaultScopes.join(" ")} />
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{platform.app.source === "env" && <p className="text-muted text-[12.5px]">Currently using keys from the server's .env. Saving here overrides them.</p>}
|
||||||
|
<ErrorNote error={error} />
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PlatformCard({ platform, conn, client, isAdmin, mediaLinks, onSetup, reload, setError }) {
|
||||||
|
const [busy, setBusy] = useState(null);
|
||||||
|
const [targets, setTargets] = useState(null);
|
||||||
|
const needsMediaLinks = ["tiktok", "threads", "gbp"].includes(platform.id) && !mediaLinks;
|
||||||
|
|
||||||
|
const connect = async () => {
|
||||||
|
setBusy("connect"); setError(null);
|
||||||
|
try { const { url } = await api(`/api/oauth/${platform.id}/start?client=${encodeURIComponent(client)}`); window.location.assign(url); }
|
||||||
|
catch (e) { setError(e); setBusy(null); }
|
||||||
|
};
|
||||||
|
const run = async (what, fn) => { setBusy(what); setError(null); try { await fn(); await reload(); } catch (e) { setError(e); } setBusy(null); };
|
||||||
|
const loadTargets = useCallback(() => {
|
||||||
|
if (!conn || !platform.targetLabel) return;
|
||||||
|
api(`/api/connections/${conn.id}/targets`).then((r) => setTargets(r.targets)).catch(() => setTargets([]));
|
||||||
|
}, [conn, platform.targetLabel]);
|
||||||
|
useEffect(() => { loadTargets(); }, [loadTargets]);
|
||||||
|
|
||||||
|
const status = !platform.app.configured ? { tone: "neutral", text: "Not set up" }
|
||||||
|
: !conn ? { tone: "neutral", text: "Not connected" }
|
||||||
|
: conn.status === "connected" ? { tone: "ok", text: "Connected" }
|
||||||
|
: { tone: "bad", text: conn.status === "reconnect" ? "Reconnect needed" : "Error" };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="panel p-4 flex flex-col gap-3 min-w-0">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<span className="size-9 rounded-lg grid place-items-center text-white font-bold text-[13px] shrink-0" style={{ background: platform.color }} aria-hidden="true">{platform.name[0]}</span>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap"><span className="font-semibold">{platform.name}</span><Badge tone={status.tone} dot>{status.text}</Badge></div>
|
||||||
|
<div className="text-[12px] text-muted mt-0.5">Posts: {platform.formats.map((f) => FORMAT_LABEL[f]).join(", ")}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{conn && (
|
||||||
|
<div className="text-[13px] space-y-1">
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
{conn.account?.avatar && <img src={conn.account.avatar} alt="" className="size-6 rounded-full shrink-0" />}
|
||||||
|
<span className="font-medium truncate">{conn.account?.name}</span>
|
||||||
|
{conn.account?.username && <span className="text-muted truncate">@{conn.account.username}</span>}
|
||||||
|
</div>
|
||||||
|
<div className="text-[12px] text-muted">
|
||||||
|
{conn.account?.followers != null && <>{fmt(conn.account.followers)} {platform.id === "youtube" ? "subscribers" : platform.id === "reddit" ? "karma" : "followers"} · </>}
|
||||||
|
Connected {timeAgo(conn.connectedAt)} by {conn.connectedBy}
|
||||||
|
{conn.account?.url && <> · <a className="text-brand-deep" href={conn.account.url} target="_blank" rel="noreferrer">View profile</a></>}
|
||||||
|
</div>
|
||||||
|
{conn.lastError && <div className="text-[12px] text-bad">{conn.lastError}</div>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{conn && platform.targetLabel && (
|
||||||
|
<Field label={platform.targetLabel}>
|
||||||
|
{targets === null ? <div className="text-[12.5px] text-muted">Loading…</div> : (
|
||||||
|
<select id={`target-${platform.id}`} className="input" value={conn.target?.id || ""} disabled={!isAdmin || busy === "target"}
|
||||||
|
onChange={(e) => run("target", () => api(`/api/connections/${conn.id}`, { method: "PATCH", body: { targetId: e.target.value } }))}>
|
||||||
|
<option value="" disabled>{targets.length ? "Choose…" : "Nothing found for this account"}</option>
|
||||||
|
{targets.map((t) => <option key={t.id} value={t.id}>{t.name}</option>)}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{needsMediaLinks && <div className="text-[12px] text-warn">Image and video posts need PUBLIC_URL set on the server (e.g. https://app.quorlasocial.com).</div>}
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2 mt-auto pt-1">
|
||||||
|
{isAdmin && !platform.app.configured && <Button size="sm" variant="primary" icon={Settings2} onClick={onSetup}>Set up</Button>}
|
||||||
|
{isAdmin && platform.app.configured && !conn && <Button size="sm" variant="primary" icon={Plug} loading={busy === "connect"} onClick={connect}>Connect</Button>}
|
||||||
|
{isAdmin && conn && <Button size="sm" icon={Plug} loading={busy === "connect"} onClick={connect}>Reconnect</Button>}
|
||||||
|
{isAdmin && conn && <Button size="sm" variant="ghost" icon={RefreshCw} loading={busy === "check"} onClick={() => run("check", () => api(`/api/connections/${conn.id}/check`, { method: "POST" }))}>Check</Button>}
|
||||||
|
{isAdmin && conn && <Button size="sm" variant="ghost" icon={Unplug} loading={busy === "remove"} onClick={() => run("remove", () => api(`/api/connections/${conn.id}`, { method: "DELETE" }))}>Disconnect</Button>}
|
||||||
|
{isAdmin && platform.app.configured && <Button size="sm" variant="ghost" icon={Settings2} onClick={onSetup} aria-label={`${platform.name} app settings`} />}
|
||||||
|
{!isAdmin && !conn && <span className="text-[12.5px] text-muted">An admin connects this.</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Connections() {
|
||||||
|
const { isAdmin } = useAuth();
|
||||||
|
const [params, setParams] = useSearchParams();
|
||||||
|
const [clients, setClients] = useState(null);
|
||||||
|
const [platforms, setPlatforms] = useState(null);
|
||||||
|
const [conns, setConns] = useState([]);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const [setup, setSetup] = useState(null);
|
||||||
|
const client = params.get("client") || "";
|
||||||
|
const notice = params.get("connected");
|
||||||
|
const oauthError = params.get("error");
|
||||||
|
|
||||||
|
const loadPlatforms = useCallback(() => api("/api/platforms").then(setPlatforms).catch(setError), []);
|
||||||
|
const loadConns = useCallback(() => (client ? api(`/api/connections?client=${encodeURIComponent(client)}`).then((r) => setConns(r.connections)).catch(setError) : Promise.resolve()), [client]);
|
||||||
|
|
||||||
|
useEffect(() => { api("/api/overview").then((r) => setClients(r.clients)).catch(setError); loadPlatforms(); }, [loadPlatforms]);
|
||||||
|
useEffect(() => { loadConns(); }, [loadConns]);
|
||||||
|
useEffect(() => { if (!client && clients?.length) setParams({ client: clients[0].slug }, { replace: true }); }, [client, clients, setParams]);
|
||||||
|
|
||||||
|
const current = useMemo(() => clients?.find((c) => c.slug === client), [clients, client]);
|
||||||
|
const pickClient = (slug) => setParams({ client: slug });
|
||||||
|
const dismiss = () => setParams({ client });
|
||||||
|
|
||||||
|
if (!clients || !platforms) return error ? <ErrorNote error={error} /> : <Spinner />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<header className="flex items-end justify-between gap-4 flex-wrap">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-[22px] font-extrabold">Connections</h1>
|
||||||
|
<p className="text-muted mt-0.5">Each client's accounts on every platform. Connect once; posts then go through Publishing with the usual approval.</p>
|
||||||
|
</div>
|
||||||
|
<select id="conn-client" className="input h-9 w-auto" value={client} onChange={(e) => pickClient(e.target.value)} aria-label="Client">
|
||||||
|
{clients.map((c) => <option key={c.slug} value={c.slug}>{c.name}</option>)}
|
||||||
|
</select>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{notice && (
|
||||||
|
<div className="rounded-md border border-[#cfe6d7] bg-ok-soft text-ok px-3 py-2 text-[13px] flex justify-between gap-3">
|
||||||
|
<span>{platforms.platforms.find((p) => p.id === notice)?.name || notice} is connected for {current?.name}.</span>
|
||||||
|
<button type="button" className="underline" onClick={dismiss}>Dismiss</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{oauthError && (
|
||||||
|
<div className="rounded-md border border-[#f2cdc8] bg-bad-soft text-bad px-3 py-2 text-[13px] flex justify-between gap-3">
|
||||||
|
<span className="flex gap-2"><CircleAlert className="size-4 shrink-0 mt-0.5" />{oauthError}</span>
|
||||||
|
<button type="button" className="underline shrink-0" onClick={dismiss}>Dismiss</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<ErrorNote error={error} />
|
||||||
|
|
||||||
|
<Panel title="Facebook & Instagram" subtitle="Connected through the agency's Meta system user, not here.">
|
||||||
|
<div className="px-4 py-3 text-[13px] flex flex-wrap gap-x-6 gap-y-1">
|
||||||
|
<span><b>Instagram:</b> {current?.igUsername ? `@${current.igUsername}` : <span className="text-muted">not linked</span>}</span>
|
||||||
|
<span><b>Facebook:</b> {current?.pageId ? "Page linked" : current?.igOnly ? <span className="text-muted">none (Instagram only)</span> : <span className="text-muted">not linked</span>}</span>
|
||||||
|
{!current?.linked && <span className="text-muted">Link it from the client's page.</span>}
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
<div className="grid gap-4 grid-cols-[repeat(auto-fill,minmax(300px,1fr))]">
|
||||||
|
{platforms.platforms.map((p) => (
|
||||||
|
<PlatformCard key={`${p.id}-${client}`} platform={p} conn={conns.find((c) => c.platform === p.id)} client={client} isAdmin={isAdmin}
|
||||||
|
mediaLinks={platforms.mediaLinks} onSetup={() => setSetup(p)} reload={loadConns} setError={setError} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{setup && <AppDialog platform={setup} onClose={() => setSetup(null)} onSaved={() => { setSetup(null); loadPlatforms(); }} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -3,6 +3,7 @@ import { Archive, CalendarPlus, Check, FolderUp, Image as ImageIcon, MessageSqua
|
|||||||
import { api } from "../api.js";
|
import { api } from "../api.js";
|
||||||
import { useAuth } from "../App.jsx";
|
import { useAuth } from "../App.jsx";
|
||||||
import { Badge, Button, cx, Dialog, Empty, ErrorNote, Field, Panel, Spinner, timeAgo } from "../ui.jsx";
|
import { Badge, Button, cx, Dialog, Empty, ErrorNote, Field, Panel, Spinner, timeAgo } from "../ui.jsx";
|
||||||
|
import PlatformPicker, { defaultPlatforms, selectionFor, useClientPlatforms } from "../PlatformPicker.jsx";
|
||||||
|
|
||||||
const TONE = { submitted: "brand", changes: "warn", approved: "ok", scheduled: "neutral", archived: "neutral" };
|
const TONE = { submitted: "brand", changes: "warn", approved: "ok", scheduled: "neutral", archived: "neutral" };
|
||||||
const LABEL = { submitted: "To review", changes: "Changes requested", approved: "Ready to schedule", scheduled: "Scheduled", archived: "Archived" };
|
const LABEL = { submitted: "To review", changes: "Changes requested", approved: "Ready to schedule", scheduled: "Scheduled", archived: "Archived" };
|
||||||
@ -215,32 +216,35 @@ function ReviewDialog({ item, onClose, onDone }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ScheduleDialog({ item, clientName, onClose, onDone }) {
|
function ScheduleDialog({ item, clientName, onClose, onDone }) {
|
||||||
const [f, setF] = useState({ platforms: ["instagram", "facebook"], date: item.forDate || "", time: "", caption: item.captionIdea || "", fbCaption: "", collaborators: "" });
|
const [f, setF] = useState({ date: item.forDate || "", time: "", caption: item.captionIdea || "", fbCaption: "", collaborators: "" });
|
||||||
|
const available = useClientPlatforms(item.client);
|
||||||
|
const [sel, setSel] = useState({ platforms: [], options: {} });
|
||||||
|
useEffect(() => { if (!available.loading) setSel({ platforms: defaultPlatforms(available.list), options: {} }); }, [available]);
|
||||||
|
const chosen = selectionFor(sel, available, item.format);
|
||||||
const [tz, setTz] = useState(null);
|
const [tz, setTz] = useState(null);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
const set = (k, v) => setF((s) => ({ ...s, [k]: v }));
|
const set = (k, v) => setF((s) => ({ ...s, [k]: v }));
|
||||||
useEffect(() => { api(`/api/schedule/suggest?client=${item.client}`).then(setTz).catch(() => {}); }, [item.client]);
|
useEffect(() => { api(`/api/schedule/suggest?client=${item.client}`).then(setTz).catch(() => {}); }, [item.client]);
|
||||||
const toggle = (p) => set("platforms", f.platforms.includes(p) ? f.platforms.filter((x) => x !== p) : [...f.platforms, p]);
|
|
||||||
const submit = async () => {
|
const submit = async () => {
|
||||||
setBusy(true); setError(null);
|
setBusy(true); setError(null);
|
||||||
try {
|
try {
|
||||||
await api(`/api/library/${item.id}/schedule`, { method: "POST", body: { platforms: f.platforms, localTime: `${f.date} ${f.time}`, caption: f.caption, fbCaption: f.fbCaption || undefined, collaborators: f.collaborators.split(/[\s,]+/).map((x) => x.replace(/^@/, "")).filter(Boolean) } });
|
await api(`/api/library/${item.id}/schedule`, { method: "POST", body: { platforms: chosen.platforms, options: chosen.options, localTime: `${f.date} ${f.time}`, caption: f.caption, fbCaption: f.fbCaption || undefined, collaborators: f.collaborators.split(/[\s,]+/).map((x) => x.replace(/^@/, "")).filter(Boolean) } });
|
||||||
onDone();
|
onDone();
|
||||||
} catch (e) { setError(e); setBusy(false); }
|
} catch (e) { setError(e); setBusy(false); }
|
||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
<Dialog open onClose={onClose} title={`Schedule for ${clientName}`} wide footer={<><Button onClick={onClose}>Cancel</Button><Button variant="primary" loading={busy} disabled={!f.date || !f.time || !f.platforms.length} onClick={submit}>Add to Publishing as draft</Button></>}>
|
<Dialog open onClose={onClose} title={`Schedule for ${clientName}`} wide footer={<><Button onClick={onClose}>Cancel</Button><Button variant="primary" loading={busy} disabled={!f.date || !f.time || !chosen.platforms.length} onClick={submit}>Add to Publishing as draft</Button></>}>
|
||||||
<p className="text-[12.5px] text-muted">This creates a draft in Publishing. An admin still approves it there before it goes live.</p>
|
<p className="text-[12.5px] text-muted">This creates a draft in Publishing. An admin still approves it there before it goes live.</p>
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<Field label="Post to"><div className="flex gap-4 h-9 items-center text-[13px]">{["instagram", "facebook"].map((p) => <label key={p} className="flex items-center gap-1.5 capitalize"><input type="checkbox" checked={f.platforms.includes(p)} onChange={() => toggle(p)} /> {p}</label>)}</div></Field>
|
|
||||||
<Field label={`When (${tz?.timezone?.replace("_", " ") || "client time"})`} hint={tz ? `Good times: ${tz.times.join(", ")}` : null}>
|
<Field label={`When (${tz?.timezone?.replace("_", " ") || "client time"})`} hint={tz ? `Good times: ${tz.times.join(", ")}` : null}>
|
||||||
<div className="flex gap-2"><input type="date" className="input flex-1" value={f.date} onChange={(e) => set("date", e.target.value)} /><input type="time" className="input w-[120px]" value={f.time} onChange={(e) => set("time", e.target.value)} /></div>
|
<div className="flex gap-2"><input type="date" className="input flex-1" value={f.date} onChange={(e) => set("date", e.target.value)} /><input type="time" className="input w-[120px]" value={f.time} onChange={(e) => set("time", e.target.value)} /></div>
|
||||||
</Field>
|
</Field>
|
||||||
</div>
|
</div>
|
||||||
<Field label="Caption"><textarea className="input h-28" value={f.caption} onChange={(e) => set("caption", e.target.value)} /></Field>
|
<Field label="Caption"><textarea className="input h-28" value={f.caption} onChange={(e) => set("caption", e.target.value)} /></Field>
|
||||||
{f.platforms.includes("facebook") && <Field label="Facebook caption (optional)"><textarea className="input h-16" value={f.fbCaption} onChange={(e) => set("fbCaption", e.target.value)} /></Field>}
|
<Field label="Post to"><PlatformPicker client={item.client} type={item.format} caption={f.caption} value={sel} onChange={setSel} available={available} /></Field>
|
||||||
{f.platforms.includes("instagram") && item.format !== "STORY" && <Field label="Collab invites (optional)"><input className="input" placeholder="@username" value={f.collaborators} onChange={(e) => set("collaborators", e.target.value)} /></Field>}
|
{sel.platforms.includes("facebook") && <Field label="Facebook caption (optional)"><textarea className="input h-16" value={f.fbCaption} onChange={(e) => set("fbCaption", e.target.value)} /></Field>}
|
||||||
|
{sel.platforms.includes("instagram") && item.format !== "STORY" && <Field label="Collab invites (optional)"><input className="input" placeholder="@username" value={f.collaborators} onChange={(e) => set("collaborators", e.target.value)} /></Field>}
|
||||||
<ErrorNote error={error} />
|
<ErrorNote error={error} />
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -3,9 +3,10 @@ import { CalendarClock, Check, Image as ImageIcon, Plus, Upload, X } from "lucid
|
|||||||
import { api } from "../api.js";
|
import { api } from "../api.js";
|
||||||
import { useAuth } from "../App.jsx";
|
import { useAuth } from "../App.jsx";
|
||||||
import { Badge, Button, cx, Dialog, Empty, ErrorNote, Field, Panel, Spinner } from "../ui.jsx";
|
import { Badge, Button, cx, Dialog, Empty, ErrorNote, Field, Panel, Spinner } from "../ui.jsx";
|
||||||
|
import PlatformPicker, { defaultPlatforms, selectionFor, useClientPlatforms } from "../PlatformPicker.jsx";
|
||||||
|
|
||||||
const STATUS = { draft: "neutral", approved: "brand", publishing: "brand", published: "ok", partial: "warn", failed: "bad", cancelled: "neutral" };
|
const STATUS = { draft: "neutral", approved: "brand", publishing: "brand", published: "ok", partial: "warn", failed: "bad", cancelled: "neutral" };
|
||||||
const TYPES = [["IMAGE", "Single image"], ["REELS", "Reel"], ["CAROUSEL", "Carousel (2–10)"], ["STORY", "Story"], ["TEXT", "Text (Facebook only)"]];
|
const TYPES = [["IMAGE", "Single image"], ["REELS", "Reel / video"], ["CAROUSEL", "Carousel / multi-image"], ["STORY", "Story (Instagram, Facebook)"], ["TEXT", "Text only (no Instagram)"]];
|
||||||
|
|
||||||
export default function Publishing() {
|
export default function Publishing() {
|
||||||
const { isAdmin } = useAuth();
|
const { isAdmin } = useAuth();
|
||||||
@ -104,8 +105,10 @@ function Thumb({ post }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function Compose({ clients, onClose, onSaved }) {
|
function Compose({ clients, onClose, onSaved }) {
|
||||||
const linked = clients.filter((c) => c.linked);
|
const [f, setF] = useState({ client: clients[0]?.slug || "", type: "IMAGE", media: [], caption: "", fbCaption: "", collaborators: "", date: "", time: "" });
|
||||||
const [f, setF] = useState({ client: linked[0]?.slug || "", platforms: ["instagram", "facebook"], type: "IMAGE", media: [], caption: "", fbCaption: "", collaborators: "", date: "", time: "" });
|
const available = useClientPlatforms(f.client);
|
||||||
|
const [sel, setSel] = useState({ platforms: [], options: {} });
|
||||||
|
useEffect(() => { if (!available.loading) setSel({ platforms: defaultPlatforms(available.list), options: {} }); }, [available]);
|
||||||
const [tz, setTz] = useState(null);
|
const [tz, setTz] = useState(null);
|
||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
@ -130,22 +133,22 @@ function Compose({ clients, onClose, onSaved }) {
|
|||||||
const save = async () => {
|
const save = async () => {
|
||||||
setSaving(true); setError(null);
|
setSaving(true); setError(null);
|
||||||
try {
|
try {
|
||||||
|
const { platforms, options } = selectionFor(sel, available, f.type);
|
||||||
await api("/api/schedule", { method: "POST", body: {
|
await api("/api/schedule", { method: "POST", body: {
|
||||||
client: f.client, platforms: f.platforms, type: f.type, media: f.media, caption: f.caption, fbCaption: f.fbCaption || undefined,
|
client: f.client, platforms, options, type: f.type, media: f.media, caption: f.caption, fbCaption: f.fbCaption || undefined,
|
||||||
collaborators: f.collaborators.split(/[\s,]+/).map((x) => x.replace(/^@/, "")).filter(Boolean), localTime: `${f.date} ${f.time}`,
|
collaborators: f.collaborators.split(/[\s,]+/).map((x) => x.replace(/^@/, "")).filter(Boolean), localTime: `${f.date} ${f.time}`,
|
||||||
} });
|
} });
|
||||||
onSaved();
|
onSaved();
|
||||||
} catch (e) { setError(e); }
|
} catch (e) { setError(e); }
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
};
|
};
|
||||||
const togglePlat = (p) => set("platforms", f.platforms.includes(p) ? f.platforms.filter((x) => x !== p) : [...f.platforms, p]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open onClose={onClose} title="New post" wide footer={<><Button onClick={onClose}>Close</Button><Button variant="primary" loading={saving} disabled={!f.client || !f.date || !f.time} onClick={save}>Save as draft</Button></>}>
|
<Dialog open onClose={onClose} title="New post" wide footer={<><Button onClick={onClose}>Close</Button><Button variant="primary" loading={saving} disabled={!f.client || !f.date || !f.time || !selectionFor(sel, available, f.type).platforms.length} onClick={save}>Save as draft</Button></>}>
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<Field label="Client">
|
<Field label="Client">
|
||||||
<select id="c-client" className="input w-full" value={f.client} onChange={(e) => set("client", e.target.value)}>
|
<select id="c-client" className="input w-full" value={f.client} onChange={(e) => set("client", e.target.value)}>
|
||||||
{linked.map((c) => <option key={c.slug} value={c.slug}>{c.name}</option>)}
|
{clients.map((c) => <option key={c.slug} value={c.slug}>{c.name}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="Format">
|
<Field label="Format">
|
||||||
@ -153,11 +156,6 @@ function Compose({ clients, onClose, onSaved }) {
|
|||||||
{TYPES.map(([v, l]) => <option key={v} value={v}>{l}</option>)}
|
{TYPES.map(([v, l]) => <option key={v} value={v}>{l}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="Post to">
|
|
||||||
<div className="flex gap-4 h-9 items-center text-[13px]">
|
|
||||||
{["instagram", "facebook"].map((p) => <label key={p} className="flex items-center gap-1.5 capitalize"><input type="checkbox" checked={f.platforms.includes(p)} onChange={() => togglePlat(p)} /> {p}</label>)}
|
|
||||||
</div>
|
|
||||||
</Field>
|
|
||||||
<Field label={`When (${tz?.timezone?.replace("_", " ") || "client time"})`} hint={tz ? `Good times: ${tz.times.join(", ")}` : null}>
|
<Field label={`When (${tz?.timezone?.replace("_", " ") || "client time"})`} hint={tz ? `Good times: ${tz.times.join(", ")}` : null}>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<input id="c-date" type="date" className="input flex-1" value={f.date} onChange={(e) => set("date", e.target.value)} />
|
<input id="c-date" type="date" className="input flex-1" value={f.date} onChange={(e) => set("date", e.target.value)} />
|
||||||
@ -179,12 +177,13 @@ function Compose({ clients, onClose, onSaved }) {
|
|||||||
<Field label="Caption" hint="Plain text works best for search. Hashtags at the end.">
|
<Field label="Caption" hint="Plain text works best for search. Hashtags at the end.">
|
||||||
<textarea id="c-caption" className="input w-full h-28 py-2" value={f.caption} onChange={(e) => set("caption", e.target.value)} />
|
<textarea id="c-caption" className="input w-full h-28 py-2" value={f.caption} onChange={(e) => set("caption", e.target.value)} />
|
||||||
</Field>
|
</Field>
|
||||||
{f.platforms.includes("facebook") && (
|
<Field label="Post to"><PlatformPicker client={f.client} type={f.type} caption={f.caption} value={sel} onChange={setSel} available={available} /></Field>
|
||||||
|
{sel.platforms.includes("facebook") && (
|
||||||
<Field label="Facebook caption (optional)" hint="Leave empty to reuse the Instagram caption.">
|
<Field label="Facebook caption (optional)" hint="Leave empty to reuse the Instagram caption.">
|
||||||
<textarea id="c-fbcaption" className="input w-full h-20 py-2" value={f.fbCaption} onChange={(e) => set("fbCaption", e.target.value)} />
|
<textarea id="c-fbcaption" className="input w-full h-20 py-2" value={f.fbCaption} onChange={(e) => set("fbCaption", e.target.value)} />
|
||||||
</Field>
|
</Field>
|
||||||
)}
|
)}
|
||||||
{f.platforms.includes("instagram") && ["IMAGE", "REELS", "CAROUSEL"].includes(f.type) && (
|
{sel.platforms.includes("instagram") && ["IMAGE", "REELS", "CAROUSEL"].includes(f.type) && (
|
||||||
<Field label="Collab invites (optional)" hint="Instagram usernames. They must accept the invite for the post to appear on their profile.">
|
<Field label="Collab invites (optional)" hint="Instagram usernames. They must accept the invite for the post to appear on their profile.">
|
||||||
<input id="c-collab" className="input w-full" placeholder="@username, @username" value={f.collaborators} onChange={(e) => set("collaborators", e.target.value)} />
|
<input id="c-collab" className="input w-full" placeholder="@username, @username" value={f.collaborators} onChange={(e) => set("collaborators", e.target.value)} />
|
||||||
</Field>
|
</Field>
|
||||||
|
|||||||
64
docs/platforms.md
Normal file
64
docs/platforms.md
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
# Other platforms: LinkedIn, Google Business Profile, YouTube, TikTok, Pinterest, Threads, Reddit
|
||||||
|
|
||||||
|
Facebook and Instagram still come through the agency's Meta system user. Every other platform is connected
|
||||||
|
**per client** on the **Connections** page: an admin signs in to the client's account once ("Connect"), and
|
||||||
|
from then on posts to that platform go through Publishing with the same draft → approve → publish flow.
|
||||||
|
|
||||||
|
## How it fits together
|
||||||
|
|
||||||
|
| Piece | Where |
|
||||||
|
|---|---|
|
||||||
|
| Connections page (set up apps, connect/disconnect accounts, choose company Page / location) | `dashboard/web/src/pages/Connections.jsx` |
|
||||||
|
| "Post to" picker with each platform's fields, used in New post and library Schedule | `dashboard/web/src/PlatformPicker.jsx` |
|
||||||
|
| API routes, OAuth callback `/oauth/<platform>/callback`, media links `/m/<token>/<file>` | `dashboard/server/index.js` |
|
||||||
|
| Encrypted store for app keys and tokens (`data/connections.json`, AES-256-GCM) | `connector/src/platforms/vault.js` |
|
||||||
|
| Sign-in (OAuth 2, PKCE for Google), token refresh | `connector/src/platforms/oauth.js`, `index.js` |
|
||||||
|
| One adapter per platform | `connector/src/platforms/<platform>.js` |
|
||||||
|
| Queue integration (validation, publishing) | `connector/src/schedule.js` |
|
||||||
|
| Agent tools: `list_connections`; `schedule_post` / `schedule_from_library` accept all platforms + `options` | `connector/src/server.js` |
|
||||||
|
| Offline tests (simulated platform APIs) | `connector/test/platforms.test.mjs` → `node --test connector/test/platforms.test.mjs` |
|
||||||
|
|
||||||
|
Tokens and app secrets are encrypted with a key derived from `DASHBOARD_SECRET` (or `QUORLA_SECRET_KEY`).
|
||||||
|
They are never sent to the browser. If that secret changes, every platform shows "Reconnect needed".
|
||||||
|
|
||||||
|
## What each platform needs before it works for real
|
||||||
|
|
||||||
|
Every platform requires a developer app owned by the agency, with the Redirect URI shown in its **Set up** dialog
|
||||||
|
(`https://app.quorlasocial.com/oauth/<platform>/callback`), plus the privacy policy and terms pages on quorlasocial.com.
|
||||||
|
|
||||||
|
| Platform | Posts | Developer app | Approval needed for real use | Until approved |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| LinkedIn | text, image, multi-image, video; as a person or company Page | linkedin.com/developers | **Community Management API** (company Pages, analytics) | Personal profiles only: set Scopes to `openid profile w_member_social` |
|
||||||
|
| Google Business Profile | update with photo and button | Google Cloud (OAuth client) | **Business Profile API access** form + OAuth consent verification | API calls return quota errors; test users only |
|
||||||
|
| YouTube | video (Shorts when vertical, under 3 min) | Google Cloud (same OAuth client) | **YouTube API audit** + consent verification | Uploads are forced to Private; small daily quota |
|
||||||
|
| TikTok | video, photo / photo carousel | developers.tiktok.com | **App review** for Content Posting API (Direct Post) | Posts are "Only me"; sandbox users only |
|
||||||
|
| Pinterest | image, multi-image (2–5), video Pins on a board | developers.pinterest.com | **Standard access** (demo video) | Trial: set `PINTEREST_SANDBOX=true`, Pins go to the sandbox |
|
||||||
|
| Threads | text, image, video, carousel | Meta for Developers (Threads use case) | **Meta app review** for Threads permissions | Only accounts added as Threads testers |
|
||||||
|
| Reddit | text or link post to a subreddit | reddit.com/prefs/apps (web app) | Reddit's approval for commercial API use | Works for the agency's own account; check each subreddit's rules |
|
||||||
|
|
||||||
|
Platforms that fetch media from a link (TikTok photos, Threads, Google Business Profile) get a signed link to the
|
||||||
|
file that expires after 6 hours. On the server this uses `PUBLIC_URL`, or `https://APP_DOMAIN` when that's set.
|
||||||
|
TikTok also needs the domain verified in its developer portal.
|
||||||
|
|
||||||
|
## What was tested, and what wasn't
|
||||||
|
|
||||||
|
- **Tested offline (15 tests, all passing):** encryption at rest, sign-in links for every platform (client ID
|
||||||
|
parameter, scopes, PKCE, offline access), the callback state check, token refresh and "reconnect needed",
|
||||||
|
and each platform's publish requests (LinkedIn image to a company Page with hashtags, YouTube resumable upload,
|
||||||
|
GBP post with button and photo link, TikTok video chunks and photo pull, Pinterest base64 Pin, Threads long-lived
|
||||||
|
token and image post, Reddit text post and error handling), plus format and field validation.
|
||||||
|
- **Tested against the local dashboard (21 checks):** admin-only setup and connect, secrets never returned,
|
||||||
|
sign-in redirect and callback errors (forged state, cancelled, signed out), media links (served, tampered
|
||||||
|
refused), drafts to connected and unconnected platforms, per-platform captions, disconnect.
|
||||||
|
- **Not tested:** real calls to each platform. Each platform needs its developer app and keys first. The request
|
||||||
|
formats follow each platform's documentation as of this build, but expect small fixes the first time each one
|
||||||
|
connects and posts. Test with the agency's own accounts before connecting clients.
|
||||||
|
- **Not built yet:** comments/inbox and analytics for these platforms (only follower counts are shown),
|
||||||
|
Reddit image posts, Pinterest video cover images, LinkedIn document (PDF) posts, X (paid API).
|
||||||
|
|
||||||
|
## Suggested order after merging
|
||||||
|
|
||||||
|
1. Deploy (`git pull && docker compose up -d --build`), then Connections → Set up for one platform at a time.
|
||||||
|
2. Start with **LinkedIn** (personal-profile scopes) and **Google Business Profile**, using Metatroncube's own accounts.
|
||||||
|
3. Apply for the approvals in the table above. They take days to weeks, which is why they should start early.
|
||||||
|
4. Connect client accounts once each platform is approved.
|
||||||
Loading…
x
Reference in New Issue
Block a user