mirror of
https://github.com/devkitshq/notifkit.git
synced 2026-10-19 18:24:52 +02:00
Merge branch 'main' into feature/provider-twilio
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
Generated
+18
@@ -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",
|
||||
|
||||
@@ -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/**/*"]
|
||||
}
|
||||
Reference in New Issue
Block a user