mirror of
https://github.com/devkitshq/notifkit.git
synced 2026-10-19 18:24:52 +02:00
Merge remote-tracking branch 'origin/main' into feature/provider-twilio
This commit is contained in:
@@ -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
|
||||
|
||||
Generated
+32
-6
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user