From 989bf5d487612ea043d69685d429919080ae6391 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 23:33:06 +0000 Subject: [PATCH] feat(scripts): add slack-webhook script for Slack Incoming Webhooks Co-authored-by: Nasyarobby Putra --- packages/server/scripts/slack-webhook.js | 265 +++++++++++++++++++++++ 1 file changed, 265 insertions(+) create mode 100644 packages/server/scripts/slack-webhook.js diff --git a/packages/server/scripts/slack-webhook.js b/packages/server/scripts/slack-webhook.js new file mode 100644 index 0000000..9d2dba6 --- /dev/null +++ b/packages/server/scripts/slack-webhook.js @@ -0,0 +1,265 @@ +import jsonata from "jsonata"; + +function passContext(ctx) { + if (ctx?.context != null && typeof ctx.context === "object" && !Array.isArray(ctx.context)) { + return { ...ctx.context }; + } + return {}; +} + +function isPlainObject(value) { + return value != null && typeof value === "object" && !Array.isArray(value); +} + +function resolveFingerprintKey(config) { + if (config?.fingerprint === false) return null; + const fingerprint = config?.fingerprint; + if (fingerprint == null || fingerprint === true) return "fingerprint:slack-webhook"; + if (typeof fingerprint === "string" && fingerprint.length > 0) return fingerprint; + return null; +} + +function resolveFingerprintMaxAge(config) { + if (config?.fingerprintMaxAge == null || config.fingerprintMaxAge === "") { + return "1h"; + } + return config.fingerprintMaxAge; +} + +function defaultFingerprintSource(ctx) { + return buildPayload(ctx); +} + +async function resolveFingerprintValue(ctx) { + const expr = ctx.config?.fingerprintJsonata; + if (typeof expr === "string" && expr.length > 0) { + const expression = jsonata(expr); + return await expression.evaluate(ctx); + } + return defaultFingerprintSource(ctx); +} + +async function resolveWebhookUrl(ctx) { + const secretName = ctx.config?.webhookUrlSecret; + if (typeof secretName !== "string" || secretName.length === 0) { + throw new Error("slack-webhook: config.webhookUrlSecret is required"); + } + return $secrets.reveal(await $secrets.get(secretName)); +} + +function buildPayload(ctx) { + if (isPlainObject(ctx.config?.payload)) { + return { ...ctx.config.payload }; + } + if (isPlainObject(ctx.data?.payload)) { + return { ...ctx.data.payload }; + } + + const text = + typeof ctx.data?.text === "string" + ? ctx.data.text + : typeof ctx.data?.message === "string" + ? ctx.data.message + : typeof ctx.config?.text === "string" + ? ctx.config.text + : undefined; + + const blocks = Array.isArray(ctx.data?.blocks) + ? ctx.data.blocks + : Array.isArray(ctx.config?.blocks) + ? ctx.config.blocks + : undefined; + + if ((text == null || text === "") && (!blocks || blocks.length === 0)) { + throw new Error( + "slack-webhook: text or blocks is required (or provide config.payload / data.payload)", + ); + } + + /** @type {Record} */ + const payload = {}; + if (typeof text === "string" && text.length > 0) payload.text = text; + if (blocks) payload.blocks = blocks; + + if (typeof ctx.config?.username === "string" && ctx.config.username.length > 0) { + payload.username = ctx.config.username; + } + if (typeof ctx.config?.iconEmoji === "string" && ctx.config.iconEmoji.length > 0) { + payload.icon_emoji = ctx.config.iconEmoji; + } + if (typeof ctx.config?.iconUrl === "string" && ctx.config.iconUrl.length > 0) { + payload.icon_url = ctx.config.iconUrl; + } + + return payload; +} + +function assertSlackResponse(response) { + const status = response.status; + if (status < 200 || status >= 300) { + throw new Error(`slack-webhook: request failed with status ${status}`); + } + + const body = response.data; + if (typeof body === "string") { + const trimmed = body.trim(); + if (trimmed.length > 0 && trimmed !== "ok") { + throw new Error(`slack-webhook: ${trimmed}`); + } + return; + } + + if (isPlainObject(body) && body.ok === false) { + const detail = typeof body.error === "string" ? body.error : "request failed"; + throw new Error(`slack-webhook: ${detail}`); + } +} + +async function slackWebhook(ctx) { + const payload = buildPayload(ctx); + const fingerprintKey = resolveFingerprintKey(ctx.config); + /** @type {{ hash: string, previousAt: string | null, ageMs: number | null } | null} */ + let fp = null; + + log.info( + { + hasText: typeof payload.text === "string", + blockCount: Array.isArray(payload.blocks) ? payload.blocks.length : 0, + fingerprint: fingerprintKey, + }, + "slack-webhook: preparing message", + ); + + if (fingerprintKey) { + const source = await resolveFingerprintValue(ctx); + const checked = await $fingerprint.check(fingerprintKey, source, { + maxAge: resolveFingerprintMaxAge(ctx.config), + }); + if (!checked.changed) { + log.info( + { + key: fingerprintKey, + fingerprint: checked.hash, + ageMs: checked.ageMs, + }, + "slack-webhook: skipped, fingerprint unchanged", + ); + return { + output: { + sent: "false", + skipped: true, + fingerprint: checked.hash, + fingerprintAt: checked.previousAt, + fingerprintAge: checked.ageMs, + }, + context: passContext(ctx), + }; + } + fp = checked; + } + + const webhookUrl = await resolveWebhookUrl(ctx); + log.info("slack-webhook: sending message"); + + const response = await $axios.post(webhookUrl, payload, { + headers: { "Content-Type": "application/json" }, + }); + assertSlackResponse(response); + + /** @type {Record} */ + const sent = { sent: "true", slackStatus: response.status }; + if (fingerprintKey && fp) { + const stored = await $fingerprint.remember(fingerprintKey, fp.hash); + sent.fingerprint = stored.hash; + sent.fingerprintAt = stored.at; + } + + log.info({ slackStatus: response.status }, "slack-webhook: message sent"); + return { output: sent, context: passContext(ctx) }; +} + +slackWebhook.meta = { + description: "Send a message to Slack using an Incoming Webhook URL stored as a secret", + previewConfigKey: "webhookUrlSecret", + tags: ["Slack", "channel"], + config: { + webhookUrlSecret: { + type: "string", + required: true, + description: "Named secret holding the Slack Incoming Webhook URL", + }, + text: { + type: "string", + required: false, + description: "Fallback message text when data.text / data.message are omitted", + }, + blocks: { + type: "array", + required: false, + description: "Slack Block Kit blocks (merged unless data.blocks or data.payload is set)", + }, + payload: { + type: "object", + required: false, + description: "Full Slack webhook JSON body override", + }, + username: { + type: "string", + required: false, + description: "Override the displayed bot username", + }, + iconEmoji: { + type: "string", + required: false, + description: "Override the bot icon emoji (e.g. :robot_face:)", + }, + iconUrl: { + type: "string", + required: false, + description: "Override the bot icon with an image URL", + }, + fingerprint: { + type: "string", + required: false, + description: "true (default), a KV key, or false to disable deduplication", + }, + fingerprintJsonata: { + type: "string", + required: false, + description: "JSONata against full ctx; default hashes the outgoing Slack payload", + }, + fingerprintMaxAge: { + type: "string", + default: "1h", + description: "Deduplication window (e.g. 1h, 24h, 7d, or milliseconds)", + }, + }, + input: { + text: { type: "string", required: false, description: "Message text" }, + message: { type: "string", required: false, description: "Alias for text" }, + blocks: { type: "array", required: false, description: "Slack Block Kit blocks" }, + payload: { type: "object", required: false, description: "Full Slack webhook JSON body override" }, + }, + reads: "ctx", + output: { + sent: { type: "string", description: '"true" when sent, "false" when fingerprint skipped' }, + skipped: { type: "boolean", required: false, description: "Present when deduplication skipped the send" }, + slackStatus: { type: "number", required: false, description: "HTTP status from Slack" }, + fingerprint: { type: "string", required: false }, + fingerprintAt: { type: "string", required: false }, + fingerprintAge: { type: "number", required: false }, + }, + context: {}, + example: { + data: { + message: "Deploy finished successfully", + }, + config: { + webhookUrlSecret: "slack_deploy_webhook", + fingerprint: true, + fingerprintMaxAge: "1h", + }, + }, +}; + +export default slackWebhook;