diff --git a/modules/rsocials/lib/postiz-client.ts b/modules/rsocials/lib/postiz-client.ts index bc285d52..4d83907c 100644 --- a/modules/rsocials/lib/postiz-client.ts +++ b/modules/rsocials/lib/postiz-client.ts @@ -57,20 +57,44 @@ export async function getIntegrations(config: PostizConfig) { return res.json(); } -/** POST /public/v1/posts — create a single post (draft, schedule, or now). */ +/** 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' +} + export async function createPost( config: PostizConfig, payload: { content: string; - integrationIds: 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: { __type: integ.identifier }, + })), + }; const res = await postizFetch(config, "/public/v1/posts", { method: "POST", - body: JSON.stringify(payload), + body: JSON.stringify(body), }); if (!res.ok) { const text = await res.text().catch(() => ''); @@ -107,27 +131,38 @@ export async function listPosts( return []; } -/** Create a thread — sends multiple grouped posts sharing a group ID. */ +/** 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: { - integrationIds: string[]; + integrations: PostizIntegrationRef[]; type: 'draft' | 'schedule' | 'now'; scheduledAt?: string; }, ) { - const group = `thread-${Date.now()}-${Math.random().toString(36).substring(2, 8)}`; - const results = []; - for (const content of tweets) { - const result = await createPost(config, { - content, - integrationIds: opts.integrationIds, - type: opts.type, - scheduledAt: opts.scheduledAt, - group, - }); - results.push(result); + 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: { __type: 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 { group, posts: results }; + return res.json(); } diff --git a/modules/rsocials/mod.ts b/modules/rsocials/mod.ts index 80a8097e..6ec5383a 100644 --- a/modules/rsocials/mod.ts +++ b/modules/rsocials/mod.ts @@ -31,7 +31,7 @@ import { } from "./lib/image-gen"; import { DEMO_FEED } from "./lib/types"; import { getListmonkConfig, listmonkFetch } from "./lib/listmonk-proxy"; -import { getPostizConfig, getIntegrations, createPost, createThread, listPosts } from "./lib/postiz-client"; +import { getPostizConfig, getIntegrations, createPost, createThread, listPosts, type PostizIntegrationRef } from "./lib/postiz-client"; import { verifyToken, extractToken } from "../../server/auth"; import type { EncryptIDClaims } from "../../server/auth"; import { resolveCallerRole, roleAtLeast } from "../../server/spaces"; @@ -790,9 +790,16 @@ routes.post("/api/postiz/posts", async (c) => { } try { + const fetched = await getIntegrations(config); + const byId = new Map((fetched || []).map((i: any) => [i.id, i])); + const integrations: PostizIntegrationRef[] = integrationIds + .map((id: string) => byId.get(id)) + .filter(Boolean) + .map((i: any) => ({ id: i.id, identifier: i.identifier || i.providerIdentifier })); + if (!integrations.length) return c.json({ error: "No integration matched the requested IDs" }, 400); const result = await createPost(config, { content, - integrationIds, + integrations, type: type || 'draft', scheduledAt, }); @@ -803,14 +810,25 @@ routes.post("/api/postiz/posts", async (c) => { }); // Resolve an integration id from a platform name against a live Postiz integrations list. -function resolveIntegrationId(integrations: any[], platform: string): string | null { - if (!platform) return integrations[0]?.id ?? null; +// Resolve a full integration tuple {id, identifier} from a platform name. +// Postiz's public API returns integrations with `identifier` (e.g. 'x'), while +// the internal/admin shape uses `providerIdentifier`. Exact match on either +// beats a loose name includes(). +function resolveIntegration(integrations: any[], platform: string): PostizIntegrationRef | null { + const toRef = (i: any): PostizIntegrationRef | null => { + const id = i?.id; + const identifier = i?.identifier || i?.providerIdentifier; + return id && identifier ? { id, identifier } : null; + }; + if (!platform) return toRef(integrations[0]); const needle = platform.toLowerCase(); - const match = integrations.find((i: any) => - i.name?.toLowerCase().includes(needle) || - i.providerIdentifier?.toLowerCase().includes(needle) + const exact = integrations.find((i: any) => + i.identifier?.toLowerCase() === needle || + i.providerIdentifier?.toLowerCase() === needle ); - return match?.id ?? integrations[0]?.id ?? null; + if (exact) return toRef(exact); + const loose = integrations.find((i: any) => i.name?.toLowerCase().includes(needle)); + return toRef(loose) || toRef(integrations[0]); } // Push a single campaign-flow Post node to Postiz. Writes postizPostId + status @@ -836,15 +854,20 @@ async function sendCampaignNodeToPostiz( const content = (data.content || '').trim(); if (!content) return { ok: false, error: "Post has no content", code: 400 }; - let integrationIds: string[]; + let integrations: PostizIntegrationRef[]; try { - const integrations = await getIntegrations(config); - if (!Array.isArray(integrations) || integrations.length === 0) { + const fetched = await getIntegrations(config); + if (!Array.isArray(fetched) || fetched.length === 0) { return { ok: false, error: "No Postiz integrations configured", code: 400 }; } - const id = data.postizIntegrationId || resolveIntegrationId(integrations, data.platform); - if (!id) return { ok: false, error: `No integration matches platform "${data.platform}"`, code: 400 }; - integrationIds = [id]; + let ref: PostizIntegrationRef | null = null; + if (data.postizIntegrationId) { + const matched = fetched.find((i: any) => i.id === data.postizIntegrationId); + if (matched) ref = { id: matched.id, identifier: matched.identifier || matched.providerIdentifier }; + } + ref = ref || resolveIntegration(fetched, data.platform); + if (!ref) return { ok: false, error: `No integration matches platform "${data.platform}"`, code: 400 }; + integrations = [ref]; } catch (err: any) { return { ok: false, error: `Postiz integrations fetch failed: ${err.message}`, code: 502 }; } @@ -855,7 +878,7 @@ async function sendCampaignNodeToPostiz( const hashtagLine = data.hashtags?.length ? '\n\n' + data.hashtags.map(h => h.startsWith('#') ? h : `#${h}`).join(' ') : ''; const payload = { content: content + hashtagLine, - integrationIds, + integrations, type, scheduledAt: type === 'schedule' ? scheduledAt!.toISOString() : undefined, }; @@ -875,7 +898,7 @@ async function sendCampaignNodeToPostiz( if (!n || n.type !== 'post') return; const nd = n.data as PostNodeData; nd.postizPostId = postizPostId || `postiz-${Date.now()}`; - nd.postizIntegrationId = integrationIds[0]; + nd.postizIntegrationId = integrations[0].id; nd.postizStatus = 'queued'; nd.postizSentAt = Date.now(); nd.postizError = ''; @@ -1054,20 +1077,25 @@ routes.post("/api/postiz/threads", async (c) => { } // If no integrationIds provided, try to auto-detect from configured integrations - let ids = integrationIds; - if (!ids?.length) { - try { - const integrations = await getIntegrations(config); - ids = (integrations || []).map((i: any) => i.id).slice(0, 1); - } catch { /* fall through */ } - } - if (!ids?.length) { - return c.json({ error: "No integrationIds provided and no integrations found" }, 400); + let integrations: PostizIntegrationRef[] = []; + try { + const fetched = await getIntegrations(config); + const byId = new Map((fetched || []).map((i: any) => [i.id, i])); + const ids: string[] = Array.isArray(integrationIds) && integrationIds.length + ? integrationIds + : (fetched || []).slice(0, 1).map((i: any) => i.id); + integrations = ids + .map((id: string) => byId.get(id)) + .filter(Boolean) + .map((i: any) => ({ id: i.id, identifier: i.identifier || i.providerIdentifier })); + } catch { /* fall through */ } + if (!integrations.length) { + return c.json({ error: "No integrations provided and no integrations found" }, 400); } try { const result = await createThread(config, tweets, { - integrationIds: ids, + integrations, type: type || 'draft', scheduledAt, }); @@ -1421,29 +1449,24 @@ routes.post("/api/campaign-workflows/:id/run", async (c) => { switch (node.type) { case 'post-to-platform': { if (!postizConfig) throw new Error('Postiz not configured'); - const integrations = await getIntegrations(postizConfig); - const platformName = (cfg.platform as string || '').toLowerCase(); - const match = (integrations || []).find((i: any) => - i.name?.toLowerCase().includes(platformName) || i.providerIdentifier?.toLowerCase().includes(platformName) - ); - const integrationIds = match ? [match.id] : (integrations || []).slice(0, 1).map((i: any) => i.id); + const fetched = await getIntegrations(postizConfig); + const ref = resolveIntegration(fetched || [], (cfg.platform as string) || ''); + if (!ref) throw new Error(`No integration matches platform "${cfg.platform}"`); const content = (cfg.content as string || '') + (cfg.hashtags ? '\n' + cfg.hashtags : ''); - await createPost(postizConfig, { content, integrationIds, type: 'draft' }); + await createPost(postizConfig, { content, integrations: [ref], type: 'draft' }); results.push({ nodeId: node.id, status: 'success', message: `Draft created on ${cfg.platform || 'default'}`, durationMs: Date.now() - start }); break; } case 'cross-post': { if (!postizConfig) throw new Error('Postiz not configured'); const platforms = (cfg.platforms as string || '').split(',').map(s => s.trim().toLowerCase()).filter(Boolean); - const integrations = await getIntegrations(postizConfig); + const fetched = await getIntegrations(postizConfig); const content = (cfg.content as string || ''); const posted: string[] = []; for (const plat of platforms) { - const match = (integrations || []).find((i: any) => - i.name?.toLowerCase().includes(plat) || i.providerIdentifier?.toLowerCase().includes(plat) - ); - if (match) { - await createPost(postizConfig, { content, integrationIds: [match.id], type: 'draft' }); + const ref = resolveIntegration(fetched || [], plat); + if (ref) { + await createPost(postizConfig, { content, integrations: [ref], type: 'draft' }); posted.push(plat); } } @@ -1461,13 +1484,10 @@ routes.post("/api/campaign-workflows/:id/run", async (c) => { } const tweets = threadContent.split(/\n---\n/).map(s => s.trim()).filter(Boolean); if (tweets.length === 0) throw new Error('No thread content or thread ID provided'); - const integrations = await getIntegrations(postizConfig); - const platformName = (cfg.platform as string || '').toLowerCase(); - const match = (integrations || []).find((i: any) => - i.name?.toLowerCase().includes(platformName) || i.providerIdentifier?.toLowerCase().includes(platformName) - ); - const integrationIds = match ? [match.id] : (integrations || []).slice(0, 1).map((i: any) => i.id); - await createThread(postizConfig, tweets, { integrationIds, type: 'draft' }); + const fetched = await getIntegrations(postizConfig); + const ref = resolveIntegration(fetched || [], (cfg.platform as string) || ''); + if (!ref) throw new Error(`No integration matches platform "${cfg.platform}"`); + await createThread(postizConfig, tweets, { integrations: [ref], type: 'draft' }); results.push({ nodeId: node.id, status: 'success', message: `Thread draft created (${tweets.length} tweets)`, durationMs: Date.now() - start }); break; }