fix(rsocials): Postiz createPost/createThread use nested posts[] shape
Postiz's /public/v1/posts expects a nested envelope:
{ type, date, shortLink, tags, posts: [{ integration:{id}, value:[{content}], settings:{__type} }] }
The earlier shim's flat {content, integrationIds, type, scheduledAt} was
rejected with "All posts must have an integration id" (the field is named
integration, not integrationIds; it's a nested object, not an array of strings).
Also: Postiz treats PostItem.value[] as thread segments, so createThread now
sends ONE request with multiple value entries rather than N grouped posts.
Callers now pass full {id, identifier} tuples so settings.__type is set
correctly (otherwise Postiz can't route to the provider handler).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
878646a399
commit
1cde95f3fc
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string, any>((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<string, any>((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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue