sc-postiz-poster/lib/action.js
2026-10-19 01:20:42 -05:00

229 lines
10 KiB
JavaScript

// The post_to_postiz action. Attach it to a table trigger (row Insert / Update)
// or a row button: it maps the triggering row into a Postiz post and publishes
// through the configured channels. The body follows the documented public API
// (docs.postiz.com/public-api, create post): type, date, shortLink, tags, and per
// channel integration.id, value[{ content, image[{ id, path }] }] and settings
// with the provider's __type.
//
// The Postiz API key is stored sealed in the plugin config (lib/apiKey.js); it
// is decrypted only at the point of use here.
const { makePostizClient } = require("./postizClient");
const { resolveApiKey } = require("./apiKey");
// Saltcorn renders a field sublabel as raw HTML; channel names come from Postiz.
const { escapeHtml } = require("./common/htmlEscape");
const { PLUGIN_NAME, ACTION_NAME, POST_TYPES, PROVIDER_DEFAULT_SETTINGS, CONFIG_LIST_TIMEOUT_MS, CHANNEL_CACHE_TTL_MS, MEDIA_PLACEHOLDER } = require("./constants");
// Row fields offered as content / media URL sources, and as a schedule date.
const TEXT_FIELD_TYPES = ["String"];
const DATE_FIELD_TYPES = ["Date", "String"];
// Free-text schedule dates must name their zone (Z, +hh:mm, UTC/GMT); without
// one, Date parses them in the server's local time zone.
const ZONED_DATE_RE = /(Z|UTC|GMT|[+-]\d{2}:?\d{2})$/i;
// Why an MCP token needs admin scope to create, change or run a trigger using
// the action (saltcorn-mcp docs/pluginTools.md).
const MCP_ADMIN_REASON = "it publishes posts to social networks through Postiz";
// The run() result message per timing.
const NOTIFY_TEXT = {
[POST_TYPES.NOW]: "Posted to Postiz",
[POST_TYPES.SCHEDULE]: "Scheduled on Postiz",
[POST_TYPES.DRAFT]: "Saved as a Postiz draft"
};
// Last config-page channel listing: { key, at, channels, error }.
let channelListing = null;
const isConfigured = (pluginCfg) => {
return !!(pluginCfg && pluginCfg.baseUrl && pluginCfg.apiKey);
};
// A Postiz client from saved config with the key decrypted for use. Throws an
// actionable error when the connection is not configured.
const configuredClient = (pluginCfg, opts) => {
if (!isConfigured(pluginCfg)) {
throw new Error(`${PLUGIN_NAME}: set the Postiz base URL and API key in the plugin configuration`);
}
return makePostizClient({
baseUrl: pluginCfg.baseUrl,
apiKey: resolveApiKey(pluginCfg)
}, opts);
};
const parseIds = (text) => {
return String(text || "").split(",").map((s) => s.trim()).filter(Boolean);
};
// The connected channels for the config page, reused for CHANNEL_CACHE_TTL_MS
// (Saltcorn rebuilds every action's config fields on each view-builder load, and
// on each list-row action click) and fetched with a short timeout; a failure is
// cached too, so an unreachable Postiz does not stall every page.
const listChannelsForConfig = async (pluginCfg) => {
const key = `${pluginCfg.baseUrl}\n${pluginCfg.apiKey}`;
if (channelListing && channelListing.key === key && Date.now() - channelListing.at < CHANNEL_CACHE_TTL_MS) {
return channelListing;
}
let channels = [];
let error = null;
try {
channels = await configuredClient(pluginCfg, { timeoutMs: CONFIG_LIST_TIMEOUT_MS }).listIntegrations();
} catch (e) {
error = e.message;
}
channelListing = { key: key, at: Date.now(), channels: channels, error: error };
return channelListing;
};
const channelSublabel = (listing) => {
if (listing.error) {
return `Could not list channels from Postiz (${escapeHtml(listing.error)}).`;
}
if (listing.channels.length === 0) {
return "No channels are connected in Postiz yet.";
}
return "Connected: " + listing.channels.map((c) => `${escapeHtml(c.name)} (${escapeHtml(c.identifier)}) = <code>${escapeHtml(c.id)}</code>`).join("; ");
};
// The ISO date the post is published / scheduled at.
const postDate = (configuration, row, nowIso) => {
if (configuration.when !== POST_TYPES.SCHEDULE) {
return nowIso;
}
if (!configuration.scheduleField) {
throw new Error(`${PLUGIN_NAME}: timing is "schedule" but no schedule date field is set`);
}
// A Date-typed field arrives as a Date; free text is row data that may be
// blank, unparseable, or zone-less -- fail with an actionable message.
const raw = row[configuration.scheduleField];
const zoned = raw instanceof Date || ZONED_DATE_RE.test(String(raw === null || raw === undefined ? "" : raw).trim());
const parsed = new Date(raw);
if (!zoned || Number.isNaN(parsed.getTime())) {
throw new Error(`${PLUGIN_NAME}: schedule field "${configuration.scheduleField}" is not a valid date with a time zone (${JSON.stringify(raw)}); use e.g. 2026-12-25T09:00:00Z or a Date field`);
}
return parsed.toISOString();
};
// Pure: (action config, row, uploaded media [{ id, path }], now, the account's
// Postiz integrations) -> create-post body. Side-effect free so the test suite
// asserts the documented shape without a live server. Throws an actionable error
// for an empty content field, no / unknown / disabled channels, or a bad
// schedule date.
const buildPostPayload = (configuration, row, media, nowIso, integrations) => {
if (!Object.values(POST_TYPES).includes(configuration.when)) {
throw new Error(`${PLUGIN_NAME}: timing must be one of ${Object.values(POST_TYPES).join(", ")}`);
}
const content = row[configuration.contentField];
if (content === undefined || content === null || String(content).trim() === "") {
throw new Error(`${PLUGIN_NAME}: content field "${configuration.contentField}" is empty`);
}
const ids = parseIds(configuration.integrationIds);
if (ids.length === 0) {
throw new Error(`${PLUGIN_NAME}: no Postiz channel ids configured`);
}
const byId = new Map(integrations.map((i) => [String(i.id), i]));
const channels = ids.map((id) => {
const channel = byId.get(id);
if (!channel || channel.disabled) {
throw new Error(`${PLUGIN_NAME}: Postiz channel "${id}" is not connected (or is disabled) on this Postiz account`);
}
return channel;
});
const value = [{ content: String(content), image: media.map((m) => ({ id: m.id, path: m.path })) }];
return {
type: configuration.when,
shortLink: false,
date: postDate(configuration, row, nowIso),
tags: [],
posts: channels.map((channel) => ({
integration: { id: channel.id },
value: value,
settings: { __type: channel.identifier, ...PROVIDER_DEFAULT_SETTINGS[channel.identifier] }
}))
};
};
// Builds the per-action config form. Channels are a free-text, comma-separated
// list of Postiz integration ids, with the live connected channels (name,
// provider, id) listed in the sublabel. A select is not used: Saltcorn's String
// select is single-choice, and a required select with no options (Postiz
// unreachable) renders no input at all, leaving the trigger unsavable.
const buildConfigFields = (pluginCfg) => async ({ table }) => {
const fields = table && table.fields ? table.fields : [];
const namesOf = (types) => fields.filter((f) => f.type && types.includes(f.type.name)).map((f) => f.name);
const textFields = namesOf(TEXT_FIELD_TYPES);
const listing = await listChannelsForConfig(pluginCfg);
return [
{ name: "contentField", label: "Content field", type: "String", required: true, attributes: { options: textFields } },
{ name: "mediaUrlField", label: "Media URL field (optional)", type: "String", attributes: { options: textFields } },
{ name: "integrationIds", label: "Post to channels", type: "String", required: true, sublabel: "Comma-separated Postiz integration ids. " + channelSublabel(listing) },
{ name: "when", label: "Timing", type: "String", required: true, attributes: { options: Object.values(POST_TYPES) } },
{ name: "scheduleField", label: "Schedule date field", type: "String", attributes: { options: namesOf(DATE_FIELD_TYPES) }, showIf: { when: POST_TYPES.SCHEDULE } }
];
};
// One post of a row through the configured Postiz: list the account's channels,
// build and validate the create-post body, have Postiz fetch the media, create
// the post. Returns { payload, mediaUrl, notify, response } (response is the
// raw Postiz answer: remote data, for callers to allow-list). A dry run
// (opts.dryRun) makes only the channel listing GET -- no upload (Postiz would
// fetch an arbitrary URL) and no post; its payload carries MEDIA_PLACEHOLDER.
const publish = async (pluginCfg, configuration, row, opts) => {
const dryRun = !!(opts && opts.dryRun);
const client = configuredClient(pluginCfg);
const integrations = await client.listIntegrations();
const nowIso = new Date().toISOString();
// Validate everything before uploading media, so a bad config or row
// does not leave an orphan upload on Postiz.
buildPostPayload(configuration, row, [], nowIso, integrations);
const field = configuration.mediaUrlField;
const mediaUrl = field && row[field] ? String(row[field]) : null;
let media = [];
if (mediaUrl) {
media = [dryRun ? MEDIA_PLACEHOLDER : await client.uploadFromUrl(mediaUrl)];
}
const payload = buildPostPayload(configuration, row, media, nowIso, integrations);
const response = dryRun ? null : await client.createPost(payload);
return {
payload: payload,
mediaUrl: mediaUrl,
notify: `${NOTIFY_TEXT[configuration.when]} (${payload.posts.length} channel(s))`,
response: response
};
};
const makePostAction = (pluginCfg) => ({
requireRow: true,
mcp_admin_reason: MCP_ADMIN_REASON,
configFields: buildConfigFields(pluginCfg),
// Returns only a fixed { notify } shape: Saltcorn merges an action's result
// into the response it acts on (goto, eval_js, ...), so the remote Postiz
// response must not pass through.
run: async ({ row, configuration }) => {
if (!row) {
throw new Error(`${PLUGIN_NAME}: ${ACTION_NAME} needs a row (attach it to a table trigger or a row button)`);
}
const posted = await publish(pluginCfg, configuration, row);
return { notify: posted.notify };
}
});
module.exports = {
buildPostPayload,
configuredClient,
isConfigured,
makePostAction,
publish
};