Merge branch 'main' into feature/provider-twilio

This commit is contained in:
Suhaanth V V
2026-09-04 11:28:36 +05:30
committed by GitHub
7 changed files with 399 additions and 0 deletions
+1
View File
@@ -321,6 +321,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-twilio`](./packages/provider-twilio) — SMS via Twilio, with signature-verified delivery status callbacks
- [`@notifkit/provider-slack`](./packages/provider-slack) — Slack messages via Incoming Webhooks or the Web API
- [`@notifkit/provider-telegram`](./packages/provider-telegram) — messages via a Telegram bot
- [`@notifkit/provider-discord`](./packages/provider-discord) — messages via a Discord webhook
+18
View File
@@ -1773,6 +1773,12 @@
},
"node_modules/@notifkit/provider-twilio": {
"resolved": "packages/provider-twilio",
"link": true
},
"node_modules/@notifkit/provider-slack": {
"resolved": "packages/provider-slack",
"link": true
},
"node_modules/@notifkit/provider-telegram": {
"resolved": "packages/provider-telegram",
"link": true
@@ -13193,6 +13199,18 @@
"dependencies": {
"twilio": "^5.0.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"
}
},
"packages/provider-telegram": {
"name": "@notifkit/provider-telegram",
"version": "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/**/*"]
}