diff --git a/README.md b/README.md index db792d4..f8e4a43 100644 --- a/README.md +++ b/README.md @@ -320,6 +320,7 @@ First-party packages: - [`@notifkit/provider-resend`](./packages/provider-resend) — transactional email via Resend - [`@notifkit/provider-fcm`](./packages/provider-fcm) — push notifications via Firebase Cloud Messaging +- [`@notifkit/provider-slack`](./packages/provider-slack) — Slack messages via Incoming Webhooks or the Web API - [`@notifkit/provider-twilio`](./packages/provider-twilio) — SMS via Twilio, with signature-verified delivery status callbacks - [`@notifkit/provider-telegram`](./packages/provider-telegram) — messages via a Telegram bot - [`@notifkit/provider-discord`](./packages/provider-discord) — messages via a Discord webhook diff --git a/package-lock.json b/package-lock.json index 5152632..463f402 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1771,12 +1771,18 @@ "resolved": "packages/provider-resend", "link": true }, - "node_modules/@notifkit/provider-twilio": { - "resolved": "packages/provider-twilio", + "node_modules/@notifkit/provider-slack": { + "resolved": "packages/provider-slack", + "link": true + }, "node_modules/@notifkit/provider-telegram": { "resolved": "packages/provider-telegram", "link": true }, + "node_modules/@notifkit/provider-twilio": { + "resolved": "packages/provider-twilio", + "link": true + }, "node_modules/@one-ini/wasm": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/@one-ini/wasm/-/wasm-0.1.1.tgz", @@ -13186,13 +13192,18 @@ "notifkit": "^0.1.0" } }, - "packages/provider-twilio": { - "name": "@notifkit/provider-twilio", + "packages/provider-slack": { + "name": "@notifkit/provider-slack", "version": "0.1.0", "license": "MIT", - "dependencies": { - "twilio": "^5.0.0" + "devDependencies": { + "notifkit": "file:../../", + "typescript": "^5.8.3" }, + "peerDependencies": { + "notifkit": "^0.1.0" + } + }, "packages/provider-telegram": { "name": "@notifkit/provider-telegram", "version": "0.1.0", @@ -13204,6 +13215,21 @@ "peerDependencies": { "notifkit": "^0.1.0" } + }, + "packages/provider-twilio": { + "name": "@notifkit/provider-twilio", + "version": "0.1.0", + "license": "MIT", + "dependencies": { + "twilio": "^5.0.0" + }, + "devDependencies": { + "notifkit": "file:../../", + "typescript": "^5.8.3" + }, + "peerDependencies": { + "notifkit": "^0.1.0" + } } } } diff --git a/packages/provider-slack/LICENSE b/packages/provider-slack/LICENSE new file mode 100644 index 0000000..235464d --- /dev/null +++ b/packages/provider-slack/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 devkitshq + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/provider-slack/README.md b/packages/provider-slack/README.md new file mode 100644 index 0000000..5facd43 --- /dev/null +++ b/packages/provider-slack/README.md @@ -0,0 +1,90 @@ +# @notifkit/provider-slack + +Slack transport for [notifkit](https://github.com/devkitshq/notifkit). Registers +against the `webhook` channel and posts through either a Slack bot token +(`chat.postMessage`) or a plain Incoming Webhook URL — whichever a recipient's +stored contact target resolves to. + +```bash +npm install @notifkit/provider-slack +``` + +```ts +import { NotifkitServer } from "notifkit"; +import { SlackTransport } from "@notifkit/provider-slack"; + +const server = new NotifkitServer({ + services: ["all"], + providers: [ + new SlackTransport({ + botToken: process.env.SLACK_BOT_TOKEN, // xoxb-... + }), + ], +}); +``` + +## Whichever credential you have + +`SlackTransport` picks up whatever you give it — there's no one required +shape: + +| Option | What it's for | +| --------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | +| `botToken` (or `token`) | A bot/user OAuth token (`xoxb-...`/`xoxp-...`). Needed for `chat.postMessage` sends to a channel or user ID. | +| `webhookUrl` | A default Incoming Webhook URL, used when a message names no destination of its own. Lets a single-channel setup skip contacts entirely. | +| `appId` | Purely descriptive — shows up in log lines so you can tell multiple Slack app installations apart. Doesn't affect sending. | +| `clientId` / `clientSecret` | Accepted, but **not** used to authenticate sends — see below. | + +A recipient's `webhook` contact target (or a template's own `channel` +override, or the transport's `webhookUrl` fallback) decides how the message +is actually delivered: + +- A Slack **channel ID** (`C0123456789`) or **user ID** (`U0123456789`, for a + DM) is sent via `chat.postMessage` using `botToken`/`token`. +- A full **Incoming Webhook URL** (`https://hooks.slack.com/services/...`) is + posted to directly — no token required, so a single-channel integration + needs nothing but that URL. + +A template may also pin its own channel with a `channel` field in its +content, the same way other transports let a template override their default +sender — useful for routing everything to one shared room (e.g. +`system-alerts`) regardless of the recipient's own contact target. + +### About `clientId` / `clientSecret` + +These authenticate Slack's OAuth **install** flow — exchanging a workspace +admin's "Add to Slack" click for a token — not an individual send. This +transport doesn't perform that exchange, so passing only `clientId`/ +`clientSecret` with no `botToken`/`token` logs a warning and every send still +fails until you complete the OAuth handshake yourself (or use a static bot +token from **OAuth & Permissions** in your Slack app's settings, which needs +no OAuth flow at all for a single workspace). + +Templates render into `text` (or `body`) and, optionally, a Slack +[Block Kit](https://api.slack.com/block-kit) `blocks` array: + +```ts +await notifkit.syncTemplates({ + templates: [ + { + id: "deploy-failed", + channel: "webhook", + content: { + text: "Deploy failed for {{service}}", + blocks: [ + { + type: "section", + text: { type: "mrkdwn", text: "*Deploy failed* for `{{service}}`" }, + }, + ], + }, + }, + ], +}); +``` + +A destination Slack reports as gone (`channel_not_found`, `is_archived`, +`user_not_found`, …) is returned as an invalid token, so notifkit stops +retrying it and can flag the contact. Requires `notifkit` as a peer +dependency. Documentation: [notifkit.dev/docs](https://notifkit.dev/docs/). +MIT licensed. diff --git a/packages/provider-slack/package.json b/packages/provider-slack/package.json new file mode 100644 index 0000000..019a07c --- /dev/null +++ b/packages/provider-slack/package.json @@ -0,0 +1,40 @@ +{ + "name": "@notifkit/provider-slack", + "version": "0.1.0", + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "files": [ + "dist", + "src", + "README.md", + "LICENSE" + ], + "scripts": { + "build": "tsc", + "dev": "tsc --watch", + "typecheck": "tsc --noEmit" + }, + "peerDependencies": { + "notifkit": "^0.1.0" + }, + "devDependencies": { + "notifkit": "file:../../", + "typescript": "^5.8.3" + }, + "description": "Slack transport for notifkit, via Incoming Webhooks or the chat.postMessage Web API.", + "license": "MIT", + "author": "devkitshq", + "homepage": "https://notifkit.dev", + "repository": { + "type": "git", + "url": "git+https://github.com/devkitshq/notifkit.git", + "directory": "packages/provider-slack" + }, + "bugs": { + "url": "https://github.com/devkitshq/notifkit/issues" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/provider-slack/src/index.ts b/packages/provider-slack/src/index.ts new file mode 100644 index 0000000..99343c8 --- /dev/null +++ b/packages/provider-slack/src/index.ts @@ -0,0 +1,221 @@ +import type { DeliveryResult, Transport } from "notifkit"; +import type { NotificationDispatchedPayload, Logger } from "notifkit"; + +const WEB_API_URL = "https://slack.com/api/chat.postMessage"; +const INCOMING_WEBHOOK_PREFIX = "https://hooks.slack.com/"; + +// Errors chat.postMessage returns for a destination that is dead, not one +// that is merely temporarily rejected (rate limits, auth hiccups). Only these +// suppress the contact — anything else is worth retrying. +// https://api.slack.com/methods/chat.postMessage#errors +const INVALID_DESTINATION_ERRORS = new Set([ + "channel_not_found", + "is_archived", + "channel_is_archived", + "user_not_found", + "account_inactive", + "not_in_channel", +]); + +// Incoming webhooks report the same class of failure as plain-text response +// bodies rather than a JSON error code. +// https://api.slack.com/messaging/webhooks#handling_errors +const INVALID_WEBHOOK_BODIES = new Set(["channel_not_found", "channel_is_archived", "no_service"]); + +export interface SlackTransportOptions { + /** + * Bot or user OAuth token (xoxb-.../xoxp-...) used for chat.postMessage. + * Not needed if every recipient's destination is a full Incoming Webhook + * URL, which needs no token at all. + */ + botToken?: string; + /** Alias for `botToken` — same field, whichever name reads naturally. */ + token?: string; + /** + * Fallback Incoming Webhook URL used when a message names no destination + * of its own (no contact target, no template `channel` override). Lets a + * single-channel integration skip per-recipient contacts entirely. + */ + webhookUrl?: string; + /** + * Purely descriptive — included in log lines so several Slack app + * installations stay distinguishable when more than one transport is + * registered. Has no effect on how messages are authenticated or sent. + */ + appId?: string; + /** + * `clientId`/`clientSecret` authenticate Slack's OAuth *install* flow — + * exchanging a workspace admin's "Add to Slack" click for a token. They + * cannot authenticate a `chat.postMessage` call on their own, so passing + * them here without a `botToken`/`token` almost always means the OAuth + * exchange hasn't happened yet. Accepted so that case logs one clear + * warning instead of a confusing 401 on every send. + */ + clientId?: string; + clientSecret?: string; + logger?: Logger; + limits?: { limit: number; windowSeconds: number }; +} + +export class SlackTransport implements Transport { + readonly channel = "webhook" as const; + readonly limits?: { limit: number; windowSeconds: number }; + + private readonly botToken?: string; + private readonly webhookUrl?: string; + private readonly appId?: string; + private readonly logger?: Logger; + + constructor({ + botToken, + token, + webhookUrl, + appId, + clientId, + clientSecret, + logger, + // Slack's own guidance for chat.postMessage is roughly one message per + // second per channel; this is a conservative workspace-wide default, not + // a promise, so callers on a higher app tier should raise it. + limits = { limit: 50, windowSeconds: 60 }, + }: SlackTransportOptions = {}) { + this.botToken = botToken ?? token; + this.webhookUrl = webhookUrl; + this.appId = appId; + this.logger = logger; + this.limits = limits; + + if ((clientId || clientSecret) && !this.botToken) { + this.logger?.warn( + { appId }, + "SlackTransport received clientId/clientSecret but no botToken/token. " + + "clientId/clientSecret authenticate Slack's OAuth install flow, not chat.postMessage " + + "sends — exchange them for a token first, then pass it as `botToken` (or `token`).", + ); + } + } + + async send(task: NotificationDispatchedPayload): Promise { + // `destination` is optional on the contract, so a malformed task can + // reach a transport without one. Failing here costs one clear error; + // reaching Slack without one costs an opaque provider rejection. + const content = task.renderedContent.content as any; + + // A template may pin its own channel (e.g. a shared "system-alerts" room) + // the same way ResendTransport lets a template pin its own `from` — + // one transport can then serve both per-recipient DMs and fixed rooms. + // Falling back to `webhookUrl` covers the single-channel case, where no + // recipient needs a contact record at all. + const destination = + (typeof content.channel === "string" && content.channel) || + task.destination || + this.webhookUrl; + if (!destination) { + return { + success: false, + error: "No destination (Slack channel/user ID, or webhook URL) on task", + }; + } + + const text = content.text || content.body; + const blocks = Array.isArray(content.blocks) ? content.blocks : undefined; + // Slack always needs `text` for notification previews and accessibility, + // even when the visible message is carried entirely by `blocks`. + const fallbackText = text || content.subject || "New notification"; + + if (destination.startsWith(INCOMING_WEBHOOK_PREFIX)) { + return this.sendViaIncomingWebhook(destination, fallbackText, blocks, task); + } + + if (!this.botToken) { + return { + success: false, + error: "Destination is a Slack channel/user ID but no botToken (or token) is configured", + }; + } + + return this.sendViaWebApi(destination, fallbackText, blocks, task); + } + + private async sendViaWebApi( + channel: string, + text: string, + blocks: unknown[] | undefined, + task: NotificationDispatchedPayload, + ): Promise { + try { + const response = await fetch(WEB_API_URL, { + method: "POST", + headers: { + Authorization: `Bearer ${this.botToken}`, + "Content-Type": "application/json; charset=utf-8", + }, + body: JSON.stringify({ channel, text, ...(blocks ? { blocks } : {}) }), + }); + + const body = (await response.json()) as { + ok: boolean; + ts?: string; + channel?: string; + error?: string; + }; + + if (!body.ok) { + const invalidToken = INVALID_DESTINATION_ERRORS.has(body.error ?? ""); + this.logger?.warn( + { taskId: task.taskId, appId: this.appId, error: body.error, invalidToken }, + "Slack chat.postMessage failed", + ); + return { success: false, invalidToken, error: body.error ?? "Slack API error" }; + } + + const providerMessageId = body.ts ? `${body.channel ?? channel}:${body.ts}` : undefined; + this.logger?.debug( + { taskId: task.taskId, appId: this.appId, providerMessageId }, + "Slack message sent", + ); + return { success: true, providerMessageId }; + } catch (err) { + const error = err as Error; + this.logger?.error({ taskId: task.taskId, error: error.message }, "Slack unexpected error"); + return { success: false, error: error.message }; + } + } + + private async sendViaIncomingWebhook( + url: string, + text: string, + blocks: unknown[] | undefined, + task: NotificationDispatchedPayload, + ): Promise { + try { + const response = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json; charset=utf-8" }, + body: JSON.stringify({ text, ...(blocks ? { blocks } : {}) }), + }); + + const body = await response.text(); + + if (!response.ok || body.trim() !== "ok") { + const invalidToken = INVALID_WEBHOOK_BODIES.has(body.trim()); + this.logger?.warn( + { taskId: task.taskId, appId: this.appId, status: response.status, body, invalidToken }, + "Slack incoming webhook failed", + ); + return { success: false, invalidToken, error: body || `HTTP ${response.status}` }; + } + + const providerMessageId = `slack-webhook-${task.taskId}`; + this.logger?.debug( + { taskId: task.taskId, appId: this.appId, providerMessageId }, + "Slack webhook message sent", + ); + return { success: true, providerMessageId }; + } catch (err) { + const error = err as Error; + this.logger?.error({ taskId: task.taskId, error: error.message }, "Slack unexpected error"); + return { success: false, error: error.message }; + } + } +} diff --git a/packages/provider-slack/tsconfig.json b/packages/provider-slack/tsconfig.json new file mode 100644 index 0000000..a013e0c --- /dev/null +++ b/packages/provider-slack/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src/**/*"] +} diff --git a/tests/providers.test.ts b/tests/providers.test.ts index eb36d62..b1a317a 100644 --- a/tests/providers.test.ts +++ b/tests/providers.test.ts @@ -953,3 +953,894 @@ describe("TwilioTransport (Client and logging)", () => { expect(events[0]).not.toHaveProperty("recipient"); }); }); + +// ─── Telegram ──────────────────────────────────────────────────────────────── + +import { TelegramTransport } from "../packages/provider-telegram/src/index.js"; + +const BOT_TOKEN = "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11"; + +/** + * Stubs `fetch` with the Bot API's envelope. Telegram signals failure in the + * JSON body (`ok: false`) rather than the HTTP status, so these tests drive + * the body and leave the status at 200 the way the real API does. + */ +function stubTelegram(body: unknown) { + const fn = vi.fn().mockResolvedValue({ json: async () => body }); + vi.stubGlobal("fetch", fn); + return fn; +} + +/** The JSON payload sent to the Bot API on the Nth call. */ +function telegramBody(fn: ReturnType, n = 0) { + return JSON.parse((fn.mock.calls[n]![1] as RequestInit).body as string); +} + +function telegramTransport(overrides: Record = {}) { + return new TelegramTransport({ botToken: BOT_TOKEN, ...overrides } as any); +} + +function telegramTask(overrides: Record = {}): any { + return { + taskId: "task-uuid-tg-1", + destination: "987654321", + renderedContent: { content: { body: "Your build is green" } }, + ...overrides, + }; +} + +const okResult = (messageId: number) => ({ ok: true, result: { message_id: messageId } }); + +describe("TelegramTransport (Telegram Provider)", () => { + const logger = () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }) as any; + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("registers against the telegram channel", () => { + expect(telegramTransport().channel).toBe("telegram"); + }); + + it("defaults to the bot-wide rate ceiling and accepts an override", () => { + expect(telegramTransport().limits).toEqual({ limit: 30, windowSeconds: 1 }); + expect(telegramTransport({ limits: { limit: 1, windowSeconds: 1 } }).limits).toEqual({ + limit: 1, + windowSeconds: 1, + }); + }); + + it("posts to the bot's sendMessage endpoint and returns the message id", async () => { + const fetchMock = stubTelegram(okResult(4242)); + + const result = await telegramTransport().send(telegramTask()); + + expect(fetchMock.mock.calls[0]![0]).toBe( + `https://api.telegram.org/bot${BOT_TOKEN}/sendMessage`, + ); + expect(telegramBody(fetchMock)).toEqual({ + chat_id: "987654321", + text: "Your build is green", + }); + expect(result).toEqual({ success: true, providerMessageId: "4242" }); + }); + + it("prefers content.text over content.body", async () => { + const fetchMock = stubTelegram(okResult(1)); + + await telegramTransport().send( + telegramTask({ renderedContent: { content: { text: "from text", body: "from body" } } }), + ); + + expect(telegramBody(fetchMock).text).toBe("from text"); + }); + + it("folds a rendered subject onto the first line", async () => { + const fetchMock = stubTelegram(okResult(2)); + + await telegramTransport().send( + telegramTask({ renderedContent: { content: { subject: "Deploy", body: "shipped" } } }), + ); + + expect(telegramBody(fetchMock).text).toBe("Deploy\n\nshipped"); + }); + + it("sends plain text unless a parse mode is configured", async () => { + const fetchMock = stubTelegram(okResult(3)); + + await telegramTransport().send(telegramTask()); + expect(telegramBody(fetchMock, 0)).not.toHaveProperty("parse_mode"); + + await telegramTransport({ parseMode: "HTML" }).send(telegramTask()); + expect(telegramBody(fetchMock, 1).parse_mode).toBe("HTML"); + }); + + it("lets a template pin its own parse mode, ignoring a non-string one", async () => { + const fetchMock = stubTelegram(okResult(4)); + + await telegramTransport({ parseMode: "HTML" }).send( + telegramTask({ renderedContent: { content: { body: "hi", parseMode: "MarkdownV2" } } }), + ); + expect(telegramBody(fetchMock, 0).parse_mode).toBe("MarkdownV2"); + + await telegramTransport({ parseMode: "HTML" }).send( + telegramTask({ renderedContent: { content: { body: "hi", parseMode: 7 } } }), + ); + expect(telegramBody(fetchMock, 1).parse_mode).toBe("HTML"); + }); + + it("passes through the preview and notification opt-outs", async () => { + const fetchMock = stubTelegram(okResult(5)); + + await telegramTransport().send( + telegramTask({ + renderedContent: { + content: { body: "quiet", disableWebPagePreview: true, disableNotification: true }, + }, + }), + ); + + expect(telegramBody(fetchMock)).toMatchObject({ + link_preview_options: { is_disabled: true }, + disable_notification: true, + }); + }); + + it("fails without reaching Telegram when the task carries no destination", async () => { + const fetchMock = stubTelegram(okResult(6)); + + const result = await telegramTransport().send(telegramTask({ destination: undefined })); + + expect(fetchMock).not.toHaveBeenCalled(); + expect(result).toEqual({ + success: false, + error: "No destination (Telegram chat id) on task", + }); + }); + + it("returns an empty provider id when the API omits the message id", async () => { + stubTelegram({ ok: true }); + + const result = await telegramTransport().send(telegramTask()); + + expect(result).toEqual({ success: true, providerMessageId: "" }); + }); + + it("treats a 403 as a dead target", async () => { + const log = logger(); + stubTelegram({ ok: false, error_code: 403, description: "Forbidden: bot was blocked" }); + + const result = await telegramTransport({ logger: log }).send(telegramTask()); + + expect(result).toEqual({ + success: false, + invalidToken: true, + error: "Forbidden: bot was blocked", + }); + expect(log.warn).toHaveBeenCalledWith( + { taskId: "task-uuid-tg-1", errorCode: 403, description: "Forbidden: bot was blocked" }, + "Telegram send failed", + ); + }); + + it.each([ + ["chat not found", true], + ["user is deactivated", true], + ["message text is empty", false], + ])("classifies a 400 describing %j as dead=%s", async (description, invalidToken) => { + stubTelegram({ ok: false, error_code: 400, description }); + + const result = await telegramTransport().send(telegramTask()); + + expect(result).toEqual({ success: false, invalidToken, error: description }); + }); + + it("falls back to a synthetic message when the API omits a description", async () => { + stubTelegram({ ok: false, error_code: 429 }); + expect(await telegramTransport().send(telegramTask())).toEqual({ + success: false, + invalidToken: false, + error: "Telegram API error 429", + }); + + stubTelegram({ ok: false }); + expect(await telegramTransport().send(telegramTask())).toEqual({ + success: false, + invalidToken: false, + error: "Telegram API error unknown", + }); + }); + + it("does not treat a bare 400 as a dead target", async () => { + // A 400 with no description at all still has to be matched against the + // dead-target patterns without throwing on the missing string. + stubTelegram({ ok: false, error_code: 400 }); + + expect(await telegramTransport().send(telegramTask())).toEqual({ + success: false, + invalidToken: false, + error: "Telegram API error 400", + }); + }); + + it("reports a transport-level failure without marking the target dead", async () => { + const log = logger(); + vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("socket hang up"))); + + const result = await telegramTransport({ logger: log }).send(telegramTask()); + + expect(result).toEqual({ success: false, error: "socket hang up" }); + expect(log.error).toHaveBeenCalledWith( + { taskId: "task-uuid-tg-1", error: "socket hang up" }, + "Telegram unexpected error", + ); + }); + + it("logs an accepted send", async () => { + const log = logger(); + stubTelegram(okResult(99)); + + await telegramTransport({ logger: log }).send(telegramTask()); + + expect(log.debug).toHaveBeenCalledWith( + { taskId: "task-uuid-tg-1", providerMessageId: "99" }, + "Telegram message sent", + ); + }); +}); + +// ─── Discord ───────────────────────────────────────────────────────────────── + +import { DiscordTransport } from "../packages/provider-discord/src/index.js"; + +const WEBHOOK_URL = "https://discord.com/api/webhooks/123/abcdef"; + +/** + * Stubs `fetch` with a webhook Response double. Discord signals failure with + * the HTTP status, and the transport reads the body as text on that path and + * as JSON on the success path — so both readers are stubbed here. + */ +function stubDiscord(body: unknown, status = 200) { + const fn = vi.fn().mockResolvedValue({ + status, + ok: status >= 200 && status < 300, + json: async () => body, + text: async () => (typeof body === "string" ? body : JSON.stringify(body)), + }); + vi.stubGlobal("fetch", fn); + return fn; +} + +/** The JSON payload posted to the webhook on the Nth call. */ +function discordBody(fn: ReturnType, n = 0) { + return JSON.parse((fn.mock.calls[n]![1] as RequestInit).body as string); +} + +function discordTask(overrides: Record = {}): any { + return { + taskId: "task-uuid-dc-1", + destination: WEBHOOK_URL, + renderedContent: { content: { body: "Build finished" } }, + ...overrides, + }; +} + +describe("DiscordTransport (Discord Provider)", () => { + const logger = () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }) as any; + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("registers against the discord channel", () => { + expect(new DiscordTransport().channel).toBe("discord"); + }); + + it("constructs without options and defaults to the per-webhook rate ceiling", () => { + expect(new DiscordTransport().limits).toEqual({ limit: 5, windowSeconds: 2 }); + expect(new DiscordTransport({ limits: { limit: 1, windowSeconds: 1 } }).limits).toEqual({ + limit: 1, + windowSeconds: 1, + }); + }); + + it("posts to the webhook with wait=true and returns the message id", async () => { + const fetchMock = stubDiscord({ id: "msg-9001" }); + + const result = await new DiscordTransport().send(discordTask()); + + // `wait=true` is what makes Discord answer with the created message rather + // than an empty 204, so the id can be recorded. + expect(String(fetchMock.mock.calls[0]![0])).toBe(`${WEBHOOK_URL}?wait=true`); + expect(discordBody(fetchMock)).toEqual({ content: "Build finished" }); + expect(result).toEqual({ success: true, providerMessageId: "msg-9001" }); + }); + + it("prefers content.text over content.body", async () => { + const fetchMock = stubDiscord({ id: "m1" }); + + await new DiscordTransport().send( + discordTask({ renderedContent: { content: { text: "from text", body: "from body" } } }), + ); + + expect(discordBody(fetchMock).content).toBe("from text"); + }); + + it("bolds a rendered subject onto the first line", async () => { + const fetchMock = stubDiscord({ id: "m2" }); + + await new DiscordTransport().send( + discordTask({ renderedContent: { content: { subject: "Deploy", body: "shipped" } } }), + ); + + expect(discordBody(fetchMock).content).toBe("**Deploy**\nshipped"); + }); + + it("omits sender identity unless configured", async () => { + const fetchMock = stubDiscord({ id: "m3" }); + + await new DiscordTransport().send(discordTask()); + expect(discordBody(fetchMock, 0)).not.toHaveProperty("username"); + expect(discordBody(fetchMock, 0)).not.toHaveProperty("avatar_url"); + + await new DiscordTransport({ username: "notifkit", avatarUrl: "https://img/a.png" }).send( + discordTask(), + ); + expect(discordBody(fetchMock, 1)).toMatchObject({ + username: "notifkit", + avatar_url: "https://img/a.png", + }); + }); + + it("lets a template pin its own sender, ignoring blank and non-string values", async () => { + const fetchMock = stubDiscord({ id: "m4" }); + const transport = new DiscordTransport({ username: "base", avatarUrl: "https://img/base.png" }); + + await transport.send( + discordTask({ + renderedContent: { + content: { body: "hi", username: "alerts", avatarUrl: "https://img/alerts.png" }, + }, + }), + ); + expect(discordBody(fetchMock, 0)).toMatchObject({ + username: "alerts", + avatar_url: "https://img/alerts.png", + }); + + // Empty strings and wrong types fall back to the transport's own defaults. + await transport.send( + discordTask({ renderedContent: { content: { body: "hi", username: "", avatarUrl: 5 } } }), + ); + expect(discordBody(fetchMock, 1)).toMatchObject({ + username: "base", + avatar_url: "https://img/base.png", + }); + }); + + it("forwards embeds only when they are an array", async () => { + const fetchMock = stubDiscord({ id: "m5" }); + const embeds = [{ title: "Coverage", description: "80%" }]; + + await new DiscordTransport().send( + discordTask({ renderedContent: { content: { body: "hi", embeds } } }), + ); + expect(discordBody(fetchMock, 0).embeds).toEqual(embeds); + + await new DiscordTransport().send( + discordTask({ renderedContent: { content: { body: "hi", embeds: "nope" } } }), + ); + expect(discordBody(fetchMock, 1)).not.toHaveProperty("embeds"); + }); + + it("fails without reaching Discord when the task carries no destination", async () => { + const fetchMock = stubDiscord({ id: "m6" }); + + const result = await new DiscordTransport().send(discordTask({ destination: undefined })); + + expect(fetchMock).not.toHaveBeenCalled(); + expect(result).toEqual({ + success: false, + error: "No destination (Discord webhook URL) on task", + }); + }); + + it("returns an empty provider id when the response carries no JSON", async () => { + const fn = vi.fn().mockResolvedValue({ + status: 200, + ok: true, + json: async () => { + throw new Error("Unexpected end of JSON input"); + }, + text: async () => "", + }); + vi.stubGlobal("fetch", fn); + + const result = await new DiscordTransport().send(discordTask()); + + expect(result).toEqual({ success: true, providerMessageId: "" }); + }); + + it.each([ + [404, true], + [401, true], + [500, false], + [429, false], + ])("classifies a %i response as dead=%s", async (status, invalidToken) => { + stubDiscord("rate limited or gone", status); + + const result = await new DiscordTransport().send(discordTask()); + + expect(result).toEqual({ + success: false, + invalidToken, + error: `Discord webhook error ${status}: rate limited or gone`, + }); + }); + + it("logs a rejected send", async () => { + const log = logger(); + stubDiscord("missing permissions", 403); + + await new DiscordTransport({ logger: log }).send(discordTask()); + + expect(log.warn).toHaveBeenCalledWith( + { taskId: "task-uuid-dc-1", status: 403, errorBody: "missing permissions" }, + "Discord send failed", + ); + }); + + it("reports a malformed webhook URL without marking the target dead", async () => { + const log = logger(); + const fetchMock = stubDiscord({ id: "m7" }); + + const result = await new DiscordTransport({ logger: log }).send( + discordTask({ destination: "not-a-url" }), + ); + + // `new URL()` throws before any request is attempted. + expect(fetchMock).not.toHaveBeenCalled(); + expect(result.success).toBe(false); + expect(log.error).toHaveBeenCalledWith( + expect.objectContaining({ taskId: "task-uuid-dc-1" }), + "Discord unexpected error", + ); + }); + + it("reports a transport-level failure", async () => { + const log = logger(); + vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("socket hang up"))); + + const result = await new DiscordTransport({ logger: log }).send(discordTask()); + + expect(result).toEqual({ success: false, error: "socket hang up" }); + expect(log.error).toHaveBeenCalledWith( + { taskId: "task-uuid-dc-1", error: "socket hang up" }, + "Discord unexpected error", + ); + }); + + it("logs an accepted send", async () => { + const log = logger(); + stubDiscord({ id: "msg-42" }); + + await new DiscordTransport({ logger: log }).send(discordTask()); + + expect(log.debug).toHaveBeenCalledWith( + { taskId: "task-uuid-dc-1", providerMessageId: "msg-42" }, + "Discord message sent", + ); + }); +}); + +// ─── Slack ─────────────────────────────────────────────────────────────────── + +import { SlackTransport } from "../packages/provider-slack/src/index.js"; + +const SLACK_BOT_TOKEN = "xoxb-0000-1111-test"; +const HOOK_URL = "https://hooks.slack.com/services/T000/B000/xyz"; +const POST_MESSAGE_URL = "https://slack.com/api/chat.postMessage"; + +/** Stubs `fetch` for the Web API, which reports failure in a JSON envelope. */ +function stubSlackApi(body: unknown, status = 200) { + const fn = vi.fn().mockResolvedValue({ + status, + ok: status >= 200 && status < 300, + json: async () => body, + text: async () => JSON.stringify(body), + }); + vi.stubGlobal("fetch", fn); + return fn; +} + +/** Stubs `fetch` for Incoming Webhooks, which answer with a plain-text body. */ +function stubSlackHook(body: string, status = 200) { + const fn = vi.fn().mockResolvedValue({ + status, + ok: status >= 200 && status < 300, + text: async () => body, + json: async () => ({}), + }); + vi.stubGlobal("fetch", fn); + return fn; +} + +/** The JSON payload sent to Slack on the Nth call. */ +function slackBody(fn: ReturnType, n = 0) { + return JSON.parse((fn.mock.calls[n]![1] as RequestInit).body as string); +} + +function slackTask(overrides: Record = {}): any { + return { + taskId: "task-uuid-slack-1", + destination: "C0123456789", + renderedContent: { content: { body: "Deploy finished" } }, + ...overrides, + }; +} + +describe("SlackTransport (Slack Provider)", () => { + const logger = () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }) as any; + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("rides the existing webhook channel rather than adding one of its own", () => { + expect(new SlackTransport().channel).toBe("webhook"); + }); + + it("constructs without options and defaults to a conservative rate ceiling", () => { + expect(new SlackTransport().limits).toEqual({ limit: 50, windowSeconds: 60 }); + expect(new SlackTransport({ limits: { limit: 1, windowSeconds: 1 } }).limits).toEqual({ + limit: 1, + windowSeconds: 1, + }); + }); + + it("accepts `token` as an alias for `botToken`", async () => { + const fetchMock = stubSlackApi({ ok: true, ts: "1700000000.000100" }); + + await new SlackTransport({ token: SLACK_BOT_TOKEN }).send(slackTask()); + + const init = fetchMock.mock.calls[0]![1] as RequestInit; + expect((init.headers as Record).Authorization).toBe( + `Bearer ${SLACK_BOT_TOKEN}`, + ); + }); + + it.each([ + ["clientId", { clientId: "123.456" }], + ["clientSecret", { clientSecret: "shhh" }], + ])("warns when %s arrives without a usable token", (_label, opts) => { + const log = logger(); + + new SlackTransport({ ...opts, appId: "A123", logger: log }); + + expect(log.warn).toHaveBeenCalledWith( + { appId: "A123" }, + expect.stringContaining("clientId/clientSecret authenticate Slack's OAuth install flow"), + ); + }); + + it("stays quiet when OAuth credentials accompany a real token", () => { + const log = logger(); + + new SlackTransport({ + clientId: "123.456", + clientSecret: "shhh", + botToken: SLACK_BOT_TOKEN, + logger: log, + }); + + expect(log.warn).not.toHaveBeenCalled(); + }); + + describe("destination resolution", () => { + it("prefers a template's pinned channel over the task destination", async () => { + const fetchMock = stubSlackApi({ ok: true, ts: "1.1" }); + + await new SlackTransport({ botToken: SLACK_BOT_TOKEN }).send( + slackTask({ renderedContent: { content: { body: "hi", channel: "#system-alerts" } } }), + ); + + expect(slackBody(fetchMock).channel).toBe("#system-alerts"); + }); + + it("ignores a non-string pinned channel and falls back to the task destination", async () => { + const fetchMock = stubSlackApi({ ok: true, ts: "1.2" }); + + await new SlackTransport({ botToken: SLACK_BOT_TOKEN }).send( + slackTask({ renderedContent: { content: { body: "hi", channel: 42 } } }), + ); + + expect(slackBody(fetchMock).channel).toBe("C0123456789"); + }); + + it("falls back to the configured webhook for a task with no destination", async () => { + const fetchMock = stubSlackHook("ok"); + + const result = await new SlackTransport({ webhookUrl: HOOK_URL }).send( + slackTask({ destination: undefined }), + ); + + expect(String(fetchMock.mock.calls[0]![0])).toBe(HOOK_URL); + expect(result.success).toBe(true); + }); + + it("fails without reaching Slack when nothing names a destination", async () => { + const fetchMock = stubSlackApi({ ok: true }); + + const result = await new SlackTransport({ botToken: SLACK_BOT_TOKEN }).send( + slackTask({ destination: undefined }), + ); + + expect(fetchMock).not.toHaveBeenCalled(); + expect(result).toEqual({ + success: false, + error: "No destination (Slack channel/user ID, or webhook URL) on task", + }); + }); + + it("refuses a channel id when no token is configured", async () => { + const fetchMock = stubSlackApi({ ok: true }); + + const result = await new SlackTransport().send(slackTask()); + + expect(fetchMock).not.toHaveBeenCalled(); + expect(result).toEqual({ + success: false, + error: "Destination is a Slack channel/user ID but no botToken (or token) is configured", + }); + }); + }); + + describe("message body", () => { + it("prefers content.text over content.body", async () => { + const fetchMock = stubSlackApi({ ok: true, ts: "1.3" }); + + await new SlackTransport({ botToken: SLACK_BOT_TOKEN }).send( + slackTask({ renderedContent: { content: { text: "from text", body: "from body" } } }), + ); + + expect(slackBody(fetchMock).text).toBe("from text"); + }); + + it("forwards blocks only when they are an array", async () => { + const fetchMock = stubSlackApi({ ok: true, ts: "1.4" }); + const blocks = [{ type: "section", text: { type: "mrkdwn", text: "hi" } }]; + + await new SlackTransport({ botToken: SLACK_BOT_TOKEN }).send( + slackTask({ renderedContent: { content: { body: "hi", blocks } } }), + ); + expect(slackBody(fetchMock, 0).blocks).toEqual(blocks); + + await new SlackTransport({ botToken: SLACK_BOT_TOKEN }).send( + slackTask({ renderedContent: { content: { body: "hi", blocks: "nope" } } }), + ); + expect(slackBody(fetchMock, 1)).not.toHaveProperty("blocks"); + }); + + it("always sends fallback text, even when the message is carried by blocks", async () => { + const fetchMock = stubSlackApi({ ok: true, ts: "1.5" }); + const blocks = [{ type: "divider" }]; + + // Subject stands in when there is no body at all … + await new SlackTransport({ botToken: SLACK_BOT_TOKEN }).send( + slackTask({ renderedContent: { content: { subject: "Nightly build", blocks } } }), + ); + expect(slackBody(fetchMock, 0).text).toBe("Nightly build"); + + // … and a constant stands in when there is neither. + await new SlackTransport({ botToken: SLACK_BOT_TOKEN }).send( + slackTask({ renderedContent: { content: { blocks } } }), + ); + expect(slackBody(fetchMock, 1).text).toBe("New notification"); + }); + }); + + describe("chat.postMessage", () => { + it("posts to the Web API and returns a channel-qualified message id", async () => { + const fetchMock = stubSlackApi({ ok: true, ts: "1700000000.000100", channel: "C999" }); + + const result = await new SlackTransport({ botToken: SLACK_BOT_TOKEN }).send(slackTask()); + + expect(fetchMock.mock.calls[0]![0]).toBe(POST_MESSAGE_URL); + expect(result).toEqual({ success: true, providerMessageId: "C999:1700000000.000100" }); + }); + + it("falls back to the requested channel when the response omits one", async () => { + stubSlackApi({ ok: true, ts: "1700000000.000200" }); + + const result = await new SlackTransport({ botToken: SLACK_BOT_TOKEN }).send(slackTask()); + + expect(result).toEqual({ + success: true, + providerMessageId: "C0123456789:1700000000.000200", + }); + }); + + it("reports no message id when the response omits the timestamp", async () => { + stubSlackApi({ ok: true }); + + const result = await new SlackTransport({ botToken: SLACK_BOT_TOKEN }).send(slackTask()); + + expect(result).toEqual({ success: true, providerMessageId: undefined }); + }); + + it.each([ + ["channel_not_found", true], + ["is_archived", true], + ["channel_is_archived", true], + ["user_not_found", true], + ["account_inactive", true], + ["not_in_channel", true], + ["ratelimited", false], + ["invalid_auth", false], + ])("classifies %s as dead-destination=%s", async (error, invalidToken) => { + stubSlackApi({ ok: false, error }); + + const result = await new SlackTransport({ botToken: SLACK_BOT_TOKEN }).send(slackTask()); + + expect(result).toEqual({ success: false, invalidToken, error }); + }); + + it("falls back to a generic message when the API names no error", async () => { + stubSlackApi({ ok: false }); + + const result = await new SlackTransport({ botToken: SLACK_BOT_TOKEN }).send(slackTask()); + + expect(result).toEqual({ success: false, invalidToken: false, error: "Slack API error" }); + }); + + it("logs both outcomes with the app id that produced them", async () => { + const log = logger(); + stubSlackApi({ ok: true, ts: "1.6", channel: "C1" }); + await new SlackTransport({ botToken: SLACK_BOT_TOKEN, appId: "A123", logger: log }).send( + slackTask(), + ); + expect(log.debug).toHaveBeenCalledWith( + { taskId: "task-uuid-slack-1", appId: "A123", providerMessageId: "C1:1.6" }, + "Slack message sent", + ); + + stubSlackApi({ ok: false, error: "channel_not_found" }); + await new SlackTransport({ botToken: SLACK_BOT_TOKEN, appId: "A123", logger: log }).send( + slackTask(), + ); + expect(log.warn).toHaveBeenCalledWith( + { + taskId: "task-uuid-slack-1", + appId: "A123", + error: "channel_not_found", + invalidToken: true, + }, + "Slack chat.postMessage failed", + ); + }); + + it("reports a transport-level failure", async () => { + const log = logger(); + vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("socket hang up"))); + + const result = await new SlackTransport({ botToken: SLACK_BOT_TOKEN, logger: log }).send( + slackTask(), + ); + + expect(result).toEqual({ success: false, error: "socket hang up" }); + expect(log.error).toHaveBeenCalledWith( + { taskId: "task-uuid-slack-1", error: "socket hang up" }, + "Slack unexpected error", + ); + }); + }); + + describe("incoming webhooks", () => { + const hookTask = () => slackTask({ destination: HOOK_URL }); + + it("posts the message unauthenticated and synthesises a message id", async () => { + const fetchMock = stubSlackHook("ok"); + + const result = await new SlackTransport().send(hookTask()); + + const init = fetchMock.mock.calls[0]![1] as RequestInit; + // A webhook URL carries its own authorisation, so no bearer token is sent. + expect(init.headers).not.toHaveProperty("Authorization"); + expect(result).toEqual({ + success: true, + providerMessageId: "slack-webhook-task-uuid-slack-1", + }); + }); + + it("forwards blocks over the webhook route too", async () => { + const fetchMock = stubSlackHook("ok"); + const blocks = [{ type: "divider" }]; + + await new SlackTransport().send( + slackTask({ + destination: HOOK_URL, + renderedContent: { content: { body: "hi", blocks } }, + }), + ); + + expect(slackBody(fetchMock)).toEqual({ text: "hi", blocks }); + }); + + it("tolerates the trailing whitespace Slack sometimes returns", async () => { + stubSlackHook("ok\n"); + + const result = await new SlackTransport().send(hookTask()); + + expect(result.success).toBe(true); + }); + + it.each([ + ["channel_not_found", true], + ["channel_is_archived", true], + ["no_service", true], + ["invalid_payload", false], + ])("classifies the %j response body as dead-destination=%s", async (body, invalidToken) => { + stubSlackHook(body, 400); + + const result = await new SlackTransport().send(hookTask()); + + expect(result).toEqual({ success: false, invalidToken, error: body }); + }); + + it("falls back to the status code when the body is empty", async () => { + stubSlackHook("", 500); + + const result = await new SlackTransport().send(hookTask()); + + expect(result).toEqual({ success: false, invalidToken: false, error: "HTTP 500" }); + }); + + it("fails a 200 that does not actually say ok", async () => { + stubSlackHook("invalid_payload", 200); + + const result = await new SlackTransport().send(hookTask()); + + expect(result).toEqual({ success: false, invalidToken: false, error: "invalid_payload" }); + }); + + it("logs both outcomes", async () => { + const log = logger(); + stubSlackHook("ok"); + await new SlackTransport({ appId: "A9", logger: log }).send(hookTask()); + expect(log.debug).toHaveBeenCalledWith( + { + taskId: "task-uuid-slack-1", + appId: "A9", + providerMessageId: "slack-webhook-task-uuid-slack-1", + }, + "Slack webhook message sent", + ); + + stubSlackHook("no_service", 404); + await new SlackTransport({ appId: "A9", logger: log }).send(hookTask()); + expect(log.warn).toHaveBeenCalledWith( + { + taskId: "task-uuid-slack-1", + appId: "A9", + status: 404, + body: "no_service", + invalidToken: true, + }, + "Slack incoming webhook failed", + ); + }); + + it("reports a transport-level failure", async () => { + const log = logger(); + vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("dns failure"))); + + const result = await new SlackTransport({ logger: log }).send(hookTask()); + + expect(result).toEqual({ success: false, error: "dns failure" }); + expect(log.error).toHaveBeenCalledWith( + { taskId: "task-uuid-slack-1", error: "dns failure" }, + "Slack unexpected error", + ); + }); + }); +});