From 4c2d47717334425d53365108990f71e4916ed239 Mon Sep 17 00:00:00 2001 From: Suhaanth V V Date: Tue, 1 Sep 2026 14:49:47 +0530 Subject: [PATCH 1/2] feat: add slack as a new provider --- README.md | 1 + package-lock.json | 17 +++ packages/provider-slack/LICENSE | 21 ++++ packages/provider-slack/README.md | 63 ++++++++++ packages/provider-slack/package.json | 40 ++++++ packages/provider-slack/src/index.ts | 169 ++++++++++++++++++++++++++ packages/provider-slack/tsconfig.json | 8 ++ 7 files changed, 319 insertions(+) create mode 100644 packages/provider-slack/LICENSE create mode 100644 packages/provider-slack/README.md create mode 100644 packages/provider-slack/package.json create mode 100644 packages/provider-slack/src/index.ts create mode 100644 packages/provider-slack/tsconfig.json diff --git a/README.md b/README.md index bfb552a..516bcc1 100644 --- a/README.md +++ b/README.md @@ -322,6 +322,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 For anything else, implement a simple `Transport`: diff --git a/package-lock.json b/package-lock.json index d5d09ef..74e2901 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "": { "name": "notifkit", "version": "0.1.3", + "dev": true, "license": "MIT", "workspaces": [ "packages/*" @@ -1766,6 +1767,10 @@ "resolved": "packages/provider-resend", "link": true }, + "node_modules/@notifkit/provider-slack": { + "resolved": "packages/provider-slack", + "link": true + }, "node_modules/@one-ini/wasm": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/@one-ini/wasm/-/wasm-0.1.1.tgz", @@ -13022,6 +13027,18 @@ "peerDependencies": { "notifkit": "^0.1.0" } + }, + "packages/provider-slack": { + "name": "@notifkit/provider-slack", + "version": "0.1.0", + "license": "MIT", + "devDependencies": { + "notifkit": "file:../../", + "typescript": "^5.8.3" + }, + "peerDependencies": { + "notifkit": "^0.1.0" + } } } } diff --git a/packages/provider-slack/LICENSE b/packages/provider-slack/LICENSE new file mode 100644 index 0000000..235464d --- /dev/null +++ b/packages/provider-slack/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 devkitshq + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/provider-slack/README.md b/packages/provider-slack/README.md new file mode 100644 index 0000000..10b6bdb --- /dev/null +++ b/packages/provider-slack/README.md @@ -0,0 +1,63 @@ +# @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 { SlackTransport } from "@notifkit/provider-slack"; + +registry.register( + "webhook", + new SlackTransport({ + botToken: process.env.SLACK_BOT_TOKEN, // xoxb-... + }), +); +``` + +A recipient's `webhook` contact target decides how the message is delivered: + +- A Slack **channel ID** (`C0123456789`) or **user ID** (`U0123456789`, for a + DM) is sent via `chat.postMessage` using `botToken`. +- A full **Incoming Webhook URL** (`https://hooks.slack.com/services/...`) is + posted to directly — no bot 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. + +Templates render into `text` (or `body`) and, optionally, a Slack +[Block Kit](https://api.slack.com/block-kit) `blocks` array: + +```ts +await notifkit.syncTemplates({ + templates: [ + { + id: "deploy-failed", + channel: "webhook", + content: { + text: "Deploy failed for {{service}}", + blocks: [ + { + type: "section", + text: { type: "mrkdwn", text: "*Deploy failed* for `{{service}}`" }, + }, + ], + }, + }, + ], +}); +``` + +A destination Slack reports as gone (`channel_not_found`, `is_archived`, +`user_not_found`, …) is returned as an invalid token, so notifkit stops +retrying it and can flag the contact. Requires `notifkit` as a peer +dependency. Documentation: [notifkit.dev/docs](https://notifkit.dev/docs/). +MIT licensed. diff --git a/packages/provider-slack/package.json b/packages/provider-slack/package.json new file mode 100644 index 0000000..019a07c --- /dev/null +++ b/packages/provider-slack/package.json @@ -0,0 +1,40 @@ +{ + "name": "@notifkit/provider-slack", + "version": "0.1.0", + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "files": [ + "dist", + "src", + "README.md", + "LICENSE" + ], + "scripts": { + "build": "tsc", + "dev": "tsc --watch", + "typecheck": "tsc --noEmit" + }, + "peerDependencies": { + "notifkit": "^0.1.0" + }, + "devDependencies": { + "notifkit": "file:../../", + "typescript": "^5.8.3" + }, + "description": "Slack transport for notifkit, via Incoming Webhooks or the chat.postMessage Web API.", + "license": "MIT", + "author": "devkitshq", + "homepage": "https://notifkit.dev", + "repository": { + "type": "git", + "url": "git+https://github.com/devkitshq/notifkit.git", + "directory": "packages/provider-slack" + }, + "bugs": { + "url": "https://github.com/devkitshq/notifkit/issues" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/provider-slack/src/index.ts b/packages/provider-slack/src/index.ts new file mode 100644 index 0000000..2598aae --- /dev/null +++ b/packages/provider-slack/src/index.ts @@ -0,0 +1,169 @@ +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 token (xoxb-...) used for chat.postMessage. Required unless every + * recipient's destination is a full Incoming Webhook URL, which needs no + * token at all. + */ + botToken?: 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 logger?: Logger; + + constructor({ + botToken, + 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; + this.logger = logger; + this.limits = limits; + } + + async send(task: NotificationDispatchedPayload): Promise { + // `destination` is optional on the contract, so a malformed task can + // reach a transport without one. Failing here costs one clear error; + // reaching Slack without one costs an opaque provider rejection. + const content = task.renderedContent.content as any; + + // A template may pin its own channel (e.g. a shared "system-alerts" room) + // the same way ResendTransport lets a template pin its own `from` — + // one transport can then serve both per-recipient DMs and fixed rooms. + const destination = + (typeof content.channel === "string" && content.channel) || task.destination; + 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 is configured", + }; + } + + return this.sendViaWebApi(destination, fallbackText, blocks, task); + } + + private async sendViaWebApi( + channel: string, + text: string, + blocks: unknown[] | undefined, + task: NotificationDispatchedPayload, + ): Promise { + try { + const response = await fetch(WEB_API_URL, { + method: "POST", + headers: { + Authorization: `Bearer ${this.botToken}`, + "Content-Type": "application/json; charset=utf-8", + }, + body: JSON.stringify({ channel, text, ...(blocks ? { blocks } : {}) }), + }); + + const body = (await response.json()) as { + ok: boolean; + ts?: string; + channel?: string; + error?: string; + }; + + if (!body.ok) { + const invalidToken = INVALID_DESTINATION_ERRORS.has(body.error ?? ""); + this.logger?.warn( + { taskId: task.taskId, 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, providerMessageId }, "Slack message sent"); + return { success: true, providerMessageId }; + } catch (err) { + const error = err as Error; + this.logger?.error({ taskId: task.taskId, error: error.message }, "Slack unexpected error"); + return { success: false, error: error.message }; + } + } + + private async sendViaIncomingWebhook( + url: string, + text: string, + blocks: unknown[] | undefined, + task: NotificationDispatchedPayload, + ): Promise { + try { + const response = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json; charset=utf-8" }, + body: JSON.stringify({ text, ...(blocks ? { blocks } : {}) }), + }); + + const body = await response.text(); + + if (!response.ok || body.trim() !== "ok") { + const invalidToken = INVALID_WEBHOOK_BODIES.has(body.trim()); + this.logger?.warn( + { taskId: task.taskId, 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, providerMessageId }, "Slack webhook message sent"); + return { success: true, providerMessageId }; + } catch (err) { + const error = err as Error; + this.logger?.error({ taskId: task.taskId, error: error.message }, "Slack unexpected error"); + return { success: false, error: error.message }; + } + } +} diff --git a/packages/provider-slack/tsconfig.json b/packages/provider-slack/tsconfig.json new file mode 100644 index 0000000..a013e0c --- /dev/null +++ b/packages/provider-slack/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src/**/*"] +} From 42a23c0939eed012e7868306df4adeac626f5b60 Mon Sep 17 00:00:00 2001 From: Suhaanth V V Date: Wed, 2 Sep 2026 15:36:23 +0530 Subject: [PATCH 2/2] add bot token send support --- package-lock.json | 1 - packages/provider-slack/README.md | 45 +++++++++++++---- packages/provider-slack/src/index.ts | 72 ++++++++++++++++++++++++---- 3 files changed, 98 insertions(+), 20 deletions(-) diff --git a/package-lock.json b/package-lock.json index 74e2901..8d6fcb6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,6 @@ "": { "name": "notifkit", "version": "0.1.3", - "dev": true, "license": "MIT", "workspaces": [ "packages/*" diff --git a/packages/provider-slack/README.md b/packages/provider-slack/README.md index 10b6bdb..5facd43 100644 --- a/packages/provider-slack/README.md +++ b/packages/provider-slack/README.md @@ -10,22 +10,39 @@ npm install @notifkit/provider-slack ``` ```ts +import { NotifkitServer } from "notifkit"; import { SlackTransport } from "@notifkit/provider-slack"; -registry.register( - "webhook", - new SlackTransport({ - botToken: process.env.SLACK_BOT_TOKEN, // xoxb-... - }), -); +const server = new NotifkitServer({ + services: ["all"], + providers: [ + new SlackTransport({ + botToken: process.env.SLACK_BOT_TOKEN, // xoxb-... + }), + ], +}); ``` -A recipient's `webhook` contact target decides how the message is delivered: +## 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`. + DM) is sent via `chat.postMessage` using `botToken`/`token`. - A full **Incoming Webhook URL** (`https://hooks.slack.com/services/...`) is - posted to directly — no bot token required, so a single-channel integration + 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 @@ -33,6 +50,16 @@ 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: diff --git a/packages/provider-slack/src/index.ts b/packages/provider-slack/src/index.ts index 2598aae..99343c8 100644 --- a/packages/provider-slack/src/index.ts +++ b/packages/provider-slack/src/index.ts @@ -24,11 +24,35 @@ const INVALID_WEBHOOK_BODIES = new Set(["channel_not_found", "channel_is_archive export interface SlackTransportOptions { /** - * Bot token (xoxb-...) used for chat.postMessage. Required unless every - * recipient's destination is a full Incoming Webhook URL, which needs no - * token at all. + * 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 }; } @@ -38,19 +62,37 @@ export class SlackTransport implements Transport { 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; + 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 { @@ -62,8 +104,12 @@ export class SlackTransport implements Transport { // 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; + (typeof content.channel === "string" && content.channel) || + task.destination || + this.webhookUrl; if (!destination) { return { success: false, @@ -84,7 +130,7 @@ export class SlackTransport implements Transport { if (!this.botToken) { return { success: false, - error: "Destination is a Slack channel/user ID but no botToken is configured", + error: "Destination is a Slack channel/user ID but no botToken (or token) is configured", }; } @@ -117,14 +163,17 @@ export class SlackTransport implements Transport { if (!body.ok) { const invalidToken = INVALID_DESTINATION_ERRORS.has(body.error ?? ""); this.logger?.warn( - { taskId: task.taskId, error: body.error, invalidToken }, + { 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, providerMessageId }, "Slack message sent"); + this.logger?.debug( + { taskId: task.taskId, appId: this.appId, providerMessageId }, + "Slack message sent", + ); return { success: true, providerMessageId }; } catch (err) { const error = err as Error; @@ -151,14 +200,17 @@ export class SlackTransport implements Transport { if (!response.ok || body.trim() !== "ok") { const invalidToken = INVALID_WEBHOOK_BODIES.has(body.trim()); this.logger?.warn( - { taskId: task.taskId, status: response.status, body, invalidToken }, + { 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, providerMessageId }, "Slack webhook message sent"); + 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;