Merge remote-tracking branch 'origin/main' into feature/provider-twilio

This commit is contained in:
Suhaanth V V
2026-09-04 12:18:57 +05:30
8 changed files with 1304 additions and 6 deletions
+1
View File
@@ -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
+32 -6
View File
@@ -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"
}
}
}
}
+21
View File
@@ -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.
+90
View File
@@ -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.
+40
View File
@@ -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"
}
}
+221
View File
@@ -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<DeliveryResult> {
// `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<DeliveryResult> {
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<DeliveryResult> {
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 };
}
}
}
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": ["src/**/*"]
}
+891
View File
@@ -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<typeof stubTelegram>, n = 0) {
return JSON.parse((fn.mock.calls[n]![1] as RequestInit).body as string);
}
function telegramTransport(overrides: Record<string, unknown> = {}) {
return new TelegramTransport({ botToken: BOT_TOKEN, ...overrides } as any);
}
function telegramTask(overrides: Record<string, unknown> = {}): 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<typeof stubDiscord>, n = 0) {
return JSON.parse((fn.mock.calls[n]![1] as RequestInit).body as string);
}
function discordTask(overrides: Record<string, unknown> = {}): 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<typeof stubSlackApi>, n = 0) {
return JSON.parse((fn.mock.calls[n]![1] as RequestInit).body as string);
}
function slackTask(overrides: Record<string, unknown> = {}): 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<string, string>).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",
);
});
});
});