mirror of
https://github.com/ReCloudStudio/WebHooker.git
synced 2026-09-22 16:11:29 +00:00
feat(feishu): add Feishu inbound webhook, /gh commands and card actions
- add feishu_links D1 table and link store helpers - implement X-Lark-Signature verification, url_verification, /gh login|logout|comment|merge|close and card.action.trigger Merge/Close - render interactive cards with clickable title link, inline links and callback buttons (no whole-card card_link) - bind Feishu account in OAuth callback - document event subscription and required scopes
This commit is contained in:
parent
3437ac5513
commit
4b99f6d33d
38 changed files with 2449 additions and 1412 deletions
|
|
@ -18,6 +18,10 @@ TELEGRAM_TOKEN=your-bot-token
|
||||||
TELEGRAM_WEBHOOK_SECRET=your-webhook-secret
|
TELEGRAM_WEBHOOK_SECRET=your-webhook-secret
|
||||||
# TELEGRAM_RICH_HEADER_HOST=https://your-domain
|
# TELEGRAM_RICH_HEADER_HOST=https://your-domain
|
||||||
|
|
||||||
|
# Feishu (Lark / 飞书)
|
||||||
|
FEISHU_APP_ID=your-app-id
|
||||||
|
FEISHU_APP_SECRET=your-app-secret
|
||||||
|
|
||||||
# Admin
|
# Admin
|
||||||
ADMIN_USER_IDS=your-github-id,your-github-login
|
ADMIN_USER_IDS=your-github-id,your-github-login
|
||||||
# ALLOW_SELF_SIGNUP=1 # non-admin GitHub users get a personal group on first login (default off)
|
# ALLOW_SELF_SIGNUP=1 # non-admin GitHub users get a personal group on first login (default off)
|
||||||
|
|
|
||||||
10
AGENTS.md
10
AGENTS.md
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
## Project Purpose
|
## Project Purpose
|
||||||
|
|
||||||
Nuxt 4 (Nitro) app deployed as a Cloudflare Worker that receives GitHub webhooks and dispatches processed events to Discord channels/threads and Telegram chats/topics, and receives Discord interactions (slash commands, buttons, modals) via the Interactions Endpoint plus Telegram bot `/gh` commands via the Telegram webhook.
|
Nuxt 4 (Nitro) app deployed as a Cloudflare Worker that receives GitHub webhooks and dispatches processed events to Discord channels/threads, Telegram chats/topics and Feishu group chats, and receives Discord interactions (slash commands, buttons, modals) via the Interactions Endpoint plus Telegram bot `/gh` commands and Feishu bot `/gh` commands + card buttons via their respective webhooks.
|
||||||
|
|
||||||
Core pipeline: GitHub Webhook → Worker (verify + filter + format) → Discord (REST) / Telegram (Bot API)
|
Core pipeline: GitHub Webhook → Worker (verify + filter + format) → Discord (REST) / Telegram (Bot API)
|
||||||
|
|
||||||
|
|
@ -88,7 +88,7 @@ server/ # Nitro server
|
||||||
│ # milestone, discussion, repository, security, generic, ping, custom
|
│ # milestone, discussion, repository, security, generic, ping, custom
|
||||||
├── drivers/ # Platform drivers (pluggable push targets)
|
├── drivers/ # Platform drivers (pluggable push targets)
|
||||||
│ ├── types.ts # PlatformDriver interface + SendResult (send + edit)
|
│ ├── types.ts # PlatformDriver interface + SendResult (send + edit)
|
||||||
│ ├── index.ts # getDriver() registry (discord default + telegram)
|
│ ├── index.ts # getDriver() registry (discord default + telegram + feishu)
|
||||||
│ ├── discord/
|
│ ├── discord/
|
||||||
│ │ ├── index.ts # DiscordDriver: send/edit → renderNeutralMessage + rest.sendMessage/editMessage
|
│ │ ├── index.ts # DiscordDriver: send/edit → renderNeutralMessage + rest.sendMessage/editMessage
|
||||||
│ │ ├── render.ts # renderNeutralMessage: NeutralMessage → Discord FormattedMessage
|
│ │ ├── render.ts # renderNeutralMessage: NeutralMessage → Discord FormattedMessage
|
||||||
|
|
@ -101,6 +101,11 @@ server/ # Nitro server
|
||||||
│ ├── rest.ts # Telegram Bot API sendMessage/sendPhoto/editMessage* (chat_id + message_thread_id), retry
|
│ ├── rest.ts # Telegram Bot API sendMessage/sendPhoto/editMessage* (chat_id + message_thread_id), retry
|
||||||
│ ├── updates.ts # POST /telegram/webhook: secret-token verify + handleTelegramUpdate
|
│ ├── updates.ts # POST /telegram/webhook: secret-token verify + handleTelegramUpdate
|
||||||
│ └── commands.ts # Telegram /gh login|logout|comment|merge|close + reply-message parsing + syncTelegramWebhook
|
│ └── commands.ts # Telegram /gh login|logout|comment|merge|close + reply-message parsing + syncTelegramWebhook
|
||||||
|
│ └── feishu/
|
||||||
|
│ ├── index.ts # FeishuDriver: send/edit → renderNeutralMessage + rest.sendMessage/updateMessage
|
||||||
|
│ ├── render.ts # renderNeutralMessage: NeutralMessage → Feishu interactive card
|
||||||
|
│ ├── rest.ts # getTenantAccessToken (KV cache) + sendMessage/updateMessage/sendText/updateCard, retry
|
||||||
|
│ └── updates.ts # X-Lark-Signature verify + url_verification + /gh commands + card.action.trigger buttons
|
||||||
├── github/
|
├── github/
|
||||||
│ ├── oauth.ts # OAuth URL, callback token exchange, getUserOctokit, comment/getComment/editComment/deleteComment/merge/close actions
|
│ ├── oauth.ts # OAuth URL, callback token exchange, getUserOctokit, comment/getComment/editComment/deleteComment/merge/close actions
|
||||||
│ └── store.ts # KV token CRUD + D1 discord-link/telegram-link mapping (was token-store.ts)
|
│ └── store.ts # KV token CRUD + D1 discord-link/telegram-link mapping (was token-store.ts)
|
||||||
|
|
@ -170,6 +175,7 @@ tests/__snapshots__/ # formatter snapshot golden files (toMatchSnapshot)
|
||||||
- Send a per-event summary (event, repo, delivery id, per route×target ✅/❌ outcome) to the group's `logTarget` when configured
|
- Send a per-event summary (event, repo, delivery id, per route×target ✅/❌ outcome) to the group's `logTarget` when configured
|
||||||
- Serve `/gh` slash commands + message context-menu commands + PR merge/close buttons + comment modals
|
- 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
|
- Serve Telegram `/gh` commands (login/logout/comment/merge/close) via reply-message parsing
|
||||||
|
- Serve Feishu `/gh` commands (login/logout/comment/merge/close) and card Merge/Close buttons via the `/feishu/webhook` endpoint (X-Lark-Signature verify + `url_verification` challenge)
|
||||||
- Sync application commands from the scheduled trigger (global ~1h propagation + per-guild instant)
|
- Sync application commands from the scheduled trigger (global ~1h propagation + per-guild instant)
|
||||||
- Sync the Telegram webhook URL from the scheduled trigger (setWebhook)
|
- Sync the Telegram webhook URL from the scheduled trigger (setWebhook)
|
||||||
|
|
||||||
|
|
|
||||||
18
README.md
18
README.md
|
|
@ -1,6 +1,6 @@
|
||||||
# WebHooker
|
# WebHooker
|
||||||
|
|
||||||
GitHub / Gitea webhook → Discord / Telegram dispatcher. Receives webhook events via Cloudflare Workers, applies filters, and routes formatted messages to Discord channels/threads and Telegram chats/topics. Forge-specific adapters live under `server/lib/providers/` (GitHub + Gitea today; GitLab etc. can be added later).
|
GitHub / Gitea webhook → Discord / Telegram / Feishu dispatcher. Receives webhook events via Cloudflare Workers, applies filters, and routes formatted messages to Discord channels/threads, Telegram chats/topics, and Feishu chat cards. Forge-specific adapters live under `server/lib/providers/` (GitHub + Gitea today; GitLab etc. can be added later).
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
|
|
@ -12,9 +12,9 @@ GitHub / Gitea webhook → Discord / Telegram dispatcher. Receives webhook event
|
||||||
- Filter by event type, repo, actor, action, branch, keyword, or any payload field (`field` with a JSONPath `path`, e.g. `pull_request.user.login`); supports `*`/`?` globs and `/regex/` patterns plus 12 comparison operators (`eq`/`ne`/`contains`/`startsWith`/`endsWith`/`regex`/`gt`/`gte`/`lt`/`lte`/`in`/`exists`)
|
- Filter by event type, repo, actor, action, branch, keyword, or any payload field (`field` with a JSONPath `path`, e.g. `pull_request.user.login`); supports `*`/`?` globs and `/regex/` patterns plus 12 comparison operators (`eq`/`ne`/`contains`/`startsWith`/`endsWith`/`regex`/`gt`/`gte`/`lt`/`lte`/`in`/`exists`)
|
||||||
- Combine filters into a boolean AST (`all` / `any` / `not` nodes) via the route editor's visual builder; reuse named filter fragments and dry-run any filter against a pasted JSON payload without storing it
|
- Combine filters into a boolean AST (`all` / `any` / `not` nodes) via the route editor's visual builder; reuse named filter fragments and dry-run any filter against a pasted JSON payload without storing it
|
||||||
- Rich messages with color coding, author avatars, fields, and timestamps — rendered as Discord embeds and Telegram HTML
|
- Rich messages with color coding, author avatars, fields, and timestamps — rendered as Discord embeds and Telegram HTML
|
||||||
- Route to Discord channels/threads and Telegram chats/topics (multi-target routes)
|
- Route to Discord channels/threads, Telegram chats/topics, and Feishu group chats (multi-target routes)
|
||||||
- `workflow_run` / `check_run` progress is edited **in place** (single message updated as the run advances) on both platforms
|
- `workflow_run` / `check_run` progress is edited **in place** (single message updated as the run advances) on Discord, Telegram, and Feishu
|
||||||
- **Per-group webhook log channel** — point a group at a Discord channel/thread or Telegram chat/topic and every webhook the group's routes dispatch is summarized there (✅/❌ per route × target)
|
- **Per-group webhook log channel** — point a group at a Discord channel/thread, Telegram chat/topic, or Feishu chat and every webhook the group's routes dispatch is summarized there (✅/❌ per route × target)
|
||||||
- GitHub OAuth for user actions (comment, edit comment, delete comment, merge, close, react)
|
- GitHub OAuth for user actions (comment, edit comment, delete comment, merge, close, react)
|
||||||
- **Web UI config console** (`/admin`) — manage routes and groups with GitHub OAuth + admin whitelist, view send logs
|
- **Web UI config console** (`/admin`) — manage routes and groups with GitHub OAuth + admin whitelist, view send logs
|
||||||
- **Discord Interactions Endpoint** (Ed25519-verified) for `/gh` slash commands, message context-menu commands, PR merge/close buttons, and comment modals
|
- **Discord Interactions Endpoint** (Ed25519-verified) for `/gh` slash commands, message context-menu commands, PR merge/close buttons, and comment modals
|
||||||
|
|
@ -27,7 +27,7 @@ GitHub / Gitea webhook → Discord / Telegram dispatcher. Receives webhook event
|
||||||
|
|
||||||
```text
|
```text
|
||||||
GitHub Webhook → Cloudflare Worker (Nuxt 4 / Nitro)
|
GitHub Webhook → Cloudflare Worker (Nuxt 4 / Nitro)
|
||||||
├── POST /webhook → verify → dedup → enqueue (Queue) → dispatch → Discord (REST) / Telegram (Bot API)
|
├── POST /webhook → verify → dedup → enqueue (Queue) → dispatch → Discord (REST) / Telegram (Bot API) / Feishu (Bot API)
|
||||||
├── POST /discord/interactions → verify (Ed25519) → handle command/button/modal
|
├── POST /discord/interactions → verify (Ed25519) → handle command/button/modal
|
||||||
├── POST /telegram/webhook → verify (secret token) → handle /gh commands
|
├── POST /telegram/webhook → verify (secret token) → handle /gh commands
|
||||||
├── GET /auth/github → OAuth flow
|
├── GET /auth/github → OAuth flow
|
||||||
|
|
@ -70,6 +70,8 @@ bunx wrangler dev # Start local dev server
|
||||||
| `TELEGRAM_TOKEN` | Telegram bot token (from BotFather) — required for Telegram routes |
|
| `TELEGRAM_TOKEN` | Telegram bot token (from BotFather) — required for Telegram routes |
|
||||||
| `TELEGRAM_WEBHOOK_SECRET` | Optional secret token for `POST /telegram/webhook` verification |
|
| `TELEGRAM_WEBHOOK_SECRET` | Optional secret token for `POST /telegram/webhook` verification |
|
||||||
| `TELEGRAM_RICH_HEADER_HOST` | Optional base URL overriding the built-in `GET /api/richheader` for Telegram avatar cards |
|
| `TELEGRAM_RICH_HEADER_HOST` | Optional base URL overriding the built-in `GET /api/richheader` for Telegram avatar cards |
|
||||||
|
| `FEISHU_APP_ID` | Feishu app ID (from the app Credentials page) — required for Feishu routes |
|
||||||
|
| `FEISHU_APP_SECRET` | Feishu app secret — required for Feishu routes |
|
||||||
| `BASE_URL` | Public URL for OAuth callbacks and the Telegram webhook sync |
|
| `BASE_URL` | Public URL for OAuth callbacks and the Telegram webhook sync |
|
||||||
| `ADMIN_USER_IDS` | Comma-separated GitHub user IDs (or logins) allowed to access `/admin` |
|
| `ADMIN_USER_IDS` | Comma-separated GitHub user IDs (or logins) allowed to access `/admin` |
|
||||||
| `ALLOW_SELF_SIGNUP` | `1` to give access-less GitHub users a personal group on first login (default off) |
|
| `ALLOW_SELF_SIGNUP` | `1` to give access-less GitHub users a personal group on first login (default off) |
|
||||||
|
|
@ -93,13 +95,14 @@ Routes are stored in D1 (`d1_routes`, seeded from legacy KV `config:routes` on f
|
||||||
"stop": true,
|
"stop": true,
|
||||||
"targets": [
|
"targets": [
|
||||||
{ "platform": "discord", "channelId": "CHANNEL_ID" },
|
{ "platform": "discord", "channelId": "CHANNEL_ID" },
|
||||||
{ "platform": "telegram", "chatId": "-1001234567890" }
|
{ "platform": "telegram", "chatId": "-1001234567890" },
|
||||||
|
{ "platform": "feishu", "chatId": "oc_xxx" }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
```
|
```
|
||||||
|
|
||||||
`target.platform` selects the push target: `discord` (default) or `telegram`. Discord targets require `target.channelId` (optional `threadId` for a thread); Telegram targets require `target.chatId` (optional `topicId` for a topic). A route may also set `stop: true` (skip later routes). Routes belong to **groups** (D1 `d1_groups`, seeded from legacy KV `config:groups`) that scope admin access and can restrict which org/user events flow in. See the [Routes & Targets](https://webhooker.docs.worldexecute.me/guide/routes) and [Groups & Access Control](https://webhooker.docs.worldexecute.me/guide/groups) guides for the full schema.
|
`target.platform` selects the push target: `discord` (default), `telegram`, or `feishu`. Discord targets require `target.channelId` (optional `threadId` for a thread); Telegram targets require `target.chatId` (optional `topicId` for a topic); Feishu targets require `target.chatId` (optional `topicId` for a topic). A route may also set `stop: true` (skip later routes). Routes belong to **groups** (D1 `d1_groups`, seeded from legacy KV `config:groups`) that scope admin access and can restrict which org/user events flow in. See the [Routes & Targets](https://webhooker.docs.worldexecute.me/guide/routes) and [Groups & Access Control](https://webhooker.docs.worldexecute.me/guide/groups) guides for the full schema.
|
||||||
|
|
||||||
### Web UI (`/admin`)
|
### Web UI (`/admin`)
|
||||||
|
|
||||||
|
|
@ -161,6 +164,7 @@ Every filter supports plain text, `*`/`?` globs, and `/regex/` patterns (case-in
|
||||||
- **GitHub App** — create the app, subscribe to events, configure OAuth, and set the _Setup URL_ for tenant isolation: see [GitHub App Setup](https://webhooker.docs.worldexecute.me/guide/deployment#github-app-setup)
|
- **GitHub App** — create the app, subscribe to events, configure OAuth, and set the _Setup URL_ for tenant isolation: see [GitHub App Setup](https://webhooker.docs.worldexecute.me/guide/deployment#github-app-setup)
|
||||||
- **Discord bot** — create the bot, invite it with `applications.commands` (combined permission integer `274877910016`), and configure the Interactions Endpoint: see [Discord Bot Setup](https://webhooker.docs.worldexecute.me/guide/deployment#discord-bot-setup). The bot never connects to the Discord Gateway, so it shows as **offline** — messaging is unaffected (always REST).
|
- **Discord bot** — create the bot, invite it with `applications.commands` (combined permission integer `274877910016`), and configure the Interactions Endpoint: see [Discord Bot Setup](https://webhooker.docs.worldexecute.me/guide/deployment#discord-bot-setup). The bot never connects to the Discord Gateway, so it shows as **offline** — messaging is unaffected (always REST).
|
||||||
- **Telegram bot** — create the bot with [@BotFather](https://t.me/BotFather), set `TELEGRAM_TOKEN` (optional `TELEGRAM_WEBHOOK_SECRET`); the webhook is synced automatically by the scheduled trigger: see [Telegram Bot Setup](https://webhooker.docs.worldexecute.me/guide/deployment#telegram-bot-setup)
|
- **Telegram bot** — create the bot with [@BotFather](https://t.me/BotFather), set `TELEGRAM_TOKEN` (optional `TELEGRAM_WEBHOOK_SECRET`); the webhook is synced automatically by the scheduled trigger: see [Telegram Bot Setup](https://webhooker.docs.worldexecute.me/guide/deployment#telegram-bot-setup)
|
||||||
|
- **Feishu bot** — create a custom app, enable the bot, add it to the target group, and set `FEISHU_APP_ID` + `FEISHU_APP_SECRET`: see [Feishu Bot Setup](https://webhooker.docs.worldexecute.me/guide/deployment#feishu-bot-setup)
|
||||||
- **Deployment** — KV namespace, D1 database + migrations, optional Queues, secrets, and deploy: see the [Deployment guide](https://webhooker.docs.worldexecute.me/guide/deployment)
|
- **Deployment** — KV namespace, D1 database + migrations, optional Queues, secrets, and deploy: see the [Deployment guide](https://webhooker.docs.worldexecute.me/guide/deployment)
|
||||||
|
|
||||||
### Bot Commands (comment on GitHub as yourself)
|
### Bot Commands (comment on GitHub as yourself)
|
||||||
|
|
|
||||||
17
README.zh.md
17
README.zh.md
|
|
@ -1,6 +1,6 @@
|
||||||
# WebHooker
|
# WebHooker
|
||||||
|
|
||||||
GitHub / Gitea webhook → Discord / Telegram 分发服务。通过 Cloudflare Workers 接收 webhook 事件,应用过滤器,将格式化消息路由到 Discord 频道/子区与 Telegram 群组/话题。各 forge 适配器位于 `server/lib/providers/`(目前支持 GitHub + Gitea;GitLab 等可后续扩展)。
|
GitHub / Gitea webhook → Discord / Telegram / 飞书 分发服务。通过 Cloudflare Workers 接收 webhook 事件,应用过滤器,将格式化消息路由到 Discord 频道/子区、Telegram 群组/话题与飞书群聊卡片。各 forge 适配器位于 `server/lib/providers/`(目前支持 GitHub + Gitea;GitLab 等可后续扩展)。
|
||||||
|
|
||||||
## 功能特性
|
## 功能特性
|
||||||
|
|
||||||
|
|
@ -12,9 +12,9 @@ GitHub / Gitea webhook → Discord / Telegram 分发服务。通过 Cloudflare W
|
||||||
- 按事件类型、仓库、操作人、操作、分支、关键词,或任意载荷字段(`field` + JSONPath `path`,如 `pull_request.user.login`)过滤;支持 `*`/`?` 通配符与 `/正则/`,另有 12 个比较操作符(`eq`/`ne`/`contains`/`startsWith`/`endsWith`/`regex`/`gt`/`gte`/`lt`/`lte`/`in`/`exists`)
|
- 按事件类型、仓库、操作人、操作、分支、关键词,或任意载荷字段(`field` + JSONPath `path`,如 `pull_request.user.login`)过滤;支持 `*`/`?` 通配符与 `/正则/`,另有 12 个比较操作符(`eq`/`ne`/`contains`/`startsWith`/`endsWith`/`regex`/`gt`/`gte`/`lt`/`lte`/`in`/`exists`)
|
||||||
- 在路由编辑器的可视化构建器中把过滤器组合成布尔 AST(`all` / `any` / `not` 节点);可复用命名过滤器片段,并可对粘贴的 JSON 载荷做无存储的试匹配
|
- 在路由编辑器的可视化构建器中把过滤器组合成布尔 AST(`all` / `any` / `not` 节点);可复用命名过滤器片段,并可对粘贴的 JSON 载荷做无存储的试匹配
|
||||||
- 富消息:颜色编码、作者头像、字段、时间戳——渲染为 Discord embed 与 Telegram HTML
|
- 富消息:颜色编码、作者头像、字段、时间戳——渲染为 Discord embed 与 Telegram HTML
|
||||||
- 路由到 Discord 频道/子区与 Telegram 群组/话题(一条路由可多目标)
|
- 路由到 Discord 频道/子区、Telegram 群组/话题与飞书群聊(一条路由可多目标)
|
||||||
- `workflow_run` / `check_run` 进度**原地编辑**同一条消息(运行推进时更新),两个平台均支持
|
- `workflow_run` / `check_run` 进度**原地编辑**同一条消息(运行推进时更新),Discord、Telegram 与飞书均支持
|
||||||
- **分组级 Webhook 日志频道** —— 为分组指定一个 Discord 频道/子区或 Telegram 群组/话题,该分组路由每次分发 webhook 都会向其中发送摘要(每条「路由 × 目标」一行,✅/❌ 结果)
|
- **分组级 Webhook 日志频道** —— 为分组指定一个 Discord 频道/子区、Telegram 群组/话题或飞书群聊,该分组路由每次分发 webhook 都会向其中发送摘要(每条「路由 × 目标」一行,✅/❌ 结果)
|
||||||
- GitHub OAuth 用户授权(评论、编辑评论、删除评论、合并、关闭、反应)
|
- GitHub OAuth 用户授权(评论、编辑评论、删除评论、合并、关闭、反应)
|
||||||
- **Web 配置控制台**(`/admin`)— 通过 GitHub OAuth + 管理员白名单管理路由与分组、查看发送日志
|
- **Web 配置控制台**(`/admin`)— 通过 GitHub OAuth + 管理员白名单管理路由与分组、查看发送日志
|
||||||
- **Discord Interactions Endpoint**(Ed25519 验签)支持 `/gh` 斜杠命令、消息右键菜单命令、PR 合并/关闭按钮与评论 modal
|
- **Discord Interactions Endpoint**(Ed25519 验签)支持 `/gh` 斜杠命令、消息右键菜单命令、PR 合并/关闭按钮与评论 modal
|
||||||
|
|
@ -27,7 +27,7 @@ GitHub / Gitea webhook → Discord / Telegram 分发服务。通过 Cloudflare W
|
||||||
|
|
||||||
```text
|
```text
|
||||||
GitHub Webhook → Cloudflare Worker (Nuxt 4 / Nitro)
|
GitHub Webhook → Cloudflare Worker (Nuxt 4 / Nitro)
|
||||||
├── POST /webhook → 验证 → 去重 → 入队 (Queue) → 分发 → Discord (REST) / Telegram (Bot API)
|
├── POST /webhook → 验证 → 去重 → 入队 (Queue) → 分发 → Discord (REST) / Telegram (Bot API) / 飞书 (Bot API)
|
||||||
├── POST /discord/interactions → 验证 (Ed25519) → 处理命令/按钮/modal
|
├── POST /discord/interactions → 验证 (Ed25519) → 处理命令/按钮/modal
|
||||||
├── POST /telegram/webhook → 验证 (secret token) → 处理 /gh 命令
|
├── POST /telegram/webhook → 验证 (secret token) → 处理 /gh 命令
|
||||||
├── GET /auth/github → OAuth 流程
|
├── GET /auth/github → OAuth 流程
|
||||||
|
|
@ -70,6 +70,8 @@ bunx wrangler dev # 启动本地开发服务器
|
||||||
| `TELEGRAM_TOKEN` | Telegram Bot Token(BotFather 获取)—— Telegram 路由必需 |
|
| `TELEGRAM_TOKEN` | Telegram Bot Token(BotFather 获取)—— Telegram 路由必需 |
|
||||||
| `TELEGRAM_WEBHOOK_SECRET` | 可选;`POST /telegram/webhook` 的验签密钥 |
|
| `TELEGRAM_WEBHOOK_SECRET` | 可选;`POST /telegram/webhook` 的验签密钥 |
|
||||||
| `TELEGRAM_RICH_HEADER_HOST` | 可选;覆盖内置 `GET /api/richheader` 的 Telegram 头像卡片地址 |
|
| `TELEGRAM_RICH_HEADER_HOST` | 可选;覆盖内置 `GET /api/richheader` 的 Telegram 头像卡片地址 |
|
||||||
|
| `FEISHU_APP_ID` | 飞书应用 ID(应用凭证页获取)—— 飞书路由必需 |
|
||||||
|
| `FEISHU_APP_SECRET` | 飞书应用密钥 —— 飞书路由必需 |
|
||||||
| `BASE_URL` | 公网地址(用于 OAuth 回调与 Telegram webhook 同步) |
|
| `BASE_URL` | 公网地址(用于 OAuth 回调与 Telegram webhook 同步) |
|
||||||
| `ADMIN_USER_IDS` | 允许访问 `/admin` 的 GitHub 用户 ID(或登录名),逗号分隔 |
|
| `ADMIN_USER_IDS` | 允许访问 `/admin` 的 GitHub 用户 ID(或登录名),逗号分隔 |
|
||||||
| `ALLOW_SELF_SIGNUP` | 设为 `1` 时,无权限的 GitHub 用户首次登录自动获得个人分组(默认关闭) |
|
| `ALLOW_SELF_SIGNUP` | 设为 `1` 时,无权限的 GitHub 用户首次登录自动获得个人分组(默认关闭) |
|
||||||
|
|
@ -93,13 +95,14 @@ bunx wrangler dev # 启动本地开发服务器
|
||||||
"stop": true,
|
"stop": true,
|
||||||
"targets": [
|
"targets": [
|
||||||
{ "platform": "discord", "channelId": "频道ID" },
|
{ "platform": "discord", "channelId": "频道ID" },
|
||||||
{ "platform": "telegram", "chatId": "-1001234567890" }
|
{ "platform": "telegram", "chatId": "-1001234567890" },
|
||||||
|
{ "platform": "feishu", "chatId": "oc_xxx" }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
```
|
```
|
||||||
|
|
||||||
`target.platform` 选择推送目标:`discord`(默认)或 `telegram`。Discord 目标需 `target.channelId`(可选 `threadId` 指向子区);Telegram 目标需 `target.chatId`(可选 `topicId` 指向话题)。路由还可设置 `stop: true`(跳过后续路由)。路由隶属于**分组**(D1 `d1_groups`,首次加载时从旧版 KV `config:groups` 同步),分组用于限定管理权限,并可限制哪些组织/用户的事件流入。完整模式见[路由与目标](https://webhooker.docs.worldexecute.me/zh/guide/routes)与[分组与访问控制](https://webhooker.docs.worldexecute.me/zh/guide/groups)指南。
|
`target.platform` 选择推送目标:`discord`(默认)、`telegram` 或 `feishu`。Discord 目标需 `target.channelId`(可选 `threadId` 指向子区);Telegram 目标需 `target.chatId`(可选 `topicId` 指向话题);飞书目标需 `target.chatId`(可选 `topicId` 指向子话题)。路由还可设置 `stop: true`(跳过后续路由)。路由隶属于**分组**(D1 `d1_groups`,首次加载时从旧版 KV `config:groups` 同步),分组用于限定管理权限,并可限制哪些组织/用户的事件流入。完整模式见[路由与目标](https://webhooker.docs.worldexecute.me/zh/guide/routes)与[分组与访问控制](https://webhooker.docs.worldexecute.me/zh/guide/groups)指南。
|
||||||
|
|
||||||
### Web 控制台(`/admin`)
|
### Web 控制台(`/admin`)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -104,7 +104,10 @@ function fmtTime(ts: number): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
function platformLabel(p?: string): string {
|
function platformLabel(p?: string): string {
|
||||||
return p === "telegram" ? "TG" : p === "discord" ? "DC" : "—";
|
if (p === "telegram") return "TG";
|
||||||
|
if (p === "feishu") return "FS";
|
||||||
|
if (p === "discord") return "DC";
|
||||||
|
return "—";
|
||||||
}
|
}
|
||||||
|
|
||||||
function routeEvents(r: Route): string[] {
|
function routeEvents(r: Route): string[] {
|
||||||
|
|
|
||||||
|
|
@ -186,6 +186,7 @@
|
||||||
<option value="">{{ t("groupEditor.logDisabled") }}</option>
|
<option value="">{{ t("groupEditor.logDisabled") }}</option>
|
||||||
<option value="discord">Discord</option>
|
<option value="discord">Discord</option>
|
||||||
<option value="telegram">Telegram</option>
|
<option value="telegram">Telegram</option>
|
||||||
|
<option value="feishu">{{ t("groupEditor.logFeishu") }}</option>
|
||||||
</select>
|
</select>
|
||||||
<template v-if="form.logPlatform === 'discord'">
|
<template v-if="form.logPlatform === 'discord'">
|
||||||
<input
|
<input
|
||||||
|
|
@ -201,7 +202,7 @@
|
||||||
:placeholder="t('routeEditor.threadPlaceholder')"
|
:placeholder="t('routeEditor.threadPlaceholder')"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="form.logPlatform === 'telegram'">
|
<template v-else-if="form.logPlatform === 'telegram' || form.logPlatform === 'feishu'">
|
||||||
<input
|
<input
|
||||||
v-model="form.logChatId"
|
v-model="form.logChatId"
|
||||||
type="text"
|
type="text"
|
||||||
|
|
@ -209,6 +210,7 @@
|
||||||
:placeholder="t('routeEditor.chatPlaceholder')"
|
:placeholder="t('routeEditor.chatPlaceholder')"
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
|
v-if="form.logPlatform === 'telegram'"
|
||||||
v-model="form.logTopicId"
|
v-model="form.logTopicId"
|
||||||
type="text"
|
type="text"
|
||||||
class="input mt-2"
|
class="input mt-2"
|
||||||
|
|
@ -263,7 +265,7 @@ const form = reactive({
|
||||||
emoji: true,
|
emoji: true,
|
||||||
forgeSources: [] as ForgeSource[],
|
forgeSources: [] as ForgeSource[],
|
||||||
lang: "",
|
lang: "",
|
||||||
logPlatform: "" as "" | "discord" | "telegram",
|
logPlatform: "" as "" | "discord" | "telegram" | "feishu",
|
||||||
logChannelId: "",
|
logChannelId: "",
|
||||||
logThreadId: "",
|
logThreadId: "",
|
||||||
logChatId: "",
|
logChatId: "",
|
||||||
|
|
@ -323,11 +325,8 @@ function save(): void {
|
||||||
}
|
}
|
||||||
let logTarget:
|
let logTarget:
|
||||||
| { platform: "discord"; channelId: string; threadId?: string }
|
| { platform: "discord"; channelId: string; threadId?: string }
|
||||||
| {
|
| { platform: "telegram"; chatId: string; topicId?: string }
|
||||||
platform: "telegram";
|
| { platform: "feishu"; chatId: string }
|
||||||
chatId: string;
|
|
||||||
topicId?: string;
|
|
||||||
}
|
|
||||||
| undefined;
|
| undefined;
|
||||||
if (form.logPlatform === "discord") {
|
if (form.logPlatform === "discord") {
|
||||||
const channelId = form.logChannelId.trim();
|
const channelId = form.logChannelId.trim();
|
||||||
|
|
@ -343,6 +342,13 @@ function save(): void {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
logTarget = { platform: "telegram", chatId, topicId: form.logTopicId.trim() || undefined };
|
logTarget = { platform: "telegram", chatId, topicId: form.logTopicId.trim() || undefined };
|
||||||
|
} else if (form.logPlatform === "feishu") {
|
||||||
|
const chatId = form.logChatId.trim();
|
||||||
|
if (!chatId) {
|
||||||
|
formError.value = t("groupEditor.errLogFeishuChat");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
logTarget = { platform: "feishu", chatId };
|
||||||
}
|
}
|
||||||
const installationText = form.installationId.trim();
|
const installationText = form.installationId.trim();
|
||||||
let installationId: number | undefined;
|
let installationId: number | undefined;
|
||||||
|
|
|
||||||
|
|
@ -16,13 +16,25 @@
|
||||||
v-for="(tg, i) in route.targets"
|
v-for="(tg, i) in route.targets"
|
||||||
:key="i"
|
:key="i"
|
||||||
class="route-badge"
|
class="route-badge"
|
||||||
:class="tg.platform === 'telegram' ? 'route-badge-tg' : 'route-badge-dc'"
|
:class="
|
||||||
|
tg.platform === 'telegram'
|
||||||
|
? 'route-badge-tg'
|
||||||
|
: tg.platform === 'feishu'
|
||||||
|
? 'route-badge-fs'
|
||||||
|
: 'route-badge-dc'
|
||||||
|
"
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
class="route-badge-dot"
|
class="route-badge-dot"
|
||||||
:class="tg.platform === 'telegram' ? 'bg-info' : 'bg-accent'"
|
:class="
|
||||||
|
tg.platform === 'telegram'
|
||||||
|
? 'bg-info'
|
||||||
|
: tg.platform === 'feishu'
|
||||||
|
? 'bg-warn'
|
||||||
|
: 'bg-accent'
|
||||||
|
"
|
||||||
></span>
|
></span>
|
||||||
{{ tg.platform === "telegram" ? "Telegram" : "Discord" }}
|
{{ tg.platform === "telegram" ? "Telegram" : tg.platform === "feishu" ? t("route.feishu") : "Discord" }}
|
||||||
</span>
|
</span>
|
||||||
<span v-if="route.fallback" class="route-badge route-badge-fallback">{{
|
<span v-if="route.fallback" class="route-badge route-badge-fallback">{{
|
||||||
t("route.fallback")
|
t("route.fallback")
|
||||||
|
|
@ -47,12 +59,12 @@
|
||||||
<div v-for="(tg, i) in route.targets" :key="i" class="route-target">
|
<div v-for="(tg, i) in route.targets" :key="i" class="route-target">
|
||||||
<div class="route-target-row">
|
<div class="route-target-row">
|
||||||
<span class="route-target-label">
|
<span class="route-target-label">
|
||||||
<template v-if="tg.platform === 'telegram'">{{ t("route.chat") }}</template>
|
<template v-if="tg.platform === 'telegram' || tg.platform === 'feishu'">{{ t("route.chat") }}</template>
|
||||||
<template v-else-if="tg.threadId">{{ t("route.thread") }}</template>
|
<template v-else-if="tg.threadId">{{ t("route.thread") }}</template>
|
||||||
<template v-else>{{ t("route.channel") }}</template>
|
<template v-else>{{ t("route.channel") }}</template>
|
||||||
</span>
|
</span>
|
||||||
<code class="route-target-id">
|
<code class="route-target-id">
|
||||||
<template v-if="tg.platform === 'telegram'">{{ tg.chatId }}</template>
|
<template v-if="tg.platform === 'telegram' || tg.platform === 'feishu'">{{ tg.chatId }}</template>
|
||||||
<template v-else-if="tg.threadId">{{ tg.threadId }}</template>
|
<template v-else-if="tg.threadId">{{ tg.threadId }}</template>
|
||||||
<template v-else>{{ tg.channelId }}</template>
|
<template v-else>{{ tg.channelId }}</template>
|
||||||
</code>
|
</code>
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ import {
|
||||||
} from "~/composables/useFilterNode";
|
} from "~/composables/useFilterNode";
|
||||||
|
|
||||||
interface TargetForm {
|
interface TargetForm {
|
||||||
platform: "discord" | "telegram";
|
platform: "discord" | "telegram" | "feishu";
|
||||||
channelId: string;
|
channelId: string;
|
||||||
threadId: string;
|
threadId: string;
|
||||||
chatId: string;
|
chatId: string;
|
||||||
|
|
@ -129,6 +129,10 @@ function collect(): Route | null {
|
||||||
const chatId = tg.chatId.trim();
|
const chatId = tg.chatId.trim();
|
||||||
if (!chatId) continue;
|
if (!chatId) continue;
|
||||||
targets.push({ platform: "telegram", chatId, topicId: tg.topicId.trim() || undefined });
|
targets.push({ platform: "telegram", chatId, topicId: tg.topicId.trim() || undefined });
|
||||||
|
} else if (tg.platform === "feishu") {
|
||||||
|
const chatId = tg.chatId.trim();
|
||||||
|
if (!chatId) continue;
|
||||||
|
targets.push({ platform: "feishu", chatId });
|
||||||
} else {
|
} else {
|
||||||
const channelId = tg.channelId.trim();
|
const channelId = tg.channelId.trim();
|
||||||
if (!channelId) continue;
|
if (!channelId) continue;
|
||||||
|
|
@ -170,7 +174,7 @@ function save(): void {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
form.targets.forEach((tg, i) => {
|
form.targets.forEach((tg, i) => {
|
||||||
if (tg.platform === "telegram" && !tg.chatId.trim()) {
|
if ((tg.platform === "telegram" || tg.platform === "feishu") && !tg.chatId.trim()) {
|
||||||
targetError.value = t("routeEditor.errChat", { n: i + 1 });
|
targetError.value = t("routeEditor.errChat", { n: i + 1 });
|
||||||
} else if (tg.platform === "discord" && !tg.channelId.trim()) {
|
} else if (tg.platform === "discord" && !tg.channelId.trim()) {
|
||||||
targetError.value = t("routeEditor.errChannel", { n: i + 1 });
|
targetError.value = t("routeEditor.errChannel", { n: i + 1 });
|
||||||
|
|
@ -283,7 +287,7 @@ watch(
|
||||||
? r.targets.map((tg) => ({
|
? r.targets.map((tg) => ({
|
||||||
...blankTarget(),
|
...blankTarget(),
|
||||||
...tg,
|
...tg,
|
||||||
platform: tg.platform === "telegram" ? "telegram" : "discord",
|
platform: tg.platform === "telegram" ? "telegram" : tg.platform === "feishu" ? "feishu" : "discord",
|
||||||
}))
|
}))
|
||||||
: [blankTarget()];
|
: [blankTarget()];
|
||||||
if (r?.ast) {
|
if (r?.ast) {
|
||||||
|
|
@ -481,6 +485,7 @@ watch(
|
||||||
<select v-model="tg.platform" class="tg-select">
|
<select v-model="tg.platform" class="tg-select">
|
||||||
<option value="discord">Discord</option>
|
<option value="discord">Discord</option>
|
||||||
<option value="telegram">Telegram</option>
|
<option value="telegram">Telegram</option>
|
||||||
|
<option value="feishu">{{ t("routeEditor.platformFeishu") }}</option>
|
||||||
</select>
|
</select>
|
||||||
<template v-if="tg.platform === 'discord'">
|
<template v-if="tg.platform === 'discord'">
|
||||||
<input
|
<input
|
||||||
|
|
@ -494,7 +499,7 @@ watch(
|
||||||
:placeholder="t('routeEditor.threadPlaceholder')"
|
:placeholder="t('routeEditor.threadPlaceholder')"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else-if="tg.platform === 'telegram'">
|
||||||
<input
|
<input
|
||||||
v-model="tg.chatId"
|
v-model="tg.chatId"
|
||||||
class="input tg-in1"
|
class="input tg-in1"
|
||||||
|
|
@ -506,6 +511,13 @@ watch(
|
||||||
:placeholder="t('routeEditor.topicPlaceholder')"
|
:placeholder="t('routeEditor.topicPlaceholder')"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<input
|
||||||
|
v-model="tg.chatId"
|
||||||
|
class="input tg-in1"
|
||||||
|
:placeholder="t('routeEditor.feishuChatPlaceholder')"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="icon-btn danger tg-del"
|
class="icon-btn danger tg-del"
|
||||||
|
|
|
||||||
|
|
@ -143,6 +143,7 @@ const en: Dict = {
|
||||||
"route.thread": "THREAD",
|
"route.thread": "THREAD",
|
||||||
"route.chat": "CHAT",
|
"route.chat": "CHAT",
|
||||||
"route.topic": "TOPIC",
|
"route.topic": "TOPIC",
|
||||||
|
"route.feishu": "Feishu",
|
||||||
"route.fallback": "fallback",
|
"route.fallback": "fallback",
|
||||||
"route.stop": "stop",
|
"route.stop": "stop",
|
||||||
"route.moveUp": "Move up",
|
"route.moveUp": "Move up",
|
||||||
|
|
@ -224,11 +225,13 @@ const en: Dict = {
|
||||||
"routeEditor.threadNote": "(optional)",
|
"routeEditor.threadNote": "(optional)",
|
||||||
"routeEditor.threadPlaceholder": "Optional thread ID",
|
"routeEditor.threadPlaceholder": "Optional thread ID",
|
||||||
"routeEditor.platform": "Platform",
|
"routeEditor.platform": "Platform",
|
||||||
|
"routeEditor.platformFeishu": "Feishu",
|
||||||
"routeEditor.chat": "Chat ID",
|
"routeEditor.chat": "Chat ID",
|
||||||
"routeEditor.chatPlaceholder": "Telegram group chat ID",
|
"routeEditor.chatPlaceholder": "Telegram group chat ID",
|
||||||
"routeEditor.topic": "Topic ID",
|
"routeEditor.topic": "Topic ID",
|
||||||
"routeEditor.topicNote": "(optional)",
|
"routeEditor.topicNote": "(optional)",
|
||||||
"routeEditor.topicPlaceholder": "Optional topic (message_thread_id)",
|
"routeEditor.topicPlaceholder": "Optional topic (message_thread_id)",
|
||||||
|
"routeEditor.feishuChatPlaceholder": "Feishu chat ID (chat_id)",
|
||||||
"routeEditor.errChat": "Target {n} chat ID is required",
|
"routeEditor.errChat": "Target {n} chat ID is required",
|
||||||
"routeEditor.targets": "Targets",
|
"routeEditor.targets": "Targets",
|
||||||
"routeEditor.targetsNote": "(one or more destinations)",
|
"routeEditor.targetsNote": "(one or more destinations)",
|
||||||
|
|
@ -327,10 +330,12 @@ const en: Dict = {
|
||||||
"groupEditor.logTarget": "Webhook log channel",
|
"groupEditor.logTarget": "Webhook log channel",
|
||||||
"groupEditor.logTargetNote": "(optional)",
|
"groupEditor.logTargetNote": "(optional)",
|
||||||
"groupEditor.logDisabled": "— Disabled —",
|
"groupEditor.logDisabled": "— Disabled —",
|
||||||
|
"groupEditor.logFeishu": "Feishu",
|
||||||
"groupEditor.logTargetHint":
|
"groupEditor.logTargetHint":
|
||||||
"A Discord channel/thread or Telegram chat/topic that receives a summary of every webhook this group's routes dispatch.",
|
"A Discord channel/thread, Telegram chat/topic or Feishu chat that receives a summary of every webhook this group's routes dispatch.",
|
||||||
"groupEditor.errLogChannel": "Log channel ID is required when Discord is selected",
|
"groupEditor.errLogChannel": "Log channel ID is required when Discord is selected",
|
||||||
"groupEditor.errLogChat": "Log chat ID is required when Telegram is selected",
|
"groupEditor.errLogChat": "Log chat ID is required when Telegram is selected",
|
||||||
|
"groupEditor.errLogFeishuChat": "Log chat ID is required when Feishu is selected",
|
||||||
"groupEditor.cancel": "Cancel",
|
"groupEditor.cancel": "Cancel",
|
||||||
"groupEditor.save": "Save group",
|
"groupEditor.save": "Save group",
|
||||||
"groupEditor.errIdFormat": "ID must be a-z / 0-9 / dashes",
|
"groupEditor.errIdFormat": "ID must be a-z / 0-9 / dashes",
|
||||||
|
|
@ -521,6 +526,7 @@ const zh: Dict = {
|
||||||
"route.thread": "子区",
|
"route.thread": "子区",
|
||||||
"route.chat": "群组",
|
"route.chat": "群组",
|
||||||
"route.topic": "话题",
|
"route.topic": "话题",
|
||||||
|
"route.feishu": "飞书",
|
||||||
"route.fallback": "兜底",
|
"route.fallback": "兜底",
|
||||||
"route.stop": "停止",
|
"route.stop": "停止",
|
||||||
"route.moveUp": "上移",
|
"route.moveUp": "上移",
|
||||||
|
|
@ -601,11 +607,13 @@ const zh: Dict = {
|
||||||
"routeEditor.threadNote": "(可选)",
|
"routeEditor.threadNote": "(可选)",
|
||||||
"routeEditor.threadPlaceholder": "可选的子区 ID",
|
"routeEditor.threadPlaceholder": "可选的子区 ID",
|
||||||
"routeEditor.platform": "平台",
|
"routeEditor.platform": "平台",
|
||||||
|
"routeEditor.platformFeishu": "飞书",
|
||||||
"routeEditor.chat": "群组 ID",
|
"routeEditor.chat": "群组 ID",
|
||||||
"routeEditor.chatPlaceholder": "Telegram 群组聊天 ID",
|
"routeEditor.chatPlaceholder": "Telegram 群组聊天 ID",
|
||||||
"routeEditor.topic": "话题 ID",
|
"routeEditor.topic": "话题 ID",
|
||||||
"routeEditor.topicNote": "(可选)",
|
"routeEditor.topicNote": "(可选)",
|
||||||
"routeEditor.topicPlaceholder": "可选的话题 (message_thread_id)",
|
"routeEditor.topicPlaceholder": "可选的话题 (message_thread_id)",
|
||||||
|
"routeEditor.feishuChatPlaceholder": "飞书群组 ID (chat_id)",
|
||||||
"routeEditor.errChat": "第 {n} 个目标群组 ID 为必填项",
|
"routeEditor.errChat": "第 {n} 个目标群组 ID 为必填项",
|
||||||
"routeEditor.targets": "目标",
|
"routeEditor.targets": "目标",
|
||||||
"routeEditor.targetsNote": "(一个或多个推送目标)",
|
"routeEditor.targetsNote": "(一个或多个推送目标)",
|
||||||
|
|
@ -703,10 +711,12 @@ const zh: Dict = {
|
||||||
"groupEditor.logTarget": "Webhook 日志频道",
|
"groupEditor.logTarget": "Webhook 日志频道",
|
||||||
"groupEditor.logTargetNote": "(可选)",
|
"groupEditor.logTargetNote": "(可选)",
|
||||||
"groupEditor.logDisabled": "— 未启用 —",
|
"groupEditor.logDisabled": "— 未启用 —",
|
||||||
|
"groupEditor.logFeishu": "飞书",
|
||||||
"groupEditor.logTargetHint":
|
"groupEditor.logTargetHint":
|
||||||
"一个 Discord 频道/子区或 Telegram 群组/话题,本分组路由分发(dispatch)的每个 webhook 都会向其发送摘要。",
|
"一个 Discord 频道/子区、Telegram 群组/话题或飞书群组,本分组路由分发(dispatch)的每个 webhook 都会向其发送摘要。",
|
||||||
"groupEditor.errLogChannel": "选择 Discord 时必须填写日志频道 ID",
|
"groupEditor.errLogChannel": "选择 Discord 时必须填写日志频道 ID",
|
||||||
"groupEditor.errLogChat": "选择 Telegram 时必须填写日志群组 ID",
|
"groupEditor.errLogChat": "选择 Telegram 时必须填写日志群组 ID",
|
||||||
|
"groupEditor.errLogFeishuChat": "选择飞书时必须填写日志群组 ID",
|
||||||
"groupEditor.cancel": "取消",
|
"groupEditor.cancel": "取消",
|
||||||
"groupEditor.save": "保存分组",
|
"groupEditor.save": "保存分组",
|
||||||
"groupEditor.errIdFormat": "ID 只能是 a-z / 0-9 / 短横线",
|
"groupEditor.errIdFormat": "ID 只能是 a-z / 0-9 / 短横线",
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,7 @@ export interface NamedFragment {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RouteTarget {
|
export interface RouteTarget {
|
||||||
platform?: "discord" | "telegram";
|
platform?: "discord" | "telegram" | "feishu";
|
||||||
channelId?: string;
|
channelId?: string;
|
||||||
threadId?: string;
|
threadId?: string;
|
||||||
chatId?: string;
|
chatId?: string;
|
||||||
|
|
|
||||||
|
|
@ -61,9 +61,11 @@ routes:
|
||||||
# - platform: discord
|
# - platform: discord
|
||||||
# channelId: "CHANNEL_ID_HERE"
|
# channelId: "CHANNEL_ID_HERE"
|
||||||
# threadId: "THREAD_ID_HERE"
|
# threadId: "THREAD_ID_HERE"
|
||||||
# - platform: telegram
|
# - platform: telegram
|
||||||
# chatId: "-1001234567890"
|
# chatId: "-1001234567890"
|
||||||
# topicId: "9876543210"
|
# topicId: "9876543210"
|
||||||
|
# - platform: feishu
|
||||||
|
# chatId: "oc_xxxxxxxxxxxxxxxx" # Feishu group chat_id
|
||||||
|
|
||||||
# - id: exclude-bot
|
# - id: exclude-bot
|
||||||
# name: "Exclude Bot Actions"
|
# name: "Exclude Bot Actions"
|
||||||
|
|
|
||||||
|
|
@ -48,7 +48,7 @@ server/ # Nitro server (H3 handlers in server/routes/)
|
||||||
│ # discussion, repository, security, generic, ping, custom
|
│ # discussion, repository, security, generic, ping, custom
|
||||||
├── drivers/ # Platform drivers (pluggable push targets)
|
├── drivers/ # Platform drivers (pluggable push targets)
|
||||||
│ ├── types.ts # PlatformDriver interface + SendResult (send + edit)
|
│ ├── types.ts # PlatformDriver interface + SendResult (send + edit)
|
||||||
│ ├── index.ts # getDriver() registry (discord + telegram)
|
│ ├── index.ts # getDriver() registry (discord + telegram + feishu)
|
||||||
│ ├── discord/ # index.ts (driver), render.ts (NeutralMessage → embed),
|
│ ├── discord/ # index.ts (driver), render.ts (NeutralMessage → embed),
|
||||||
│ │ # rest.ts, interactions.ts, commands.ts
|
│ │ # rest.ts, interactions.ts, commands.ts
|
||||||
│ └── telegram/ # index.ts (driver), render.ts (NeutralMessage → Telegram HTML),
|
│ └── telegram/ # index.ts (driver), render.ts (NeutralMessage → Telegram HTML),
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,8 @@ WebHooker requires several secrets to function. For local development, store the
|
||||||
| `GITHUB_CLIENT_SECRET` | OAuth client secret from App settings |
|
| `GITHUB_CLIENT_SECRET` | OAuth client secret from App settings |
|
||||||
| `DISCORD_TOKEN` | Discord bot token |
|
| `DISCORD_TOKEN` | Discord bot token |
|
||||||
| `TELEGRAM_TOKEN` | Telegram bot token (from BotFather) — required for Telegram routes |
|
| `TELEGRAM_TOKEN` | Telegram bot token (from BotFather) — required for Telegram routes |
|
||||||
|
| `FEISHU_APP_ID` | Feishu app ID (from the app Credentials page) — required for Feishu routes |
|
||||||
|
| `FEISHU_APP_SECRET` | Feishu app secret — required for Feishu routes |
|
||||||
|
|
||||||
> [!NOTE]
|
> [!NOTE]
|
||||||
> `GITHUB_APP_ID` and `GITHUB_PRIVATE_KEY` (PKCS#8 PEM) are used by the GitHub App
|
> `GITHUB_APP_ID` and `GITHUB_PRIVATE_KEY` (PKCS#8 PEM) are used by the GitHub App
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,8 @@ bunx wrangler secret put GITHUB_CLIENT_SECRET
|
||||||
bunx wrangler secret put DISCORD_TOKEN
|
bunx wrangler secret put DISCORD_TOKEN
|
||||||
bunx wrangler secret put DISCORD_PUBLIC_KEY # Discord app public key (Developer Portal) — required for interactions
|
bunx wrangler secret put DISCORD_PUBLIC_KEY # Discord app public key (Developer Portal) — required for interactions
|
||||||
bunx wrangler secret put TELEGRAM_TOKEN # Telegram bot token (BotFather) — required for Telegram routes
|
bunx wrangler secret put TELEGRAM_TOKEN # Telegram bot token (BotFather) — required for Telegram routes
|
||||||
|
bunx wrangler secret put FEISHU_APP_ID # Feishu app ID — required for Feishu routes
|
||||||
|
bunx wrangler secret put FEISHU_APP_SECRET # Feishu app secret — required for Feishu routes
|
||||||
bunx wrangler secret put ADMIN_USER_IDS # comma-separated GitHub IDs/logins allowed into the Web UI
|
bunx wrangler secret put ADMIN_USER_IDS # comma-separated GitHub IDs/logins allowed into the Web UI
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -196,6 +198,48 @@ In Telegram, `/gh` commands (`/gh login`, `/gh logout`, `/gh comment <text>`, `/
|
||||||
|
|
||||||
Avatars are rendered as a link-preview card using the built-in `GET /api/richheader` (overridable with `TELEGRAM_RICH_HEADER_HOST`).
|
Avatars are rendered as a link-preview card using the built-in `GET /api/richheader` (overridable with `TELEGRAM_RICH_HEADER_HOST`).
|
||||||
|
|
||||||
|
## Feishu Bot Setup
|
||||||
|
|
||||||
|
1. Go to [Feishu Open Platform](https://open.feishu.cn/app) → **Create App** → choose **Custom App** → give it a name.
|
||||||
|
2. In **Credentials & Basic Info**, copy **App ID** and **App Secret** to `FEISHU_APP_ID` and `FEISHU_APP_SECRET`.
|
||||||
|
3. In **Permissions & Scopes**, add at least one of the following message scopes so the app can send (and read) messages:
|
||||||
|
- `im:message` (read and send direct messages and group chat messages)
|
||||||
|
- `im:message:send_as_bot` (send messages as an app)
|
||||||
|
- `im:message:send` (historical version)
|
||||||
|
4. In **Bot** tab, turn on the bot capability. Add the bot to the target group chat (or create a new group) and copy the **Chat ID** from the group settings.
|
||||||
|
5. In WebHooker `/admin`, create or edit a route and add a target with `platform: "feishu"`, `chatId` set to the Feishu **Chat ID**, and optional `topicId` for a topic inside the chat.
|
||||||
|
|
||||||
|
WebHooker uses the app-level credentials (`FEISHU_APP_ID` / `FEISHU_APP_SECRET`) to request a `tenant_access_token`, caches it until expiry, and sends messages as an interactive card (`interactive` message type). The same token is used to edit messages in place for `workflow_run` / `check_run` progress updates.
|
||||||
|
|
||||||
|
### Inbound: commands & buttons
|
||||||
|
|
||||||
|
WebHooker can receive Feishu events and let users act on PRs/Issues from chat, the same way as Discord and Telegram:
|
||||||
|
|
||||||
|
- `/gh login` — link your GitHub account (opens an OAuth page).
|
||||||
|
- `/gh logout` — unlink your GitHub account.
|
||||||
|
- `/gh comment <PR/Issue 链接> <内容>` — comment as your linked GitHub user.
|
||||||
|
- `/gh merge <PR 链接>` / `/gh close <PR 链接>` — merge / close the PR as your linked user.
|
||||||
|
- The **Merge** / **Close** buttons on a card trigger the same actions.
|
||||||
|
|
||||||
|
To enable inbound:
|
||||||
|
|
||||||
|
1. In the app **Events & Callbacks** → **Event Subscriptions**, set the **Request URL** to `https://<your-worker>/feishu/webhook` (Feishu will send a `url_verification` challenge, which WebHooker answers automatically).
|
||||||
|
2. Subscribe to the events:
|
||||||
|
- `im.message.receive_v1` — receive `/gh` commands (requires the `im:message` scope).
|
||||||
|
- `card.action.trigger` — receive button clicks on cards.
|
||||||
|
3. In **Credentials & Basic Info**, set the **App Secret** (already used for `FEISHU_APP_SECRET`) — it also signs the inbound callback via the `X-Lark-Signature` header, and WebHooker verifies it.
|
||||||
|
|
||||||
|
### Required permissions
|
||||||
|
|
||||||
|
| Permission | Purpose |
|
||||||
|
| ---------- | ------- |
|
||||||
|
| `im:message` | Read and send direct messages and group chat messages. |
|
||||||
|
| `im:message:send_as_bot` | Send messages as an app bot (alternative to `im:message`). |
|
||||||
|
| `im:message:send` | Send messages V2 (historical version, alternative). |
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> Custom bots (group-level webhook URL) are not supported. WebHooker uses an **app bot** so message editing, token caching, multi-group routing, and inbound commands work the same way as Discord and Telegram.
|
||||||
|
|
||||||
## Custom Domain (Optional)
|
## Custom Domain (Optional)
|
||||||
|
|
||||||
To use a custom domain instead of `*.workers.dev`:
|
To use a custom domain instead of `*.workers.dev`:
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ Every dispatch attempt is recorded in the D1 `send_logs` table and browsable in
|
||||||
| `event` | Event type (e.g. `push`, `pull_request`, `custom`) |
|
| `event` | Event type (e.g. `push`, `pull_request`, `custom`) |
|
||||||
| `repo` | Repository full name (when present) |
|
| `repo` | Repository full name (when present) |
|
||||||
| `target` | Target id the message was sent to |
|
| `target` | Target id the message was sent to |
|
||||||
| `platform` | `discord` or `telegram` |
|
| `platform` | `discord`, `telegram` or `feishu` |
|
||||||
| `ok` | Whether the send succeeded |
|
| `ok` | Whether the send succeeded |
|
||||||
| `status` | HTTP status from the platform API (when applicable) |
|
| `status` | HTTP status from the platform API (when applicable) |
|
||||||
| `error` | Error message (when failed) |
|
| `error` | Error message (when failed) |
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@ There are **no default routes** — each route must define its own target. If no
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
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.
|
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), `telegram` or `feishu`. For **Discord**, `target.channelId` is required (a thread in `target.threadId` is optional). For **Telegram** and **Feishu**, `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.
|
||||||
|
|
||||||
| Field | Type | Required | Description |
|
| Field | Type | Required | Description |
|
||||||
| ---------------- | -------- | -------- | ----------------------------------------------------------------------------------------------------------------- |
|
| ---------------- | -------- | -------- | ----------------------------------------------------------------------------------------------------------------- |
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,8 @@ WebHooker 的运行需要若干密钥。本地开发时放入 `.dev.vars`,生
|
||||||
| `GITHUB_CLIENT_SECRET` | App 设置中的 OAuth 客户端密钥 |
|
| `GITHUB_CLIENT_SECRET` | App 设置中的 OAuth 客户端密钥 |
|
||||||
| `DISCORD_TOKEN` | Discord 机器人 Token |
|
| `DISCORD_TOKEN` | Discord 机器人 Token |
|
||||||
| `TELEGRAM_TOKEN` | Telegram 机器人 Token(BotFather 获取)—— Telegram 路由必需 |
|
| `TELEGRAM_TOKEN` | Telegram 机器人 Token(BotFather 获取)—— Telegram 路由必需 |
|
||||||
|
| `FEISHU_APP_ID` | 飞书应用 ID(应用凭证页获取)—— 飞书路由必需 |
|
||||||
|
| `FEISHU_APP_SECRET` | 飞书应用密钥 —— 飞书路由必需 |
|
||||||
|
|
||||||
> [!NOTE]
|
> [!NOTE]
|
||||||
> `GITHUB_APP_ID` 与 `GITHUB_PRIVATE_KEY`(PKCS#8 PEM)用于 GitHub App **安装流程**
|
> `GITHUB_APP_ID` 与 `GITHUB_PRIVATE_KEY`(PKCS#8 PEM)用于 GitHub App **安装流程**
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,8 @@ bunx wrangler secret put GITHUB_CLIENT_SECRET
|
||||||
bunx wrangler secret put DISCORD_TOKEN
|
bunx wrangler secret put DISCORD_TOKEN
|
||||||
bunx wrangler secret put DISCORD_PUBLIC_KEY # Discord 应用的公钥(开发者门户获取),交互功能必需
|
bunx wrangler secret put DISCORD_PUBLIC_KEY # Discord 应用的公钥(开发者门户获取),交互功能必需
|
||||||
bunx wrangler secret put TELEGRAM_TOKEN # Telegram Bot Token(BotFather 获取)—— Telegram 路由必需
|
bunx wrangler secret put TELEGRAM_TOKEN # Telegram Bot Token(BotFather 获取)—— Telegram 路由必需
|
||||||
|
bunx wrangler secret put FEISHU_APP_ID # 飞书应用 ID —— 飞书路由必需
|
||||||
|
bunx wrangler secret put FEISHU_APP_SECRET # 飞书应用密钥 —— 飞书路由必需
|
||||||
bunx wrangler secret put ADMIN_USER_IDS # 逗号分隔的 GitHub ID/登录名,允许进入 Web UI
|
bunx wrangler secret put ADMIN_USER_IDS # 逗号分隔的 GitHub ID/登录名,允许进入 Web UI
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -196,6 +198,48 @@ Worker 现在可通过 `https://webhooker.<your-subdomain>.workers.dev` 访问
|
||||||
|
|
||||||
头像使用内置 `GET /api/richheader` 渲染为链接预览卡片(可用 `TELEGRAM_RICH_HEADER_HOST` 覆盖)。
|
头像使用内置 `GET /api/richheader` 渲染为链接预览卡片(可用 `TELEGRAM_RICH_HEADER_HOST` 覆盖)。
|
||||||
|
|
||||||
|
## 飞书机器人配置
|
||||||
|
|
||||||
|
1. 进入[飞书开放平台](https://open.feishu.cn/app) → **创建应用** → 选择**企业自建应用** → 填写应用名称。
|
||||||
|
2. 在**凭证与基础信息**中复制 **App ID** 与 **App Secret**,分别填入 `FEISHU_APP_ID` 和 `FEISHU_APP_SECRET`。
|
||||||
|
3. 在**权限管理**中添加以下任一消息发送权限,用于发送(与读取)消息:
|
||||||
|
- `im:message`(读取和发送单聊与群聊消息)
|
||||||
|
- `im:message:send_as_bot`(以应用机器人身份发送消息)
|
||||||
|
- `im:message:send`(旧版发送消息权限)
|
||||||
|
4. 在**机器人**功能中启用机器人。将机器人添加到目标群聊(或创建新群),并在群设置中复制 **Chat ID**。
|
||||||
|
5. 在 WebHooker `/admin` 中创建或编辑路由,添加目标:`platform: "feishu"`,`chatId` 填写飞书 **Chat ID**,子话题可填 `topicId`。
|
||||||
|
|
||||||
|
WebHooker 使用应用级凭证(`FEISHU_APP_ID` / `FEISHU_APP_SECRET`)请求 `tenant_access_token`(有效期约 2 小时),缓存到期前复用,并以**卡片消息**(`interactive`)形式发送。`workflow_run` / `check_run` 的进度更新同样会调用飞书消息编辑接口,原地更新消息。
|
||||||
|
|
||||||
|
### 入站:指令与按钮
|
||||||
|
|
||||||
|
与 Discord、Telegram 一样,WebHooker 可接收飞书事件,让用户直接在聊天里操作 PR/Issue:
|
||||||
|
|
||||||
|
- `/gh login` —— 绑定 GitHub 账号(打开 OAuth 页面)。
|
||||||
|
- `/gh logout` —— 解绑 GitHub 账号。
|
||||||
|
- `/gh comment <PR/Issue 链接> <内容>` —— 以绑定的 GitHub 身份发表评论。
|
||||||
|
- `/gh merge <PR 链接>` / `/gh close <PR 链接>` —— 以绑定身份合并 / 关闭 PR。
|
||||||
|
- 卡片上的 **合并** / **关闭** 按钮触发相同操作。
|
||||||
|
|
||||||
|
开启入站:
|
||||||
|
|
||||||
|
1. 在应用的**事件订阅**中,把**请求地址**设为 `https://<你的-worker>/feishu/webhook`(飞书会发送 `url_verification` 校验,WebHooker 自动应答)。
|
||||||
|
2. 订阅以下事件:
|
||||||
|
- `im.message.receive_v1` —— 接收 `/gh` 指令(依赖 `im:message` 权限)。
|
||||||
|
- `card.action.trigger` —— 接收卡片按钮点击。
|
||||||
|
3. **凭证与基础信息**中的 **App Secret**(即 `FEISHU_APP_SECRET`)同时用于对入站回调做 `X-Lark-Signature` 签名,WebHooker 会校验它。
|
||||||
|
|
||||||
|
### 所需权限
|
||||||
|
|
||||||
|
| 权限 | 用途 |
|
||||||
|
| ---- | ---- |
|
||||||
|
| `im:message` | 读取和发送单聊与群聊消息。 |
|
||||||
|
| `im:message:send_as_bot` | 以应用机器人身份发送消息(`im:message` 的替代)。 |
|
||||||
|
| `im:message:send` | 旧版发送消息权限(`im:message` 的替代)。 |
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> 不支持“自定义机器人”的群级 Webhook URL。WebHooker 统一使用**应用机器人**,以保持与 Discord、Telegram 一致的凭证管理、消息编辑、多群路由与入站指令能力。
|
||||||
|
|
||||||
## 自定义域名(可选)
|
## 自定义域名(可选)
|
||||||
|
|
||||||
要使用自定义域名替代 `*.workers.dev`:
|
要使用自定义域名替代 `*.workers.dev`:
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@
|
||||||
| `event` | 事件类型(如 `push`、`pull_request`、`custom`) |
|
| `event` | 事件类型(如 `push`、`pull_request`、`custom`) |
|
||||||
| `repo` | 仓库全名(存在时) |
|
| `repo` | 仓库全名(存在时) |
|
||||||
| `target` | 消息发送到的目标 id |
|
| `target` | 消息发送到的目标 id |
|
||||||
| `platform` | `discord` 或 `telegram` |
|
| `platform` | `discord`、`telegram` 或 `feishu` |
|
||||||
| `ok` | 发送是否成功 |
|
| `ok` | 发送是否成功 |
|
||||||
| `status` | 平台 API 的 HTTP 状态码(适用时) |
|
| `status` | 平台 API 的 HTTP 状态码(适用时) |
|
||||||
| `error` | 失败时的错误信息 |
|
| `error` | 失败时的错误信息 |
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
`targets` 的每一项都是一个推送目标,因此一条路由可同时转发到多个频道(例如一个 Discord 频道**和**一个 Telegram 群组)。`target.platform` 选择平台:`discord`(默认)或 `telegram`。**Discord** 目标要求 `target.channelId`(可选 `target.threadId` 指定子区);**Telegram** 目标要求 `target.chatId`(群组/超级群组 id,如 `-1001234567890`),可选 `target.topicId`(话题的 `message_thread_id`,相当于 Discord 子区)。没有默认频道回退。
|
`targets` 的每一项都是一个推送目标,因此一条路由可同时转发到多个频道(例如一个 Discord 频道**和**一个 Telegram 群组)。`target.platform` 选择平台:`discord`(默认)、`telegram` 或 `feishu`。**Discord** 目标要求 `target.channelId`(可选 `target.threadId` 指定子区);**Telegram** 与 **飞书** 目标要求 `target.chatId`(群组/超级群组 id,如 `-1001234567890`),可选 `target.topicId`(话题的 `message_thread_id`,相当于 Discord 子区)。没有默认频道回退。
|
||||||
|
|
||||||
| 字段 | 类型 | 必需 | 说明 |
|
| 字段 | 类型 | 必需 | 说明 |
|
||||||
| ---------------- | -------- | ---- | ------------------------------------------------------------------------------------- |
|
| ---------------- | -------- | ---- | ------------------------------------------------------------------------------------- |
|
||||||
|
|
|
||||||
4
migrations/0011_feishu_links.sql
Normal file
4
migrations/0011_feishu_links.sql
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
CREATE TABLE IF NOT EXISTS feishu_links (
|
||||||
|
feishu_user_id TEXT PRIMARY KEY,
|
||||||
|
github_user_id TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
@ -29,7 +29,7 @@ export const filterSchema = v.object({
|
||||||
});
|
});
|
||||||
|
|
||||||
export const routeTargetSchema = v.object({
|
export const routeTargetSchema = v.object({
|
||||||
platform: v.optional(v.picklist(["discord", "telegram"])),
|
platform: v.optional(v.picklist(["discord", "telegram", "feishu"])),
|
||||||
channelId: v.optional(v.string()),
|
channelId: v.optional(v.string()),
|
||||||
threadId: v.optional(v.string()),
|
threadId: v.optional(v.string()),
|
||||||
chatId: v.optional(v.string()),
|
chatId: v.optional(v.string()),
|
||||||
|
|
|
||||||
|
|
@ -192,9 +192,11 @@ export async function dispatchEvent(
|
||||||
? target.topicId
|
? target.topicId
|
||||||
? `${target.chatId}/${target.topicId}`
|
? `${target.chatId}/${target.topicId}`
|
||||||
: (target.chatId ?? "")
|
: (target.chatId ?? "")
|
||||||
: target.threadId
|
: target.platform === "feishu"
|
||||||
? `${target.channelId}/${target.threadId}`
|
? (target.chatId ?? "")
|
||||||
: (target.channelId ?? "");
|
: target.threadId
|
||||||
|
? `${target.channelId}/${target.threadId}`
|
||||||
|
: (target.channelId ?? "");
|
||||||
|
|
||||||
const base: {
|
const base: {
|
||||||
ts: number;
|
ts: number;
|
||||||
|
|
|
||||||
37
server/lib/drivers/feishu/index.ts
Normal file
37
server/lib/drivers/feishu/index.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
import type { RouteTarget, Env, NeutralMessage } from "../../types";
|
||||||
|
import type { PlatformDriver, SendResult } from "../types";
|
||||||
|
import { getTenantAccessToken, sendMessage, updateMessage } from "./rest";
|
||||||
|
import { renderNeutralMessage } from "./render";
|
||||||
|
|
||||||
|
export class FeishuDriver implements PlatformDriver {
|
||||||
|
readonly id = "feishu";
|
||||||
|
|
||||||
|
async send(message: NeutralMessage, target: RouteTarget, env: Env): Promise<SendResult> {
|
||||||
|
const chatId = target.chatId ?? "";
|
||||||
|
if (!chatId) {
|
||||||
|
return { ok: false, error: "target.chatId is required", errorCode: "NO_TARGET" };
|
||||||
|
}
|
||||||
|
const tokenRes = await getTenantAccessToken(env);
|
||||||
|
if (!tokenRes.ok || !tokenRes.token) {
|
||||||
|
return { ok: false, error: tokenRes.error ?? "No token", errorCode: tokenRes.errorCode ?? "NO_TOKEN" };
|
||||||
|
}
|
||||||
|
return sendMessage(tokenRes.token, chatId, renderNeutralMessage(message));
|
||||||
|
}
|
||||||
|
|
||||||
|
async edit(
|
||||||
|
message: NeutralMessage,
|
||||||
|
target: RouteTarget,
|
||||||
|
env: Env,
|
||||||
|
messageId: string,
|
||||||
|
): Promise<SendResult> {
|
||||||
|
const chatId = target.chatId ?? "";
|
||||||
|
if (!chatId) {
|
||||||
|
return { ok: false, error: "target.chatId is required", errorCode: "NO_TARGET" };
|
||||||
|
}
|
||||||
|
const tokenRes = await getTenantAccessToken(env);
|
||||||
|
if (!tokenRes.ok || !tokenRes.token) {
|
||||||
|
return { ok: false, error: tokenRes.error ?? "No token", errorCode: tokenRes.errorCode ?? "NO_TOKEN" };
|
||||||
|
}
|
||||||
|
return updateMessage(tokenRes.token, messageId, renderNeutralMessage(message));
|
||||||
|
}
|
||||||
|
}
|
||||||
153
server/lib/drivers/feishu/render.ts
Normal file
153
server/lib/drivers/feishu/render.ts
Normal file
|
|
@ -0,0 +1,153 @@
|
||||||
|
import type { NeutralMessage } from "../../types";
|
||||||
|
import { cap, splitMessageTitle } from "../../formatters/helpers";
|
||||||
|
|
||||||
|
const MAX_HEADER_TITLE = 100;
|
||||||
|
const MAX_MARKDOWN = 4000;
|
||||||
|
const MAX_NOTE = 500;
|
||||||
|
|
||||||
|
type FeishuTemplate =
|
||||||
|
| "blue"
|
||||||
|
| "green"
|
||||||
|
| "red"
|
||||||
|
| "yellow"
|
||||||
|
| "orange"
|
||||||
|
| "purple"
|
||||||
|
| "indigo"
|
||||||
|
| "wathet"
|
||||||
|
| "lime"
|
||||||
|
| "grey";
|
||||||
|
|
||||||
|
function colorToTemplate(color?: number): FeishuTemplate {
|
||||||
|
if (color == null) return "blue";
|
||||||
|
const r = (color >> 16) & 0xff;
|
||||||
|
const g = (color >> 8) & 0xff;
|
||||||
|
const b = color & 0xff;
|
||||||
|
const max = Math.max(r, g, b);
|
||||||
|
if (max < 80) return "grey";
|
||||||
|
if (r > g + b && g < 100) return "red";
|
||||||
|
if (g > r + b && g > 150) return "green";
|
||||||
|
if (b > r + g) return "indigo";
|
||||||
|
if (r > 180 && g > 120 && b < 80) return "orange";
|
||||||
|
if (r > 200 && g > 180 && b < 120) return "yellow";
|
||||||
|
if (r > 120 && g < 80 && b > 120) return "purple";
|
||||||
|
if (r > 160 && g > 180 && b > 200) return "wathet";
|
||||||
|
if (g > 150 && r > 150 && b < 80) return "lime";
|
||||||
|
return "blue";
|
||||||
|
}
|
||||||
|
|
||||||
|
function mdText(content: string): { tag: "lark_md"; content: string } {
|
||||||
|
return { tag: "lark_md", content: cap(content, MAX_MARKDOWN) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function divMarkdown(content: string): { tag: "div"; text: { tag: "lark_md"; content: string } } {
|
||||||
|
return { tag: "div", text: mdText(content) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTimestamp(ts?: string): string {
|
||||||
|
if (!ts) return "";
|
||||||
|
const d = new Date(ts);
|
||||||
|
if (Number.isNaN(d.getTime())) return ts;
|
||||||
|
const pad = (n: number): string => String(n).padStart(2, "0");
|
||||||
|
return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())} ${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())} UTC`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderNeutralMessage(message: NeutralMessage): Record<string, unknown> {
|
||||||
|
const { head, subject } = splitMessageTitle(message.title);
|
||||||
|
|
||||||
|
const elements: Record<string, unknown>[] = [];
|
||||||
|
|
||||||
|
if (message.url) {
|
||||||
|
elements.push(divMarkdown(`**[${head}](${message.url})**`));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.author?.name) {
|
||||||
|
const author = message.author.url
|
||||||
|
? `[${message.author.name}](${message.author.url})`
|
||||||
|
: message.author.name;
|
||||||
|
elements.push(divMarkdown(`**${author}**`));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subject || message.url || message.description) {
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (subject) {
|
||||||
|
parts.push(`**${subject}**`);
|
||||||
|
} else if (message.url) {
|
||||||
|
parts.push(`**${message.title}**`);
|
||||||
|
}
|
||||||
|
if (message.description) {
|
||||||
|
parts.push(message.description);
|
||||||
|
}
|
||||||
|
if (parts.length) {
|
||||||
|
elements.push(divMarkdown(parts.join("\n\n")));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.fields?.length) {
|
||||||
|
const lines = message.fields.map((f) => `**${f.name}**: ${f.value}`);
|
||||||
|
elements.push(divMarkdown(lines.join("\n\n")));
|
||||||
|
}
|
||||||
|
|
||||||
|
const meta: string[] = [];
|
||||||
|
if (message.forge?.name) {
|
||||||
|
meta.push(message.forge.url ? `[${message.forge.name}](${message.forge.url})` : message.forge.name);
|
||||||
|
}
|
||||||
|
if (message.footer) meta.push(message.footer);
|
||||||
|
const ts = formatTimestamp(message.timestamp);
|
||||||
|
if (ts) meta.push(ts);
|
||||||
|
|
||||||
|
if (meta.length) {
|
||||||
|
if (elements.length) elements.push({ tag: "hr" });
|
||||||
|
elements.push({
|
||||||
|
tag: "note",
|
||||||
|
elements: [{ tag: "lark_md", content: cap(meta.join(" · "), MAX_NOTE) }],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.url && !message.actions?.length) {
|
||||||
|
elements.push({
|
||||||
|
tag: "action",
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
tag: "button",
|
||||||
|
text: { tag: "plain_text", content: "Open" },
|
||||||
|
type: "primary",
|
||||||
|
multi_url: {
|
||||||
|
url: message.url,
|
||||||
|
pc_url: message.url,
|
||||||
|
android_url: message.url,
|
||||||
|
ios_url: message.url,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.actions?.length) {
|
||||||
|
elements.push({
|
||||||
|
tag: "action",
|
||||||
|
actions: message.actions.map((action) => ({
|
||||||
|
tag: "button",
|
||||||
|
text: { tag: "plain_text", content: action.label },
|
||||||
|
type: action.style === "danger" ? "danger" : action.style === "primary" ? "primary" : "default",
|
||||||
|
action_id: action.id,
|
||||||
|
value: { v: action.id },
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
config: {
|
||||||
|
wide_screen_mode: true,
|
||||||
|
enable_forward: true,
|
||||||
|
update_multi: true,
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
title: {
|
||||||
|
tag: "plain_text",
|
||||||
|
content: cap(head, MAX_HEADER_TITLE),
|
||||||
|
},
|
||||||
|
template: colorToTemplate(message.color),
|
||||||
|
},
|
||||||
|
elements,
|
||||||
|
};
|
||||||
|
}
|
||||||
194
server/lib/drivers/feishu/rest.ts
Normal file
194
server/lib/drivers/feishu/rest.ts
Normal file
|
|
@ -0,0 +1,194 @@
|
||||||
|
import { log } from "../../lib/log";
|
||||||
|
import type { Env } from "../../types";
|
||||||
|
import type { SendResult } from "../types";
|
||||||
|
|
||||||
|
const FEISHU_API = "https://open.feishu.cn";
|
||||||
|
const TOKEN_KEY = "feishu:token";
|
||||||
|
|
||||||
|
interface FeishuResponse {
|
||||||
|
code?: number;
|
||||||
|
msg?: string;
|
||||||
|
data?: { message_id?: string } & Record<string, unknown>;
|
||||||
|
error?: { message?: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function feishuRequest(
|
||||||
|
url: string,
|
||||||
|
method: string,
|
||||||
|
token: string,
|
||||||
|
body: Record<string, unknown>,
|
||||||
|
label: string,
|
||||||
|
): Promise<SendResult> {
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
};
|
||||||
|
|
||||||
|
let lastStatus = 0;
|
||||||
|
let lastError = "";
|
||||||
|
|
||||||
|
for (let attempt = 0; attempt < 3; attempt++) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method,
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
lastStatus = res.status;
|
||||||
|
|
||||||
|
if (res.status === 429) {
|
||||||
|
const retryAfter = Number(res.headers.get("retry-after") || "1");
|
||||||
|
lastError = `Rate limited (retry_after=${retryAfter})`;
|
||||||
|
log.warn({ retryAfter, attempt, label }, "Feishu rate limited");
|
||||||
|
await new Promise((r) => setTimeout(r, retryAfter * 1000));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await res.json().catch(() => null)) as FeishuResponse | null;
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
lastError = data?.msg ?? data?.error?.message ?? `HTTP ${res.status}`;
|
||||||
|
log.error({ status: res.status, err: lastError, label, attempts: attempt + 1 }, "Feishu API error");
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: lastError,
|
||||||
|
errorCode: res.status >= 500 ? "FEISHU_5XX" : "FEISHU_ERROR",
|
||||||
|
status: res.status,
|
||||||
|
attempts: attempt + 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data && typeof data.code === "number" && data.code !== 0) {
|
||||||
|
lastError = data.msg ?? `Feishu code ${data.code}`;
|
||||||
|
log.error({ code: data.code, err: lastError, label, attempts: attempt + 1 }, "Feishu business error");
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: lastError,
|
||||||
|
errorCode: `FEISHU_${data.code}`,
|
||||||
|
status: res.status,
|
||||||
|
attempts: attempt + 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
status: res.status,
|
||||||
|
messageId: data?.data?.message_id ?? undefined,
|
||||||
|
attempts: attempt + 1,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
lastError = err instanceof Error ? err.message : String(err);
|
||||||
|
log.error({ err, label, attempts: attempt + 1 }, "Failed to call Feishu API");
|
||||||
|
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 || "Max retries exceeded",
|
||||||
|
errorCode: "RETRIES",
|
||||||
|
status: lastStatus,
|
||||||
|
attempts: 3,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TokenResult {
|
||||||
|
ok: boolean;
|
||||||
|
token?: string;
|
||||||
|
error?: string;
|
||||||
|
errorCode?: string;
|
||||||
|
status?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getTenantAccessToken(env: Env): Promise<TokenResult> {
|
||||||
|
const appId = env.FEISHU_APP_ID?.trim();
|
||||||
|
const appSecret = env.FEISHU_APP_SECRET?.trim();
|
||||||
|
if (!appId || !appSecret) {
|
||||||
|
return { ok: false, error: "FEISHU_APP_ID/FEISHU_APP_SECRET not configured", errorCode: "NO_TOKEN" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const cached = await env.KV.get(TOKEN_KEY);
|
||||||
|
if (cached) return { ok: true, token: cached };
|
||||||
|
|
||||||
|
const url = `${FEISHU_API}/open-apis/auth/v3/tenant_access_token/internal`;
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ app_id: appId, app_secret: appSecret }),
|
||||||
|
});
|
||||||
|
const data = (await res.json().catch(() => null)) as
|
||||||
|
| { code?: number; msg?: string; tenant_access_token?: string; expire?: number }
|
||||||
|
| null;
|
||||||
|
if (!res.ok || !data || data.code !== 0 || !data.tenant_access_token) {
|
||||||
|
const err = data?.msg ?? `HTTP ${res.status}`;
|
||||||
|
return { ok: false, error: err, errorCode: "FEISHU_TOKEN", status: res.status };
|
||||||
|
}
|
||||||
|
const ttl = Math.max(60, (data.expire ?? 7200) - 60);
|
||||||
|
await env.KV.put(TOKEN_KEY, data.tenant_access_token, { expirationTtl: ttl });
|
||||||
|
return { ok: true, token: data.tenant_access_token };
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: err instanceof Error ? err.message : String(err),
|
||||||
|
errorCode: "NETWORK",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendMessage(
|
||||||
|
token: string,
|
||||||
|
chatId: string,
|
||||||
|
content: Record<string, unknown>,
|
||||||
|
): Promise<SendResult> {
|
||||||
|
const url = `${FEISHU_API}/open-apis/im/v1/messages?receive_id_type=chat_id`;
|
||||||
|
const body: Record<string, unknown> = {
|
||||||
|
receive_id: chatId,
|
||||||
|
msg_type: "interactive",
|
||||||
|
content: JSON.stringify(content),
|
||||||
|
};
|
||||||
|
return feishuRequest(url, "POST", token, body, chatId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateMessage(
|
||||||
|
token: string,
|
||||||
|
messageId: string,
|
||||||
|
content: Record<string, unknown>,
|
||||||
|
): Promise<SendResult> {
|
||||||
|
const url = `${FEISHU_API}/open-apis/im/v1/messages/${messageId}`;
|
||||||
|
const body: Record<string, unknown> = { content: JSON.stringify(content) };
|
||||||
|
return feishuRequest(url, "PATCH", token, body, messageId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendText(
|
||||||
|
token: string,
|
||||||
|
chatId: string,
|
||||||
|
text: string,
|
||||||
|
): Promise<SendResult> {
|
||||||
|
const url = `${FEISHU_API}/open-apis/im/v1/messages?receive_id_type=chat_id`;
|
||||||
|
const body: Record<string, unknown> = {
|
||||||
|
receive_id: chatId,
|
||||||
|
msg_type: "text",
|
||||||
|
content: JSON.stringify({ text }),
|
||||||
|
};
|
||||||
|
return feishuRequest(url, "POST", token, body, chatId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateCard(
|
||||||
|
token: string,
|
||||||
|
cardToken: string,
|
||||||
|
card: Record<string, unknown>,
|
||||||
|
): Promise<SendResult> {
|
||||||
|
const url = `${FEISHU_API}/open-apis/interactive/v1/config/update`;
|
||||||
|
const body: Record<string, unknown> = { token: cardToken, card };
|
||||||
|
return feishuRequest(url, "POST", token, body, "card");
|
||||||
|
}
|
||||||
335
server/lib/drivers/feishu/updates.ts
Normal file
335
server/lib/drivers/feishu/updates.ts
Normal file
|
|
@ -0,0 +1,335 @@
|
||||||
|
import type { Env } from "../../types";
|
||||||
|
import { log } from "../../lib/log";
|
||||||
|
import { getOAuthURL, commentAsUser, mergePullRequestAsUser, closePullRequestAsUser } from "../../github/oauth";
|
||||||
|
import { getFeishuLink, removeFeishuLink } from "../../github/store";
|
||||||
|
import { getTenantAccessToken, sendText, updateCard } from "./rest";
|
||||||
|
|
||||||
|
const FEISHU_API = "https://open.feishu.cn";
|
||||||
|
|
||||||
|
const GITHUB_TARGET_RE = /github\.com\/([^/\s]+)\/([^/\s]+)\/(?:issues|pull)\/(\d+)/;
|
||||||
|
const BTN_PREFIX = "ghpr|";
|
||||||
|
|
||||||
|
interface FeishuSender {
|
||||||
|
sender_id?: { open_id?: string; union_id?: string };
|
||||||
|
}
|
||||||
|
interface FeishuMessage {
|
||||||
|
chat_id?: string;
|
||||||
|
content?: string;
|
||||||
|
}
|
||||||
|
interface FeishuAction {
|
||||||
|
action_id?: string;
|
||||||
|
value?: { v?: string };
|
||||||
|
}
|
||||||
|
interface FeishuEvent {
|
||||||
|
sender?: FeishuSender;
|
||||||
|
message?: FeishuMessage;
|
||||||
|
action?: FeishuAction;
|
||||||
|
operator?: { operator_id?: { open_id?: string } };
|
||||||
|
token?: string;
|
||||||
|
open_message_id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitThree(rest: string): [string, string, string] {
|
||||||
|
const parts = rest.split("|");
|
||||||
|
const number = parts.pop() ?? "";
|
||||||
|
const repo = parts.pop() ?? "";
|
||||||
|
const owner = parts.pop() ?? "";
|
||||||
|
return [owner, repo, number];
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function hmacSha256Base64(secret: string, payload: string): Promise<string> {
|
||||||
|
const key = await crypto.subtle.importKey(
|
||||||
|
"raw",
|
||||||
|
new TextEncoder().encode(secret),
|
||||||
|
{ name: "HMAC", hash: "SHA-256" },
|
||||||
|
false,
|
||||||
|
["sign"],
|
||||||
|
);
|
||||||
|
const sig = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(payload));
|
||||||
|
let binary = "";
|
||||||
|
const bytes = new Uint8Array(sig);
|
||||||
|
for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i] ?? 0);
|
||||||
|
return btoa(binary);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyFeishuSignature(
|
||||||
|
appSecret: string,
|
||||||
|
timestamp: string,
|
||||||
|
nonce: string,
|
||||||
|
body: string,
|
||||||
|
signature: string,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const raw = `${timestamp}\n${nonce}\n${body}`;
|
||||||
|
const computed = await hmacSha256Base64(appSecret, raw);
|
||||||
|
return timingSafeEqual(computed, signature);
|
||||||
|
}
|
||||||
|
|
||||||
|
function describeError(err: unknown): string {
|
||||||
|
if (err instanceof Error) {
|
||||||
|
const msg = err.message;
|
||||||
|
if (msg.includes("GITHUB_TOKEN_EXPIRED")) return "GitHub 绑定已过期,请重新用 /gh login 绑定。";
|
||||||
|
if (msg.includes("GITHUB_FORBIDDEN")) return "没有权限操作该仓库(GitHub 返回 403),请确认你的账号权限。";
|
||||||
|
if (msg.includes("GITHUB_NOT_FOUND")) return "未找到对应的 GitHub 资源(404)。";
|
||||||
|
return msg || "操作失败";
|
||||||
|
}
|
||||||
|
return "操作失败";
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractTarget(text: string): { owner: string; repo: string; number: number } | null {
|
||||||
|
const m = text.match(GITHUB_TARGET_RE);
|
||||||
|
if (!m) return null;
|
||||||
|
const number = Number(m[3] ?? "");
|
||||||
|
if (!Number.isInteger(number)) return null;
|
||||||
|
return { owner: m[1] ?? "", repo: m[2] ?? "", number };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function replyToken(env: Env, chatId: string, text: string): Promise<void> {
|
||||||
|
const tokenRes = await getTenantAccessToken(env);
|
||||||
|
if (!tokenRes.ok || !tokenRes.token) {
|
||||||
|
log.warn({ err: tokenRes.error }, "Feishu token unavailable for reply");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await sendText(tokenRes.token, chatId, text);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleLogin(env: Env, openId: string, chatId: string): Promise<void> {
|
||||||
|
const state = crypto.randomUUID();
|
||||||
|
const pending = {
|
||||||
|
redirectTo: "/admin",
|
||||||
|
expiresAt: Date.now() + 10 * 60 * 1000,
|
||||||
|
feishuUserId: openId,
|
||||||
|
feishuChatId: chatId,
|
||||||
|
};
|
||||||
|
await env.KV.put(`state:${state}`, JSON.stringify(pending), { expirationTtl: 600 });
|
||||||
|
const url = getOAuthURL(env.GITHUB_CLIENT_ID ?? "", state);
|
||||||
|
await replyToken(env, chatId, [
|
||||||
|
"点击下方链接绑定 GitHub 账号:",
|
||||||
|
url,
|
||||||
|
"绑定后可在飞书里用 /gh comment 评论、/gh merge 合并、/gh close 关闭 PR。",
|
||||||
|
].join("\n"));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleLogout(env: Env, openId: string, chatId: string): Promise<void> {
|
||||||
|
const linked = await getFeishuLink(env.DB, openId);
|
||||||
|
if (!linked) {
|
||||||
|
await replyToken(env, chatId, "你还没有绑定 GitHub 账号。");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await removeFeishuLink(env.DB, openId);
|
||||||
|
await replyToken(env, chatId, "已解绑 GitHub 账号。");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleComment(env: Env, openId: string, chatId: string, text: string): Promise<void> {
|
||||||
|
const githubUserId = await getFeishuLink(env.DB, openId);
|
||||||
|
if (!githubUserId) {
|
||||||
|
await replyToken(env, chatId, "请先 /gh login 绑定 GitHub 账号。");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const target = extractTarget(text);
|
||||||
|
if (!target) {
|
||||||
|
await replyToken(env, chatId, "请在消息里带上 PR/Issue 链接,例如:/gh comment https://github.com/o/r/pull/7 看起来不错");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const ghIdx = text.indexOf("/gh");
|
||||||
|
const rest = text.slice(ghIdx + 3).trim();
|
||||||
|
const parts = rest.split(/\s+/);
|
||||||
|
const linkIdx = parts.findIndex((p) => p.includes("github.com"));
|
||||||
|
const body = parts.slice(linkIdx + 1).join(" ").trim() || "(来自飞书)";
|
||||||
|
try {
|
||||||
|
const res = await commentAsUser(env.KV, githubUserId, target.owner, target.repo, target.number, body);
|
||||||
|
await replyToken(env, chatId, `✅ 已评论:[查看](${res.htmlUrl})(@${res.login})`);
|
||||||
|
} catch (err) {
|
||||||
|
await replyToken(env, chatId, `❌ ${describeError(err)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleMergeOrClose(
|
||||||
|
env: Env,
|
||||||
|
openId: string,
|
||||||
|
chatId: string,
|
||||||
|
action: "merge" | "close",
|
||||||
|
body: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const githubUserId = await getFeishuLink(env.DB, openId);
|
||||||
|
if (!githubUserId) {
|
||||||
|
await replyToken(env, chatId, "请先 /gh login 绑定 GitHub 账号。");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const target = extractTarget(body);
|
||||||
|
if (!target) {
|
||||||
|
await replyToken(env, chatId, action === "merge"
|
||||||
|
? "请在消息里带上 PR 链接,例如:/gh merge https://github.com/o/r/pull/7"
|
||||||
|
: "请在消息里带上 PR 链接,例如:/gh close https://github.com/o/r/pull/7");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (action === "merge") {
|
||||||
|
await mergePullRequestAsUser(env.KV, githubUserId, target.owner, target.repo, target.number);
|
||||||
|
} else {
|
||||||
|
await closePullRequestAsUser(env.KV, githubUserId, target.owner, target.repo, target.number);
|
||||||
|
}
|
||||||
|
await replyToken(env, chatId, `✅ 已${action === "merge" ? "合并" : "关闭"} ${target.owner}/${target.repo}#${target.number}`);
|
||||||
|
} catch (err) {
|
||||||
|
await replyToken(env, chatId, `❌ ${describeError(err)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleMessage(env: Env, event: FeishuEvent): Promise<void> {
|
||||||
|
const sender = event.sender ?? {};
|
||||||
|
const openId = sender.sender_id?.open_id ?? sender.sender_id?.union_id ?? "";
|
||||||
|
const message = event.message ?? {};
|
||||||
|
const chatId = message.chat_id ?? "";
|
||||||
|
let text = "";
|
||||||
|
if (message.content) {
|
||||||
|
try {
|
||||||
|
const content = JSON.parse(message.content) as { text?: string };
|
||||||
|
text = content.text ?? "";
|
||||||
|
} catch {
|
||||||
|
text = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const idx = text.indexOf("/gh");
|
||||||
|
if (idx < 0) return;
|
||||||
|
const rest = text.slice(idx + 3).trim();
|
||||||
|
const sub = rest.split(/\s+/)[0] ?? "";
|
||||||
|
switch (sub) {
|
||||||
|
case "login":
|
||||||
|
await handleLogin(env, openId, chatId);
|
||||||
|
break;
|
||||||
|
case "logout":
|
||||||
|
await handleLogout(env, openId, chatId);
|
||||||
|
break;
|
||||||
|
case "comment":
|
||||||
|
await handleComment(env, openId, chatId, text);
|
||||||
|
break;
|
||||||
|
case "merge":
|
||||||
|
await handleMergeOrClose(env, openId, chatId, "merge", text);
|
||||||
|
break;
|
||||||
|
case "close":
|
||||||
|
await handleMergeOrClose(env, openId, chatId, "close", text);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
await replyToken(env, chatId, "未知指令。可用:/gh login | logout | comment <链接> <内容> | merge <链接> | close <链接>");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildResultCard(ok: boolean, message: string): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
config: { wide_screen_mode: true },
|
||||||
|
elements: [
|
||||||
|
{
|
||||||
|
tag: "div",
|
||||||
|
text: { tag: "lark_md", content: ok ? `✅ ${message}` : `❌ ${message}` },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCardAction(env: Env, payload: Record<string, unknown>): Promise<void> {
|
||||||
|
const event = (payload.event ?? {}) as FeishuEvent;
|
||||||
|
const action = event.action ?? {};
|
||||||
|
const actionId = action.action_id ?? action.value?.v ?? "";
|
||||||
|
const openId =
|
||||||
|
event.operator?.operator_id?.open_id ?? event.sender?.sender_id?.open_id ?? "";
|
||||||
|
const cardToken = event.token ?? "";
|
||||||
|
const openMessageId = event.open_message_id ?? "";
|
||||||
|
|
||||||
|
if (!actionId.startsWith(BTN_PREFIX)) return;
|
||||||
|
|
||||||
|
const isMerge = actionId.startsWith(`${BTN_PREFIX}merge|`);
|
||||||
|
const isClose = actionId.startsWith(`${BTN_PREFIX}close|`);
|
||||||
|
|
||||||
|
if (isMerge || isClose) {
|
||||||
|
const [owner, repo, numberStr] = splitThree(actionId.slice(BTN_PREFIX.length));
|
||||||
|
const number = Number(numberStr);
|
||||||
|
if (!owner || !repo || !Number.isInteger(number)) {
|
||||||
|
await replyCard(env, cardToken, buildResultCard(false, "无效的按钮数据"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const githubUserId = await getFeishuLink(env.DB, openId);
|
||||||
|
if (!githubUserId) {
|
||||||
|
await replyCard(env, cardToken, buildResultCard(false, "请先 /gh login 绑定 GitHub 账号"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (isMerge) {
|
||||||
|
await mergePullRequestAsUser(env.KV, githubUserId, owner, repo, number);
|
||||||
|
} else {
|
||||||
|
await closePullRequestAsUser(env.KV, githubUserId, owner, repo, number);
|
||||||
|
}
|
||||||
|
await replyCard(
|
||||||
|
env,
|
||||||
|
cardToken,
|
||||||
|
buildResultCard(true, `${isMerge ? "已合并" : "已关闭"} ${owner}/${repo}#${number}`),
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
await replyCard(env, cardToken, buildResultCard(false, describeError(err)));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (openMessageId) {
|
||||||
|
await replyCard(env, cardToken, buildResultCard(false, "未知按钮"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function replyCard(env: Env, cardToken: string, card: Record<string, unknown>): Promise<void> {
|
||||||
|
const tokenRes = await getTenantAccessToken(env);
|
||||||
|
if (!tokenRes.ok || !tokenRes.token) return;
|
||||||
|
await updateCard(tokenRes.token, cardToken, card);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleFeishuWebhookRequest(request: Request, env: Env): Promise<Response> {
|
||||||
|
const rawBody = await request.text();
|
||||||
|
let payload: Record<string, unknown>;
|
||||||
|
try {
|
||||||
|
payload = JSON.parse(rawBody);
|
||||||
|
} catch {
|
||||||
|
return new Response("invalid json", { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const header = payload.header as Record<string, unknown> | undefined;
|
||||||
|
const eventPart = payload.event as Record<string, unknown> | undefined;
|
||||||
|
const type = (payload.type as string) ?? header?.event_type ?? eventPart?.type ?? "";
|
||||||
|
|
||||||
|
if (type === "url_verification") {
|
||||||
|
return Response.json({ challenge: (payload.challenge as string) ?? "" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const appSecret = env.FEISHU_APP_SECRET?.trim() ?? "";
|
||||||
|
const signature = request.headers.get("x-lark-signature") ?? "";
|
||||||
|
const timestamp = request.headers.get("x-lark-timestamp") ?? "";
|
||||||
|
const nonce = request.headers.get("x-lark-nonce") ?? "";
|
||||||
|
if (appSecret && signature) {
|
||||||
|
const ok = await verifyFeishuSignature(appSecret, timestamp, nonce, rawBody, signature);
|
||||||
|
if (!ok) {
|
||||||
|
log.warn("Feishu signature verification failed");
|
||||||
|
return new Response("invalid signature", { status: 401 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const eventType =
|
||||||
|
(header?.event_type as string) ?? eventPart?.type ?? type;
|
||||||
|
try {
|
||||||
|
if (eventType === "im.message.receive_v1" || eventType === "message") {
|
||||||
|
await handleMessage(env, (payload.event ?? payload) as FeishuEvent);
|
||||||
|
} else if (eventType === "card.action.trigger") {
|
||||||
|
await handleCardAction(env, payload);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
log.error({ err }, "Feishu webhook handling failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response("ok", { status: 200 });
|
||||||
|
}
|
||||||
|
|
||||||
|
export { FEISHU_API };
|
||||||
|
|
@ -2,10 +2,12 @@ import type { RouteTarget } from "../types";
|
||||||
import type { PlatformDriver } from "./types";
|
import type { PlatformDriver } from "./types";
|
||||||
import { DiscordDriver } from "./discord";
|
import { DiscordDriver } from "./discord";
|
||||||
import { TelegramDriver } from "./telegram";
|
import { TelegramDriver } from "./telegram";
|
||||||
|
import { FeishuDriver } from "./feishu";
|
||||||
|
|
||||||
const drivers: Record<string, PlatformDriver> = {
|
const drivers: Record<string, PlatformDriver> = {
|
||||||
discord: new DiscordDriver(),
|
discord: new DiscordDriver(),
|
||||||
telegram: new TelegramDriver(),
|
telegram: new TelegramDriver(),
|
||||||
|
feishu: new FeishuDriver(),
|
||||||
};
|
};
|
||||||
|
|
||||||
export function getDriver(target: RouteTarget): PlatformDriver {
|
export function getDriver(target: RouteTarget): PlatformDriver {
|
||||||
|
|
|
||||||
|
|
@ -119,3 +119,34 @@ export async function removeTelegramLink(db: D1Database, telegramUserId: string)
|
||||||
.bind(telegramUserId)
|
.bind(telegramUserId)
|
||||||
.run();
|
.run();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function saveFeishuLink(
|
||||||
|
db: D1Database,
|
||||||
|
feishuUserId: string,
|
||||||
|
githubUserId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
await db
|
||||||
|
.prepare(
|
||||||
|
"INSERT OR REPLACE INTO feishu_links (feishu_user_id, github_user_id) VALUES (?, ?)",
|
||||||
|
)
|
||||||
|
.bind(feishuUserId, githubUserId)
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getFeishuLink(
|
||||||
|
db: D1Database,
|
||||||
|
feishuUserId: string,
|
||||||
|
): Promise<string | null> {
|
||||||
|
const { results } = await db
|
||||||
|
.prepare("SELECT github_user_id FROM feishu_links WHERE feishu_user_id = ?")
|
||||||
|
.bind(feishuUserId)
|
||||||
|
.all<{ github_user_id: string }>();
|
||||||
|
return results[0]?.github_user_id ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeFeishuLink(db: D1Database, feishuUserId: string): Promise<void> {
|
||||||
|
await db
|
||||||
|
.prepare("DELETE FROM feishu_links WHERE feishu_user_id = ?")
|
||||||
|
.bind(feishuUserId)
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import type { Env, WebhookEvent, WebhookProvider } from "../types";
|
import type { Config, Env, Group, WebhookEvent, WebhookProvider } from "../types";
|
||||||
import { dispatchEvent } from "../core/dispatch";
|
import { dispatchEvent } from "../core/dispatch";
|
||||||
import { loadConfig } from "../config";
|
import { loadConfig } from "../config";
|
||||||
import { loadGroups } from "../web/groups";
|
import { loadGroups } from "../web/groups";
|
||||||
|
|
@ -6,6 +6,7 @@ import { log } from "../lib/log";
|
||||||
import {
|
import {
|
||||||
DELIVERY_DLQ,
|
DELIVERY_DLQ,
|
||||||
type DeliveryMessage,
|
type DeliveryMessage,
|
||||||
|
type DispatchSummary,
|
||||||
classifyDelivery,
|
classifyDelivery,
|
||||||
deliveryStateKey,
|
deliveryStateKey,
|
||||||
discardPayload,
|
discardPayload,
|
||||||
|
|
@ -18,6 +19,12 @@ import {
|
||||||
export async function handleQueueBatch(
|
export async function handleQueueBatch(
|
||||||
batch: MessageBatch<DeliveryMessage>,
|
batch: MessageBatch<DeliveryMessage>,
|
||||||
env: Env,
|
env: Env,
|
||||||
|
dispatch: (
|
||||||
|
config: Config,
|
||||||
|
event: WebhookEvent,
|
||||||
|
env: Env,
|
||||||
|
groups?: Group[],
|
||||||
|
) => Promise<DispatchSummary> = dispatchEvent,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
for (const message of batch.messages) {
|
for (const message of batch.messages) {
|
||||||
const body = message.body;
|
const body = message.body;
|
||||||
|
|
@ -26,7 +33,7 @@ export async function handleQueueBatch(
|
||||||
message.ack();
|
message.ack();
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
await processMessage(env, body, message);
|
await processMessage(env, body, message, dispatch);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -34,6 +41,12 @@ async function processMessage(
|
||||||
env: Env,
|
env: Env,
|
||||||
body: DeliveryMessage,
|
body: DeliveryMessage,
|
||||||
message: Message<DeliveryMessage>,
|
message: Message<DeliveryMessage>,
|
||||||
|
dispatch: (
|
||||||
|
config: Config,
|
||||||
|
event: WebhookEvent,
|
||||||
|
env: Env,
|
||||||
|
groups?: Group[],
|
||||||
|
) => Promise<DispatchSummary>,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const key = deliveryStateKey(body.provider, body.groupId, body.deliveryId);
|
const key = deliveryStateKey(body.provider, body.groupId, body.deliveryId);
|
||||||
const prior = await getDeliveryState(env, key);
|
const prior = await getDeliveryState(env, key);
|
||||||
|
|
@ -59,7 +72,7 @@ async function processMessage(
|
||||||
config.routes = config.routes.filter((r) => r.groupId === body.groupId);
|
config.routes = config.routes.filter((r) => r.groupId === body.groupId);
|
||||||
}
|
}
|
||||||
const groups = await loadGroups(env.KV);
|
const groups = await loadGroups(env.KV);
|
||||||
const summary = await dispatchEvent(config, event, env, groups);
|
const summary = await dispatch(config, event, env, groups);
|
||||||
const { failed, retryable } = classifyDelivery(summary);
|
const { failed, retryable } = classifyDelivery(summary);
|
||||||
|
|
||||||
if (!failed) {
|
if (!failed) {
|
||||||
|
|
|
||||||
|
|
@ -58,9 +58,9 @@ export function classifyDelivery(summary: DispatchSummary): {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function retryDelay(attempt: number): number {
|
export function retryDelay(attempt: number): number {
|
||||||
if (attempt < 1) return RETRY_DELAYS_SECONDS[0];
|
if (attempt < 1) return RETRY_DELAYS_SECONDS[0] ?? 5;
|
||||||
const idx = Math.min(attempt - 1, RETRY_DELAYS_SECONDS.length - 1);
|
const idx = Math.min(attempt - 1, RETRY_DELAYS_SECONDS.length - 1);
|
||||||
return RETRY_DELAYS_SECONDS[idx];
|
return RETRY_DELAYS_SECONDS[idx] ?? 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
function scopeKey(provider: string, groupId: string | undefined, deliveryId: string): string {
|
function scopeKey(provider: string, groupId: string | undefined, deliveryId: string): string {
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,8 @@ export interface Env {
|
||||||
TELEGRAM_TOKEN?: string;
|
TELEGRAM_TOKEN?: string;
|
||||||
TELEGRAM_WEBHOOK_SECRET?: string;
|
TELEGRAM_WEBHOOK_SECRET?: string;
|
||||||
TELEGRAM_RICH_HEADER_HOST?: string;
|
TELEGRAM_RICH_HEADER_HOST?: string;
|
||||||
|
FEISHU_APP_ID?: string;
|
||||||
|
FEISHU_APP_SECRET?: string;
|
||||||
/**
|
/**
|
||||||
* When enabled ("1"/"true"), GitHub users without any group access get a
|
* When enabled ("1"/"true"), GitHub users without any group access get a
|
||||||
* personal group on first login instead of being blocked.
|
* personal group on first login instead of being blocked.
|
||||||
|
|
@ -43,7 +45,7 @@ export interface Config {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RouteTarget {
|
export interface RouteTarget {
|
||||||
platform?: "discord" | "telegram";
|
platform?: "discord" | "telegram" | "feishu";
|
||||||
channelId?: string;
|
channelId?: string;
|
||||||
threadId?: string;
|
threadId?: string;
|
||||||
chatId?: string;
|
chatId?: string;
|
||||||
|
|
|
||||||
|
|
@ -234,10 +234,10 @@ function validateTarget(
|
||||||
target: Record<string, unknown>,
|
target: Record<string, unknown>,
|
||||||
): { ok: true; target: Route["targets"][number] } | { ok: false; error: string } {
|
): { ok: true; target: Route["targets"][number] } | { ok: false; error: string } {
|
||||||
const platform = target.platform === undefined ? "discord" : target.platform;
|
const platform = target.platform === undefined ? "discord" : target.platform;
|
||||||
if (platform !== "discord" && platform !== "telegram") {
|
if (platform !== "discord" && platform !== "telegram" && platform !== "feishu") {
|
||||||
return { ok: false, error: `${label}.platform must be "discord" or "telegram"` };
|
return { ok: false, error: `${label}.platform must be "discord", "telegram" or "feishu"` };
|
||||||
}
|
}
|
||||||
if (platform === "telegram") {
|
if (platform === "telegram" || platform === "feishu") {
|
||||||
if (typeof target.chatId !== "string" || target.chatId.trim().length === 0)
|
if (typeof target.chatId !== "string" || target.chatId.trim().length === 0)
|
||||||
return { ok: false, error: `${label}.chatId is required` };
|
return { ok: false, error: `${label}.chatId is required` };
|
||||||
if (target.topicId !== undefined && typeof target.topicId !== "string") {
|
if (target.topicId !== undefined && typeof target.topicId !== "string") {
|
||||||
|
|
@ -254,9 +254,9 @@ function validateTarget(
|
||||||
ok: true,
|
ok: true,
|
||||||
target: {
|
target: {
|
||||||
platform,
|
platform,
|
||||||
channelId: platform === "telegram" ? undefined : (target.channelId as string),
|
channelId: platform === "discord" ? (target.channelId as string) : undefined,
|
||||||
threadId: platform === "telegram" ? undefined : ((target.threadId as string) ?? undefined),
|
threadId: platform === "discord" ? ((target.threadId as string) ?? undefined) : undefined,
|
||||||
chatId: platform === "telegram" ? (target.chatId as string) : undefined,
|
chatId: platform === "telegram" || platform === "feishu" ? (target.chatId as string) : undefined,
|
||||||
topicId: platform === "telegram" ? ((target.topicId as string) ?? undefined) : undefined,
|
topicId: platform === "telegram" ? ((target.topicId as string) ?? undefined) : undefined,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ import {
|
||||||
handleOAuthCallback as handleGithubOAuthCallback,
|
handleOAuthCallback as handleGithubOAuthCallback,
|
||||||
getInstallationAccount,
|
getInstallationAccount,
|
||||||
} from "../github/oauth";
|
} from "../github/oauth";
|
||||||
import { removeToken, saveDiscordLink, saveTelegramLink } from "../github/store";
|
import { removeToken, saveDiscordLink, saveTelegramLink, saveFeishuLink } from "../github/store";
|
||||||
import { createAdminSession, adminCookie, getAdminSession } from "./session";
|
import { createAdminSession, adminCookie, getAdminSession } from "./session";
|
||||||
import {
|
import {
|
||||||
loadGroups,
|
loadGroups,
|
||||||
|
|
@ -27,6 +27,7 @@ import {
|
||||||
import { clientIp } from "./auth";
|
import { clientIp } from "./auth";
|
||||||
import { recordAudit } from "../lib/audit";
|
import { recordAudit } from "../lib/audit";
|
||||||
import { sendMessage } from "../drivers/telegram/rest";
|
import { sendMessage } from "../drivers/telegram/rest";
|
||||||
|
import { getTenantAccessToken, sendText as sendFeishuText } from "../drivers/feishu/rest";
|
||||||
import { cfEnv } from "../cf";
|
import { cfEnv } from "../cf";
|
||||||
import { initConfigStore } from "../config";
|
import { initConfigStore } from "../config";
|
||||||
import type { Env, Group } from "../types";
|
import type { Env, Group } from "../types";
|
||||||
|
|
@ -37,6 +38,8 @@ interface PendingState {
|
||||||
discordUserId?: string;
|
discordUserId?: string;
|
||||||
telegramUserId?: string;
|
telegramUserId?: string;
|
||||||
telegramChatId?: string;
|
telegramChatId?: string;
|
||||||
|
feishuUserId?: string;
|
||||||
|
feishuChatId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function linkedPage(login: string): string {
|
function linkedPage(login: string): string {
|
||||||
|
|
@ -337,6 +340,22 @@ export async function handleOAuthCallback(event: H3Event): Promise<unknown> {
|
||||||
return { ok: true, telegramUserId: pending.telegramUserId, login: result.login };
|
return { ok: true, telegramUserId: pending.telegramUserId, login: result.login };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Feishu account-linking flow: bind the Feishu user to this GitHub account.
|
||||||
|
if (pending.feishuUserId) {
|
||||||
|
await saveFeishuLink(env.DB, pending.feishuUserId, result.userId);
|
||||||
|
if (pending.feishuChatId) {
|
||||||
|
const tokenRes = await getTenantAccessToken(env);
|
||||||
|
if (tokenRes.ok && tokenRes.token) {
|
||||||
|
await sendFeishuText(
|
||||||
|
tokenRes.token,
|
||||||
|
pending.feishuChatId,
|
||||||
|
`✅ GitHub 账号已绑定:**@${result.login}**。现在可以用 /gh comment 评论了。`,
|
||||||
|
).catch(() => undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { ok: true, feishuUserId: pending.feishuUserId, login: result.login };
|
||||||
|
}
|
||||||
|
|
||||||
const isBrowser = (getHeader(event, "accept") ?? "").includes("text/html");
|
const isBrowser = (getHeader(event, "accept") ?? "").includes("text/html");
|
||||||
if (isBrowser) {
|
if (isBrowser) {
|
||||||
// Invite accept flow: the redirect target is the invite page, which
|
// Invite accept flow: the redirect target is the invite page, which
|
||||||
|
|
|
||||||
14
server/routes/feishu/webhook.post.ts
Normal file
14
server/routes/feishu/webhook.post.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
import { readRawBody } from "h3";
|
||||||
|
import { handleFeishuWebhookRequest } from "../../lib/drivers/feishu/updates";
|
||||||
|
import { cfEnv, rawRequest } from "../../lib/cf";
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const source = rawRequest(event);
|
||||||
|
const body = await readRawBody(event, "utf8");
|
||||||
|
const request = new Request(source.url, {
|
||||||
|
method: source.method,
|
||||||
|
headers: source.headers,
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
return handleFeishuWebhookRequest(request, cfEnv(event));
|
||||||
|
});
|
||||||
79
tests/feishu-inbound.test.ts
Normal file
79
tests/feishu-inbound.test.ts
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
import { describe, it, expect } from "bun:test";
|
||||||
|
import type { Env } from "../server/lib/types";
|
||||||
|
import { verifyFeishuSignature, handleFeishuWebhookRequest } from "../server/lib/drivers/feishu/updates";
|
||||||
|
|
||||||
|
function sign(secret: string, timestamp: string, nonce: string, body: string): Promise<string> {
|
||||||
|
return (async () => {
|
||||||
|
const key = await crypto.subtle.importKey(
|
||||||
|
"raw",
|
||||||
|
new TextEncoder().encode(secret),
|
||||||
|
{ name: "HMAC", hash: "SHA-256" },
|
||||||
|
false,
|
||||||
|
["sign"],
|
||||||
|
);
|
||||||
|
const sig = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(`${timestamp}\n${nonce}\n${body}`));
|
||||||
|
let binary = "";
|
||||||
|
const bytes = new Uint8Array(sig);
|
||||||
|
for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i] ?? 0);
|
||||||
|
return btoa(binary);
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
|
||||||
|
const stubKV = {
|
||||||
|
get: async () => null,
|
||||||
|
put: async () => undefined,
|
||||||
|
delete: async () => undefined,
|
||||||
|
} as unknown as KVNamespace<string>;
|
||||||
|
const stubDB = {} as unknown as D1Database;
|
||||||
|
const stubEnv: Env = {
|
||||||
|
GITHUB_WEBHOOK_SECRET: "",
|
||||||
|
FEISHU_APP_ID: "",
|
||||||
|
FEISHU_APP_SECRET: "",
|
||||||
|
KV: stubKV,
|
||||||
|
DB: stubDB,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("feishu signature", () => {
|
||||||
|
it("verifies a correct signature", async () => {
|
||||||
|
const secret = "s3cr3t";
|
||||||
|
const ts = "1700000000";
|
||||||
|
const nonce = "abc";
|
||||||
|
const body = JSON.stringify({ hello: "world" });
|
||||||
|
const sig = await sign(secret, ts, nonce, body);
|
||||||
|
expect(await verifyFeishuSignature(secret, ts, nonce, body, sig)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a wrong signature", async () => {
|
||||||
|
const secret = "s3cr3t";
|
||||||
|
const body = JSON.stringify({ hello: "world" });
|
||||||
|
const sig = await sign("other", "1", "2", body);
|
||||||
|
expect(await verifyFeishuSignature(secret, "1", "2", body, sig)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("feishu webhook", () => {
|
||||||
|
it("answers the url_verification challenge", async () => {
|
||||||
|
const body = JSON.stringify({ type: "url_verification", challenge: "xyz123" });
|
||||||
|
const req = new Request("https://x/feishu/webhook", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
const res = await handleFeishuWebhookRequest(req, stubEnv);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const json = await res.json();
|
||||||
|
expect(json.challenge).toBe("xyz123");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a bad signature with 401 when a secret is configured", async () => {
|
||||||
|
const env = { ...stubEnv, FEISHU_APP_SECRET: "s3cr3t" };
|
||||||
|
const body = JSON.stringify({ type: "other", challenge: "x" });
|
||||||
|
const req = new Request("https://x/feishu/webhook", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json", "x-lark-signature": "deadbeef" },
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
const res = await handleFeishuWebhookRequest(req, env);
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { describe, it, expect, beforeEach, mock } from "bun:test";
|
import { describe, it, expect, beforeEach } from "bun:test";
|
||||||
import { handleQueueBatch } from "../server/lib/queue/consumer";
|
import { handleQueueBatch } from "../server/lib/queue/consumer";
|
||||||
import { invalidateConfigCache } from "../server/lib/config";
|
import { invalidateConfigCache } from "../server/lib/config";
|
||||||
import { invalidateGroupsCache } from "../server/lib/web/groups";
|
import { invalidateGroupsCache } from "../server/lib/web/groups";
|
||||||
|
|
@ -8,12 +8,10 @@ import type { DeliveryMessage, DispatchSummary } from "../server/lib/queue/deliv
|
||||||
let summary: DispatchSummary;
|
let summary: DispatchSummary;
|
||||||
let dispatchCalls: number;
|
let dispatchCalls: number;
|
||||||
|
|
||||||
mock.module("../server/lib/core/dispatch", () => ({
|
async function fakeDispatch(..._args: unknown[]): Promise<DispatchSummary> {
|
||||||
dispatchEvent: async (): Promise<DispatchSummary> => {
|
dispatchCalls += 1;
|
||||||
dispatchCalls += 1;
|
return summary;
|
||||||
return summary;
|
}
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
function createMockKV(): { kv: KVNamespace; store: Map<string, string> } {
|
function createMockKV(): { kv: KVNamespace; store: Map<string, string> } {
|
||||||
const store = new Map<string, string>();
|
const store = new Map<string, string>();
|
||||||
|
|
@ -95,7 +93,7 @@ describe("handleQueueBatch", () => {
|
||||||
it("marks DLQ messages dead and acks them", async () => {
|
it("marks DLQ messages dead and acks them", async () => {
|
||||||
const { kv, store } = createMockKV();
|
const { kv, store } = createMockKV();
|
||||||
const msg = makeMessage(body());
|
const msg = makeMessage(body());
|
||||||
await handleQueueBatch(makeBatch("webhooker-delivery-dlq", [msg]), createEnv(kv));
|
await handleQueueBatch(makeBatch("webhooker-delivery-dlq", [msg]), createEnv(kv), fakeDispatch);
|
||||||
expect(msg.acked).toBe(true);
|
expect(msg.acked).toBe(true);
|
||||||
expect(JSON.parse(store.get(STATE_KEY)!)).toMatchObject({ status: "dead" });
|
expect(JSON.parse(store.get(STATE_KEY)!)).toMatchObject({ status: "dead" });
|
||||||
});
|
});
|
||||||
|
|
@ -104,7 +102,7 @@ describe("handleQueueBatch", () => {
|
||||||
const { kv, store } = createMockKV();
|
const { kv, store } = createMockKV();
|
||||||
summary = { attempts: 1, failures: [] };
|
summary = { attempts: 1, failures: [] };
|
||||||
const msg = makeMessage(body());
|
const msg = makeMessage(body());
|
||||||
await handleQueueBatch(makeBatch("webhooker-delivery", [msg]), createEnv(kv));
|
await handleQueueBatch(makeBatch("webhooker-delivery", [msg]), createEnv(kv), fakeDispatch);
|
||||||
expect(msg.acked).toBe(true);
|
expect(msg.acked).toBe(true);
|
||||||
expect(msg.retried).toBeNull();
|
expect(msg.retried).toBeNull();
|
||||||
expect(JSON.parse(store.get(STATE_KEY)!)).toMatchObject({ status: "delivered" });
|
expect(JSON.parse(store.get(STATE_KEY)!)).toMatchObject({ status: "delivered" });
|
||||||
|
|
@ -114,7 +112,7 @@ describe("handleQueueBatch", () => {
|
||||||
const { kv, store } = createMockKV();
|
const { kv, store } = createMockKV();
|
||||||
summary = { attempts: 2, failures: [{ target: "c1", errorCode: "DISCORD_5XX" }] };
|
summary = { attempts: 2, failures: [{ target: "c1", errorCode: "DISCORD_5XX" }] };
|
||||||
const msg = makeMessage(body(), 1);
|
const msg = makeMessage(body(), 1);
|
||||||
await handleQueueBatch(makeBatch("webhooker-delivery", [msg]), createEnv(kv));
|
await handleQueueBatch(makeBatch("webhooker-delivery", [msg]), createEnv(kv), fakeDispatch);
|
||||||
expect(msg.acked).toBe(false);
|
expect(msg.acked).toBe(false);
|
||||||
expect(msg.retried).toEqual({ delaySeconds: 5 });
|
expect(msg.retried).toEqual({ delaySeconds: 5 });
|
||||||
expect(JSON.parse(store.get(STATE_KEY)!)).toMatchObject({ status: "retrying" });
|
expect(JSON.parse(store.get(STATE_KEY)!)).toMatchObject({ status: "retrying" });
|
||||||
|
|
@ -124,7 +122,7 @@ describe("handleQueueBatch", () => {
|
||||||
const { kv, store } = createMockKV();
|
const { kv, store } = createMockKV();
|
||||||
summary = { attempts: 2, failures: [{ target: "c1", errorCode: "DISCORD_ERROR" }] };
|
summary = { attempts: 2, failures: [{ target: "c1", errorCode: "DISCORD_ERROR" }] };
|
||||||
const msg = makeMessage(body());
|
const msg = makeMessage(body());
|
||||||
await handleQueueBatch(makeBatch("webhooker-delivery", [msg]), createEnv(kv));
|
await handleQueueBatch(makeBatch("webhooker-delivery", [msg]), createEnv(kv), fakeDispatch);
|
||||||
expect(msg.acked).toBe(true);
|
expect(msg.acked).toBe(true);
|
||||||
expect(msg.retried).toBeNull();
|
expect(msg.retried).toBeNull();
|
||||||
expect(JSON.parse(store.get(STATE_KEY)!)).toMatchObject({ status: "failed" });
|
expect(JSON.parse(store.get(STATE_KEY)!)).toMatchObject({ status: "failed" });
|
||||||
|
|
@ -134,7 +132,7 @@ describe("handleQueueBatch", () => {
|
||||||
const { kv, store } = createMockKV();
|
const { kv, store } = createMockKV();
|
||||||
store.set(STATE_KEY, JSON.stringify({ status: "delivered", at: Date.now() }));
|
store.set(STATE_KEY, JSON.stringify({ status: "delivered", at: Date.now() }));
|
||||||
const msg = makeMessage(body());
|
const msg = makeMessage(body());
|
||||||
await handleQueueBatch(makeBatch("webhooker-delivery", [msg]), createEnv(kv));
|
await handleQueueBatch(makeBatch("webhooker-delivery", [msg]), createEnv(kv), fakeDispatch);
|
||||||
expect(msg.acked).toBe(true);
|
expect(msg.acked).toBe(true);
|
||||||
expect(dispatchCalls).toBe(0);
|
expect(dispatchCalls).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue