229 lines
10 KiB
JavaScript
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
|
|
};
|