Merge pull request #2 from devkitshq/feature/provider-telegram-and-discord

feat: add new providers
This commit is contained in:
coo1estguy
2026-09-03 15:05:09 +05:30
committed by GitHub
24 changed files with 2071 additions and 33 deletions
+2
View File
@@ -320,6 +320,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-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
@@ -1766,6 +1771,10 @@
"resolved": "packages/provider-resend",
"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",
@@ -12992,6 +13001,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",
@@ -13022,6 +13043,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: {