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

This commit is contained in:
Suhaanth V V
2026-09-04 11:11:29 +05:30
24 changed files with 2163 additions and 127 deletions
+94 -94
View File
@@ -14,12 +14,6 @@ Self-hosted notification infrastructure for product notifications. One API call
---
https://github.com/user-attachments/assets/4dff98bb-37d3-44b4-bf46-9607c1cd89b5
[▶️ Watch the AI demo](assets/ai_demo.mp4) - this is link to raw video file
---
### The first notification is easy
```ts
@@ -105,69 +99,61 @@ flowchart TD
---
## Battle-tested for production
## Agent-operable
> **Battle-tested in production:** notifkit powers production notification pipelines handling **thousands of emails, push notifications, and OTPs every day.**
>
> It is the infrastructure we built because we needed it ourselves — rather than spending months reinventing distributed notification plumbing or paying SaaS tolls per alert.
https://github.com/user-attachments/assets/4dff98bb-37d3-44b4-bf46-9607c1cd89b5
**Your servers. Your providers. Your data. Zero notification SaaS markups.**
[▶️ Watch the AI demo](assets/ai_demo.mp4) - this is link to raw video file
### Reliability & Chaos Engineering
**notifkit isn't just an API your application can call — your AI agent can operate it directly.**
Because notification delivery is mission-critical, every pipeline component is tested against extreme failure conditions:
Connect the notifkit MCP server ([`@notifkit/mcp`](./packages/mcp)) to Claude Code, Cursor, Claude Desktop, Gemini, or any MCP-compatible agent:
```mermaid
flowchart LR
S1["Redis Streams"] -->|"Kill Worker (SIGKILL)"| M1["Auto-Claim and Replay"] --> O1["Zero Lost Messages"]
S2["Connection Loss"] -->|"Drop DB / Redis"| M2["Auto-Reconnect / Retry"] --> O2["In-Flight State Intact"]
S3["10k+ Messages"] -->|"Burst"| M3["Concurrency and Limits"] --> O3["Flat Memory, No Leaks"]
classDef fault stroke:#ef4444,stroke-width:2px
classDef guard stroke:#6366f1,stroke-width:2px
classDef result stroke:#22c55e,stroke-width:2px
class S1,S2,S3 fault
class M1,M2,M3 guard
class O1,O2,O3 result
```bash
npx -y @notifkit/mcp
```
- **Chaos Monkey Testing (`tests/chaos/crash.test.ts`)**: Background worker processes are randomly terminated with `SIGKILL` during active, high-throughput message streaming. Consumer group Pending Entries List (PEL) re-claims guarantee **zero lost messages** and seamless failover.
- **Infrastructure Recovery Testing (`tests/chaos/recovery.test.ts`)**: PostgreSQL and Redis connections are forcefully severed and restored under live traffic. Verifies automatic client reconnection, worker backpressure, and durable state resumption.
- **High-Throughput Load Testing (`tests/chaos/load.test.ts`)**: Stressed with bursts of **10,000+ notifications** across parallel worker pools, verifying queue drain velocity, sliding-window rate limiters, and flat memory profiles without leaks.
- **Race Conditions & Concurrency (`tests/race-conditions.test.ts`, `tests/idempotency.test.ts`)**: Hardened against concurrent duplicate dispatches, overlapping quiet-hour boundary evaluations, atomic user updates, and 24-hour idempotency key deduplication.
- **100% Real Ephemeral Containers**: Unit, integration, and chaos test suites execute against real PostgreSQL and Redis containers via [Testcontainers](https://testcontainers.com), eliminating mocks for core storage and streaming primitives.
### Ask your agent
```text
You: Why didn't usr_9182 receive their password reset?
Agent: The notification was suppressed because usr_9182's email
address has a hard-bounce suppression from yesterday.
```
Your application and your AI agents use the **same notification infrastructure**:
- **Send & dispatch** — Send one-off notifications or campaigns to users, lists, and segments (`send_notification`, `send_campaign`)
- **Investigate & triage** — Diagnose delivery issues by inspecting message histories, provider responses, and quiet hours (`get_delivery_logs`, `get_notification`)
- **Schedule & cancel** — Schedule future sends and cancel pending notifications (`list_scheduled`, `cancel_notification`)
- **Campaign analytics** — Check delivery, open, click, bounce, and complaint metrics (`list_campaigns`, `get_campaign_stats`)
- **Template management** — List, preview, and update templates with sample data (`list_templates`, `preview_template`, `upsert_template`)
- **Users & preferences** — Look up users, contacts, preferences, and segment membership (`list_users`, `get_user_preferences`, `update_user_preferences`)
- **Workflow operations** — Trigger workflows and inspect workflow runs (`create_workflow`, `trigger_workflow`, `get_workflow_run`)
- **Suppressions & health** — Manage bounce suppressions, check system queues, and replay dead-letter messages (`list_suppressions`, `get_dead_letters`, `replay_dead_letter`)
### From “write a script” to “just ask”
| Without an agent | With NotifKit MCP |
| :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Jump into the DB to find contact info → open Twilio/Resend or write a throwaway script → format the payload → check their timezone manually → fire it off → hope it delivered | **You:** _“Send an urgent update to alex@acme.com that his package was lost in transit and support is rushing a replacement — text him if push doesn't deliver.”_<br><br>**Agent:** Looks up `alex@acme.com` → renders template → dispatches push with SMS fallback → bypasses quiet hours for urgent delivery → tracks delivery status → confirms it hit his phone |
[Set up MCP](https://notifkit.dev/docs/mcp.html) · [MCP documentation](https://notifkit.dev/docs/mcp.html)
---
## What you get
## AI-assisted migration
| The problem you don't want to build | How notifkit solves it |
| :------------------------------------------------------- | :------------------------------------------------------------------- |
| **“Should this user receive it?”** | User preferences, topic opt-outs, and consent gates |
| **“Is this a bad time to send?”** | Timezone-aware quiet hours that defer non-urgent sends |
| **“What if push fails?”** | Automatic ordered multi-channel fallback (`push` → `email` → `sms`) |
| **“What if my worker crashes?”** | Redis Streams consumer groups, retries, and durable idempotency |
| **“What if an event fires twice?”** | 24-hour deduplication via idempotency keys |
| **“Can I send this later?”** | Priority scheduling with `sendAt` and cancellation before dispatch |
| **“Can I send this 3 days after signup?”** | Stateful multi-step workflows with `wait` and `waitForEvent` |
| **“How do I know what happened?”** | Queryable delivery logs, Prometheus metrics, and campaign reporting |
| **“What happens when a provider goes down?”** | Circuit breakers, exponential backoff, and DLQ replay |
| **“What about bounces and spam complaints?”** | RFC 8058 one-click unsubscribe and automatic hard-bounce suppression |
| **“What if I don't want another SaaS holding my data?”** | 100% self-hosted on your PostgreSQL and Redis |
Already have notification code scattered across your application?
> **The idea is simple:** You decide what to say. **notifkit handles getting it there reliably.**
Point your coding agent at:
---
```text
https://notifkit.dev/llms-full.txt
```
## What notifkit is — and what it isn't
**What it is:** the durable notification infrastructure layer running directly inside your own stack.
**What it isn't:** a marketing automation suite.
notifkit is not Customer.io, OneSignal, or SendGrid. You bring your own provider accounts — your keys, your billing, your deliverability.
First-party providers ship for Resend and Firebase Cloud Messaging. Anything else is a simple `Transport` class with a `send()` method.
It can understand notifkit's API and help identify ad-hoc notification code in your repository and refactor it into durable notifkit calls.
---
@@ -239,57 +225,69 @@ curl -X POST http://localhost:3000/v1/notify \
---
## Agent-operable
## Battle-tested for production
**notifkit isn't just an API your application can call — your AI agent can operate it directly.**
> **Battle-tested in production:** notifkit powers production notification pipelines handling **thousands of emails, push notifications, and OTPs every day.**
>
> It is the infrastructure we built because we needed it ourselves — rather than spending months reinventing distributed notification plumbing or paying SaaS tolls per alert.
Connect the notifkit MCP server ([`@notifkit/mcp`](./packages/mcp)) to Claude Code, Cursor, Claude Desktop, Gemini, or any MCP-compatible agent:
**Your servers. Your providers. Your data. Zero notification SaaS markups.**
```bash
npx -y @notifkit/mcp
### Reliability & Chaos Engineering
Because notification delivery is mission-critical, every pipeline component is tested against extreme failure conditions:
```mermaid
flowchart LR
S1["Redis Streams"] -->|"Kill Worker (SIGKILL)"| M1["Auto-Claim and Replay"] --> O1["Zero Lost Messages"]
S2["Connection Loss"] -->|"Drop DB / Redis"| M2["Auto-Reconnect / Retry"] --> O2["In-Flight State Intact"]
S3["10k+ Messages"] -->|"Burst"| M3["Concurrency and Limits"] --> O3["Flat Memory, No Leaks"]
classDef fault stroke:#ef4444,stroke-width:2px
classDef guard stroke:#6366f1,stroke-width:2px
classDef result stroke:#22c55e,stroke-width:2px
class S1,S2,S3 fault
class M1,M2,M3 guard
class O1,O2,O3 result
```
### Ask your agent
```text
You: Why didn't usr_9182 receive their password reset?
Agent: The notification was suppressed because usr_9182's email
address has a hard-bounce suppression from yesterday.
```
Your application and your AI agents use the **same notification infrastructure**:
- **Send & dispatch** — Send one-off notifications or campaigns to users, lists, and segments (`send_notification`, `send_campaign`)
- **Investigate & triage** — Diagnose delivery issues by inspecting message histories, provider responses, and quiet hours (`get_delivery_logs`, `get_notification`)
- **Schedule & cancel** — Schedule future sends and cancel pending notifications (`list_scheduled`, `cancel_notification`)
- **Campaign analytics** — Check delivery, open, click, bounce, and complaint metrics (`list_campaigns`, `get_campaign_stats`)
- **Template management** — List, preview, and update templates with sample data (`list_templates`, `preview_template`, `upsert_template`)
- **Users & preferences** — Look up users, contacts, preferences, and segment membership (`list_users`, `get_user_preferences`, `update_user_preferences`)
- **Workflow operations** — Trigger workflows and inspect workflow runs (`create_workflow`, `trigger_workflow`, `get_workflow_run`)
- **Suppressions & health** — Manage bounce suppressions, check system queues, and replay dead-letter messages (`list_suppressions`, `get_dead_letters`, `replay_dead_letter`)
### From “write a script” to “just ask”
| Without an agent | With NotifKit MCP |
| :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Jump into the DB to find contact info → open Twilio/Resend or write a throwaway script → format the payload → check their timezone manually → fire it off → hope it delivered | **You:** _“Send an urgent update to alex@acme.com that his package was lost in transit and support is rushing a replacement — text him if push doesn't deliver.”_<br><br>**Agent:** Looks up `alex@acme.com` → renders template → dispatches push with SMS fallback → bypasses quiet hours for urgent delivery → tracks delivery status → confirms it hit his phone |
[Set up MCP](https://notifkit.dev/docs/mcp.html) · [MCP documentation](https://notifkit.dev/docs/mcp.html)
- **Chaos Monkey Testing (`tests/chaos/crash.test.ts`)**: Background worker processes are randomly terminated with `SIGKILL` during active, high-throughput message streaming. Consumer group Pending Entries List (PEL) re-claims guarantee **zero lost messages** and seamless failover.
- **Infrastructure Recovery Testing (`tests/chaos/recovery.test.ts`)**: PostgreSQL and Redis connections are forcefully severed and restored under live traffic. Verifies automatic client reconnection, worker backpressure, and durable state resumption.
- **High-Throughput Load Testing (`tests/chaos/load.test.ts`)**: Stressed with bursts of **10,000+ notifications** across parallel worker pools, verifying queue drain velocity, sliding-window rate limiters, and flat memory profiles without leaks.
- **Race Conditions & Concurrency (`tests/race-conditions.test.ts`, `tests/idempotency.test.ts`)**: Hardened against concurrent duplicate dispatches, overlapping quiet-hour boundary evaluations, atomic user updates, and 24-hour idempotency key deduplication.
- **100% Real Ephemeral Containers**: Unit, integration, and chaos test suites execute against real PostgreSQL and Redis containers via [Testcontainers](https://testcontainers.com), eliminating mocks for core storage and streaming primitives.
---
## AI-assisted migration
## What you get
Already have notification code scattered across your application?
| The problem you don't want to build | How notifkit solves it |
| :------------------------------------------------------- | :------------------------------------------------------------------- |
| **“Should this user receive it?”** | User preferences, topic opt-outs, and consent gates |
| **“Is this a bad time to send?”** | Timezone-aware quiet hours that defer non-urgent sends |
| **“What if push fails?”** | Automatic ordered multi-channel fallback (`push` → `email` → `sms`) |
| **“What if my worker crashes?”** | Redis Streams consumer groups, retries, and durable idempotency |
| **“What if an event fires twice?”** | 24-hour deduplication via idempotency keys |
| **“Can I send this later?”** | Priority scheduling with `sendAt` and cancellation before dispatch |
| **“Can I send this 3 days after signup?”** | Stateful multi-step workflows with `wait` and `waitForEvent` |
| **“How do I know what happened?”** | Queryable delivery logs, Prometheus metrics, and campaign reporting |
| **“What happens when a provider goes down?”** | Circuit breakers, exponential backoff, and DLQ replay |
| **“What about bounces and spam complaints?”** | RFC 8058 one-click unsubscribe and automatic hard-bounce suppression |
| **“What if I don't want another SaaS holding my data?”** | 100% self-hosted on your PostgreSQL and Redis |
Point your coding agent at:
> **The idea is simple:** You decide what to say. **notifkit handles getting it there reliably.**
```text
https://notifkit.dev/llms-full.txt
```
---
It can understand notifkit's API and help identify ad-hoc notification code in your repository and refactor it into durable notifkit calls.
## What notifkit is — and what it isn't
**What it is:** the durable notification infrastructure layer running directly inside your own stack.
**What it isn't:** a marketing automation suite.
notifkit is not Customer.io, OneSignal, or SendGrid. You bring your own provider accounts — your keys, your billing, your deliverability.
First-party providers ship for Resend and Firebase Cloud Messaging. Anything else is a simple `Transport` class with a `send()` method.
---
@@ -323,6 +321,8 @@ 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-telegram`](./packages/provider-telegram) — messages via a Telegram bot
- [`@notifkit/provider-discord`](./packages/provider-discord) — messages via a Discord webhook
For anything else, implement a simple `Transport`:
+2
View File
@@ -0,0 +1,2 @@
ALTER TYPE "public"."channel" ADD VALUE 'telegram';--> statement-breakpoint
ALTER TYPE "public"."channel" ADD VALUE 'discord';
File diff suppressed because it is too large Load Diff
+7
View File
@@ -15,6 +15,13 @@
"when": 1786643990078,
"tag": "0001_stale_shotgun",
"breakpoints": true
},
{
"idx": 2,
"version": "7",
"when": 1788339340939,
"tag": "0002_wide_colleen_wing",
"breakpoints": true
}
]
}
+33
View File
@@ -8,6 +8,7 @@
"": {
"name": "notifkit",
"version": "0.1.3",
"dev": true,
"license": "MIT",
"workspaces": [
"packages/*"
@@ -1758,6 +1759,10 @@
"resolved": "packages/provider-console",
"link": true
},
"node_modules/@notifkit/provider-discord": {
"resolved": "packages/provider-discord",
"link": true
},
"node_modules/@notifkit/provider-fcm": {
"resolved": "packages/provider-fcm",
"link": true
@@ -1770,6 +1775,10 @@
"resolved": "packages/provider-slack",
"link": true
},
"node_modules/@notifkit/provider-telegram": {
"resolved": "packages/provider-telegram",
"link": true
},
"node_modules/@one-ini/wasm": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/@one-ini/wasm/-/wasm-0.1.1.tgz",
@@ -12996,6 +13005,18 @@
"notifkit": "^0.1.0"
}
},
"packages/provider-discord": {
"name": "@notifkit/provider-discord",
"version": "0.1.0",
"license": "MIT",
"devDependencies": {
"notifkit": "file:../../",
"typescript": "^5.8.3"
},
"peerDependencies": {
"notifkit": "^0.1.0"
}
},
"packages/provider-fcm": {
"name": "@notifkit/provider-fcm",
"version": "0.1.0",
@@ -13038,6 +13059,18 @@
"peerDependencies": {
"notifkit": "^0.1.0"
}
},
"packages/provider-telegram": {
"name": "@notifkit/provider-telegram",
"version": "0.1.0",
"license": "MIT",
"devDependencies": {
"notifkit": "file:../../",
"typescript": "^5.8.3"
},
"peerDependencies": {
"notifkit": "^0.1.0"
}
}
}
}
+11 -11
View File
@@ -90,17 +90,17 @@ Because triple braces `{{{body}}}` preserve unescaped HTML, your agent in Claude
**Campaigns & Sending**
| Tool | What it does |
| --------------------- | --------------------------------------------------------------------------------------------------------- |
| `send_campaign` | Send a template to a list of recipients across `email`, `sms`, `push`, or `webhook` with campaign tagging |
| `send_notification` | Send to existing users, a segment, or a topic with `sendAt` scheduling and priority lanes |
| `list_campaigns` | Recent campaign labels with size and last activity (supports `search`, `channel`, date range) |
| `get_campaign_stats` | Sent, delivered, failed, opened, clicked, bounced, complained, unsubscribed, with rates |
| `list_segments` | Every segment tag in use — the valid audiences to broadcast to |
| `list_scheduled` | Sends queued for the future, including quiet-hours deferrals |
| `get_delivery_logs` | Per-message detail with filters for template, channel, status, campaign, or user search |
| `get_notification` | Status and delivery attempts for a specific task |
| `cancel_notification` | Cancel a pending scheduled or quiet-hours deferred notification |
| Tool | What it does |
| --------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
| `send_campaign` | Send a template to a list of recipients across `email`, `sms`, `push`, `webhook`, `telegram`, or `discord` with campaign tagging |
| `send_notification` | Send to existing users, a segment, or a topic with `sendAt` scheduling and priority lanes |
| `list_campaigns` | Recent campaign labels with size and last activity (supports `search`, `channel`, date range) |
| `get_campaign_stats` | Sent, delivered, failed, opened, clicked, bounced, complained, unsubscribed, with rates |
| `list_segments` | Every segment tag in use — the valid audiences to broadcast to |
| `list_scheduled` | Sends queued for the future, including quiet-hours deferrals |
| `get_delivery_logs` | Per-message detail with filters for template, channel, status, campaign, or user search |
| `get_notification` | Status and delivery attempts for a specific task |
| `cancel_notification` | Cancel a pending scheduled or quiet-hours deferred notification |
**Templates** — `list_templates`, `get_template`, `upsert_template`, `delete_template`, `preview_template`, `render_template`
+20 -13
View File
@@ -3,8 +3,8 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import type { NotifkitApi } from "./client.js";
import { NotifkitApiError } from "./client.js";
const CHANNELS = ["email", "sms", "push", "webhook", "in-app"] as const;
const CONTACT_CHANNELS = ["email", "sms", "push", "webhook"] as const;
const CHANNELS = ["email", "sms", "push", "webhook", "in-app", "telegram", "discord"] as const;
const CONTACT_CHANNELS = ["email", "sms", "push", "webhook", "telegram", "discord"] as const;
const PRIORITIES = ["low", "normal", "high", "critical"] as const;
interface ToolResult {
@@ -209,14 +209,15 @@ export function registerTools(server: McpServer, api: NotifkitApi): void {
{
title: "Send a campaign to a list of addresses",
description:
"Send one template to a list of recipient destinations (email, SMS/phone, push tokens, or webhook URLs) " +
"given directly — the tool creates or updates a user record for each address, then sends, and " +
"tags every message with a campaign label so the results can be read back later with get_campaign_stats. " +
"Supports 'email', 'sms', 'push', and 'webhook' channels. Use this when the person supplies the " +
"recipients themselves (a pasted list, a spreadsheet column). Always report the returned campaign label " +
"back to the user — it is how they ask about results later. Suppressed addresses (previous " +
"unsubscribes, complaints, hard bounces) are dropped automatically at send time, so the delivered " +
"count is normally lower than the list size.",
"Send one template to a list of recipient destinations (email, SMS/phone, push tokens, webhook URLs, " +
"Telegram chat ids, or Discord webhook URLs) given directly — the tool creates or updates a user " +
"record for each address, then sends, and tags every message with a campaign label so the results " +
"can be read back later with get_campaign_stats. Supports 'email', 'sms', 'push', 'webhook', " +
"'telegram', and 'discord' channels. Use this when the person supplies the recipients themselves " +
"(a pasted list, a spreadsheet column). Always report the returned campaign label back to the user " +
"— it is how they ask about results later. Suppressed addresses (previous unsubscribes, complaints, " +
"hard bounces) are dropped automatically at send time, so the delivered count is normally lower than " +
"the list size.",
inputSchema: {
campaign: z
.string()
@@ -231,7 +232,7 @@ export function registerTools(server: McpServer, api: NotifkitApi): void {
.enum(CONTACT_CHANNELS)
.optional()
.describe(
"Channel to send on ('email', 'sms', 'push', or 'webhook'). Defaults to 'email'.",
"Channel to send on ('email', 'sms', 'push', 'webhook', 'telegram', or 'discord'). Defaults to 'email'.",
),
recipients: z
.array(z.string().min(1))
@@ -239,7 +240,8 @@ export function registerTools(server: McpServer, api: NotifkitApi): void {
.max(10000)
.optional()
.describe(
"Recipient destination addresses: email addresses for email, E.164 phone numbers for sms, device tokens for push, or URLs for webhook.",
"Recipient destination addresses: email addresses for email, E.164 phone numbers for sms, " +
"device tokens for push, URLs for webhook, chat ids for telegram, or webhook URLs for discord.",
),
emails: z
.array(z.string().email())
@@ -288,6 +290,10 @@ export function registerTools(server: McpServer, api: NotifkitApi): void {
userRecord = { id: `phone:${target}`, phone: target };
} else if (channel === "push") {
userRecord = { id: `push:${target}`, pushToken: target };
} else if (channel === "telegram") {
userRecord = { id: `telegram:${target}` };
} else if (channel === "discord") {
userRecord = { id: `discord:${target}` };
} else {
userRecord = { id: `webhook:${target}` };
}
@@ -830,7 +836,8 @@ export function registerTools(server: McpServer, api: NotifkitApi): void {
.enum(CONTACT_CHANNELS)
.optional()
.describe(
"Only list users who have a registered contact for this channel (email, sms, push, webhook).",
"Only list users who have a registered contact for this channel " +
"(email, sms, push, webhook, telegram, discord).",
),
},
annotations: { title: "List users", readOnlyHint: true },
+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.
+59
View File
@@ -0,0 +1,59 @@
# @notifkit/provider-discord
Discord webhook transport for [notifkit](https://github.com/devkitshq/notifkit).
Sends messages through an incoming Discord webhook and reports deleted/invalid
webhooks back so notifkit can deactivate the contact.
```bash
npm install @notifkit/provider-discord
```
```ts
import { NotifkitServer } from "notifkit";
import { DiscordTransport } from "@notifkit/provider-discord";
const server = new NotifkitServer({
services: ["all"],
providers: [new DiscordTransport()],
});
```
## Getting a webhook URL
In the target Discord channel: **Edit Channel → Integrations → Webhooks →
New Webhook**, then copy its URL. Unlike email or push, the webhook URL _is_
the destination — register it as the contact target directly:
```ts
await notifkit.addContact("usr_123", {
channel: "discord",
target: "https://discord.com/api/webhooks/123456789012345678/abcdef...",
});
```
Then register a template on the `discord` channel and send:
```ts
await notifkit.syncTemplates({
templates: [
{
id: "order-shipped",
channel: "discord",
content: { text: "Order #{{orderId}} shipped!" },
},
],
});
await notifkit.notify({
user: "usr_123",
template: "order-shipped",
channels: ["discord"],
data: { orderId: "9481" },
});
```
A template's content may set `username` and `avatarUrl` to override the
webhook's default identity per message, and `embeds` (an array of [Discord
embed objects](https://discord.com/developers/docs/resources/webhook#execute-webhook))
for richer formatting. 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-discord",
"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": "Discord webhook transport for notifkit — sends messages through an incoming Discord webhook.",
"license": "MIT",
"author": "devkitshq",
"homepage": "https://notifkit.dev",
"repository": {
"type": "git",
"url": "git+https://github.com/devkitshq/notifkit.git",
"directory": "packages/provider-discord"
},
"bugs": {
"url": "https://github.com/devkitshq/notifkit/issues"
},
"publishConfig": {
"access": "public"
}
}
+115
View File
@@ -0,0 +1,115 @@
import type { DeliveryResult, Transport } from "notifkit";
import type { NotificationDispatchedPayload, Logger } from "notifkit";
export interface DiscordTransportOptions {
/**
* Default sender name shown on messages, overridable per template via
* `content.username`. Left unset, Discord uses the webhook's own name.
*/
username?: string;
/** Default avatar URL, overridable per template via `content.avatarUrl`. */
avatarUrl?: string;
logger?: Logger;
/**
* Discord enforces roughly 5 requests / 2 seconds per webhook. Tighten
* this if several templates share one webhook and could burst together.
*/
limits?: { limit: number; windowSeconds: number };
}
export class DiscordTransport implements Transport {
readonly channel = "discord" as const;
readonly limits?: { limit: number; windowSeconds: number };
private readonly username?: string;
private readonly avatarUrl?: string;
private readonly logger?: Logger;
constructor({
username,
avatarUrl,
logger,
limits = { limit: 5, windowSeconds: 2 },
}: DiscordTransportOptions = {}) {
this.username = username;
this.avatarUrl = avatarUrl;
this.logger = logger;
this.limits = limits;
}
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; passing
// `undefined` as the webhook URL costs an opaque fetch failure instead.
const webhookUrl = task.destination;
if (!webhookUrl) {
return { success: false, error: "No destination (Discord webhook URL) on task" };
}
const content = task.renderedContent.content as any;
const body = content.text || content.body;
const subject = content.subject;
// Discord messages have no subject line of their own, so a template that
// renders one is bolded onto the first line rather than dropped.
const text = subject ? `**${subject}**\n${body}` : body;
// A template may carry its own sender, so one webhook can post as
// different bots for different alert types.
const username =
typeof content.username === "string" && content.username ? content.username : this.username;
const avatarUrl =
typeof content.avatarUrl === "string" && content.avatarUrl
? content.avatarUrl
: this.avatarUrl;
const embeds = Array.isArray(content.embeds) ? content.embeds : undefined;
try {
// `wait=true` makes Discord return the created message (with its id)
// in the response body instead of an empty 204 — without it there is
// no providerMessageId to record in the delivery log.
const url = new URL(webhookUrl);
url.searchParams.set("wait", "true");
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
content: text,
...(username ? { username } : {}),
...(avatarUrl ? { avatar_url: avatarUrl } : {}),
...(embeds ? { embeds } : {}),
}),
});
if (!res.ok) {
const errorBody = await res.text();
// A deleted or regenerated webhook answers 404/401 for every future
// request — the target itself is dead, not a transient failure, so
// the caller should deactivate the contact rather than retry it.
const invalidToken = res.status === 404 || res.status === 401;
this.logger?.warn(
{ taskId: task.taskId, status: res.status, errorBody },
"Discord send failed",
);
return {
success: false,
invalidToken,
error: `Discord webhook error ${res.status}: ${errorBody}`,
};
}
const data = (await res.json().catch(() => null)) as { id?: string } | null;
const providerMessageId = data?.id ?? "";
this.logger?.debug({ taskId: task.taskId, providerMessageId }, "Discord message sent");
return { success: true, providerMessageId };
} catch (err) {
const error = err as Error;
this.logger?.error({ taskId: task.taskId, error: error.message }, "Discord 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/**/*"]
}
+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.
+66
View File
@@ -0,0 +1,66 @@
# @notifkit/provider-telegram
Telegram Bot API transport for [notifkit](https://github.com/devkitshq/notifkit).
Sends messages through a Telegram bot to a chat id, and reports blocked/kicked
bots back so notifkit can deactivate the contact.
```bash
npm install @notifkit/provider-telegram
```
```ts
import { NotifkitServer } from "notifkit";
import { TelegramTransport } from "@notifkit/provider-telegram";
const server = new NotifkitServer({
services: ["all"],
providers: [
new TelegramTransport({
botToken: process.env.TELEGRAM_BOT_TOKEN!,
}),
],
});
```
## Getting a bot token
Message [@BotFather](https://t.me/BotFather) on Telegram, run `/newbot`, and
copy the token it gives you.
## Getting a chat id
A user must start a conversation with your bot before it can message them —
Telegram has no way to message someone who hasn't. Once they've sent `/start`,
read their chat id from `getUpdates` (or a webhook on your own bot) and
register it as their contact:
```ts
await notifkit.addContact("usr_123", { channel: "telegram", target: "5910738042" });
```
Then register a template on the `telegram` channel and send:
```ts
await notifkit.syncTemplates({
templates: [
{
id: "order-shipped",
channel: "telegram",
content: { text: "Your order #{{orderId}} shipped!" },
},
],
});
await notifkit.notify({
user: "usr_123",
template: "order-shipped",
channels: ["telegram"],
data: { orderId: "9481" },
});
```
A template's content may set `parseMode` (`"HTML"`, `"MarkdownV2"`, or
`"Markdown"`) to opt into formatted messages, `disableWebPagePreview` to
suppress link previews, and `disableNotification` for a silent send.
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-telegram",
"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": "Telegram Bot API transport for notifkit — sends messages through a Telegram bot.",
"license": "MIT",
"author": "devkitshq",
"homepage": "https://notifkit.dev",
"repository": {
"type": "git",
"url": "git+https://github.com/devkitshq/notifkit.git",
"directory": "packages/provider-telegram"
},
"bugs": {
"url": "https://github.com/devkitshq/notifkit/issues"
},
"publishConfig": {
"access": "public"
}
}
+115
View File
@@ -0,0 +1,115 @@
import type { DeliveryResult, Transport } from "notifkit";
import type { NotificationDispatchedPayload, Logger } from "notifkit";
export interface TelegramTransportOptions {
/** Bot token from @BotFather, e.g. "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11". */
botToken: string;
/**
* Formatting mode applied to every message unless a template's content
* names its own `parseMode`. Left unset, Telegram renders plain text —
* the safe default, since HTML/MarkdownV2 both reject malformed markup
* with a 400 rather than falling back to plain text.
*/
parseMode?: "HTML" | "MarkdownV2" | "Markdown";
logger?: Logger;
/**
* Telegram enforces ~30 messages/sec bot-wide and 1/sec per chat. The
* default throttles to the bot-wide ceiling; tighten it if most sends
* land in a handful of chats.
*/
limits?: { limit: number; windowSeconds: number };
}
export class TelegramTransport implements Transport {
readonly channel = "telegram" as const;
readonly limits?: { limit: number; windowSeconds: number };
private readonly botToken: string;
private readonly parseMode?: string;
private readonly logger?: Logger;
constructor({
botToken,
parseMode,
logger,
limits = { limit: 30, windowSeconds: 1 },
}: TelegramTransportOptions) {
this.botToken = botToken;
this.parseMode = parseMode;
this.logger = logger;
this.limits = limits;
}
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; passing
// `undefined` to the Bot API costs an opaque provider rejection and a
// retry cycle.
const chatId = task.destination;
if (!chatId) {
return { success: false, error: "No destination (Telegram chat id) on task" };
}
const content = task.renderedContent.content as any;
const body = content.text || content.body;
const subject = content.subject;
// Telegram messages have no subject line of their own, so a template
// that renders one gets folded into the first line rather than dropped.
const text = subject ? `${subject}\n\n${body}` : body;
const parseMode = typeof content.parseMode === "string" ? content.parseMode : this.parseMode;
try {
const res = await fetch(`https://api.telegram.org/bot${this.botToken}/sendMessage`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
chat_id: chatId,
text,
...(parseMode ? { parse_mode: parseMode } : {}),
...(content.disableWebPagePreview ? { link_preview_options: { is_disabled: true } } : {}),
...(content.disableNotification ? { disable_notification: true } : {}),
}),
});
const data = (await res.json()) as {
ok: boolean;
result?: { message_id: number };
error_code?: number;
description?: string;
};
if (!data.ok) {
// The bot was blocked/kicked, or the chat id no longer resolves to
// anything — the target itself is dead, not a transient failure, so
// the caller should deactivate the contact rather than retry it.
const invalidToken =
data.error_code === 403 ||
(data.error_code === 400 &&
/chat not found|user is deactivated/i.test(data.description ?? ""));
this.logger?.warn(
{ taskId: task.taskId, errorCode: data.error_code, description: data.description },
"Telegram send failed",
);
return {
success: false,
invalidToken,
error: data.description ?? `Telegram API error ${data.error_code ?? "unknown"}`,
};
}
const providerMessageId = String(data.result?.message_id ?? "");
this.logger?.debug({ taskId: task.taskId, providerMessageId }, "Telegram message sent");
return { success: true, providerMessageId };
} catch (err) {
const error = err as Error;
this.logger?.error(
{ taskId: task.taskId, error: error.message },
"Telegram 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/**/*"]
}
+9 -1
View File
@@ -1,6 +1,14 @@
import { z } from "zod";
export const NotificationChannelSchema = z.enum(["email", "sms", "push", "webhook", "in-app"]);
export const NotificationChannelSchema = z.enum([
"email",
"sms",
"push",
"webhook",
"in-app",
"telegram",
"discord",
]);
export type NotificationChannel = z.infer<typeof NotificationChannelSchema>;
export const NotificationPrioritySchema = z.enum(["low", "normal", "high", "critical"]);
@@ -8,6 +8,8 @@ export const RecipientProfileSchema = z.object({
webhook: z.string().url().optional(),
pushTokens: z.array(z.string()).optional(),
pushToken: z.string().optional(),
telegram: z.string().optional(),
discord: z.string().url().optional(),
locale: z.string().default("en"),
timezone: z.string().default("UTC"),
preferences: z.object({
+9 -2
View File
@@ -22,8 +22,15 @@ export type Preferences = z.infer<typeof PreferencesSchema>;
// ─── Contacts ─────────────────────────────────────────────────────────────────
/** Channels that carry an addressable target (email address, phone, push token, url). */
export const ContactChannelSchema = z.enum(["email", "sms", "push", "webhook"]);
/** Channels that carry an addressable target (email address, phone, push token, url, chat id). */
export const ContactChannelSchema = z.enum([
"email",
"sms",
"push",
"webhook",
"telegram",
"discord",
]);
export type ContactChannel = z.infer<typeof ContactChannelSchema>;
/** Accept a single value or an array; always normalise to a non-empty array. */
+9 -1
View File
@@ -17,7 +17,15 @@ import {
import { sql } from "drizzle-orm";
import { createInsertSchema, createSelectSchema } from "drizzle-zod";
export const channelEnum = pgEnum("channel", ["email", "sms", "push", "webhook", "in-app"]);
export const channelEnum = pgEnum("channel", [
"email",
"sms",
"push",
"webhook",
"in-app",
"telegram",
"discord",
]);
export const projects = pgTable("projects", {
id: uuid("id").primaryKey().defaultRandom(),
+7 -3
View File
@@ -168,9 +168,13 @@ export class AiWorker extends BaseWorker {
? pending.recipient.phone
: pending.channel === "webhook"
? pending.recipient.webhook
: pending.channel === "push"
? (pending.recipient.pushTokens?.[0] ?? pending.recipient.pushToken)
: undefined;
: pending.channel === "telegram"
? pending.recipient.telegram
: pending.channel === "discord"
? pending.recipient.discord
: pending.channel === "push"
? (pending.recipient.pushTokens?.[0] ?? pending.recipient.pushToken)
: undefined;
const resolvedDestination =
destination ?? (pending.channel === "push" ? undefined : pending.recipientId);
+5 -2
View File
@@ -422,10 +422,13 @@ export class DeliveryWorker extends BaseWorker {
await this.contactRepo.deactivate(
task.projectId,
task.recipientId,
"push",
task.channel as any,
task.destination,
);
this.logger.info({ token: task.destination }, "deactivated invalid push token");
this.logger.info(
{ channel: task.channel, target: task.destination },
"deactivated invalid contact target",
);
this.globalEmitter.emit(
"delivery:failed",
task.taskId,
+2
View File
@@ -429,6 +429,8 @@ export class EnricherWorker extends BaseWorker {
: (profile.email ?? undefined),
phone: channel === "sms" ? destination : undefined,
webhook: channel === "webhook" ? destination : undefined,
telegram: channel === "telegram" ? destination : undefined,
discord: channel === "discord" ? destination : undefined,
locale: profile.language ?? "en",
timezone: profile.timezone ?? "UTC",
preferences: {