feat(scripts): add slack-webhook script for Slack Incoming Webhooks

Co-authored-by: Nasyarobby Putra <nasyarobby@gmail.com>
This commit is contained in:
Cursor Agent
2026-08-16 23:33:06 +00:00
co-authored by nsrb
parent 56a5d0e7d2
commit 989bf5d487
+265
View File
@@ -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<string, unknown>} */
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<string, unknown>} */
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;