diff --git a/.env.example b/.env.example index c9d57c1..4501211 100644 --- a/.env.example +++ b/.env.example @@ -11,6 +11,10 @@ DISCORD_PUBLIC_KEY=your-public-key DISCORD_APPLICATION_ID=your-application-id DISCORD_CHANNEL_ID=your-channel-id +# Telegram +TELEGRAM_TOKEN=your-bot-token +TELEGRAM_WEBHOOK_SECRET=your-webhook-secret + # Server PORT=3000 BASE_URL=http://localhost:3000 diff --git a/AGENTS.md b/AGENTS.md index 701a641..e89dd93 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -40,7 +40,7 @@ src/ │ # milestone, discussion, repository, security, generic ├── drivers/ # Platform drivers (pluggable push targets) │ ├── types.ts # PlatformDriver interface + SendResult -│ ├── index.ts # getDriver() registry (discord default + telegram stub) +│ ├── index.ts # getDriver() registry (discord default + telegram) │ ├── discord/ │ │ ├── index.ts # DiscordDriver: send → renderNeutralMessage + rest.sendMessage │ │ ├── render.ts # renderNeutralMessage: NeutralMessage → Discord FormattedMessage @@ -48,10 +48,14 @@ src/ │ │ ├── interactions.ts # Ed25519 verify + interaction handlers (/gh, buttons, modals) │ │ └── commands.ts # APP_COMMANDS + registerGlobalCommands/syncGuildCommands/syncCommands │ └── telegram/ -│ └── index.ts # TelegramDriver stub (not implemented yet) +│ ├── index.ts # TelegramDriver: send → renderNeutralMessage + rest.sendMessage +│ ├── render.ts # renderNeutralMessage: NeutralMessage → Telegram HTML (parse_mode HTML) +│ ├── rest.ts # Telegram Bot API sendMessage (chat_id + message_thread_id), retry +│ ├── updates.ts # POST /telegram/webhook: secret-token verify + handleTelegramUpdate +│ └── commands.ts # Telegram /gh login|logout|comment|merge|close + reply-message parsing + syncTelegramWebhook ├── github/ │ ├── oauth.ts # OAuth URL, callback token exchange, getUserOctokit, comment/merge/close actions -│ └── store.ts # KV token CRUD + D1 discord-link mapping (was token-store.ts) +│ └── store.ts # KV token CRUD + D1 discord-link/telegram-link mapping (was token-store.ts) ├── web/ # HTTP UI/API routes │ ├── oauth-routes.ts # GET /auth/github, callback (admin session / discord-link), DELETE /token/:userId │ ├── action-routes.ts # POST /api/comment|merge|react (Bearer token auth via KV lookup) @@ -71,11 +75,14 @@ src/ - Verify GitHub webhook signatures (Web Crypto HMAC-SHA256) - Verify Discord interactions (Web Crypto Ed25519, X-Signature-Ed25519 over timestamp + body) +- Verify Telegram webhook calls (X-Telegram-Bot-Api-Secret-Token when configured) - Filter events by: event type, repo name, actor, action, branch, keyword (regex supported) - Format 23+ event types as Discord embeds -- Route messages to Discord channels/threads via REST +- Route messages to Discord channels/threads and Telegram chats/topics via REST - Serve `/gh` slash commands + message context-menu commands + PR merge/close buttons + comment modals +- Serve Telegram `/gh` commands (login/logout/comment/merge/close) via reply-message parsing - Sync application commands from the scheduled trigger (global ~1h propagation + per-guild instant) +- Sync the Telegram webhook URL from the scheduled trigger (setWebhook) ## Message Format Spec @@ -105,8 +112,9 @@ npm run lint # ESLint - **Production**: `wrangler secret put ` for each secret - **Routes**: KV key `config:routes` (JSON array, empty until configured) - **KV namespace**: Required binding for token/state/config/session storage -- **D1 database**: Binding `DB` (database `webhooker`, id `214a0104-3235-47c0-b7bf-ddda95f3c8ac`) for `send_logs` + `discord_links` tables +- **D1 database**: Binding `DB` (database `webhooker`, id `214a0104-3235-47c0-b7bf-ddda95f3c8ac`) for `send_logs` + `discord_links` + `telegram_links` tables - **Discord**: `DISCORD_PUBLIC_KEY` (Interactions Endpoint signature verification, from Discord Developer Portal) and `DISCORD_APPLICATION_ID` (optional, auto-resolved via `GET /oauth2/applications/@me` when omitted) are required for interactions +- **Telegram**: `TELEGRAM_TOKEN` (Bot API token from BotFather) required for Telegram routes; `TELEGRAM_WEBHOOK_SECRET` (optional secret token for `POST /telegram/webhook` verification) ## Deployment diff --git a/README.md b/README.md index f3d3baa..2dc2547 100644 --- a/README.md +++ b/README.md @@ -57,7 +57,7 @@ npx wrangler dev # Start local dev server ### Routes -Routes are stored in KV (`config:routes` as JSON). There are **no default routes** — every route (including its target `channelId` / `threadId`) must be defined explicitly, either via the Web UI (`/admin`) or by storing a JSON array in KV: +Routes are stored in KV (`config:routes` as JSON). There are **no default routes** — every route (including its target) must be defined explicitly, either via the Web UI (`/admin`) or by storing a JSON array in KV: ```json [ @@ -66,12 +66,19 @@ Routes are stored in KV (`config:routes` as JSON). There are **no default routes "name": "Push Events", "enabled": true, "filters": [{ "type": "event", "match": "push" }], - "target": { "channelId": "CHANNEL_ID" } + "target": { "platform": "discord", "channelId": "CHANNEL_ID" } + }, + { + "id": "telegram-issues", + "name": "Issues to Telegram", + "enabled": true, + "filters": [{ "type": "event", "match": "issues" }], + "target": { "platform": "telegram", "chatId": "-1001234567890" } } ] ``` -The `target.channelId` is required and is used as-is; there is no fallback to a default channel. +`target.platform` selects the push target: `discord` (default) or `telegram`. Discord routes require `target.channelId` (optional `threadId` for a thread); Telegram routes require `target.chatId` (the group chat id, optional `topicId` for a topic). There is no fallback to a default channel. ### Web UI (`/admin`) diff --git a/README.zh.md b/README.zh.md index 553a8b7..b8ff790 100644 --- a/README.zh.md +++ b/README.zh.md @@ -57,7 +57,7 @@ npx wrangler dev # 启动本地开发服务器 ### 路由配置 -路由存储在 KV(`config:routes`,JSON 格式)。**没有默认路由**——每条路由(包括目标 `channelId` / `threadId`)都必须显式定义,可通过 Web 控制台(`/admin`)或直接向 KV 存储 JSON 数组: +路由存储在 KV(`config:routes`,JSON 格式)。**没有默认路由**——每条路由(包括目标)都必须显式定义,可通过 Web 控制台(`/admin`)或直接向 KV 存储 JSON 数组: ```json [ @@ -66,12 +66,19 @@ npx wrangler dev # 启动本地开发服务器 "name": "Push 事件", "enabled": true, "filters": [{ "type": "event", "match": "push" }], - "target": { "channelId": "频道ID" } + "target": { "platform": "discord", "channelId": "频道ID" } + }, + { + "id": "telegram-issues", + "name": "Issue 推送 Telegram", + "enabled": true, + "filters": [{ "type": "event", "match": "issues" }], + "target": { "platform": "telegram", "chatId": "-1001234567890" } } ] ``` -`target.channelId` 必填且按原样使用,不存在默认频道回退。 +`target.platform` 选择推送目标:`discord`(默认)或 `telegram`。Discord 路由需 `target.channelId`(可选 `threadId` 指向子区);Telegram 路由需 `target.chatId`(群组聊天 ID,可选 `topicId` 指向话题)。不存在默认频道回退。 ### Web 控制台(`/admin`) diff --git a/admin/components/RouteCard.vue b/admin/components/RouteCard.vue index e37e4d6..8d0eed5 100644 --- a/admin/components/RouteCard.vue +++ b/admin/components/RouteCard.vue @@ -9,6 +9,13 @@ {{ route.name || t("route.untitled") }} {{ route.id }} {{ route.lang }} + {{ tg.platform === "telegram" ? "Telegram" : "Discord" }} {{ t("route.fallback") }}
@@ -36,14 +43,20 @@ >
- {{ t("route.channel") }}{{ route.target.channelId }} - {{ t("route.thread") }}{{ route.target.threadId }} +
+ {{ tg.platform === "telegram" ? t("route.chat") : t("route.channel") }}{{ tg.platform === "telegram" ? tg.chatId : tg.channelId }} + {{ t("route.topic") }}{{ tg.topicId }} + {{ t("route.thread") }}{{ tg.threadId }} +
diff --git a/admin/components/RouteEditor.vue b/admin/components/RouteEditor.vue index d5a69bd..bb1fafa 100644 --- a/admin/components/RouteEditor.vue +++ b/admin/components/RouteEditor.vue @@ -74,27 +74,48 @@
{{ filterError }}
-
-
- - -
-
- - +
+ +
+ + + +
+ +
{{ targetError }}
{{ formError }}
@@ -113,7 +134,7 @@ diff --git a/admin/composables/useI18n.ts b/admin/composables/useI18n.ts index 225b299..f296c3c 100644 --- a/admin/composables/useI18n.ts +++ b/admin/composables/useI18n.ts @@ -34,6 +34,8 @@ const en: Dict = { "route.noFilters": "no filters", "route.channel": "CHANNEL", "route.thread": "THREAD", + "route.chat": "CHAT", + "route.topic": "TOPIC", "route.fallback": "fallback", "filter.event": "Event", "filter.repo": "Repo", @@ -64,13 +66,24 @@ const en: Dict = { "routeEditor.thread": "Thread ID", "routeEditor.threadNote": "(optional)", "routeEditor.threadPlaceholder": "Optional thread ID", + "routeEditor.platform": "Platform", + "routeEditor.chat": "Chat ID", + "routeEditor.chatPlaceholder": "Telegram group chat ID", + "routeEditor.topic": "Topic ID", + "routeEditor.topicNote": "(optional)", + "routeEditor.topicPlaceholder": "Optional topic (message_thread_id)", + "routeEditor.errChat": "Target {n} chat ID is required", + "routeEditor.targets": "Targets", + "routeEditor.targetsNote": "(one or more destinations)", + "routeEditor.addTarget": "+ Add target", + "routeEditor.errTargets": "Add at least one target", "routeEditor.cancel": "Cancel", "routeEditor.save": "Save route", "routeEditor.errFilterMatch": "Filter {n} needs a match value", "routeEditor.errAddFilter": "Add at least one filter", "routeEditor.errIdFormat": "ID must be a-z / 0-9 / dashes", "routeEditor.errName": "Name is required", - "routeEditor.errChannel": "Channel ID is required", + "routeEditor.errChannel": "Target {n} channel ID is required", "groupEditor.editTitle": "Edit group", "groupEditor.newTitle": "New group", "groupEditor.close": "Close", @@ -161,6 +174,8 @@ const zh: Dict = { "route.noFilters": "无过滤器", "route.channel": "频道", "route.thread": "子区", + "route.chat": "群组", + "route.topic": "话题", "route.fallback": "兜底", "filter.event": "事件", "filter.repo": "仓库", @@ -191,13 +206,24 @@ const zh: Dict = { "routeEditor.thread": "子区 ID", "routeEditor.threadNote": "(可选)", "routeEditor.threadPlaceholder": "可选的子区 ID", + "routeEditor.platform": "平台", + "routeEditor.chat": "群组 ID", + "routeEditor.chatPlaceholder": "Telegram 群组聊天 ID", + "routeEditor.topic": "话题 ID", + "routeEditor.topicNote": "(可选)", + "routeEditor.topicPlaceholder": "可选的话题 (message_thread_id)", + "routeEditor.errChat": "第 {n} 个目标群组 ID 为必填项", + "routeEditor.targets": "目标", + "routeEditor.targetsNote": "(一个或多个推送目标)", + "routeEditor.addTarget": "+ 添加目标", + "routeEditor.errTargets": "至少添加一个目标", "routeEditor.cancel": "取消", "routeEditor.save": "保存路由", "routeEditor.errFilterMatch": "第 {n} 个过滤器需要匹配值", "routeEditor.errAddFilter": "至少添加一个过滤器", "routeEditor.errIdFormat": "ID 只能是 a-z / 0-9 / 短横线", "routeEditor.errName": "名称为必填项", - "routeEditor.errChannel": "频道 ID 为必填项", + "routeEditor.errChannel": "第 {n} 个目标频道 ID 为必填项", "groupEditor.editTitle": "编辑分组", "groupEditor.newTitle": "新建分组", "groupEditor.close": "关闭", diff --git a/admin/types.ts b/admin/types.ts index 9021bde..5fe8d2b 100644 --- a/admin/types.ts +++ b/admin/types.ts @@ -4,15 +4,20 @@ export interface Filter { exclude?: boolean; } +export interface RouteTarget { + platform?: "discord" | "telegram"; + channelId?: string; + threadId?: string; + chatId?: string; + topicId?: string; +} + export interface Route { id: string; name: string; enabled: boolean; filters: Filter[]; - target: { - channelId: string; - threadId?: string; - }; + targets: RouteTarget[]; lang?: string; groupId?: string; fallback?: boolean; diff --git a/docs/api/overview.md b/docs/api/overview.md index 696ebd2..789ff09 100644 --- a/docs/api/overview.md +++ b/docs/api/overview.md @@ -15,6 +15,7 @@ https://your-worker.workers.dev | `GET` | `/health` | None | Health check | | `POST` | `/webhook` | HMAC signature | GitHub webhook ingestion | | `POST` | `/discord/interactions` | Ed25519 signature | Discord interactions (slash commands, buttons, modals) | +| `POST` | `/telegram/webhook` | Secret token | Telegram updates (bot `/gh` commands) | | `GET` | `/auth/github` | None | Start GitHub OAuth flow | | `GET` | `/auth/github/callback` | None | OAuth callback | | `DELETE` | `/auth/token/:userId` | None | Revoke user token | @@ -38,7 +39,7 @@ See [Configuration → Web UI](../guide/configuration.md#web-ui) for setup. Admi - `GET /admin` — Serves the config console HTML - `GET /admin/api/routes` — Returns `{ "routes": Route[] }` -- `PUT /admin/api/routes` — Body `{ "routes": Route[] }`; validates each route (id pattern, unique id, name, enabled, `groupId`, filters — empty only allowed for `fallback` routes — and string `target.channelId`) and persists to KV `config:routes`. Returns `200 { ok, count }` or `400 { error }` / `401 { error }`. +- `PUT /admin/api/routes` — Body `{ "routes": Route[] }`; validates each route (id pattern, unique id, name, enabled, `groupId`, filters — empty only allowed for `fallback` routes — and platform-aware target: `target.channelId` for Discord, `target.chatId` for Telegram) and persists to KV `config:routes`. Returns `200 { ok, count }` or `400 { error }` / `401 { error }`. ## Health Check diff --git a/docs/contributing.md b/docs/contributing.md index 640ad25..a5128ee 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -31,13 +31,15 @@ src/ │ └── *.ts # push, pull-request, issues, comments, workflow, release, repo, ... ├── drivers/ # Platform drivers (pluggable push targets) │ ├── types.ts # PlatformDriver interface + SendResult -│ ├── index.ts # getDriver() registry (discord + telegram stub) +│ ├── index.ts # getDriver() registry (discord + telegram) │ ├── discord/ # index.ts (driver), render.ts (NeutralMessage → embed), │ │ # rest.ts, interactions.ts, commands.ts -│ └── telegram/ # TelegramDriver stub (not implemented yet) +│ └── telegram/ # index.ts (driver), render.ts (NeutralMessage → Telegram HTML), +│ # rest.ts (chat_id + message_thread_id), updates.ts (webhook verify), +│ # commands.ts (/gh login|logout|comment|merge|close + reply parsing) ├── github/ # GitHub OAuth + as-user actions │ ├── oauth.ts # OAuth URL, callback token exchange, getUserOctokit, actions -│ └── store.ts # KV token CRUD + D1 discord-link mapping +│ └── store.ts # KV token CRUD + D1 discord-link/telegram-link mapping ├── web/ # HTTP UI/API routes │ ├── oauth-routes.ts # GET /auth/github, callback, DELETE /token/:userId (KV state) │ ├── action-routes.ts # POST /api/comment|merge|close|react (Bearer token auth via KV lookup) diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index 378fa1c..000b454 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -14,6 +14,7 @@ WebHooker requires several secrets to function. For local development, store the | `GITHUB_CLIENT_ID` | OAuth client ID from App settings | | `GITHUB_CLIENT_SECRET` | OAuth client secret from App settings | | `DISCORD_TOKEN` | Discord bot token | +| `TELEGRAM_TOKEN` | Telegram bot token (from BotFather) — required for Telegram routes | ### Optional Secrets @@ -21,6 +22,7 @@ WebHooker requires several secrets to function. For local development, store the | ------------------------ | ----------------------------------------------------------------------------- | --------------------------------- | | `DISCORD_PUBLIC_KEY` | Discord application public key (Developer Portal) — required for interactions | Unset → interactions return `401` | | `DISCORD_APPLICATION_ID` | Discord application id; auto-resolved when omitted | Auto-resolved | +| `TELEGRAM_WEBHOOK_SECRET`| Secret token for `POST /telegram/webhook` verification (X-Telegram-Bot-Api-Secret-Token) | Disabled (no verification) | | `BASE_URL` | Public URL for OAuth callbacks | `http://localhost:8787` | | `ADMIN_USER_IDS` | Comma-separated GitHub user IDs (or logins) allowed to access the Web UI | Disabled | @@ -54,7 +56,7 @@ The console lets you add, edit, delete, and toggle routes. Saved routes are writ ## Routes -Routes define which events get forwarded to which Discord channels. They are stored in Cloudflare KV under the key `config:routes` as a JSON array. +Routes define which events get forwarded to which channel (Discord or Telegram). They are stored in Cloudflare KV under the key `config:routes` as a JSON array. There are **no default routes** — each route must define its own target. If no routes are configured, no events are forwarded. @@ -71,14 +73,17 @@ There are **no default routes** — each route must define its own target. If no { "type": "event", "match": "push" }, { "type": "repo", "match": "org/repo", "exclude": false } ], - "target": { - "channelId": "REQUIRED_CHANNEL_ID", - "threadId": "OPTIONAL_THREAD_ID" - } + "targets": [ + { + "platform": "discord", + "channelId": "REQUIRED_CHANNEL_ID", + "threadId": "OPTIONAL_THREAD_ID" + } + ] } ``` -`target.channelId` is required and used as-is; there is no fallback to a default channel. +Each entry of `targets` is a push destination, so one route can forward to several channels at once (e.g. a Discord channel **and** a Telegram group). `target.platform` selects the platform: `discord` (default) or `telegram`. For **Discord**, `target.channelId` is required (a thread in `target.threadId` is optional). For **Telegram**, `target.chatId` (the group/supergroup chat id, e.g. `-1001234567890`) is required and `target.topicId` (the `message_thread_id` of a topic, equivalent of a Discord thread) is optional. There is no fallback to a default channel. Other route fields: @@ -102,10 +107,13 @@ Other route fields: { "type": "event", "match": "pull_request" }, { "type": "actor", "match": "[bot]", "exclude": true } ], - "target": { - "channelId": "1234567890", - "threadId": "9876543210" - } + "targets": [ + { + "platform": "telegram", + "chatId": "-1001234567890", + "topicId": "9876543210" + } + ] } ] ``` diff --git a/docs/guide/deployment.md b/docs/guide/deployment.md index 8d9bd80..166684a 100644 --- a/docs/guide/deployment.md +++ b/docs/guide/deployment.md @@ -31,6 +31,7 @@ npx wrangler secret put GITHUB_CLIENT_ID npx wrangler secret put GITHUB_CLIENT_SECRET npx wrangler secret put DISCORD_TOKEN npx wrangler secret put DISCORD_PUBLIC_KEY # Discord app public key (Developer Portal) — required for interactions +npx wrangler secret put TELEGRAM_TOKEN # Telegram bot token (BotFather) — required for Telegram routes npx wrangler secret put ADMIN_USER_IDS # comma-separated GitHub IDs/logins allowed into the Web UI ``` diff --git a/docs/zh/api/overview.md b/docs/zh/api/overview.md index c64b949..4d09cc9 100644 --- a/docs/zh/api/overview.md +++ b/docs/zh/api/overview.md @@ -15,6 +15,7 @@ https://your-worker.workers.dev | `GET` | `/health` | 无 | 健康检查 | | `POST` | `/webhook` | HMAC 签名 | GitHub webhook 接入 | | `POST` | `/discord/interactions` | Ed25519 签名 | Discord 交互(斜杠命令、按钮、modal) | +| `POST` | `/telegram/webhook` | Secret token | Telegram 更新(bot `/gh` 命令) | | `GET` | `/auth/github` | 无 | 启动 GitHub OAuth 流程 | | `GET` | `/auth/github/callback` | 无 | OAuth 回调 | | `DELETE` | `/auth/token/:userId` | 无 | 撤销用户 Token | @@ -38,7 +39,7 @@ https://your-worker.workers.dev - `GET /admin` — 提供配置控制台 HTML - `GET /admin/api/routes` — 返回 `{ "routes": Route[] }` -- `PUT /admin/api/routes` — 请求体为 `{ "routes": Route[] }`;校验每条路由(id 格式、唯一 id、name、enabled、groupId、过滤器、字符串 `target.channelId`)并持久化到 KV `config:routes`。返回 `200 { ok, count }` 或 `400 { error }` / `401 { error }`。 +- `PUT /admin/api/routes` — 请求体为 `{ "routes": Route[] }`;校验每条路由(id 格式、唯一 id、name、enabled、groupId、过滤器、平台感知的 target:Discord 需 `target.channelId`,Telegram 需 `target.chatId`)并持久化到 KV `config:routes`。返回 `200 { ok, count }` 或 `400 { error }` / `401 { error }`。 ## 健康检查 diff --git a/docs/zh/contributing.md b/docs/zh/contributing.md index 77eca18..c9bb82a 100644 --- a/docs/zh/contributing.md +++ b/docs/zh/contributing.md @@ -31,13 +31,15 @@ src/ │ └── *.ts # push、pull-request、issues、comments、workflow、release、repo 等 ├── drivers/ # 平台驱动(可插拔推送目标) │ ├── types.ts # PlatformDriver 接口 + SendResult -│ ├── index.ts # getDriver() 注册表(discord + telegram 占位) +│ ├── index.ts # getDriver() 注册表(discord + telegram) │ ├── discord/ # index.ts (驱动)、render.ts (NeutralMessage → embed)、 │ │ # rest.ts、interactions.ts、commands.ts -│ └── telegram/ # TelegramDriver 占位(未实现) +│ └── telegram/ # index.ts (驱动)、render.ts (NeutralMessage → Telegram HTML)、 +│ # rest.ts (chat_id + message_thread_id)、updates.ts (webhook 验签)、 +│ # commands.ts (/gh login|logout|comment|merge|close + 引用消息解析) ├── github/ # GitHub OAuth + 以用户身份操作 │ ├── oauth.ts # OAuth URL、回调 Token 交换、getUserOctokit、操作 -│ └── store.ts # KV Token CRUD + D1 discord-link 映射 +│ └── store.ts # KV Token CRUD + D1 discord-link/telegram-link 映射 ├── web/ # HTTP UI/API 路由 │ ├── oauth-routes.ts # GET /auth/github、回调、DELETE /token/:userId (KV 状态) │ ├── action-routes.ts # POST /api/comment|merge|close|react (通过 KV 查找进行 Bearer Token 鉴权) diff --git a/docs/zh/guide/configuration.md b/docs/zh/guide/configuration.md index 29161a2..0847be2 100644 --- a/docs/zh/guide/configuration.md +++ b/docs/zh/guide/configuration.md @@ -14,6 +14,7 @@ WebHooker 需要多个密钥才能运行。本地开发时存储在 `.dev.vars` | `GITHUB_CLIENT_ID` | App 设置中的 OAuth 客户端 ID | | `GITHUB_CLIENT_SECRET` | App 设置中的 OAuth 客户端密钥 | | `DISCORD_TOKEN` | Discord Bot Token | +| `TELEGRAM_TOKEN` | Telegram Bot Token(BotFather 获取)—— Telegram 路由必需 | ### 可选密钥 @@ -23,6 +24,7 @@ WebHooker 需要多个密钥才能运行。本地开发时存储在 `.dev.vars` | `ADMIN_USER_IDS` | 允许访问 WebUI 的 GitHub 用户 ID(或登录名),逗号分隔 | 未设置时 WebUI 关闭 | | `DISCORD_PUBLIC_KEY` | Discord 应用的公钥(开发者门户获取),交互功能必需 | 未设置时交互返回 401 | | `DISCORD_APPLICATION_ID` | Discord 应用 ID;省略时自动获取 | 自动获取 | +| `TELEGRAM_WEBHOOK_SECRET`| `POST /telegram/webhook` 验签密钥(X-Telegram-Bot-Api-Secret-Token) | 未设置时不校验 | ## Web 控制台 @@ -54,7 +56,7 @@ WebHooker 内置了位于 `/admin` 的配置控制台,可在浏览器中管理 ## 路由 -路由定义了哪些事件被转发到哪些 Discord 频道。它们以 JSON 数组形式存储在 Cloudflare KV 中,键为 `config:routes`。 +路由定义了哪些事件被转发到哪些频道(Discord 或 Telegram)。它们以 JSON 数组形式存储在 Cloudflare KV 中,键为 `config:routes`。 **没有默认路由**——每条路由必须自行定义目标频道。若未配置任何路由,则不会转发任何事件。 @@ -71,14 +73,17 @@ WebHooker 内置了位于 `/admin` 的配置控制台,可在浏览器中管理 { "type": "event", "match": "push" }, { "type": "repo", "match": "org/repo", "exclude": false } ], - "target": { - "channelId": "必填频道ID", - "threadId": "可选线程ID" - } + "targets": [ + { + "platform": "discord", + "channelId": "必填频道ID", + "threadId": "可选线程ID" + } + ] } ``` -`target.channelId` 必填且按原样使用,不存在默认频道回退。 +`targets` 数组的每一项是一个推送目标,因此一条路由可同时转发到多个频道(例如同时发到 Discord 频道 **和** Telegram 群组)。`target.platform` 选择推送目标:`discord`(默认)或 `telegram`。**Discord** 需 `target.channelId`(`target.threadId` 可选的子区);**Telegram** 需 `target.chatId`(群组/超级群组聊天 id,如 `-1001234567890`),`target.topicId`(话题的 `message_thread_id`,相当于 Discord 的子区)可选。不存在默认频道回退。 其他路由字段: @@ -102,10 +107,13 @@ WebHooker 内置了位于 `/admin` 的配置控制台,可在浏览器中管理 { "type": "event", "match": "pull_request" }, { "type": "actor", "match": "[bot]", "exclude": true } ], - "target": { - "channelId": "1234567890", - "threadId": "9876543210" - } + "targets": [ + { + "platform": "telegram", + "chatId": "-1001234567890", + "topicId": "9876543210" + } + ] } ] ``` diff --git a/docs/zh/guide/deployment.md b/docs/zh/guide/deployment.md index 1e474fb..0096ee5 100644 --- a/docs/zh/guide/deployment.md +++ b/docs/zh/guide/deployment.md @@ -31,6 +31,7 @@ npx wrangler secret put GITHUB_CLIENT_ID npx wrangler secret put GITHUB_CLIENT_SECRET npx wrangler secret put DISCORD_TOKEN npx wrangler secret put DISCORD_PUBLIC_KEY # Discord 应用的公钥(开发者门户获取),交互功能必需 +npx wrangler secret put TELEGRAM_TOKEN # Telegram Bot Token(BotFather 获取)—— Telegram 路由必需 npx wrangler secret put ADMIN_USER_IDS # 逗号分隔的 GitHub ID/登录名,允许进入 Web UI ``` diff --git a/migrations/0003_telegram_links.sql b/migrations/0003_telegram_links.sql new file mode 100644 index 0000000..c5a962a --- /dev/null +++ b/migrations/0003_telegram_links.sql @@ -0,0 +1,4 @@ +CREATE TABLE IF NOT EXISTS telegram_links ( + telegram_user_id TEXT PRIMARY KEY, + github_user_id TEXT NOT NULL +); diff --git a/src/__tests__/admin.test.ts b/src/__tests__/admin.test.ts index 65abd30..2acce1d 100644 --- a/src/__tests__/admin.test.ts +++ b/src/__tests__/admin.test.ts @@ -52,7 +52,7 @@ const sampleRoutes: Route[] = [ name: "Backend PRs", enabled: true, filters: [{ type: "event", match: "pull_request" }], - target: { channelId: "111" }, + targets: [{ channelId: "111" }], }, ]; @@ -133,6 +133,6 @@ describe("config routes persistence", () => { const second = await loadConfig(env); expect(second.routes).toHaveLength(1); expect(second.routes[0]!.id).toBe("backend-prs"); - expect(second.routes[0]!.target.channelId).toBe("111"); + expect(second.routes[0]!.targets[0]!.channelId).toBe("111"); }); }); diff --git a/src/__tests__/discord.test.ts b/src/__tests__/discord.test.ts index aa6d3cb..7e7fdf1 100644 --- a/src/__tests__/discord.test.ts +++ b/src/__tests__/discord.test.ts @@ -129,7 +129,7 @@ describe("dispatchEvent fallback routing", () => { name: "Regular Push", enabled: true, filters: [{ type: "event", match: "push" }], - target: { channelId: "111" }, + targets: [{ channelId: "111" }], }, { id: "catch-all", @@ -137,7 +137,7 @@ describe("dispatchEvent fallback routing", () => { enabled: true, filters: [], fallback: true, - target: { channelId: "222" }, + targets: [{ channelId: "222" }], }, ]; diff --git a/src/__tests__/formatter.test.ts b/src/__tests__/formatter.test.ts index fbb7b83..a69e3db 100644 --- a/src/__tests__/formatter.test.ts +++ b/src/__tests__/formatter.test.ts @@ -7,7 +7,7 @@ const route: Route = { name: "Test", enabled: true, filters: [], - target: { channelId: "111" }, + targets: [{ channelId: "111" }], }; function event(ev: string, payload: Record): WebhookEvent { diff --git a/src/__tests__/telegram-commands.test.ts b/src/__tests__/telegram-commands.test.ts new file mode 100644 index 0000000..7e6912b --- /dev/null +++ b/src/__tests__/telegram-commands.test.ts @@ -0,0 +1,201 @@ +import { describe, it, expect, afterEach } from "bun:test"; +import { handleTelegramUpdate } from "../drivers/telegram/commands"; +import { handleTelegramWebhookRequest } from "../drivers/telegram/updates"; +import type { Env } from "../types"; + +function mockFetch(handler: (url: string, init?: RequestInit) => Response): void { + globalThis.fetch = (input: RequestInfo | URL, init?: RequestInit): Promise => + Promise.resolve(handler(String(input), init)); +} + +const restoredFetch = globalThis.fetch; + +afterEach(() => { + globalThis.fetch = restoredFetch; +}); + +function createMockKV(): KVNamespace { + const store = new Map(); + return { + get: async (key: string, type?: string) => { + const v = store.get(key); + if (v === undefined) return null; + return type === "json" ? JSON.parse(v) : v; + }, + put: async (key: string, value: string) => { + store.set(key, value); + }, + delete: async (key: string) => { + store.delete(key); + }, + list: async () => ({ keys: [] }), + } as unknown as KVNamespace; +} + +function createMockDB(): D1Database { + const links = new Map(); + return { + prepare: (sql: string) => ({ + bind: (...args: unknown[]) => ({ + run: async (): Promise<{ success: boolean }> => { + const m = sql.match(/INSERT OR REPLACE INTO telegram_links \(telegram_user_id, github_user_id\) VALUES \(\?, \?\)/); + if (m) links.set(String(args[0]), String(args[1])); + const del = sql.match(/DELETE FROM telegram_links WHERE telegram_user_id = \?/); + if (del) links.delete(String(args[0])); + return { success: true }; + }, + all: async (): Promise<{ results: Array> }> => { + const sel = sql.match(/SELECT github_user_id FROM telegram_links WHERE telegram_user_id = \?/); + if (sel) { + const val = links.get(String(args[0])); + return { results: val ? [{ github_user_id: val }] : [] }; + } + return { results: [] }; + }, + first: async () => null, + }), + }), + } as unknown as D1Database; +} + +function createEnv(): Env { + return { + GITHUB_WEBHOOK_SECRET: "secret", + GITHUB_CLIENT_ID: "client-id", + TELEGRAM_TOKEN: "tg-token", + TELEGRAM_WEBHOOK_SECRET: "wh-secret", + KV: createMockKV(), + DB: createMockDB(), + } as Env; +} + +function reply(chatId: string, topicId?: number): Record { + return { + message_id: 1, + from: { id: 111, first_name: "Rhen" }, + chat: { id: chatId, type: "supergroup" }, + message_thread_id: topicId, + }; +} + +describe("telegram-commands /gh login", () => { + it("stores state with telegramUserId and replies with OAuth URL", async () => { + let sentBody: Record | undefined; + mockFetch((_url, init) => { + sentBody = JSON.parse(String(init!.body)) as Record; + return new Response(JSON.stringify({ ok: true, result: { message_id: 1 } }), { status: 200 }); + }); + + const env = createEnv(); + await handleTelegramUpdate(env, { + message: { ...reply("-100123"), text: "/gh login" }, + }); + + expect(sentBody?.chat_id).toBe("-100123"); + expect(String(sentBody?.text)).toContain("github.com/login/oauth/authorize"); + }); +}); + +describe("telegram-commands /gh logout", () => { + it("replies bound/unbound message", async () => { + let sentText = ""; + mockFetch((_url, init) => { + sentText = String((JSON.parse(String(init!.body)) as Record).text); + return new Response(JSON.stringify({ ok: true, result: { message_id: 1 } }), { status: 200 }); + }); + + const env = createEnv(); + await handleTelegramUpdate(env, { + message: { ...reply("-100123"), text: "/gh logout" }, + }); + + expect(sentText).toContain("已解绑"); + }); +}); + +describe("telegram-commands /gh comment", () => { + it("replies when no reply_to_message link present", async () => { + let sentText = ""; + mockFetch((_url, init) => { + sentText = String((JSON.parse(String(init!.body)) as Record).text); + return new Response(JSON.stringify({ ok: true, result: { message_id: 1 } }), { status: 200 }); + }); + + const env = createEnv(); + await handleTelegramUpdate(env, { + message: { ...reply("-100123"), text: "/gh comment hello" }, + }); + + expect(sentText).toContain("还没有绑定"); + }); + + it("parses the replied-to GitHub link as target", async () => { + const env = createEnv(); + const { saveTelegramLink } = await import("../github/store"); + await saveTelegramLink(env.DB, "111", "111980217"); + await env.KV.put( + "token:111980217", + JSON.stringify({ + userId: "111980217", + accessToken: "ghu_test", + expiresAt: Date.now() + 3600_000, + }), + ); + + const calls: Array<{ url: string; body: string }> = []; + mockFetch((url, init) => { + calls.push({ url, body: String(init!.body) }); + if (String(url).endsWith("/sendMessage")) { + return new Response(JSON.stringify({ ok: true, result: { message_id: 1 } }), { status: 200 }); + } + return new Response( + JSON.stringify({ html_url: "https://github.com/acme/widget/issues/7#issuecomment-9" }), + { status: 200 }, + ); + }); + + await handleTelegramUpdate(env, { + message: { + ...reply("-100123"), + text: "/gh comment hello", + reply_to_message: { + ...reply("-100123"), + text: "acme/widget#7: Add feature", + entities: [{ type: "text_link", url: "https://github.com/acme/widget/issues/7" }], + }, + }, + }); + + const ghCall = calls.find((c) => c.url.includes("/repos/")); + expect(ghCall).toBeDefined(); + expect(ghCall!.url).toContain("/repos/acme/widget/issues/7/comments"); + const ghBody = JSON.parse(ghCall!.body) as Record; + expect(ghBody.body).toBe("hello"); + }); +}); + +describe("telegram-updates webhook", () => { + it("rejects requests without the secret token", async () => { + const env = createEnv(); + const res = await handleTelegramWebhookRequest( + new Request("https://example.com/telegram/webhook", { method: "POST", body: "{}" }), + env, + ); + expect(res.status).toBe(401); + }); + + it("accepts requests with the correct secret token", async () => { + mockFetch(() => new Response(JSON.stringify({ ok: true, result: { message_id: 1 } }), { status: 200 })); + const env = createEnv(); + const res = await handleTelegramWebhookRequest( + new Request("https://example.com/telegram/webhook", { + method: "POST", + headers: { "X-Telegram-Bot-Api-Secret-Token": "wh-secret" }, + body: JSON.stringify({ update_id: 1 }), + }), + env, + ); + expect(res.status).toBe(200); + expect(await res.text()).toBe("ok"); + }); +}); diff --git a/src/__tests__/telegram.test.ts b/src/__tests__/telegram.test.ts new file mode 100644 index 0000000..7cf0708 --- /dev/null +++ b/src/__tests__/telegram.test.ts @@ -0,0 +1,92 @@ +import { describe, it, expect, afterEach } from "bun:test"; +import { sendMessage } from "../drivers/telegram/rest"; +import { renderNeutralMessage } from "../drivers/telegram/render"; +import type { NeutralMessage } from "../types"; + +function mockFetch(handler: (url: string, init?: RequestInit) => Response): void { + globalThis.fetch = (input: RequestInfo | URL, init?: RequestInit): Promise => + Promise.resolve(handler(String(input), init)); +} + +const restoredFetch = globalThis.fetch; + +afterEach(() => { + globalThis.fetch = restoredFetch; +}); + +describe("telegram renderNeutralMessage", () => { + it("renders title, fields and footer as HTML", () => { + const message: NeutralMessage = { + title: "acme/widget: Add feature", + url: "https://github.com/acme/widget", + fields: [{ name: "Status", value: "success" }], + footer: "acme/widget", + }; + const out = renderNeutralMessage(message); + expect(out).toContain('acme/widget: Add feature'); + expect(out).toContain("Status: success"); + expect(out).toContain("acme/widget"); + }); + + it("escapes HTML special characters", () => { + const out = renderNeutralMessage({ + title: "a & \"c\"", + fields: [{ name: "body", value: "" }], + }); + expect(out).not.toContain("acme"); + expect(out).toContain("<script>alert(1)</script>"); + }); +}); + +describe("telegram-rest sendMessage", () => { + it("posts to the bot API with chat_id and parse_mode", async () => { + let capturedUrl = ""; + let capturedInit: RequestInit | undefined; + mockFetch((url, init) => { + capturedUrl = url; + capturedInit = init; + return new Response(JSON.stringify({ ok: true, result: { message_id: 42 } }), { + status: 200, + }); + }); + + const result = await sendMessage("token-abc", "-100123", "hello"); + + expect(result.ok).toBe(true); + expect(result.messageId).toBe("42"); + expect(capturedUrl).toBe("https://api.telegram.org/bottoken-abc/sendMessage"); + const body = JSON.parse(String(capturedInit!.body)) as Record; + expect(body.chat_id).toBe("-100123"); + expect(body.parse_mode).toBe("HTML"); + expect(body.message_thread_id).toBeUndefined(); + }); + + it("includes message_thread_id when topicId is given", async () => { + let capturedInit: RequestInit | undefined; + mockFetch((_url, init) => { + capturedInit = init; + return new Response(JSON.stringify({ ok: true, result: { message_id: 1 } }), { + status: 200, + }); + }); + + await sendMessage("t", "-100123", "hello", "999"); + const body = JSON.parse(String(capturedInit!.body)) as Record; + expect(body.message_thread_id).toBe(999); + }); + + it("returns error on non-ok response", async () => { + mockFetch(() => + new Response(JSON.stringify({ ok: false, description: "chat not found" }), { status: 400 }), + ); + const result = await sendMessage("t", "-100123", "hello"); + expect(result.ok).toBe(false); + expect(result.error).toContain("chat not found"); + }); + + it("returns error when token is missing", async () => { + const result = await sendMessage("", "-100123", "hello"); + expect(result.ok).toBe(false); + expect(result.errorCode).toBe("NO_TOKEN"); + }); +}); diff --git a/src/__tests__/webhook.test.ts b/src/__tests__/webhook.test.ts index 78ec396..d2701ae 100644 --- a/src/__tests__/webhook.test.ts +++ b/src/__tests__/webhook.test.ts @@ -51,7 +51,7 @@ describe("matchRoute", () => { name: "Test", enabled: true, filters: [], - target: { channelId: "123" }, + targets: [{ channelId: "123" }], }; it("matches all events when no filters", () => { diff --git a/src/config.ts b/src/config.ts index c3db813..87f8c4d 100644 --- a/src/config.ts +++ b/src/config.ts @@ -8,13 +8,26 @@ let configCache: { config: Config; expiresAt: number } | null = null; export async function loadRoutes(kv: KVNamespace): Promise { try { const stored = await kv.get(ROUTES_KEY, "json"); - if (stored) return stored; + if (stored) return normalizeRoutes(stored); } catch (err) { log.warn({ err }, "Failed to load routes from KV"); } return []; } +/** + * Migrate legacy single-target routes (`target`) to the array form (`targets`). + */ +function normalizeRoutes(routes: Route[]): Route[] { + return routes.map((r) => { + if (r.targets && r.targets.length > 0) return r; + const legacy = (r as Route & { target?: Route["targets"][number] }).target; + if (!legacy) return r; + const { target: _target, ...rest } = r as Route & { target?: Route["targets"][number] }; + return { ...rest, targets: [legacy] }; + }); +} + export async function saveRoutes(kv: KVNamespace, routes: Route[]): Promise { await kv.put(ROUTES_KEY, JSON.stringify(routes)); configCache = null; diff --git a/src/core/dispatch.ts b/src/core/dispatch.ts index 23c4194..4218ab8 100644 --- a/src/core/dispatch.ts +++ b/src/core/dispatch.ts @@ -37,59 +37,70 @@ export async function dispatchEvent(config: Config, event: WebhookEvent, env: En return matchRoute(route, event); }) .map(async (route) => { - const target = route.target.threadId - ? `${route.target.channelId}/${route.target.threadId}` - : route.target.channelId; + const targets = route.targets && route.targets.length > 0 ? route.targets : []; + if (targets.length === 0) return; - const base: { - ts: number; - routeId: string; - event: string; - repo: string | undefined; - target: string; - deliveryId: string | undefined; - actor: string | undefined; - action: string | undefined; - } = { - ts: Date.now(), - routeId: route.id, - event: event.event, - repo: (event.payload.repository as { full_name?: string } | undefined)?.full_name, - target, - deliveryId: event.deliveryId, - actor: (event.payload.sender as { login?: string } | undefined)?.login, - action: (event.payload.action as string | undefined), - }; + const tr = trMap.get(route.lang ?? "en")!; + const group = route.groupId ? groupById.get(route.groupId) : undefined; + const showEmoji = group?.emoji !== false; + const message = formatEvent(route, event, tr, showEmoji); - const started = Date.now(); - try { - const tr = trMap.get(route.lang ?? "en")!; - const group = route.groupId ? groupById.get(route.groupId) : undefined; - const showEmoji = group?.emoji !== false; - const message = formatEvent(route, event, tr, showEmoji); - const driver = getDriver(route.target); - const result = await driver.send(message, route.target, env); - const durationMs = Date.now() - started; - if (!result.ok) throw new Error(result.error ?? "Send failed"); - await recordSend(env.DB, { - ...base, - ok: true, - status: result.status, - messageId: result.messageId, - platform: driver.id, - attempts: result.attempts, - durationMs, - errorCode: result.errorCode, - }); - } catch (err) { - const durationMs = Date.now() - started; - await recordSend(env.DB, { - ...base, - ok: false, - error: err instanceof Error ? err.message : String(err), - durationMs, - }); - log.error({ routeId: route.id, err }, "Route failed"); + for (const target of targets) { + const targetStr = + target.platform === "telegram" + ? target.topicId + ? `${target.chatId}/${target.topicId}` + : (target.chatId ?? "") + : target.threadId + ? `${target.channelId}/${target.threadId}` + : (target.channelId ?? ""); + + const base: { + ts: number; + routeId: string; + event: string; + repo: string | undefined; + target: string; + deliveryId: string | undefined; + actor: string | undefined; + action: string | undefined; + } = { + ts: Date.now(), + routeId: route.id, + event: event.event, + repo: (event.payload.repository as { full_name?: string } | undefined)?.full_name, + target: targetStr, + deliveryId: event.deliveryId, + actor: (event.payload.sender as { login?: string } | undefined)?.login, + action: (event.payload.action as string | undefined), + }; + + const started = Date.now(); + try { + const driver = getDriver(target); + const result = await driver.send(message, target, env); + const durationMs = Date.now() - started; + if (!result.ok) throw new Error(result.error ?? "Send failed"); + await recordSend(env.DB, { + ...base, + ok: true, + status: result.status, + messageId: result.messageId, + platform: driver.id, + attempts: result.attempts, + durationMs, + errorCode: result.errorCode, + }); + } catch (err) { + const durationMs = Date.now() - started; + await recordSend(env.DB, { + ...base, + ok: false, + error: err instanceof Error ? err.message : String(err), + durationMs, + }); + log.error({ routeId: route.id, target: targetStr, err }, "Route failed"); + } } }); diff --git a/src/drivers/discord/index.ts b/src/drivers/discord/index.ts index ba17864..2d68bfe 100644 --- a/src/drivers/discord/index.ts +++ b/src/drivers/discord/index.ts @@ -1,4 +1,4 @@ -import type { Route, Env, NeutralMessage } from "../../types"; +import type { RouteTarget, Env, NeutralMessage } from "../../types"; import type { PlatformDriver, SendResult } from "../types"; import { sendMessage } from "./rest"; import { renderNeutralMessage } from "./render"; @@ -6,8 +6,12 @@ import { renderNeutralMessage } from "./render"; export class DiscordDriver implements PlatformDriver { readonly id = "discord"; - async send(message: NeutralMessage, target: Route["target"], env: Env): Promise { + async send(message: NeutralMessage, target: RouteTarget, env: Env): Promise { + const channelId = target.channelId ?? ""; + if (!channelId) { + return { ok: false, error: "target.channelId is required", errorCode: "NO_TARGET" }; + } const token = env.DISCORD_TOKEN ?? ""; - return sendMessage(token, target.channelId, renderNeutralMessage(message), target.threadId); + return sendMessage(token, channelId, renderNeutralMessage(message), target.threadId); } } diff --git a/src/drivers/index.ts b/src/drivers/index.ts index d005c4a..7f0ad8a 100644 --- a/src/drivers/index.ts +++ b/src/drivers/index.ts @@ -1,4 +1,4 @@ -import type { Route } from "../types"; +import type { RouteTarget } from "../types"; import type { PlatformDriver } from "./types"; import { DiscordDriver } from "./discord"; import { TelegramDriver } from "./telegram"; @@ -8,8 +8,8 @@ const drivers: Record = { telegram: new TelegramDriver(), }; -export function getDriver(target: Route["target"]): PlatformDriver { - const platform = (target as { platform?: string }).platform ?? "discord"; +export function getDriver(target: RouteTarget): PlatformDriver { + const platform = target.platform ?? "discord"; const driver = drivers[platform]; if (!driver) throw new Error(`No driver for platform "${platform}"`); return driver; diff --git a/src/drivers/telegram/commands.ts b/src/drivers/telegram/commands.ts new file mode 100644 index 0000000..75dbc82 --- /dev/null +++ b/src/drivers/telegram/commands.ts @@ -0,0 +1,231 @@ +import { log } from "../../lib/log"; +import { + getOAuthURL, + commentAsUser, + mergePullRequestAsUser, + closePullRequestAsUser, +} from "../../github/oauth"; +import { getTelegramLink, removeTelegramLink } from "../../github/store"; +import type { Env } from "../../types"; +import { sendMessage } from "./rest"; + +const GITHUB_ISSUE_RE = /github\.com\/([^/\s]+)\/([^/\s]+)\/(?:issues|pull)\/(\d+)/; +const GITHUB_PR_RE = /github\.com\/([^/\s]+)\/([^/\s]+)\/pull\/(\d+)/; + +interface TelegramMessage { + message_id?: number; + text?: string; + from?: { id?: number; first_name?: string; username?: string }; + chat?: { id?: number; type?: string; title?: string }; + date?: number; + message_thread_id?: number; + entities?: Array<{ type?: string; url?: string; offset?: number; length?: number }>; + reply_to_message?: TelegramMessage; +} + +interface Target { + owner: string; + repo: string; + number: number; +} + +function chatIdOf(msg: TelegramMessage): string | null { + return msg.chat?.id != null ? String(msg.chat.id) : null; +} + +function userIdOf(msg: TelegramMessage): string | null { + return msg.from?.id != null ? String(msg.from.id) : null; +} + +/** Extract a GitHub issue/PR link from a message (entities text_link or raw text). */ +function extractTarget(msg: TelegramMessage, prOnly = false): Target | null { + const urls: string[] = []; + for (const ent of msg.entities ?? []) { + if (ent.type === "text_link" && ent.url) urls.push(ent.url); + } + if (msg.text) { + for (const u of msg.text.match(/https?:\/\/github\.com\/[^\s]+/g) ?? []) urls.push(u); + } + for (const url of urls) { + const re = prOnly ? GITHUB_PR_RE : GITHUB_ISSUE_RE; + const m = url.match(re); + if (m) return { owner: m[1], repo: m[2], number: Number(m[3]) }; + } + return null; +} + +async function reply(env: Env, chatId: string, topicId: string | undefined, text: string): Promise { + await sendMessage(env.TELEGRAM_TOKEN ?? "", chatId, text, topicId); +} + +function errText(err: unknown): string { + const t = err instanceof Error ? err.message : String(err); + if (t === "GITHUB_TOKEN_EXPIRED") return "GitHub 授权已过期或无效,请重新使用 /gh login 绑定。"; + if (t === "GITHUB_FORBIDDEN") return "GitHub 拒绝了此操作:你的账号没有权限。"; + if (t === "GITHUB_NOT_FOUND") return "找不到目标(可能已删除或仓库不可访问)。"; + return `操作失败:${t}`; +} + +async function cmdLogin(env: Env, msg: TelegramMessage): Promise { + const chatId = chatIdOf(msg); + const telegramUserId = userIdOf(msg); + if (!chatId || !telegramUserId) return; + const topicId = msg.message_thread_id != null ? String(msg.message_thread_id) : undefined; + + const clientId = env.GITHUB_CLIENT_ID; + if (!clientId) return reply(env, chatId, topicId, "服务器未配置 GitHub OAuth(GITHUB_CLIENT_ID)。"); + + const state = crypto.randomUUID().replace(/-/g, ""); + await env.KV.put( + `state:${state}`, + JSON.stringify({ + redirectTo: "/", + telegramUserId, + telegramChatId: chatId, + expiresAt: Date.now() + 600_000, + }), + { expirationTtl: 600 }, + ); + const url = getOAuthURL(clientId, state); + await reply(env, chatId, topicId, `点击链接授权 GitHub,即可用**本人身份**评论(10 分钟内有效):\n${url}`); +} + +async function cmdLogout(env: Env, msg: TelegramMessage): Promise { + const chatId = chatIdOf(msg); + const telegramUserId = userIdOf(msg); + if (!chatId || !telegramUserId) return; + const topicId = msg.message_thread_id != null ? String(msg.message_thread_id) : undefined; + await removeTelegramLink(env.DB, telegramUserId); + await reply(env, chatId, topicId, "已解绑你的 GitHub 账号。"); +} + +async function cmdComment(env: Env, msg: TelegramMessage, body: string): Promise { + const chatId = chatIdOf(msg); + const telegramUserId = userIdOf(msg); + if (!chatId || !telegramUserId) return; + const topicId = msg.message_thread_id != null ? String(msg.message_thread_id) : undefined; + + const githubUserId = await getTelegramLink(env.DB, telegramUserId); + if (!githubUserId) { + return reply(env, chatId, topicId, "你还没有绑定 GitHub 账号,请先使用 /gh login。"); + } + + const source = msg.reply_to_message; + const target = source ? extractTarget(source) : null; + if (!target) { + return reply( + env, + chatId, + topicId, + "找不到 issue / PR 链接,请在对应的 GitHub 通知消息上回复 /gh comment。", + ); + } + + try { + const { htmlUrl, login } = await commentAsUser( + env.KV, + githubUserId, + target.owner, + target.repo, + target.number, + body, + ); + await reply(env, chatId, topicId, `已以 **@${login}** 身份评论:${htmlUrl}`); + } catch (err) { + await reply(env, chatId, topicId, errText(err)); + } +} + +async function cmdMergeClose(env: Env, msg: TelegramMessage, op: "merge" | "close"): Promise { + const chatId = chatIdOf(msg); + const telegramUserId = userIdOf(msg); + if (!chatId || !telegramUserId) return; + const topicId = msg.message_thread_id != null ? String(msg.message_thread_id) : undefined; + + const githubUserId = await getTelegramLink(env.DB, telegramUserId); + if (!githubUserId) { + return reply(env, chatId, topicId, "你还没有绑定 GitHub 账号,请先使用 /gh login。"); + } + + const source = msg.reply_to_message; + const target = source ? extractTarget(source, true) : null; + if (!target) { + return reply( + env, + chatId, + topicId, + "找不到 PR 链接,请在对应的 GitHub PR 通知消息上回复 /gh merge 或 /gh close。", + ); + } + + try { + if (op === "merge") { + await mergePullRequestAsUser(env.KV, githubUserId, target.owner, target.repo, target.number); + } else { + await closePullRequestAsUser(env.KV, githubUserId, target.owner, target.repo, target.number); + } + const label = op === "merge" ? "合并" : "关闭"; + await reply(env, chatId, topicId, `✅ 已${label} PR ${target.owner}/${target.repo}#${target.number}`); + } catch (err) { + await reply(env, chatId, topicId, errText(err)); + } +} + +/** Register the Telegram webhook to point at this worker (called from cron). */ +export async function syncTelegramWebhook(env: Env): Promise { + const token = env.TELEGRAM_TOKEN; + if (!token) return; + const baseUrl = env.BASE_URL; + if (!baseUrl) return; + const secret = env.TELEGRAM_WEBHOOK_SECRET; + + const res = await fetch(`https://api.telegram.org/bot${token}/setWebhook`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + url: `${baseUrl.replace(/\/$/, "")}/telegram/webhook`, + secret_token: secret || undefined, + allowed_updates: ["message"], + }), + }); + if (!res.ok) { + const err = await res.text(); + log.error({ status: res.status, err }, "Failed to set Telegram webhook"); + } +} + +/** Handle a single Telegram update (message). Called from the webhook route. */ +export async function handleTelegramUpdate(env: Env, update: unknown): Promise { + const message = (update as { message?: TelegramMessage })?.message; + if (!message?.text) return; + + const text = message.text.trim(); + const m = text.match(/^\/gh(?:\s+|$)(.*)$/s); + if (!m) return; + + const rest = m[1].trim(); + const [sub, ...args] = rest.split(/\s+/); + const body = args.join(" ").trim(); + + switch (sub) { + case "login": + return cmdLogin(env, message); + case "logout": + return cmdLogout(env, message); + case "comment": + if (!body) { + const chatId = chatIdOf(message); + const topicId = + message.message_thread_id != null ? String(message.message_thread_id) : undefined; + if (chatId) await reply(env, chatId, topicId, "请附上评论内容:/gh comment 你的评论"); + return; + } + return cmdComment(env, message, body); + case "merge": + return cmdMergeClose(env, message, "merge"); + case "close": + return cmdMergeClose(env, message, "close"); + default: + log.info({ text }, "Unhandled /gh command from Telegram"); + } +} diff --git a/src/drivers/telegram/index.ts b/src/drivers/telegram/index.ts index 06dccf5..cbd47b4 100644 --- a/src/drivers/telegram/index.ts +++ b/src/drivers/telegram/index.ts @@ -1,10 +1,17 @@ -import type { Route, Env, NeutralMessage } from "../../types"; +import type { RouteTarget, Env, NeutralMessage } from "../../types"; import type { PlatformDriver, SendResult } from "../types"; +import { sendMessage } from "./rest"; +import { renderNeutralMessage } from "./render"; export class TelegramDriver implements PlatformDriver { readonly id = "telegram"; - async send(_message: NeutralMessage, _target: Route["target"], _env: Env): Promise { - return { ok: false, error: "Telegram driver not implemented yet" }; + async send(message: NeutralMessage, target: RouteTarget, env: Env): Promise { + const chatId = target.chatId ?? ""; + if (!chatId) { + return { ok: false, error: "target.chatId is required", errorCode: "NO_TARGET" }; + } + const token = env.TELEGRAM_TOKEN ?? ""; + return sendMessage(token, chatId, renderNeutralMessage(message), target.topicId); } } diff --git a/src/drivers/telegram/render.ts b/src/drivers/telegram/render.ts new file mode 100644 index 0000000..3845583 --- /dev/null +++ b/src/drivers/telegram/render.ts @@ -0,0 +1,45 @@ +import type { NeutralMessage } from "../../types"; + +function esc(s: string): string { + return s + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +function inlineUrl(url?: string, text?: string): string { + const label = esc(text ?? url ?? ""); + if (!url) return label; + return `${label}`; +} + +export function renderNeutralMessage(message: NeutralMessage): string { + const parts: string[] = []; + const title = inlineUrl(message.url, message.title); + parts.push(`${title}`); + + if (message.author) { + const author = message.author.url + ? `${esc(message.author.name)}` + : esc(message.author.name); + parts.push(`👤 ${author}`); + } + + if (message.description) { + parts.push(esc(message.description)); + } + + for (const field of message.fields ?? []) { + parts.push(`${esc(field.name)}: ${esc(field.value)}`); + } + + const meta: string[] = []; + if (message.footer) meta.push(esc(message.footer)); + if (message.timestamp) meta.push(esc(message.timestamp)); + if (meta.length > 0) { + parts.push(`${meta.join(" · ")}`); + } + + return parts.join("\n"); +} diff --git a/src/drivers/telegram/rest.ts b/src/drivers/telegram/rest.ts new file mode 100644 index 0000000..217575e --- /dev/null +++ b/src/drivers/telegram/rest.ts @@ -0,0 +1,82 @@ +import { log } from "../../lib/log"; +import type { SendResult } from "../types"; + +const TELEGRAM_API = "https://api.telegram.org"; + +interface TelegramResponse { + ok?: boolean; + description?: string; + result?: { message_id?: number }; +} + +export async function sendMessage( + token: string, + chatId: string, + text: string, + topicId?: string, +): Promise { + if (!token) { + return { ok: false, error: "TELEGRAM_TOKEN not configured", errorCode: "NO_TOKEN" }; + } + + const body: Record = { + chat_id: chatId, + text, + parse_mode: "HTML", + disable_web_page_preview: true, + }; + if (topicId) { + body.message_thread_id = Number(topicId); + } + + const url = `${TELEGRAM_API}/bot${token}/sendMessage`; + let lastStatus = 0; + let lastError = ""; + + for (let attempt = 0; attempt < 3; attempt++) { + try { + const res = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + lastStatus = res.status; + const data = (await res.json().catch(() => null)) as TelegramResponse | null; + + if (res.status === 429) { + const retryAfter = (data as { retry_after?: number })?.retry_after ?? 1; + lastError = data?.description ?? `Rate limited (retry_after=${retryAfter})`; + await new Promise((r) => setTimeout(r, retryAfter * 1000)); + continue; + } + + if (!res.ok) { + lastError = data?.description ?? `HTTP ${res.status}`; + log.error({ status: res.status, err: lastError, chatId, attempts: attempt + 1 }, "Telegram API error"); + return { + ok: false, + error: lastError, + errorCode: res.status >= 500 ? "TELEGRAM_5XX" : "TELEGRAM_ERROR", + status: res.status, + attempts: attempt + 1, + }; + } + + return { + ok: true, + status: res.status, + messageId: data?.result?.message_id != null ? String(data.result.message_id) : undefined, + attempts: attempt + 1, + }; + } catch (err) { + lastError = err instanceof Error ? err.message : String(err); + log.error({ err, chatId, attempts: attempt + 1 }, "Failed to send Telegram message"); + if (attempt === 2) { + return { ok: false, error: lastError, errorCode: "NETWORK", status: lastStatus, attempts: attempt + 1 }; + } + await new Promise((r) => setTimeout(r, 500 * (attempt + 1))); + } + } + + return { ok: false, error: lastError || "Failed to send Telegram message", errorCode: "RETRIES", status: lastStatus, attempts: 3 }; +} diff --git a/src/drivers/telegram/updates.ts b/src/drivers/telegram/updates.ts new file mode 100644 index 0000000..42506f4 --- /dev/null +++ b/src/drivers/telegram/updates.ts @@ -0,0 +1,45 @@ +import type { Env } from "../../types"; +import { handleTelegramUpdate } from "./commands"; + +const MAX_BODY_SIZE = 1024 * 1024; + +function timingSafeEqual(a: string, b: string): boolean { + if (a.length !== b.length) return false; + let diff = 0; + for (let i = 0; i < a.length; i++) { + diff |= a.charCodeAt(i) ^ b.charCodeAt(i); + } + return diff === 0; +} + +/** Handle a POST to the Telegram webhook endpoint. */ +export async function handleTelegramWebhookRequest(request: Request, env: Env): Promise { + const contentLength = Number(request.headers.get("content-length") ?? 0); + if (contentLength > MAX_BODY_SIZE) { + return new Response("Request too large", { status: 413 }); + } + + const secret = env.TELEGRAM_WEBHOOK_SECRET; + if (secret) { + const token = request.headers.get("X-Telegram-Bot-Api-Secret-Token"); + if (!token || !timingSafeEqual(token, secret)) { + return new Response("Invalid secret", { status: 401 }); + } + } + + const rawBody = await request.text(); + if (rawBody.length > MAX_BODY_SIZE) { + return new Response("Request too large", { status: 413 }); + } + + let update: unknown; + try { + update = JSON.parse(rawBody); + } catch { + return new Response("Invalid JSON", { status: 400 }); + } + + // Telegram expects a quick 200; process commands in the background. + await handleTelegramUpdate(env, update); + return new Response("ok"); +} diff --git a/src/drivers/types.ts b/src/drivers/types.ts index c082ce1..ddcf0d1 100644 --- a/src/drivers/types.ts +++ b/src/drivers/types.ts @@ -1,4 +1,4 @@ -import type { Route, Env, NeutralMessage } from "../types"; +import type { RouteTarget, Env, NeutralMessage } from "../types"; export interface SendResult { ok: boolean; @@ -11,5 +11,5 @@ export interface SendResult { export interface PlatformDriver { readonly id: string; - send(message: NeutralMessage, target: Route["target"], env: Env): Promise; + send(message: NeutralMessage, target: RouteTarget, env: Env): Promise; } diff --git a/src/github/store.ts b/src/github/store.ts index afd94ff..3a35cea 100644 --- a/src/github/store.ts +++ b/src/github/store.ts @@ -97,3 +97,29 @@ export async function getDiscordLink( export async function removeDiscordLink(db: D1Database, discordUserId: string): Promise { await db.prepare("DELETE FROM discord_links WHERE discord_user_id = ?").bind(discordUserId).run(); } + +export async function saveTelegramLink( + db: D1Database, + telegramUserId: string, + githubUserId: string, +): Promise { + await db + .prepare("INSERT OR REPLACE INTO telegram_links (telegram_user_id, github_user_id) VALUES (?, ?)") + .bind(telegramUserId, githubUserId) + .run(); +} + +export async function getTelegramLink( + db: D1Database, + telegramUserId: string, +): Promise { + const { results } = await db + .prepare("SELECT github_user_id FROM telegram_links WHERE telegram_user_id = ?") + .bind(telegramUserId) + .all<{ github_user_id: string }>(); + return results[0]?.github_user_id ?? null; +} + +export async function removeTelegramLink(db: D1Database, telegramUserId: string): Promise { + await db.prepare("DELETE FROM telegram_links WHERE telegram_user_id = ?").bind(telegramUserId).run(); +} diff --git a/src/index.ts b/src/index.ts index 3da277c..adf3ce8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,6 @@ import { createServer } from "./server"; import { syncCommands } from "./drivers/discord/commands"; +import { syncTelegramWebhook } from "./drivers/telegram/commands"; import type { Env } from "./types"; import { log } from "./lib/log"; @@ -16,5 +17,10 @@ export default { } catch (err) { log.error({ err }, "Discord command sync from cron failed"); } + try { + await syncTelegramWebhook(env); + } catch (err) { + log.error({ err }, "Telegram webhook sync from cron failed"); + } }, }; diff --git a/src/server.ts b/src/server.ts index c463c8c..a4de53a 100644 --- a/src/server.ts +++ b/src/server.ts @@ -4,6 +4,7 @@ import { verifySignature } from "./events/verify"; import { parseEvent } from "./events/parse"; import { dispatchEvent } from "./core/dispatch"; import { handleInteractionRequest } from "./drivers/discord/interactions"; +import { handleTelegramWebhookRequest } from "./drivers/telegram/updates"; import { createOAuthRoutes } from "./web/oauth-routes"; import { createActionRoutes } from "./web/action-routes"; import { createAdminRoutes } from "./web/admin-routes"; @@ -71,6 +72,7 @@ export function createServer(): Hono<{ Bindings: Env }> { }); app.post("/discord/interactions", (c) => handleInteractionRequest(c.req.raw, c.env)); + app.post("/telegram/webhook", (c) => handleTelegramWebhookRequest(c.req.raw, c.env)); app.notFound((c) => { if (c.env.ASSETS) { diff --git a/src/types.ts b/src/types.ts index c4480db..8f63be7 100644 --- a/src/types.ts +++ b/src/types.ts @@ -13,6 +13,8 @@ export interface Env { GITHUB_REPO_URL?: string; DISCORD_PUBLIC_KEY?: string; DISCORD_APPLICATION_ID?: string; + TELEGRAM_TOKEN?: string; + TELEGRAM_WEBHOOK_SECRET?: string; ASSETS?: Fetcher; KV: KVNamespace; DB: D1Database; @@ -33,15 +35,20 @@ export interface Config { routes: Route[]; } +export interface RouteTarget { + platform?: "discord" | "telegram"; + channelId?: string; + threadId?: string; + chatId?: string; + topicId?: string; +} + export interface Route { id: string; name: string; enabled: boolean; filters: Filter[]; - target: { - channelId: string; - threadId?: string; - }; + targets: RouteTarget[]; lang?: string; groupId?: string; /** diff --git a/src/web/admin-routes.ts b/src/web/admin-routes.ts index 0934463..66b7632 100644 --- a/src/web/admin-routes.ts +++ b/src/web/admin-routes.ts @@ -98,16 +98,66 @@ function validateRoutes( return { ok: false, error: `route "${r.id}" filter[${j}].exclude must be boolean` }; } } - const target = r.target as Record | undefined; - if (!target || typeof target !== "object") - return { ok: false, error: `route "${r.id}" needs a target` }; + const rawTarget = r.target as Record | undefined; + const rawTargets = r.targets as unknown; + if (rawTargets === undefined && rawTarget && typeof rawTarget === "object") { + const legacy = validateTarget(r, rawTarget); + if (!legacy.ok) return legacy; + (r as Record).targets = [legacy.target]; + delete (r as Record).target; + } else if (Array.isArray(rawTargets)) { + if (rawTargets.length === 0) { + return { ok: false, error: `route "${r.id}" needs at least one target` }; + } + const normalized: Route["targets"] = []; + for (let j = 0; j < rawTargets.length; j++) { + const t = rawTargets[j] as Record; + if (!t || typeof t !== "object") { + return { ok: false, error: `route "${r.id}".targets[${j}] is not an object` }; + } + const result = validateTarget(r, t); + if (!result.ok) return result; + normalized.push(result.target); + } + (r as Record).targets = normalized; + } else { + return { ok: false, error: `route "${r.id}" needs a targets array` }; + } + } + return { ok: true, routes: routes as Route[] }; +} + +function validateTarget( + r: Record, + target: Record, +): { ok: true; target: Route["targets"][number] } | { ok: false; error: string } { + const platform = target.platform === undefined ? "discord" : target.platform; + if (platform !== "discord" && platform !== "telegram") { + return { ok: false, error: `route "${r.id}".target.platform must be "discord" or "telegram"` }; + } + if (platform === "telegram") { + if (typeof target.chatId !== "string" || target.chatId.trim().length === 0) + return { ok: false, error: `route "${r.id}".target.chatId is required` }; + if (target.topicId !== undefined && typeof target.topicId !== "string") { + return { ok: false, error: `route "${r.id}".target.topicId must be a string` }; + } + } else { if (typeof target.channelId !== "string" || target.channelId.trim().length === 0) return { ok: false, error: `route "${r.id}".target.channelId is required` }; if (target.threadId !== undefined && typeof target.threadId !== "string") { return { ok: false, error: `route "${r.id}".target.threadId must be a string` }; } } - return { ok: true, routes: routes as Route[] }; + return { + ok: true, + target: { + platform, + channelId: platform === "telegram" ? undefined : (target.channelId as string), + threadId: platform === "telegram" ? undefined : ((target.threadId as string) ?? undefined), + chatId: platform === "telegram" ? (target.chatId as string) : undefined, + topicId: platform === "telegram" ? ((target.topicId as string) ?? undefined) : undefined, + }, + }; } function validateGroups( diff --git a/src/web/oauth-routes.ts b/src/web/oauth-routes.ts index c56966f..3bef03f 100644 --- a/src/web/oauth-routes.ts +++ b/src/web/oauth-routes.ts @@ -1,14 +1,17 @@ import { Hono } from "hono"; import { getOAuthURL, handleOAuthCallback } from "../github/oauth"; -import { removeToken, saveDiscordLink } from "../github/store"; +import { removeToken, saveDiscordLink, saveTelegramLink } from "../github/store"; import { createAdminSession, adminCookie } from "./session"; import { loadGroups, resolveScope, hasAnyAccess } from "./groups"; +import { sendMessage } from "../drivers/telegram/rest"; import type { Env } from "../types"; interface PendingState { redirectTo: string; expiresAt: number; discordUserId?: string; + telegramUserId?: string; + telegramChatId?: string; } function linkedPage(login: string): string { @@ -91,6 +94,19 @@ export function createOAuthRoutes(): Hono<{ Bindings: Env }> { return c.json({ ok: true, discordUserId: pending.discordUserId, login: result.login }); } + // Telegram account-linking flow: bind the Telegram user to this GitHub account. + if (pending.telegramUserId) { + await saveTelegramLink(c.env.DB, pending.telegramUserId, result.userId); + if (pending.telegramChatId && c.env.TELEGRAM_TOKEN) { + await sendMessage( + c.env.TELEGRAM_TOKEN, + pending.telegramChatId, + `✅ GitHub 账号已绑定:**@${result.login}**。现在可以用 /gh comment 评论了。`, + ).catch(() => undefined); + } + return c.json({ ok: true, telegramUserId: pending.telegramUserId, login: result.login }); + } + const isBrowser = (c.req.header("accept") ?? "").includes("text/html"); if (isBrowser) { const groups = await loadGroups(c.env.KV);