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,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-twilio`](./packages/provider-twilio) — SMS via Twilio, with signature-verified delivery status callbacks
|
||||
- [`@notifkit/provider-telegram`](./packages/provider-telegram) — messages via a Telegram bot
|
||||
- [`@notifkit/provider-discord`](./packages/provider-discord) — messages via a Discord webhook
|
||||
|
||||
For anything else, implement a simple `Transport`:
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Generated
+22
@@ -1759,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
|
||||
@@ -1769,6 +1773,8 @@
|
||||
},
|
||||
"node_modules/@notifkit/provider-twilio": {
|
||||
"resolved": "packages/provider-twilio",
|
||||
"node_modules/@notifkit/provider-telegram": {
|
||||
"resolved": "packages/provider-telegram",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@one-ini/wasm": {
|
||||
@@ -13137,6 +13143,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",
|
||||
@@ -13175,6 +13193,10 @@
|
||||
"dependencies": {
|
||||
"twilio": "^5.0.0"
|
||||
},
|
||||
"packages/provider-telegram": {
|
||||
"name": "@notifkit/provider-telegram",
|
||||
"version": "0.1.0",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"notifkit": "file:../../",
|
||||
"typescript": "^5.8.3"
|
||||
|
||||
+11
-11
@@ -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
@@ -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 },
|
||||
|
||||
@@ -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,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.
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
@@ -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,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.
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user