rspace-online/modules/rsocials/lib/postiz-client.ts

231 lines
7.5 KiB
TypeScript

/**
* Postiz API client — reads per-space credentials from module settings
* and forwards authenticated requests to the Postiz public API.
*
* Follows the same pattern as listmonk-proxy.ts.
*/
import { loadCommunity, getDocumentData } from "../../../server/community-store";
export interface PostizConfig {
url: string;
apiKey: string;
}
/** Read Postiz credentials from the space's module settings. */
export async function getPostizConfig(spaceSlug: string): Promise<PostizConfig | null> {
await loadCommunity(spaceSlug);
const data = getDocumentData(spaceSlug);
if (!data) return null;
const settings = data.meta.moduleSettings?.rsocials;
if (!settings) return null;
const url = settings.postizUrl as string | undefined;
const apiKey = settings.postizApiKey as string | undefined;
if (!url || !apiKey) return null;
return { url: url.replace(/\/+$/, ''), apiKey };
}
/** Make an authenticated request to the Postiz API.
*
* Postiz's public API expects the raw API key in the Authorization header
* (NOT `Bearer <key>`). The API lives at `<hostname>/api/public/v1/*`; we
* tolerate either `config.url = 'https://host'` or `config.url = 'https://host/api'`
* by inserting `/api` when missing.
*/
export async function postizFetch(
config: PostizConfig,
path: string,
opts: RequestInit = {},
): Promise<Response> {
const headers = new Headers(opts.headers);
headers.set("Authorization", config.apiKey);
if (!headers.has("Content-Type") && opts.body) {
headers.set("Content-Type", "application/json");
}
const base = config.url.endsWith('/api') ? config.url : `${config.url}/api`;
return fetch(`${base}${path}`, { ...opts, headers });
}
/** GET /public/v1/integrations — list connected social channels. */
export async function getIntegrations(config: PostizConfig) {
const res = await postizFetch(config, "/public/v1/integrations");
if (!res.ok) throw new Error(`Postiz integrations error: ${res.status}`);
return res.json();
}
/** POST /public/v1/posts — create a single post (draft, schedule, or now).
*
* Postiz expects a nested shape:
* { type, date, shortLink, tags, posts: [{ integration: {id}, value: [{content}], settings: {__type, ...} }] }
*
* Callers pass integrations as {id, identifier} tuples; identifier populates
* settings.__type which Postiz uses to route to the correct provider handler.
*/
export interface PostizIntegrationRef {
id: string;
identifier: string; // e.g. 'x', 'linkedin', 'bluesky'
/** Per-integration settings overrides merged on top of defaults. */
settings?: Record<string, unknown>;
/** Optional title (YouTube/Reddit/Pinterest) — forwarded into settings. */
title?: string;
}
// Platform-specific settings Postiz validates on POST /posts. Keys that are
// required-non-empty for each provider live here. We merge defaults when the
// caller doesn't supply overrides.
function defaultSettingsFor(identifier: string): Record<string, unknown> {
const base: Record<string, unknown> = { __type: identifier };
switch (identifier) {
case 'x':
base.who_can_reply_post = 'everyone';
break;
case 'linkedin':
base.post_to_company = false;
break;
case 'instagram':
base.post_type = 'post';
break;
case 'youtube':
base.type = 'public';
base.title = '';
base.category = '22';
break;
// Others (bluesky, threads, mastodon, reddit, ...) don't require settings
// beyond __type as of Postiz 5.x.
}
return base;
}
/** Merge caller overrides on top of defaults while preserving `__type`. */
function mergeSettings(
identifier: string,
overrides?: Record<string, unknown>,
title?: string,
): Record<string, unknown> {
const merged = { ...defaultSettingsFor(identifier), ...(overrides || {}) };
if (title !== undefined) merged.title = title;
merged.__type = identifier; // always wins — Postiz routes on this
return merged;
}
export async function createPost(
config: PostizConfig,
payload: {
content: string;
integrations: PostizIntegrationRef[];
type: 'draft' | 'schedule' | 'now';
scheduledAt?: string;
group?: string;
},
) {
const body = {
type: payload.type,
date: payload.scheduledAt || new Date().toISOString(),
shortLink: false,
tags: [] as unknown[],
posts: payload.integrations.map(integ => ({
integration: { id: integ.id },
value: [{ content: payload.content, image: [] as unknown[] }],
...(payload.group ? { group: payload.group } : {}),
settings: mergeSettings(integ.identifier, integ.settings, integ.title),
})),
};
const res = await postizFetch(config, "/public/v1/posts", {
method: "POST",
body: JSON.stringify(body),
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`Postiz createPost error: ${res.status} ${text}`);
}
return res.json();
}
/** GET /public/v1/posts — list posts in a date range (used for status reconciliation). */
export interface PostizListedPost {
id: string;
content?: string;
publishDate?: string;
releaseURL?: string;
state: 'QUEUE' | 'PUBLISHED' | 'ERROR' | 'DRAFT';
integration?: { id: string; providerIdentifier?: string; name?: string };
}
export async function listPosts(
config: PostizConfig,
startDate: Date,
endDate: Date,
): Promise<PostizListedPost[]> {
const qs = new URLSearchParams({
startDate: startDate.toISOString(),
endDate: endDate.toISOString(),
});
const res = await postizFetch(config, `/public/v1/posts?${qs}`);
if (!res.ok) throw new Error(`Postiz listPosts error: ${res.status}`);
const data = await res.json();
// Postiz wraps list as { posts: [...] }; be defensive against raw array.
if (Array.isArray(data)) return data as PostizListedPost[];
if (Array.isArray(data?.posts)) return data.posts as PostizListedPost[];
return [];
}
/** DELETE /public/v1/posts/:id — remove a queued post from Postiz.
*
* Used by the planner's "resync" flow: when a local node has drifted from
* what was sent (detected via postizSyncedHash), we delete + recreate at
* Postiz so the scheduler publishes the latest content. Postiz returns 404
* if the post was already published or never existed; we treat that as a
* no-op so the caller can proceed to recreate.
*/
export async function deletePost(
config: PostizConfig,
postizPostId: string,
): Promise<{ ok: true } | { ok: false; status: number; error: string }> {
const res = await postizFetch(config, `/public/v1/posts/${encodeURIComponent(postizPostId)}`, {
method: "DELETE",
});
if (res.ok || res.status === 404) return { ok: true };
const text = await res.text().catch(() => "");
return { ok: false, status: res.status, error: text || res.statusText };
}
/** Create a thread — single Postiz post whose `value` array carries each tweet.
*
* Postiz treats the `value` array on a PostItem as the thread segments, so we
* send ONE request with multiple value entries, not N grouped requests.
*/
export async function createThread(
config: PostizConfig,
tweets: string[],
opts: {
integrations: PostizIntegrationRef[];
type: 'draft' | 'schedule' | 'now';
scheduledAt?: string;
},
) {
const body = {
type: opts.type,
date: opts.scheduledAt || new Date().toISOString(),
shortLink: false,
tags: [] as unknown[],
posts: opts.integrations.map(integ => ({
integration: { id: integ.id },
value: tweets.map(t => ({ content: t, image: [] as unknown[] })),
settings: mergeSettings(integ.identifier, integ.settings, integ.title),
})),
};
const res = await postizFetch(config, "/public/v1/posts", {
method: "POST",
body: JSON.stringify(body),
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`Postiz createThread error: ${res.status} ${text}`);
}
return res.json();
}