From 1993b2e99c5aa6e77017ba95edd67d355358b44d Mon Sep 17 00:00:00 2001 From: Suhaanth V V Date: Wed, 2 Sep 2026 15:09:30 +0530 Subject: [PATCH] feat: add new providers --- README.md | 2 + drizzle/0002_wide_colleen_wing.sql | 2 + drizzle/meta/0002_snapshot.json | 1460 +++++++++++++++++ drizzle/meta/_journal.json | 7 + package-lock.json | 33 + packages/mcp/README.md | 22 +- packages/mcp/src/tools.ts | 33 +- packages/provider-discord/LICENSE | 21 + packages/provider-discord/README.md | 59 + packages/provider-discord/package.json | 40 + packages/provider-discord/src/index.ts | 115 ++ packages/provider-discord/tsconfig.json | 8 + packages/provider-telegram/LICENSE | 21 + packages/provider-telegram/README.md | 66 + packages/provider-telegram/package.json | 40 + packages/provider-telegram/src/index.ts | 115 ++ packages/provider-telegram/tsconfig.json | 8 + src/contracts/common.ts | 10 +- src/contracts/events/notification-enriched.ts | 2 + src/contracts/sdk.ts | 11 +- src/db/schema.ts | 10 +- src/services/ai/main.ts | 10 +- src/services/delivery/main.ts | 7 +- src/services/enricher/main.ts | 2 + 24 files changed, 2071 insertions(+), 33 deletions(-) create mode 100644 drizzle/0002_wide_colleen_wing.sql create mode 100644 drizzle/meta/0002_snapshot.json create mode 100644 packages/provider-discord/LICENSE create mode 100644 packages/provider-discord/README.md create mode 100644 packages/provider-discord/package.json create mode 100644 packages/provider-discord/src/index.ts create mode 100644 packages/provider-discord/tsconfig.json create mode 100644 packages/provider-telegram/LICENSE create mode 100644 packages/provider-telegram/README.md create mode 100644 packages/provider-telegram/package.json create mode 100644 packages/provider-telegram/src/index.ts create mode 100644 packages/provider-telegram/tsconfig.json diff --git a/README.md b/README.md index 837c529..d70e637 100644 --- a/README.md +++ b/README.md @@ -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`: diff --git a/drizzle/0002_wide_colleen_wing.sql b/drizzle/0002_wide_colleen_wing.sql new file mode 100644 index 0000000..1018a76 --- /dev/null +++ b/drizzle/0002_wide_colleen_wing.sql @@ -0,0 +1,2 @@ +ALTER TYPE "public"."channel" ADD VALUE 'telegram';--> statement-breakpoint +ALTER TYPE "public"."channel" ADD VALUE 'discord'; \ No newline at end of file diff --git a/drizzle/meta/0002_snapshot.json b/drizzle/meta/0002_snapshot.json new file mode 100644 index 0000000..112a4fe --- /dev/null +++ b/drizzle/meta/0002_snapshot.json @@ -0,0 +1,1460 @@ +{ + "id": "b2263eab-f6fb-46ed-863e-000e5a580f9d", + "prevId": "4d282198-10d4-4fbe-88e5-3df20c511678", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.contact_topic_preferences": { + "name": "contact_topic_preferences", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "contact_id": { + "name": "contact_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "topic": { + "name": "topic", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "contact_topic_preferences_contact_id_user_contacts_id_fk": { + "name": "contact_topic_preferences_contact_id_user_contacts_id_fk", + "tableFrom": "contact_topic_preferences", + "tableTo": "user_contacts", + "columnsFrom": ["contact_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "contact_topic_preferences_contact_id_topic_unique": { + "name": "contact_topic_preferences_contact_id_topic_unique", + "nullsNotDistinct": false, + "columns": ["contact_id", "topic"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.delivery_outbox": { + "name": "delivery_outbox", + "schema": "", + "columns": { + "task_id": { + "name": "task_id", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "channel": { + "name": "channel", + "type": "channel", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "destination": { + "name": "destination", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_message_id": { + "name": "provider_message_id", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "delivery_outbox_task_id_channel_destination_pk": { + "name": "delivery_outbox_task_id_channel_destination_pk", + "columns": ["task_id", "channel", "destination"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.message_logs": { + "name": "message_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "task_id": { + "name": "task_id", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "provider_message_id": { + "name": "provider_message_id", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "template_id": { + "name": "template_id", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "workflow_instance_id": { + "name": "workflow_instance_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "channel": { + "name": "channel", + "type": "channel", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "attempt": { + "name": "attempt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "kind": { + "name": "kind", + "type": "varchar", + "primaryKey": false, + "notNull": true, + "default": "'attempt'" + }, + "status": { + "name": "status", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "campaign_id": { + "name": "campaign_id", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "timestamp": { + "name": "timestamp", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "message_logs_project_idx": { + "name": "message_logs_project_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_idx": { + "name": "task_idx", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "message_logs_project_task_idx": { + "name": "message_logs_project_task_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "provider_msg_idx": { + "name": "provider_msg_idx", + "columns": [ + { + "expression": "provider_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "msg_log_proj_time_idx": { + "name": "msg_log_proj_time_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "timestamp", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "msg_log_template_idx": { + "name": "msg_log_template_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "template_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "msg_log_workflow_idx": { + "name": "msg_log_workflow_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_instance_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "msg_log_campaign_idx": { + "name": "msg_log_campaign_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "campaign_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "task_channel_attempt_uidx": { + "name": "task_channel_attempt_uidx", + "nullsNotDistinct": false, + "columns": ["task_id", "channel", "attempt", "kind"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.project_api_keys": { + "name": "project_api_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "key_hash": { + "name": "key_hash", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "api_key_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'admin'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "project_api_keys_project_id_projects_id_fk": { + "name": "project_api_keys_project_id_projects_id_fk", + "tableFrom": "project_api_keys", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "project_api_keys_key_hash_unique": { + "name": "project_api_keys_key_hash_unique", + "nullsNotDistinct": false, + "columns": ["key_hash"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.projects": { + "name": "projects", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "rate_limit_rpm": { + "name": "rate_limit_rpm", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "throttle_limit": { + "name": "throttle_limit", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "throttle_window_hours": { + "name": "throttle_window_hours", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.quiet_hours": { + "name": "quiet_hours", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "contact_id": { + "name": "contact_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "start_time": { + "name": "start_time", + "type": "time", + "primaryKey": false, + "notNull": true + }, + "end_time": { + "name": "end_time", + "type": "time", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "quiet_hours_user_idx": { + "name": "quiet_hours_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "quiet_hours_user_id_users_id_fk": { + "name": "quiet_hours_user_id_users_id_fk", + "tableFrom": "quiet_hours", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "quiet_hours_contact_id_user_contacts_id_fk": { + "name": "quiet_hours_contact_id_user_contacts_id_fk", + "tableFrom": "quiet_hours", + "tableTo": "user_contacts", + "columnsFrom": ["contact_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "check_owner": { + "name": "check_owner", + "value": "num_nonnulls(user_id, contact_id) = 1" + } + }, + "isRLSEnabled": false + }, + "public.scheduled_payloads": { + "name": "scheduled_payloads", + "schema": "", + "columns": { + "task_id": { + "name": "task_id", + "type": "varchar", + "primaryKey": true, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.suppressions": { + "name": "suppressions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "channel": { + "name": "channel", + "type": "channel", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "target": { + "name": "target", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "task_id": { + "name": "task_id", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "suppression_project_idx": { + "name": "suppression_project_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "suppression_lookup_idx": { + "name": "suppression_lookup_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "channel", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "suppression_project_channel_target_uidx": { + "name": "suppression_project_channel_target_uidx", + "nullsNotDistinct": false, + "columns": ["project_id", "channel", "target"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.templates": { + "name": "templates", + "schema": "", + "columns": { + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "id": { + "name": "id", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "channel": { + "name": "channel", + "type": "channel", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "topics": { + "name": "topics", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "ai_prompts": { + "name": "ai_prompts", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "templates_project_id_projects_id_fk": { + "name": "templates_project_id_projects_id_fk", + "tableFrom": "templates", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "templates_project_id_id_pk": { + "name": "templates_project_id_id_pk", + "columns": ["project_id", "id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_channel_preferences": { + "name": "user_channel_preferences", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "channel": { + "name": "channel", + "type": "channel", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "user_channel_preferences_user_id_users_id_fk": { + "name": "user_channel_preferences_user_id_users_id_fk", + "tableFrom": "user_channel_preferences", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_channel_preferences_user_id_channel_pk": { + "name": "user_channel_preferences_user_id_channel_pk", + "columns": ["user_id", "channel"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_contacts": { + "name": "user_contacts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "channel": { + "name": "channel", + "type": "channel", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "target": { + "name": "target", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_primary": { + "name": "is_primary", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_contacts_user_id_users_id_fk": { + "name": "user_contacts_user_id_users_id_fk", + "tableFrom": "user_contacts", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_contacts_user_id_channel_target_unique": { + "name": "user_contacts_user_id_channel_target_unique", + "nullsNotDistinct": false, + "columns": ["user_id", "channel", "target"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_segments": { + "name": "user_segments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "segment": { + "name": "segment", + "type": "varchar", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "segment_idx": { + "name": "segment_idx", + "columns": [ + { + "expression": "segment", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_segments_user_id_users_id_fk": { + "name": "user_segments_user_id_users_id_fk", + "tableFrom": "user_segments", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_segments_user_id_segment_unique": { + "name": "user_segments_user_id_segment_unique", + "nullsNotDistinct": false, + "columns": ["user_id", "segment"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_topic_preferences": { + "name": "user_topic_preferences", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "topic": { + "name": "topic", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "user_topic_preferences_user_id_users_id_fk": { + "name": "user_topic_preferences_user_id_users_id_fk", + "tableFrom": "user_topic_preferences", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_topic_preferences_user_id_topic_unique": { + "name": "user_topic_preferences_user_id_topic_unique", + "nullsNotDistinct": false, + "columns": ["user_id", "topic"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "attributes": { + "name": "attributes", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_project_id_external_id_unique": { + "name": "users_project_id_external_id_unique", + "nullsNotDistinct": false, + "columns": ["project_id", "external_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_definitions": { + "name": "workflow_definitions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "steps": { + "name": "steps", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workflow_definitions_project_id_name_unique": { + "name": "workflow_definitions_project_id_name_unique", + "nullsNotDistinct": false, + "columns": ["project_id", "name"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_instances": { + "name": "workflow_instances", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "workflow_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "input": { + "name": "input", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_name_idx": { + "name": "workflow_name_idx", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_steps": { + "name": "workflow_steps", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "instance_id": { + "name": "instance_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "step_index": { + "name": "step_index", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "output": { + "name": "output", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "workflow_steps_instance_id_workflow_instances_id_fk": { + "name": "workflow_steps_instance_id_workflow_instances_id_fk", + "tableFrom": "workflow_steps", + "tableTo": "workflow_instances", + "columnsFrom": ["instance_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workflow_steps_instance_id_step_index_unique": { + "name": "workflow_steps_instance_id_step_index_unique", + "nullsNotDistinct": false, + "columns": ["instance_id", "step_index"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_waiters": { + "name": "workflow_waiters", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "instance_id": { + "name": "instance_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "event_name": { + "name": "event_name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "match_criteria": { + "name": "match_criteria", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "waiter_event_idx": { + "name": "waiter_event_idx", + "columns": [ + { + "expression": "event_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "waiter_instance_idx": { + "name": "waiter_instance_idx", + "columns": [ + { + "expression": "instance_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "waiter_comp_idx": { + "name": "waiter_comp_idx", + "columns": [ + { + "expression": "event_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "waiter_match_idx": { + "name": "waiter_match_idx", + "columns": [ + { + "expression": "match_criteria", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "workflow_waiters_instance_id_workflow_instances_id_fk": { + "name": "workflow_waiters_instance_id_workflow_instances_id_fk", + "tableFrom": "workflow_waiters", + "tableTo": "workflow_instances", + "columnsFrom": ["instance_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.api_key_role": { + "name": "api_key_role", + "schema": "public", + "values": ["admin", "read_only"] + }, + "public.channel": { + "name": "channel", + "schema": "public", + "values": ["email", "sms", "push", "webhook", "in-app", "telegram", "discord"] + }, + "public.workflow_status": { + "name": "workflow_status", + "schema": "public", + "values": ["pending", "running", "completed", "failed"] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 6b688cb..f5b878d 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -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 } ] } diff --git a/package-lock.json b/package-lock.json index d5d09ef..792b8c3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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" + } } } } diff --git a/packages/mcp/README.md b/packages/mcp/README.md index 58f0682..d05a6e1 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -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` diff --git a/packages/mcp/src/tools.ts b/packages/mcp/src/tools.ts index fa84326..521f23d 100644 --- a/packages/mcp/src/tools.ts +++ b/packages/mcp/src/tools.ts @@ -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 }, diff --git a/packages/provider-discord/LICENSE b/packages/provider-discord/LICENSE new file mode 100644 index 0000000..235464d --- /dev/null +++ b/packages/provider-discord/LICENSE @@ -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. diff --git a/packages/provider-discord/README.md b/packages/provider-discord/README.md new file mode 100644 index 0000000..d1dd6d5 --- /dev/null +++ b/packages/provider-discord/README.md @@ -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. diff --git a/packages/provider-discord/package.json b/packages/provider-discord/package.json new file mode 100644 index 0000000..cd1e5b3 --- /dev/null +++ b/packages/provider-discord/package.json @@ -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" + } +} diff --git a/packages/provider-discord/src/index.ts b/packages/provider-discord/src/index.ts new file mode 100644 index 0000000..f8fec1b --- /dev/null +++ b/packages/provider-discord/src/index.ts @@ -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 { + // `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 }; + } + } +} diff --git a/packages/provider-discord/tsconfig.json b/packages/provider-discord/tsconfig.json new file mode 100644 index 0000000..a013e0c --- /dev/null +++ b/packages/provider-discord/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src/**/*"] +} diff --git a/packages/provider-telegram/LICENSE b/packages/provider-telegram/LICENSE new file mode 100644 index 0000000..235464d --- /dev/null +++ b/packages/provider-telegram/LICENSE @@ -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. diff --git a/packages/provider-telegram/README.md b/packages/provider-telegram/README.md new file mode 100644 index 0000000..54fc64f --- /dev/null +++ b/packages/provider-telegram/README.md @@ -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. diff --git a/packages/provider-telegram/package.json b/packages/provider-telegram/package.json new file mode 100644 index 0000000..6bc99fc --- /dev/null +++ b/packages/provider-telegram/package.json @@ -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" + } +} diff --git a/packages/provider-telegram/src/index.ts b/packages/provider-telegram/src/index.ts new file mode 100644 index 0000000..2d94486 --- /dev/null +++ b/packages/provider-telegram/src/index.ts @@ -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 { + // `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 }; + } + } +} diff --git a/packages/provider-telegram/tsconfig.json b/packages/provider-telegram/tsconfig.json new file mode 100644 index 0000000..a013e0c --- /dev/null +++ b/packages/provider-telegram/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src/**/*"] +} diff --git a/src/contracts/common.ts b/src/contracts/common.ts index 8e18933..6086188 100644 --- a/src/contracts/common.ts +++ b/src/contracts/common.ts @@ -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; export const NotificationPrioritySchema = z.enum(["low", "normal", "high", "critical"]); diff --git a/src/contracts/events/notification-enriched.ts b/src/contracts/events/notification-enriched.ts index 011b2e4..0ad3bc2 100644 --- a/src/contracts/events/notification-enriched.ts +++ b/src/contracts/events/notification-enriched.ts @@ -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({ diff --git a/src/contracts/sdk.ts b/src/contracts/sdk.ts index c7178f9..db72e9a 100644 --- a/src/contracts/sdk.ts +++ b/src/contracts/sdk.ts @@ -22,8 +22,15 @@ export type Preferences = z.infer; // ─── 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; /** Accept a single value or an array; always normalise to a non-empty array. */ diff --git a/src/db/schema.ts b/src/db/schema.ts index c24a39c..573899c 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -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(), diff --git a/src/services/ai/main.ts b/src/services/ai/main.ts index e830897..8c6d271 100644 --- a/src/services/ai/main.ts +++ b/src/services/ai/main.ts @@ -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); diff --git a/src/services/delivery/main.ts b/src/services/delivery/main.ts index 77cdac4..c3dbcba 100644 --- a/src/services/delivery/main.ts +++ b/src/services/delivery/main.ts @@ -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, diff --git a/src/services/enricher/main.ts b/src/services/enricher/main.ts index f27e234..56e3960 100644 --- a/src/services/enricher/main.ts +++ b/src/services/enricher/main.ts @@ -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: {