195 lines
6.0 KiB
TypeScript
195 lines
6.0 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'
|
|
}
|
|
|
|
// 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;
|
|
}
|
|
|
|
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: defaultSettingsFor(integ.identifier),
|
|
})),
|
|
};
|
|
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 [];
|
|
}
|
|
|
|
/** 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: defaultSettingsFor(integ.identifier),
|
|
})),
|
|
};
|
|
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();
|
|
}
|