diff --git a/.env.example b/.env.example index cca81a6..c9d57c1 100644 --- a/.env.example +++ b/.env.example @@ -7,6 +7,8 @@ GITHUB_CLIENT_SECRET=your-client-secret # Discord DISCORD_TOKEN=your-bot-token +DISCORD_PUBLIC_KEY=your-public-key +DISCORD_APPLICATION_ID=your-application-id DISCORD_CHANNEL_ID=your-channel-id # Server diff --git a/AGENTS.md b/AGENTS.md index 0573269..f158eee 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,17 +2,17 @@ ## Project Purpose -Cloudflare Worker that receives GitHub webhooks and dispatches processed events to Discord channels/threads via a Durable Object-maintained Gateway connection. +Cloudflare Worker that receives GitHub webhooks and dispatches processed events to Discord channels/threads, and receives Discord interactions (slash commands, buttons, modals) via the Interactions Endpoint. -Core pipeline: GitHub Webhook → Worker (verify + filter + format) → Durable Object (Discord Gateway) → Discord +Core pipeline: GitHub Webhook → Worker (verify + filter + format) → Discord (REST) ## Key Decisions - Runtime: Cloudflare Workers - HTTP framework: Hono -- Discord Gateway: optional (`DISCORD_GATEWAY_ENABLED=true`); only keeps bot online — messages always sent via REST +- Discord interactions: HTTPS Interactions Endpoint (`POST /discord/interactions`, Ed25519-signed) — no Discord Gateway / Durable Object; bot stays offline, messages always sent via REST - Storage: Cloudflare KV (tokens, OAuth state, route config, admin sessions) -- Signature verification: Web Crypto API (HMAC-SHA256, timing-safe) +- Signature verification: Web Crypto API (HMAC-SHA256 for GitHub, Ed25519 for Discord) - GitHub OAuth: octokit + jose (JWT) - Admin WebUI: `/admin` config console, OAuth-session protected via `ADMIN_USER_IDS` whitelist - Local dev: wrangler + Miniflare @@ -21,14 +21,14 @@ Core pipeline: GitHub Webhook → Worker (verify + filter + format) → Durable ```text src/ -├── index.ts # CF Workers entry (fetch + scheduled), exports DiscordGateway DO +├── index.ts # CF Workers entry (fetch + scheduled), scheduled = command sync ├── types.ts # Env, Config, Route, Filter, WebhookEvent, FormattedMessage ├── config.ts # loadRoutes/saveRoutes (KV config:routes, cache w/ 60s TTL), loadConfig from env -├── server.ts # Hono app: /health, /webhook, mounts /auth, /admin + / +├── server.ts # Hono app: /health, /webhook, /discord/interactions, mounts /auth, /admin + / ├── webhook.ts # HMAC verify (Web Crypto), parseEvent, extractBranch, matchRoute -├── discord.ts # Dispatch via REST (or DO RPC when gateway enabled), initGateway (scheduled) +├── discord.ts # Dispatch to Discord via REST (sendMessage) ├── discord-rest.ts # Discord REST sendMessage with retry + rate-limit handling -├── discord-gateway.ts # Optional Durable Object: Discord Gateway WS, heartbeat, channel cache, send +├── discord-interactions.ts # Ed25519 verify + interaction handlers (/gh, buttons, modals) + command registration ├── formatter.ts # 24 event formatters + generic fallback (~1570 lines) ├── github-oauth.ts # OAuth URL, callback token exchange, getUserOctokit ├── oauth-routes.ts # GET /auth/github, callback (sets admin session if redirect=/admin), DELETE /token/:userId @@ -43,10 +43,12 @@ src/ ## Responsibilities - Verify GitHub webhook signatures (Web Crypto HMAC-SHA256) +- Verify Discord interactions (Web Crypto Ed25519, X-Signature-Ed25519 over timestamp + body) - Filter events by: event type, repo name, actor, action, branch, keyword (regex supported) - Format 23+ event types as Discord embeds -- Route messages to Discord channels/threads via Durable Object RPC -- Maintain Discord Gateway connection with heartbeat and alarm-based keepalive +- Route messages to Discord channels/threads via REST +- Serve `/gh` slash commands + message context-menu commands + PR merge/close buttons + comment modals +- Sync application commands from the scheduled trigger (global ~1h propagation + per-guild instant) ## Message Format Spec @@ -76,13 +78,20 @@ npm run lint # ESLint - **Production**: `wrangler secret put ` for each secret - **Routes**: KV key `config:routes` (JSON array, empty until configured) - **KV namespace**: Required binding for token/state/config storage +- **Discord**: `DISCORD_PUBLIC_KEY` (Interactions Endpoint signature verification, from Discord Developer Portal) and `DISCORD_APPLICATION_ID` (optional, auto-resolved via `GET /oauth2/applications/@me` when omitted) are required for interactions ## Deployment ```bash npx wrangler secret put GITHUB_WEBHOOK_SECRET npx wrangler secret put DISCORD_TOKEN +npx wrangler secret put DISCORD_PUBLIC_KEY npx wrangler kv namespace create KV # Update wrangler.jsonc with KV ID npx wrangler deploy ``` + +## Notes + +- Commands sync from the scheduled trigger (`*/5 * * * *`): registered per-guild for instant availability and globally (24h dedup, ~1h propagation). +- The bot is always offline (no Discord Gateway); interactions arrive via the HTTP endpoint. diff --git a/README.md b/README.md index fa4e6e0..87c6e51 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ GitHub webhook → Discord dispatcher. Receives webhook events via Cloudflare Wo - Route to channels or threads - GitHub App OAuth for user actions (comment, merge, react) - **Web UI config console** (`/admin`) — manage routes with GitHub OAuth + admin whitelist -- Durable Object for persistent Discord Gateway connection + channel cache +- **Discord Interactions Endpoint** (Ed25519-verified) for `/gh` slash commands, message context-menu commands, PR merge/close buttons, and comment modals - Cloudflare KV for token/state/config storage - Graceful degradation (webhook-only mode if Discord unavailable) @@ -19,14 +19,15 @@ GitHub webhook → Discord dispatcher. Receives webhook events via Cloudflare Wo ```text GitHub Webhook → Cloudflare Worker (Hono) - ├── POST /webhook → verify → filter → format → DO (Discord Gateway) → Discord + ├── POST /webhook → verify → filter → format → Discord (REST) + ├── POST /discord/interactions → verify (Ed25519) → handle command/button/modal ├── GET /auth/github → OAuth flow ├── POST /api/* → user actions (Bearer token auth) └── GET /health → status check ``` -- **Cloudflare Worker** — HTTP ingress, signature verification, routing -- **Durable Object (DiscordGateway)** — Persistent WebSocket to Discord Gateway, channel cache, message dispatch with retry +- **Cloudflare Worker** — HTTP ingress, signature verification, routing, Discord REST dispatch +- **Interactions Endpoint** — HTTPS callback (no Discord Gateway connection, no Durable Object); the bot stays offline and commands are registered via the API - **KV** — Token storage (`token:{userId}`), OAuth state (`state:{hex}`), route config (`config:routes`) ## Quick Start @@ -49,9 +50,10 @@ npx wrangler dev # Start local dev server | `GITHUB_CLIENT_ID` | OAuth client ID | | `GITHUB_CLIENT_SECRET` | OAuth client secret | | `DISCORD_TOKEN` | Bot token | +| `DISCORD_PUBLIC_KEY` | Discord application public key (from the Developer Portal) — required for interactions | +| `DISCORD_APPLICATION_ID` | Discord application id (optional; auto-resolved via `GET /oauth2/applications/@me` if omitted) | | `BASE_URL` | Public URL for OAuth callbacks | | `ADMIN_USER_IDS` | Comma-separated GitHub user IDs (or logins) allowed to access `/admin` | -| `DISCORD_GATEWAY_ENABLED` | `true` to enable the Discord Gateway (bot online status); messaging works without it via REST | ### Routes @@ -173,20 +175,15 @@ Invite URL (replace `CLIENT_ID` with your bot's client ID). The `applications.co https://discord.com/oauth2/authorize?client_id=YOUR_BOT_CLIENT_ID&permissions=274877910016&scope=bot+applications.commands ``` -### Intents +### Interactions Endpoint -The Gateway connection uses the **GUILDS** intent only (`1 << 0`). No privileged intents (e.g. Message Content) are required. +Copy the application **Public Key** (Developer Portal → General Information) to `DISCORD_PUBLIC_KEY` and set the **Interactions Endpoint URL** to `https://your-domain/discord/interactions`. All interactions (slash commands, buttons, modals) are verified with Ed25519 signatures. -### Gateway (optional) - -The Discord Gateway connection only keeps the bot showing as **online** — it is **not** required for sending messages. Messages are sent via the Discord REST API, so push works with just `DISCORD_TOKEN`. - -- `DISCORD_GATEWAY_ENABLED=false` (default): messages are sent directly via REST; the Gateway is not connected. -- `DISCORD_GATEWAY_ENABLED=true`: the Durable Object connects to the Gateway to keep the bot online; messages are still sent via REST. +The bot never connects to the Discord Gateway, so it shows as **offline** — messaging is unaffected (always REST). ### Bot Commands (comment on GitHub as yourself) -When the Gateway is enabled, the bot registers native **slash** and **message context-menu** commands per guild on connect. Comments are posted using **your own** linked GitHub account (OAuth), and permission is delegated to GitHub — if GitHub rejects the action (e.g. editing someone else's comment) the bot tells you so. All replies are ephemeral (only you see them). +The bot registers native **slash** and **message context-menu** commands, synced by the scheduled trigger (every 5 minutes): per-guild for instant availability, and globally (24h dedup, ~1h propagation). Comments are posted using **your own** linked GitHub account (OAuth), and permission is delegated to GitHub — if GitHub rejects the action (e.g. editing someone else's comment) the bot tells you so. All replies are ephemeral (only you see them). **1. Link your account** (once): @@ -214,12 +211,12 @@ When the Gateway is enabled, the bot registers native **slash** and **message co **Requirements:** -| Item | How | -| --------------- | --------------------------------------------------------------------- | -| Gateway enabled | `DISCORD_GATEWAY_ENABLED=true` (interactions arrive over the Gateway) | -| Invite scope | Bot invited with `applications.commands` (see invite URL above) | -| OAuth | `GITHUB_CLIENT_ID` / `GITHUB_CLIENT_SECRET` and `BASE_URL` configured | -| User linked | Each user runs `/gh login` first | +| Item | How | +| ---------------- | ----------------------------------------------------------------- | +| Public key | `DISCORD_PUBLIC_KEY` set + Interactions Endpoint URL configured | +| Invite scope | Bot invited with `applications.commands` (see invite URL above) | +| OAuth | `GITHUB_CLIENT_ID` / `GITHUB_CLIENT_SECRET` and `BASE_URL` configured | +| User linked | Each user runs `/gh login` first | ## Deployment @@ -231,6 +228,7 @@ npx wrangler secret put GITHUB_PRIVATE_KEY npx wrangler secret put GITHUB_CLIENT_ID npx wrangler secret put GITHUB_CLIENT_SECRET npx wrangler secret put DISCORD_TOKEN +npx wrangler secret put DISCORD_PUBLIC_KEY npx wrangler secret put DISCORD_CHANNEL_ID # Create KV namespace diff --git a/README.zh.md b/README.zh.md index beaa858..59d176d 100644 --- a/README.zh.md +++ b/README.zh.md @@ -11,7 +11,7 @@ GitHub webhook → Discord 分发服务。通过 Cloudflare Workers 接收 webho - 路由到频道或子区 - GitHub App OAuth 用户授权(评论、合并、反应) - **Web 配置控制台**(`/admin`)— 通过 GitHub OAuth + 管理员白名单管理路由 -- Durable Object 维持 Discord Gateway WebSocket 连接 + 频道缓存 +- **Discord Interactions Endpoint**(Ed25519 验签)支持 `/gh` 斜杠命令、消息右键菜单命令、PR 合并/关闭按钮与评论 modal - Cloudflare KV 存储 token/状态/配置 - 优雅降级(Discord 不可用时仅 webhook 模式) @@ -19,14 +19,15 @@ GitHub webhook → Discord 分发服务。通过 Cloudflare Workers 接收 webho ```text GitHub Webhook → Cloudflare Worker (Hono) - ├── POST /webhook → 验证 → 过滤 → 格式化 → DO (Discord Gateway) → Discord + ├── POST /webhook → 验证 → 过滤 → 格式化 → Discord (REST) + ├── POST /discord/interactions → 验证 (Ed25519) → 处理命令/按钮/modal ├── GET /auth/github → OAuth 流程 ├── POST /api/* → 用户操作(Bearer token 鉴权) └── GET /health → 健康检查 ``` - **Cloudflare Worker** — HTTP 入口、签名验证、路由分发 -- **Durable Object (DiscordGateway)** — 持久 WebSocket 连接 Discord Gateway、频道缓存、消息发送(含重试) +- **Interactions Endpoint** — HTTPS 回调(无 Discord Gateway 连接、无 Durable Object);bot 保持离线,命令通过 API 注册 - **KV** — Token 存储(`token:{userId}`)、OAuth state(`state:{hex}`)、路由配置(`config:routes`) ## 快速开始 @@ -49,9 +50,10 @@ npx wrangler dev # 启动本地开发服务器 | `GITHUB_CLIENT_ID` | OAuth Client ID | | `GITHUB_CLIENT_SECRET` | OAuth Client Secret | | `DISCORD_TOKEN` | 机器人 token | +| `DISCORD_PUBLIC_KEY` | Discord 应用的公钥(开发者门户获取)—— 交互功能必需 | +| `DISCORD_APPLICATION_ID` | Discord 应用 ID(可选;省略时通过 `GET /oauth2/applications/@me` 自动获取) | | `BASE_URL` | 公网地址(用于 OAuth 回调) | | `ADMIN_USER_IDS` | 允许访问 `/admin` 的 GitHub 用户 ID(或登录名),逗号分隔 | -| `DISCORD_GATEWAY_ENABLED` | 设为 `true` 启用 Discord Gateway(bot 在线状态);不启用也能通过 REST 推送 | ### 路由配置 @@ -172,20 +174,15 @@ npx wrangler dev # 启动本地开发服务器 https://discord.com/oauth2/authorize?client_id=你的机器人CLIENT_ID&permissions=274877910016&scope=bot+applications.commands ``` -### Intents +### Interactions Endpoint -Gateway 连接仅使用 **GUILDS** intent(`1 << 0`)。无需特权 intent(如 Message Content)。 +将应用的 **Public Key**(开发者门户 → General Information)复制到 `DISCORD_PUBLIC_KEY`,并将 **Interactions Endpoint URL** 设为 `https://your-domain/discord/interactions`。所有交互(斜杠命令、按钮、modal)均通过 Ed25519 签名验证。 -### Gateway(可选) - -Discord Gateway 连接仅用于让 bot 显示为**在线**——发送消息**不需要**它。消息通过 Discord REST API 发送,因此只要有 `DISCORD_TOKEN` 即可推送。 - -- `DISCORD_GATEWAY_ENABLED=false`(默认):直接通过 REST 发送消息,不建立 Gateway 连接。 -- `DISCORD_GATEWAY_ENABLED=true`:由 Durable Object 连接 Gateway 以维持 bot 在线状态;消息仍走 REST。 +bot 从不连接 Discord Gateway,因此显示为**离线**——消息推送不受影响(始终走 REST)。 ### Bot 指令(以本人身份评论 GitHub) -启用 Gateway 后,bot 会在连接时为每个服务器注册原生的**斜杠命令**与**消息右键菜单命令**。评论以**你本人**绑定的 GitHub 账号(OAuth)发出,权限交由 GitHub 判定——若 GitHub 拒绝(例如去修改他人评论),bot 会提示你无权限。所有回复均为 ephemeral(仅你可见)。 +bot 通过定时任务(每 5 分钟)同步注册原生的**斜杠命令**与**消息右键菜单命令**:按服务器注册以获得即时可用性,并全局注册(24h 去重,约 1 小时传播)。评论以**你本人**绑定的 GitHub 账号(OAuth)发出,权限交由 GitHub 判定——若 GitHub 拒绝(例如去修改他人评论),bot 会提示你无权限。所有回复均为 ephemeral(仅你可见)。 **1. 绑定账号**(一次即可): @@ -215,7 +212,7 @@ Discord Gateway 连接仅用于让 bot 显示为**在线**——发送消息** | 项目 | 说明 | | ------------ | ---------------------------------------------------------------- | -| 启用 Gateway | `DISCORD_GATEWAY_ENABLED=true`(交互通过 Gateway 送达) | +| Public Key | 已配置 `DISCORD_PUBLIC_KEY` 且已设置 Interactions Endpoint URL | | 邀请 scope | 邀请时带上 `applications.commands`(见上方邀请链接) | | OAuth | 已配置 `GITHUB_CLIENT_ID` / `GITHUB_CLIENT_SECRET` 与 `BASE_URL` | | 用户绑定 | 每个用户先执行 `/gh login` | @@ -230,6 +227,7 @@ npx wrangler secret put GITHUB_PRIVATE_KEY npx wrangler secret put GITHUB_CLIENT_ID npx wrangler secret put GITHUB_CLIENT_SECRET npx wrangler secret put DISCORD_TOKEN +npx wrangler secret put DISCORD_PUBLIC_KEY npx wrangler secret put DISCORD_CHANNEL_ID # 创建 KV 命名空间 diff --git a/docs/api/overview.md b/docs/api/overview.md index 0905135..9b446ec 100644 --- a/docs/api/overview.md +++ b/docs/api/overview.md @@ -14,6 +14,7 @@ https://your-worker.workers.dev | -------- | ------------------------------ | -------------- | ------------------------ | | `GET` | `/health` | None | Health check | | `POST` | `/webhook` | HMAC signature | GitHub webhook ingestion | +| `POST` | `/discord/interactions` | Ed25519 signature | Discord interactions (slash commands, buttons, modals) | | `GET` | `/auth/github` | None | Start GitHub OAuth flow | | `GET` | `/auth/github/callback` | None | OAuth callback | | `DELETE` | `/auth/token/:userId` | None | Revoke user token | diff --git a/docs/contributing.md b/docs/contributing.md index ec006fa..0568c25 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -14,14 +14,14 @@ npm run dev # Start local dev server ```text src/ -├── index.ts # CF Workers entry (fetch + scheduled), exports DiscordGateway DO +├── index.ts # CF Workers entry (fetch + scheduled), scheduled = command sync ├── types.ts # Env, Config, Route, Filter, WebhookEvent, FormattedMessage ├── config.ts # Loads routes from KV (returns [] if unset), builds Config from env -├── server.ts # Hono app: /health, /webhook, mounts /auth, /admin + / +├── server.ts # Hono app: /health, /webhook, /discord/interactions, mounts /auth, /admin + / ├── webhook.ts # HMAC verify (Web Crypto), parseEvent, extractBranch, matchRoute -├── discord.ts # Dispatch via Discord REST (DO RPC when gateway enabled), initGateway (scheduled) +├── discord.ts # Dispatch to Discord via REST (sendMessage) ├── discord-rest.ts # Discord REST sendMessage with retry + rate-limit handling -├── discord-gateway.ts # Durable Object: Discord Gateway WS, heartbeat, channel cache, send +├── discord-interactions.ts # Ed25519 verify + interaction handlers (/gh, buttons, modals) + command registration ├── formatter.ts # 23 event formatters + generic fallback ├── github-oauth.ts # OAuth URL, callback token exchange, getUserOctokit ├── oauth-routes.ts # GET /auth/github, callback, DELETE /token/:userId (KV state) diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index cffbce8..dc28126 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -19,9 +19,10 @@ WebHooker requires several secrets to function. For local development, store the | Variable | Description | Default | | ------------------------- | ----------------------------------------------------------------------------------------------------- | ----------------------- | +| `DISCORD_PUBLIC_KEY` | Discord application public key (Developer Portal) — required for interactions | Unset → interactions return `401` | +| `DISCORD_APPLICATION_ID` | Discord application id; auto-resolved when omitted | Auto-resolved | | `BASE_URL` | Public URL for OAuth callbacks | `http://localhost:8787` | | `ADMIN_USER_IDS` | Comma-separated GitHub user IDs (or logins) allowed to access the Web UI | Disabled | -| `DISCORD_GATEWAY_ENABLED` | Set to `true` to connect the Discord Gateway (bot online status); messaging works without it via REST | `false` | ## Web UI @@ -180,4 +181,7 @@ Filters accept either a single string or an array of strings: | `discord-link:{userId}` | GitHub user id linked to a Discord user | Permanent | | `state:{hex}` | `{ redirectTo, expiresAt, discordUserId? }` | 600 seconds | | `delivery:{id}` | Webhook delivery id (dedup marker) | 300 seconds | +| `cmd:guild:{id}` | Guild id whose commands were registered (dedup) | Permanent | +| `cmd:registered:global` | Global command registration marker (dedup) | 1 day | +| `config:discord-app-id` | Cached Discord application id | Permanent | | `logs:send:{ts}-{hex}` | Send record | 1 hour | diff --git a/docs/guide/deployment.md b/docs/guide/deployment.md index 0db6b23..8d9bd80 100644 --- a/docs/guide/deployment.md +++ b/docs/guide/deployment.md @@ -30,6 +30,7 @@ npx wrangler secret put GITHUB_PRIVATE_KEY # PKCS#8 PEM (BEGIN PRIVATE KEY) npx wrangler secret put GITHUB_CLIENT_ID npx wrangler secret put GITHUB_CLIENT_SECRET npx wrangler secret put DISCORD_TOKEN +npx wrangler secret put DISCORD_PUBLIC_KEY # Discord app public key (Developer Portal) — required for interactions npx wrangler secret put ADMIN_USER_IDS # comma-separated GitHub IDs/logins allowed into the Web UI ``` @@ -48,7 +49,7 @@ openssl pkcs8 -topk8 -inform PEM -outform PEM -nocrypt \ Then upload `gh_pk_pkcs8.pem` as `GITHUB_PRIVATE_KEY`. ::: -The Discord Gateway is optional. Set `DISCORD_GATEWAY_ENABLED` in `wrangler.jsonc` `vars` (`"false"` by default). See [Gateway (optional)](#gateway-optional) below. +Discord interactions arrive via the HTTPS Interactions Endpoint, so set `DISCORD_PUBLIC_KEY` and point the **Interactions Endpoint URL** at `https://your-domain/discord/interactions`. See [Interactions Endpoint](#interactions-endpoint) below. ### 3. Deploy @@ -106,14 +107,17 @@ Your worker is now live at `https://webhooker..workers.dev`. 5. Configure target channels **per route** in the Web UI (`/admin`) — no global channel ID is required. -### Gateway (optional) +### Interactions Endpoint -Messages are sent via the Discord **REST API**, so pushing works with just `DISCORD_TOKEN`. The Gateway connection is only needed to (a) show the bot as **online** and (b) enable the in-Discord slash / context-menu commands. +Messages are sent via the Discord **REST API**, so pushing works with just `DISCORD_TOKEN`. Interactions (slash commands, buttons, modals) arrive through the HTTPS Interactions Endpoint: -- `DISCORD_GATEWAY_ENABLED=false` (default): REST-only, no Gateway connection. -- `DISCORD_GATEWAY_ENABLED=true`: a Durable Object holds the Gateway connection and registers the `/gh` slash command plus the `GitHub: 添加/编辑/删除评论` message commands per guild. +1. Copy the application **Public Key** (Developer Portal → General Information) to `DISCORD_PUBLIC_KEY`. +2. Set the **Interactions Endpoint URL** to `https://your-domain/discord/interactions`. +3. Every interaction request is verified with Ed25519 signatures (`X-Signature-Ed25519` over `X-Signature-Timestamp + body`). -When enabled, users run `/gh login` to link their GitHub account and can then comment on issues/PRs as themselves. See the [README](https://github.com/ReCloudStudio/WebHooker#bot-commands-comment-on-github-as-yourself) for the full command reference. +The `/gh` slash command and the `GitHub: 添加/编辑/删除评论` message commands are synced by the scheduled trigger (every 5 minutes): per-guild for instant availability, plus a global registration (24h dedup, ~1h propagation). The bot never connects to the Discord Gateway, so it shows as **offline** — messaging is unaffected (always REST). + +Users run `/gh login` to link their GitHub account and can then comment on issues/PRs as themselves. See the [README](https://github.com/ReCloudStudio/WebHooker#bot-commands-comment-on-github-as-yourself) for the full command reference. ## Custom Domain (Optional) @@ -132,4 +136,4 @@ docker build -t webhooker . docker run -p 8787:8787 --env-file .env webhooker ``` -Note: Docker mode runs without Durable Objects and KV. Use Cloudflare deployment for full functionality. +Note: Docker mode runs without KV and other Cloudflare storage. Use Cloudflare deployment for full functionality. diff --git a/docs/guide/getting-started.md b/docs/guide/getting-started.md index a2945d5..3731c7b 100644 --- a/docs/guide/getting-started.md +++ b/docs/guide/getting-started.md @@ -34,12 +34,13 @@ GITHUB_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----" GITHUB_CLIENT_ID=your-client-id GITHUB_CLIENT_SECRET=your-client-secret DISCORD_TOKEN=your-bot-token +DISCORD_PUBLIC_KEY=your-public-key ADMIN_USER_IDS=your-github-id,your-github-login BASE_URL=http://localhost:8787 ``` ::: tip -`GITHUB_PRIVATE_KEY` must be in **PKCS#8** format (`BEGIN PRIVATE KEY`). Convert a GitHub-issued PKCS#1 key with `openssl pkcs8 -topk8 -nocrypt -in app.pem -out pkcs8.pem`. Target channels are set per route in the Web UI, so no `DISCORD_CHANNEL_ID` is needed. To keep the bot online and enable `/gh` slash commands locally, also set `DISCORD_GATEWAY_ENABLED=true`. +`GITHUB_PRIVATE_KEY` must be in **PKCS#8** format (`BEGIN PRIVATE KEY`). Convert a GitHub-issued PKCS#1 key with `openssl pkcs8 -nocrypt -in app.pem -out pkcs8.pem`. Target channels are set per route in the Web UI, so no `DISCORD_CHANNEL_ID` is needed. To enable `/gh` commands locally, copy the **Public Key** from the Developer Portal into `DISCORD_PUBLIC_KEY` and set the Interactions Endpoint URL to `http://localhost:8787/discord/interactions`. ::: ::: warning diff --git a/docs/guide/introduction.md b/docs/guide/introduction.md index fa2b99c..57bff52 100644 --- a/docs/guide/introduction.md +++ b/docs/guide/introduction.md @@ -1,6 +1,6 @@ # Introduction -WebHooker is a GitHub webhook dispatcher built on Cloudflare Workers. It receives GitHub webhook events, applies configurable filters, formats them into rich Discord embeds, and delivers them to Discord channels or threads through the Discord REST API. An optional Durable Object holds a Gateway connection to keep the bot online and power the in-Discord `/gh` commands. Routes are managed through a built-in Web UI. +WebHooker is a GitHub webhook dispatcher built on Cloudflare Workers. It receives GitHub webhook events, applies configurable filters, formats them into rich Discord embeds, and delivers them to Discord channels or threads through the Discord REST API. In-Discord `/gh` interactions arrive via an HTTPS Interactions Endpoint (Ed25519-verified). Routes are managed through a built-in Web UI. ## Architecture @@ -10,9 +10,9 @@ GitHub Webhook → Cloudflare Worker (Hono) ├── GET /auth/github → OAuth flow ├── POST /api/* → user actions (Bearer token auth) ├── /admin → routes & send-log Web UI (admin session) - └── GET /health → status check + └── GET /health → status check -(optional) Durable Object ⇄ Discord Gateway → bot online + /gh slash & context commands +POST /discord/interactions → verify (Ed25519) → handle /gh slash & context commands ``` ### Components @@ -20,7 +20,7 @@ GitHub Webhook → Cloudflare Worker (Hono) | Component | Role | | ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | | **Cloudflare Worker** | HTTP ingress, signature verification, delivery dedup, event parsing, route matching, REST send | -| **Durable Object (DiscordGateway)** | _Optional._ Keeps the Gateway connection alive (bot online) and handles `/gh` interactions | +| **Interactions Endpoint** | Verifies Ed25519 signatures and handles `/gh` interactions (slash commands, context-menu commands, buttons, modals) | | **KV** | Token storage (`token:{userId}`), OAuth state (`state:{hex}`), route config (`config:routes`), send logs, delivery dedup | ### Data Flow @@ -37,7 +37,7 @@ GitHub Webhook → Cloudflare Worker (Hono) - **Runtime**: Cloudflare Workers - **HTTP Framework**: Hono -- **Discord delivery**: Discord REST API (Gateway via optional Durable Object for online status + `/gh` commands) +- **Discord delivery**: Discord REST API (interactions via an Ed25519-verified HTTPS Interactions Endpoint) - **Web UI**: Nuxt 3 static SPA served from Worker assets - **Storage**: Cloudflare KV - **Auth**: Web Crypto API (HMAC-SHA256), jose (JWT), octokit (GitHub API) diff --git a/docs/index.md b/docs/index.md index f5cd5cf..9bb71f3 100644 --- a/docs/index.md +++ b/docs/index.md @@ -19,11 +19,11 @@ features: - title: Flexible Filtering details: Filter by event type, repo, actor, action, branch (including PRs), and keyword (with regex support). Exclude patterns with a flag. - title: Cloudflare Workers - details: Runs on Cloudflare's edge network. Sends via the Discord REST API, with an optional Durable Object Gateway connection for online status and slash commands. + details: Runs on Cloudflare's edge network. Sends via the Discord REST API, with an Ed25519-verified Interactions Endpoint for `/gh` slash commands and buttons. - title: Web UI & Slash Commands details: "Manage routes and view send logs from a built-in admin console. Link your GitHub account and comment on issues/PRs as yourself via /gh commands." - title: Signature Verification - details: HMAC-SHA256 webhook signature verification using the Web Crypto API with timing-safe comparison. + details: HMAC-SHA256 webhook signature verification and Ed25519 interaction signature verification using the Web Crypto API with timing-safe comparison. - title: Graceful Degradation details: Runs in webhook-only mode if Discord token is unavailable. Health endpoint for monitoring. --- diff --git a/docs/zh/api/overview.md b/docs/zh/api/overview.md index 8cce095..5303adb 100644 --- a/docs/zh/api/overview.md +++ b/docs/zh/api/overview.md @@ -14,6 +14,7 @@ https://your-worker.workers.dev | -------- | ------------------------------ | ------------ | ------------------------ | | `GET` | `/health` | 无 | 健康检查 | | `POST` | `/webhook` | HMAC 签名 | GitHub webhook 接入 | +| `POST` | `/discord/interactions` | Ed25519 签名 | Discord 交互(斜杠命令、按钮、modal) | | `GET` | `/auth/github` | 无 | 启动 GitHub OAuth 流程 | | `GET` | `/auth/github/callback` | 无 | OAuth 回调 | | `DELETE` | `/auth/token/:userId` | 无 | 撤销用户 Token | diff --git a/docs/zh/contributing.md b/docs/zh/contributing.md index 3255b2f..25f6b3e 100644 --- a/docs/zh/contributing.md +++ b/docs/zh/contributing.md @@ -14,14 +14,14 @@ npm run dev # 启动本地开发服务器 ```text src/ -├── index.ts # CF Workers 入口 (fetch + scheduled),导出 DiscordGateway DO +├── index.ts # CF Workers 入口 (fetch + scheduled),scheduled = 命令同步 ├── types.ts # Env、Config、Route、Filter、WebhookEvent、FormattedMessage ├── config.ts # 从 KV 加载路由(未设置时返回 []),从 env 构建 Config -├── server.ts # Hono 应用: /health、/webhook,挂载 /auth、/admin + / +├── server.ts # Hono 应用: /health、/webhook、/discord/interactions,挂载 /auth、/admin + / ├── webhook.ts # HMAC 验证 (Web Crypto)、parseEvent、extractBranch、matchRoute -├── discord.ts # 通过 Discord REST 分发(启用 Gateway 时走 DO RPC)、initGateway (scheduled) +├── discord.ts # 通过 Discord REST 分发 (sendMessage) ├── discord-rest.ts # Discord REST sendMessage,带重试和限流处理 -├── discord-gateway.ts # Durable Object: Discord Gateway WS、心跳、频道缓存、发送 +├── discord-interactions.ts # Ed25519 验签 + 交互处理 (/gh、按钮、modal) + 命令注册 ├── formatter.ts # 23 种事件格式化器 + 通用回退 ├── github-oauth.ts # OAuth URL、回调 Token 交换、getUserOctokit ├── oauth-routes.ts # GET /auth/github、回调、DELETE /token/:userId (KV 状态) diff --git a/docs/zh/guide/configuration.md b/docs/zh/guide/configuration.md index f9863ca..e424154 100644 --- a/docs/zh/guide/configuration.md +++ b/docs/zh/guide/configuration.md @@ -17,11 +17,12 @@ WebHooker 需要多个密钥才能运行。本地开发时存储在 `.dev.vars` ### 可选密钥 -| 变量 | 说明 | 默认值 | -| ------------------------- | -------------------------------------------------------------------------- | ----------------------- | -| `BASE_URL` | OAuth 回调的公开 URL | `http://localhost:8787` | -| `ADMIN_USER_IDS` | 允许访问 WebUI 的 GitHub 用户 ID(或登录名),逗号分隔 | 未设置时 WebUI 关闭 | -| `DISCORD_GATEWAY_ENABLED` | 设为 `true` 连接 Discord Gateway(bot 在线状态);不启用也能通过 REST 推送 | `false` | +| 变量 | 说明 | 默认值 | +| ------------------------ | ------------------------------------------------------ | ----------------------- | +| `BASE_URL` | OAuth 回调的公开 URL | `http://localhost:8787` | +| `ADMIN_USER_IDS` | 允许访问 WebUI 的 GitHub 用户 ID(或登录名),逗号分隔 | 未设置时 WebUI 关闭 | +| `DISCORD_PUBLIC_KEY` | Discord 应用的公钥(开发者门户获取),交互功能必需 | 未设置时交互返回 401 | +| `DISCORD_APPLICATION_ID` | Discord 应用 ID;省略时自动获取 | 自动获取 | ## Web 控制台 @@ -181,3 +182,6 @@ WebHooker 内置了位于 `/admin` 的配置控制台,可在浏览器中管理 | `state:{hex}` | `{ redirectTo, expiresAt, discordUserId? }` | 600 秒 | | `delivery:{id}` | Webhook 投递 id(去重标记) | 300 秒 | | `logs:send:{ts}-{hex}` | 发送记录 | 1 小时 | +| `cmd:guild:{id}` | 已注册命令的服务器 id(去重标记) | 永久 | +| `cmd:registered:global` | 全局命令已注册标记(24h 去重) | 1 天 | +| `config:discord-app-id` | Discord 应用 id 缓存 | 永久 | diff --git a/docs/zh/guide/deployment.md b/docs/zh/guide/deployment.md index 5599ffa..1e474fb 100644 --- a/docs/zh/guide/deployment.md +++ b/docs/zh/guide/deployment.md @@ -30,6 +30,7 @@ npx wrangler secret put GITHUB_PRIVATE_KEY # PKCS#8 PEM(BEGIN PRIVATE KEY) npx wrangler secret put GITHUB_CLIENT_ID npx wrangler secret put GITHUB_CLIENT_SECRET npx wrangler secret put DISCORD_TOKEN +npx wrangler secret put DISCORD_PUBLIC_KEY # Discord 应用的公钥(开发者门户获取),交互功能必需 npx wrangler secret put ADMIN_USER_IDS # 逗号分隔的 GitHub ID/登录名,允许进入 Web UI ``` @@ -48,7 +49,7 @@ openssl pkcs8 -topk8 -inform PEM -outform PEM -nocrypt \ 然后将 `gh_pk_pkcs8.pem` 作为 `GITHUB_PRIVATE_KEY` 上传。 ::: -Discord Gateway 是可选的。在 `wrangler.jsonc` 的 `vars` 中设置 `DISCORD_GATEWAY_ENABLED`(默认为 `"false"`)。参见下方 [Gateway(可选)](#gateway可选)。 +Discord 交互通过 HTTPS Interactions Endpoint 送达,需要设置 `DISCORD_PUBLIC_KEY` 并把 **Interactions Endpoint URL** 指向 `https://your-domain/discord/interactions`。参见下方 [Interactions Endpoint](#interactions-endpoint)。 ### 3. 部署 @@ -106,14 +107,17 @@ Worker 现在可通过 `https://webhooker..workers.dev` 访问 5. 在 Web UI(`/admin`)中**按路由**配置目标频道——无需全局频道 ID。 -### Gateway(可选) +### Interactions Endpoint -消息通过 Discord **REST API** 发送,因此仅凭 `DISCORD_TOKEN` 即可推送。Gateway 连接仅用于:(a) 让 Bot 显示为**在线**,(b) 启用 Discord 内的斜杠 / 右键菜单命令。 +消息通过 Discord **REST API** 发送,因此仅凭 `DISCORD_TOKEN` 即可推送。交互(斜杠命令、按钮、modal)则通过 HTTPS Interactions Endpoint 送达: -- `DISCORD_GATEWAY_ENABLED=false`(默认):仅 REST,不建立 Gateway 连接。 -- `DISCORD_GATEWAY_ENABLED=true`:由一个 Durable Object 持有 Gateway 连接,并按服务器注册 `/gh` 斜杠命令以及 `GitHub: 添加/编辑/删除评论` 消息命令。 +1. 在 Discord 开发者门户 → General Information 复制应用的 **Public Key**,填入 `DISCORD_PUBLIC_KEY`。 +2. 将 **Interactions Endpoint URL** 设为 `https://your-domain/discord/interactions`。 +3. 所有交互请求都使用 Ed25519 签名验证(`X-Signature-Ed25519` 覆盖 `X-Signature-Timestamp + body`)。 -启用后,用户运行 `/gh login` 绑定自己的 GitHub 账号,即可以本人身份评论 issue/PR。完整命令说明见 [README](https://github.com/ReCloudStudio/WebHooker#bot-commands-comment-on-github-as-yourself)。 +`/gh` 斜杠命令与 `GitHub: 添加/编辑/删除评论` 消息命令由定时任务(每 5 分钟)同步注册:按服务器即时可用,同时全局注册(24h 去重,约 1 小时传播)。Bot 从不连接 Discord Gateway,因此显示为**离线**——消息推送不受影响(始终走 REST)。 + +用户运行 `/gh login` 绑定自己的 GitHub 账号,即可以本人身份评论 issue/PR。完整命令说明见 [README](https://github.com/ReCloudStudio/WebHooker#bot-commands-comment-on-github-as-yourself)。 ## 自定义域名(可选) @@ -132,4 +136,4 @@ docker build -t webhooker . docker run -p 8787:8787 --env-file .env webhooker ``` -注意:Docker 模式下不包含 Durable Objects 和 KV。完整功能请使用 Cloudflare 部署。 +注意:Docker 模式下不包含 KV 等 Cloudflare 存储。完整功能请使用 Cloudflare 部署。 diff --git a/docs/zh/guide/getting-started.md b/docs/zh/guide/getting-started.md index 134bd39..3026df5 100644 --- a/docs/zh/guide/getting-started.md +++ b/docs/zh/guide/getting-started.md @@ -34,12 +34,13 @@ GITHUB_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----" GITHUB_CLIENT_ID=your-client-id GITHUB_CLIENT_SECRET=your-client-secret DISCORD_TOKEN=your-bot-token +DISCORD_PUBLIC_KEY=your-public-key ADMIN_USER_IDS=your-github-id,your-github-login BASE_URL=http://localhost:8787 ``` ::: tip -`GITHUB_PRIVATE_KEY` 必须是 **PKCS#8** 格式(`BEGIN PRIVATE KEY`)。用 `openssl pkcs8 -topk8 -nocrypt -in app.pem -out pkcs8.pem` 转换 GitHub 下发的 PKCS#1 私钥。目标频道在 Web UI 中按路由设置,因此不需要 `DISCORD_CHANNEL_ID`。若要让 Bot 保持在线并在本地启用 `/gh` 斜杠命令,可额外设置 `DISCORD_GATEWAY_ENABLED=true`。 +`GITHUB_PRIVATE_KEY` 必须是 **PKCS#8** 格式(`BEGIN PRIVATE KEY`)。用 `openssl pkcs8 -nocrypt -in app.pem -out pkcs8.pem` 转换 GitHub 下发的 PKCS#1 私钥。目标频道在 Web UI 中按路由设置,因此不需要 `DISCORD_CHANNEL_ID`。若要在本地启用 `/gh` 命令,请在开发者门户复制 **Public Key** 填入 `DISCORD_PUBLIC_KEY`,并把 Interactions Endpoint URL 设为 `http://localhost:8787/discord/interactions`。 ::: ::: warning diff --git a/docs/zh/guide/introduction.md b/docs/zh/guide/introduction.md index 8b57507..90d55df 100644 --- a/docs/zh/guide/introduction.md +++ b/docs/zh/guide/introduction.md @@ -1,6 +1,6 @@ # 简介 -WebHooker 是一个基于 Cloudflare Workers 构建的 GitHub webhook 调度器。它接收 GitHub webhook 事件,应用可配置的过滤器,将事件格式化为丰富的 Discord 嵌入消息,并通过 Discord REST API 投递到 Discord 频道或帖子。一个可选的 Durable Object 持有 Gateway 连接,用于让 Bot 保持在线并支持 Discord 内的 `/gh` 命令。路由通过内置的 Web UI 管理。 +WebHooker 是一个基于 Cloudflare Workers 构建的 GitHub webhook 调度器。它接收 GitHub webhook 事件,应用可配置的过滤器,将事件格式化为丰富的 Discord 嵌入消息,并通过 Discord REST API 投递到 Discord 频道或帖子。Discord 内的 `/gh` 交互通过 HTTPS Interactions Endpoint(Ed25519 验签)送达。路由通过内置的 Web UI 管理。 ## 架构 @@ -12,7 +12,7 @@ GitHub Webhook → Cloudflare Worker (Hono) ├── /admin → 路由与发送日志 Web UI(管理员会话) └── GET /health → 健康检查 -(可选)Durable Object ⇄ Discord Gateway → Bot 在线 + /gh 斜杠与右键命令 +POST /discord/interactions → 验证 (Ed25519) → 处理 /gh 斜杠与右键命令 ``` ### 组件 @@ -20,7 +20,7 @@ GitHub Webhook → Cloudflare Worker (Hono) | 组件 | 职责 | | ----------------------------------- | --------------------------------------------------------------------------------------------------------- | | **Cloudflare Worker** | HTTP 入口、签名验证、投递去重、事件解析、路由匹配、REST 发送 | -| **Durable Object (DiscordGateway)** | _可选。_ 保持 Gateway 连接(Bot 在线)并处理 `/gh` 交互 | +| **Interactions Endpoint** | 验证 Ed25519 签名并处理 `/gh` 交互(斜杠命令、右键菜单、按钮、modal) | | **KV** | Token 存储 (`token:{userId}`)、OAuth 状态 (`state:{hex}`)、路由配置 (`config:routes`)、发送日志、投递去重 | ### 数据流 @@ -37,7 +37,7 @@ GitHub Webhook → Cloudflare Worker (Hono) - **运行时**: Cloudflare Workers - **HTTP 框架**: Hono -- **Discord 投递**: Discord REST API(Gateway 通过可选的 Durable Object 提供在线状态与 `/gh` 命令) +- **Discord 投递**: Discord REST API(交互通过 Ed25519 验签的 HTTPS Interactions Endpoint) - **Web UI**: Nuxt 3 静态 SPA,由 Worker 资源托管 - **存储**: Cloudflare KV - **鉴权**: Web Crypto API (HMAC-SHA256)、jose (JWT)、octokit (GitHub API) diff --git a/docs/zh/index.md b/docs/zh/index.md index fa1b1e4..eae2955 100644 --- a/docs/zh/index.md +++ b/docs/zh/index.md @@ -19,11 +19,11 @@ features: - title: 灵活的过滤器 details: 支持按事件类型、仓库、参与者、操作、分支(含 PR)和关键字(支持正则)过滤。支持排除模式。 - title: Cloudflare Workers - details: 运行在 Cloudflare 边缘网络上。通过 Discord REST API 发送消息,并可选地用 Durable Object 维持 Gateway 连接以提供在线状态与斜杠命令。 + details: 运行在 Cloudflare 边缘网络上。通过 Discord REST API 发送消息,并通过 Ed25519 验签的 Interactions Endpoint 支持 `/gh` 命令。 - title: Web UI 与斜杠命令 details: "在内置管理控制台中管理路由、查看发送日志。绑定你的 GitHub 账号,通过 /gh 命令以本人身份评论 issue/PR。" - title: 签名验证 - details: 使用 Web Crypto API 进行 HMAC-SHA256 webhook 签名验证,支持时间安全比较。 + details: 使用 Web Crypto API 进行 HMAC-SHA256 webhook 签名验证与 Ed25519 交互签名验证,支持时间安全比较。 - title: 优雅降级 details: 当 Discord Token 不可用时以 webhook-only 模式运行。提供健康检查端点用于监控。 --- diff --git a/src/__tests__/admin.test.ts b/src/__tests__/admin.test.ts index 0c14df0..df0a58a 100644 --- a/src/__tests__/admin.test.ts +++ b/src/__tests__/admin.test.ts @@ -42,7 +42,6 @@ function createEnv(overrides: Partial = {}): Env { return { GITHUB_WEBHOOK_SECRET: "secret", KV: createMockKV(), - DISCORD_GATEWAY: {} as DurableObjectNamespace, ...overrides, }; } diff --git a/src/__tests__/discord.test.ts b/src/__tests__/discord.test.ts index f924192..d4f368e 100644 --- a/src/__tests__/discord.test.ts +++ b/src/__tests__/discord.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, beforeEach, afterEach } from "bun:test"; import { sendMessage } from "../discord-rest"; -import { dispatchEvent, isGatewayEnabled } from "../discord"; +import { dispatchEvent } from "../discord"; import type { Env, Route } from "../types"; function mockFetch(handler: (url: string, init?: RequestInit) => Response): void { @@ -12,7 +12,6 @@ function createEnv(overrides: Partial = {}): Env { return { GITHUB_WEBHOOK_SECRET: "secret", KV: {} as KVNamespace, - DISCORD_GATEWAY: {} as DurableObjectNamespace, ...overrides, }; } @@ -68,14 +67,6 @@ describe("discord-rest sendMessage", () => { }); }); -describe("isGatewayEnabled", () => { - it("is enabled only when set to true", () => { - expect(isGatewayEnabled(createEnv({ DISCORD_GATEWAY_ENABLED: "true" }))).toBe(true); - expect(isGatewayEnabled(createEnv({ DISCORD_GATEWAY_ENABLED: "false" }))).toBe(false); - expect(isGatewayEnabled(createEnv())).toBe(false); - }); -}); - describe("dispatchEvent fallback routing", () => { function createMockKV(): KVNamespace { const store = new Map(); diff --git a/src/discord-gateway.ts b/src/discord-gateway.ts deleted file mode 100644 index b6c9ad8..0000000 --- a/src/discord-gateway.ts +++ /dev/null @@ -1,806 +0,0 @@ -import { log } from "./log"; -import { sendMessage } from "./discord-rest"; -import { - getOAuthURL, - commentAsUser, - getCommentAsUser, - editCommentAsUser, - deleteCommentAsUser, - mergePullRequestAsUser, - closePullRequestAsUser, -} from "./github-oauth"; -import { getDiscordLink, removeDiscordLink } from "./token-store"; -import type { Env } from "./types"; - -interface SendMessageBody { - channelId: string; - message: unknown; - threadId?: string; -} - -const DISCORD_API = "https://discord.com/api/v10"; -const GATEWAY_URL = "https://gateway.discord.gg/?v=10&encoding=json"; -const BASE_RECONNECT_DELAY = 1000; -const MAX_RECONNECT_DELAY = 60_000; -const ALARM_INTERVAL = 30; - -// Discord interaction protocol constants -const INTERACTION_TYPE = { COMMAND: 2, BUTTON: 3, MODAL_SUBMIT: 5 } as const; -const CALLBACK_TYPE = { MESSAGE: 4, DEFERRED_MESSAGE: 5, MODAL: 9 } as const; -const COMMAND_TYPE = { CHAT_INPUT: 1, MESSAGE: 3 } as const; -const OPTION_TYPE = { SUB_COMMAND: 1, SUB_COMMAND_GROUP: 2, STRING: 3 } as const; -const EPHEMERAL = 64; - -// Right-click (message context-menu) command names → operation. -const MSG_CMD_ADD = "GitHub: 添加评论"; -const MSG_CMD_EDIT = "GitHub: 编辑评论"; -const MSG_CMD_DEL = "GitHub: 删除评论"; - -// Modal custom_id encodings (delimiter '|' never appears in owner/repo). -const MODAL_ADD = "ghc|add|"; // ghc|add|owner|repo|issueNumber -const MODAL_EDIT = "ghc|edit|"; // ghc|edit|owner|repo|commentId - -// PR notification button custom_id encodings. -const BTN_MERGE = "ghpr|merge|"; // ghpr|merge|owner|repo|pullNumber -const BTN_CLOSE = "ghpr|close|"; // ghpr|close|owner|repo|pullNumber - -const APP_COMMANDS = [ - { - name: "gh", - type: COMMAND_TYPE.CHAT_INPUT, - description: "GitHub 集成", - options: [ - { - type: OPTION_TYPE.SUB_COMMAND, - name: "login", - description: "绑定你的 GitHub 账号以用本人身份评论", - }, - { type: OPTION_TYPE.SUB_COMMAND, name: "logout", description: "解绑你的 GitHub 账号" }, - { - type: OPTION_TYPE.SUB_COMMAND_GROUP, - name: "comment", - description: "对 issue/PR 评论进行增删改", - options: [ - { - type: OPTION_TYPE.SUB_COMMAND, - name: "add", - description: "在 issue/PR 下新增评论", - options: [ - { - type: OPTION_TYPE.STRING, - name: "link", - description: "issue/PR 链接", - required: true, - }, - ], - }, - { - type: OPTION_TYPE.SUB_COMMAND, - name: "edit", - description: "编辑一条评论", - options: [ - { - type: OPTION_TYPE.STRING, - name: "link", - description: "评论链接(含 #issuecomment-)", - required: true, - }, - ], - }, - { - type: OPTION_TYPE.SUB_COMMAND, - name: "del", - description: "删除一条评论", - options: [ - { - type: OPTION_TYPE.STRING, - name: "link", - description: "评论链接(含 #issuecomment-)", - required: true, - }, - ], - }, - ], - }, - ], - }, - { name: MSG_CMD_ADD, type: COMMAND_TYPE.MESSAGE }, - { name: MSG_CMD_EDIT, type: COMMAND_TYPE.MESSAGE }, - { name: MSG_CMD_DEL, type: COMMAND_TYPE.MESSAGE }, -]; - -// Comment link (has the comment id); check this BEFORE the plain issue regex. -const GITHUB_COMMENT_RE = - /github\.com\/([^/\s]+)\/([^/\s]+)\/(?:issues|pull)\/\d+#issuecomment-(\d+)/; -const GITHUB_ISSUE_RE = /github\.com\/([^/\s]+)\/([^/\s]+)\/(?:issues|pull)\/(\d+)/; - -export class DiscordGateway { - private state: DurableObjectState; - private env: Env; - private socket: WebSocket | null = null; - private heartbeatTimer: ReturnType | null = null; - private heartbeatInterval: number | null = null; - private lastSequence: number | null = null; - private sessionId: string | null = null; - private token: string | null = null; - private connecting = false; - private reconnectAttempt = 0; - private applicationId: string | null = null; - private registeredGuilds = new Set(); - - constructor(state: DurableObjectState, env: Env) { - this.state = state; - this.env = env; - } - - async fetch(request: Request): Promise { - const body = (await request.json()) as { - action: string; - token?: string; - } & Record; - - switch (body.action) { - case "start": { - this.token = body.token as string; - await this.state.storage.put("token", this.token); - if (this.connecting || this.socket) { - return new Response(JSON.stringify({ ok: true, status: "already_connected" })); - } - await this.connect(); - await this.state.storage.setAlarm(Date.now() + ALARM_INTERVAL * 1000); - return new Response(JSON.stringify({ ok: true })); - } - case "send": { - const { channelId, message, threadId } = body as unknown as SendMessageBody; - const result = await this.postMessage(channelId, message, threadId); - return new Response(JSON.stringify(result)); - } - case "status": { - return new Response( - JSON.stringify({ - connected: this.socket?.readyState === WebSocket.OPEN, - sessionId: this.sessionId, - }), - ); - } - default: - return new Response(JSON.stringify({ error: "Unknown action" }), { status: 400 }); - } - } - - private async connect(): Promise { - if (!this.token) return; - if (this.connecting || this.socket) return; - this.connecting = true; - log.info("Connecting to Discord Gateway"); - - try { - const resp = await fetch(GATEWAY_URL, { - headers: { Upgrade: "websocket" }, - }); - const ws = resp.webSocket; - if (!ws) { - this.connecting = false; - log.error({ status: resp.status }, "Gateway did not return a WebSocket"); - this.scheduleReconnect(); - return; - } - - ws.accept(); - this.socket = ws; - this.connecting = false; - - ws.addEventListener("message", (event) => { - this.handleMessage(event.data as string); - }); - - ws.addEventListener("close", (event) => { - this.socket = null; - this.clearHeartbeat(); - log.warn( - { code: (event as CloseEvent).code, reason: (event as CloseEvent).reason }, - "Gateway disconnected, scheduling reconnect via alarm", - ); - this.scheduleReconnect(); - }); - - ws.addEventListener("error", (event) => { - log.error( - { err: String((event as ErrorEvent).message ?? event) }, - "Gateway WebSocket error", - ); - }); - } catch (err) { - this.connecting = false; - log.error({ err: String(err) }, "Failed to connect to Gateway"); - this.scheduleReconnect(); - } - } - - private scheduleReconnect(): void { - const delay = Math.min(BASE_RECONNECT_DELAY * 2 ** this.reconnectAttempt, MAX_RECONNECT_DELAY); - this.reconnectAttempt++; - this.state.storage.setAlarm(Date.now() + delay); - } - - private handleMessage(data: string): void { - let msg: { op: number; d: unknown; s: number | null; t: string | null }; - try { - msg = JSON.parse(data) as { - op: number; - d: unknown; - s: number | null; - t: string | null; - }; - } catch { - log.warn("Gateway received malformed frame"); - return; - } - - if (msg.s !== null) this.lastSequence = msg.s; - - switch (msg.op) { - case 0: - this.reconnectAttempt = 0; - this.handleDispatch(msg.t!, msg.d); - break; - case 1: - // Heartbeat request from Discord — respond immediately - this.sendHeartbeat(); - break; - case 10: - this.handleHello(msg.d as { heartbeat_interval: number }); - break; - case 11: - break; - case 7: - log.warn("Gateway requested reconnect (op 7)"); - this.reconnect(); - break; - case 9: - log.warn({ resumable: msg.d }, "Gateway Invalid Session (op 9)"); - this.lastSequence = null; - this.sessionId = null; - // Discord asks to wait 1-5s before a fresh identify - setTimeout(() => this.identify(), 2000 + Math.floor(Math.random() * 3000)); - break; - } - } - - private handleHello(d: { heartbeat_interval: number }): void { - log.info({ heartbeatInterval: d.heartbeat_interval }, "Gateway HELLO received"); - this.heartbeatInterval = d.heartbeat_interval; - this.heartbeat(); - this.identify(); - } - - private heartbeat(): void { - this.clearHeartbeat(); - this.sendHeartbeat(); - if (this.heartbeatInterval) { - this.heartbeatTimer = setTimeout(() => this.heartbeat(), this.heartbeatInterval); - } - } - - private sendHeartbeat(): void { - if (!this.socket || this.socket.readyState !== WebSocket.OPEN) return; - this.socket.send(JSON.stringify({ op: 1, d: this.lastSequence })); - } - - private clearHeartbeat(): void { - if (this.heartbeatTimer) { - clearTimeout(this.heartbeatTimer); - this.heartbeatTimer = null; - } - } - - private identify(): void { - if (!this.socket || this.socket.readyState !== WebSocket.OPEN || !this.token) { - log.warn( - { hasSocket: !!this.socket, readyState: this.socket?.readyState ?? null }, - "Cannot identify", - ); - return; - } - log.info("Sending IDENTIFY"); - this.socket.send( - JSON.stringify({ - op: 2, - d: { - token: this.token, - // GUILDS only — interactions are delivered regardless of intents, - // and GUILDS lets us receive GUILD_CREATE to register slash commands. - intents: 1 << 0, - properties: { - os: "linux", - browser: "webhooker", - device: "webhooker", - }, - }, - }), - ); - } - - private handleDispatch(event: string, data: unknown): void { - const d = data as Record; - switch (event) { - case "READY": - this.sessionId = d.session_id as string; - this.applicationId = (d.application as { id?: string })?.id ?? this.applicationId; - log.info( - { user: (d.user as { username?: string })?.username, appId: this.applicationId }, - "Gateway READY", - ); - break; - case "GUILD_CREATE": { - const guildId = d.id as string | undefined; - if (guildId) { - this.registerGuildCommands(guildId).catch((err) => - log.error({ err: String(err), guildId }, "Failed to register guild commands"), - ); - } - break; - } - case "INTERACTION_CREATE": - this.handleInteraction(d).catch((err) => - log.error({ err: String(err) }, "Interaction handler failed"), - ); - break; - } - } - - private botToken(): string { - return this.token ?? this.env.DISCORD_TOKEN ?? ""; - } - - /** Register the slash + message commands for a guild (instant availability). */ - private async registerGuildCommands(guildId: string): Promise { - if (!this.applicationId || this.registeredGuilds.has(guildId)) return; - const res = await fetch( - `${DISCORD_API}/applications/${this.applicationId}/guilds/${guildId}/commands`, - { - method: "PUT", - headers: { - Authorization: `Bot ${this.botToken()}`, - "Content-Type": "application/json", - }, - body: JSON.stringify(APP_COMMANDS), - }, - ); - if (res.ok) { - this.registeredGuilds.add(guildId); - log.info({ guildId }, "Registered guild application commands"); - } else { - const err = await res.text(); - log.warn({ guildId, status: res.status, err }, "Command registration failed"); - } - } - - private async handleInteraction(d: Record): Promise { - const interaction = d as { - id: string; - token: string; - type: number; - guild_id?: string; - channel_id?: string; - member?: { user?: { id?: string } }; - user?: { id?: string }; - data?: Record; - }; - const userId = interaction.member?.user?.id ?? interaction.user?.id ?? null; - const id = interaction.id; - const token = interaction.token; - - if (interaction.type === INTERACTION_TYPE.BUTTON) { - const data = interaction.data as { - custom_id?: string; - message?: { id?: string }; - }; - return this.handleButton( - id, - token, - userId, - interaction.channel_id, - data.message?.id, - data.custom_id, - ); - } - - if (interaction.type === INTERACTION_TYPE.COMMAND) { - const data = interaction.data as { - name?: string; - type?: number; - target_id?: string; - options?: Array<{ - name: string; - options?: Array<{ - name: string; - value?: string; - options?: Array<{ name: string; value?: string }>; - }>; - }>; - resolved?: { - messages?: Record; content?: string }>; - }; - }; - - // Right-click (message context-menu) commands. - if (data.type === COMMAND_TYPE.MESSAGE) { - const op = - data.name === MSG_CMD_ADD - ? "add" - : data.name === MSG_CMD_EDIT - ? "edit" - : data.name === MSG_CMD_DEL - ? "del" - : null; - if (!op) return; - const target = data.target_id ? data.resolved?.messages?.[data.target_id] : undefined; - const source = target?.embeds?.[0]?.url ?? target?.content ?? ""; - return this.commentOp(id, token, userId, op, source); - } - - // Slash command /gh ... - if (data.name === "gh" && data.type === COMMAND_TYPE.CHAT_INPUT) { - const top = data.options?.[0]; - if (top?.name === "login") return this.cmdLogin(id, token, userId); - if (top?.name === "logout") return this.cmdLogout(id, token, userId); - if (top?.name === "comment") { - const sub = top.options?.[0]; - const op = - sub?.name === "add" - ? "add" - : sub?.name === "edit" - ? "edit" - : sub?.name === "del" - ? "del" - : null; - if (!op) return; - const link = sub?.options?.find((o) => o.name === "link")?.value ?? ""; - return this.commentOp(id, token, userId, op, link); - } - return; - } - return; - } - - if (interaction.type === INTERACTION_TYPE.MODAL_SUBMIT) { - return this.modalSubmit(id, token, userId, interaction.data); - } - } - - /** Respond to an interaction with an ephemeral text message. */ - private async respond(id: string, token: string, content: string): Promise { - await this.interactionCallback(id, token, { - type: CALLBACK_TYPE.MESSAGE, - data: { content, flags: EPHEMERAL }, - }); - } - - private async interactionCallback(id: string, token: string, body: unknown): Promise { - const res = await fetch(`${DISCORD_API}/interactions/${id}/${token}/callback`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(body), - }); - if (!res.ok) { - const err = await res.text(); - log.warn({ status: res.status, err }, "Interaction callback failed"); - } - } - - /** Replace the deferred (ephemeral) response body with the final result. */ - private async updateOriginal(id: string, token: string, content: string): Promise { - const res = await fetch(`${DISCORD_API}/interactions/${id}/${token}/messages/@original`, { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ content }), - }); - if (!res.ok) { - const err = await res.text(); - log.warn({ status: res.status, err }, "Failed to update interaction response"); - } - } - - /** - * PR notification buttons: merge or close the PR as the clicker's linked - * GitHub account. The clicker must have run `/gh login` first. - */ - private async handleButton( - id: string, - token: string, - userId: string | null, - channelId: string | undefined, - messageId: string | undefined, - customId: string | undefined, - ): Promise { - if (!userId) return this.respond(id, token, "无法识别你的 Discord 账号。"); - const githubUserId = await getDiscordLink(this.env.KV, userId); - if (!githubUserId) { - return this.respond(id, token, "你还没有绑定 GitHub 账号,请先使用 `/gh login`。"); - } - - let op: "merge" | "close"; - let rest: string; - if (customId?.startsWith(BTN_MERGE)) { - op = "merge"; - rest = customId.slice(BTN_MERGE.length); - } else if (customId?.startsWith(BTN_CLOSE)) { - op = "close"; - rest = customId.slice(BTN_CLOSE.length); - } else { - return; - } - - const [owner, repo, number] = rest.split("|"); - if (!owner || !repo || !number) return; - - // Acknowledge first (deferred, ephemeral) so the clicker sees a spinner - // while the GitHub API call runs. - await this.interactionCallback(id, token, { - type: CALLBACK_TYPE.DEFERRED_MESSAGE, - data: { flags: EPHEMERAL }, - }); - - try { - if (op === "merge") { - await mergePullRequestAsUser(this.env.KV, githubUserId, owner, repo, Number(number)); - } else { - await closePullRequestAsUser(this.env.KV, githubUserId, owner, repo, Number(number)); - } - // Remove the buttons from the notification so nobody double-clicks. - if (channelId && messageId) { - await fetch(`${DISCORD_API}/channels/${channelId}/messages/${messageId}`, { - method: "PATCH", - headers: { - Authorization: `Bot ${this.botToken()}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ components: [] }), - }).catch((err) => log.warn({ err: String(err) }, "Failed to strip PR buttons")); - } - const label = op === "merge" ? "合并" : "关闭"; - await this.updateOriginal(id, token, `✅ 已${label} PR ${owner}/${repo}#${number}`); - } catch (err) { - await this.updateOriginal(id, token, this.errText(err)); - } - } - - private async cmdLogin(id: string, token: string, userId: string | null): Promise { - if (!userId) return this.respond(id, token, "无法识别你的 Discord 账号。"); - const clientId = this.env.GITHUB_CLIENT_ID; - if (!clientId) - return this.respond(id, token, "服务器未配置 GitHub OAuth(GITHUB_CLIENT_ID)。"); - - const state = crypto.randomUUID().replace(/-/g, ""); - await this.env.KV.put( - `state:${state}`, - JSON.stringify({ redirectTo: "/", discordUserId: userId, expiresAt: Date.now() + 600_000 }), - { expirationTtl: 600 }, - ); - const url = getOAuthURL(clientId, state); - await this.respond( - id, - token, - `点击链接授权 GitHub,即可用**本人身份**评论(仅你可见,10 分钟内有效):\n${url}`, - ); - } - - private async cmdLogout(id: string, token: string, userId: string | null): Promise { - if (!userId) return this.respond(id, token, "无法识别你的 Discord 账号。"); - await removeDiscordLink(this.env.KV, userId); - await this.respond(id, token, "已解绑你的 GitHub 账号。"); - } - - /** Map a GitHub op error code to a user-facing (Chinese) message. */ - private errText(err: unknown): string { - const t = err instanceof Error ? err.message : String(err); - if (t === "GITHUB_TOKEN_EXPIRED") - return "GitHub 授权已过期或无效,请重新使用 `/gh login` 绑定。"; - if (t === "GITHUB_FORBIDDEN") return "GitHub 拒绝了此操作:你的账号没有权限修改/删除这条评论。"; - if (t === "GITHUB_NOT_FOUND") return "找不到目标(可能评论已被删除或仓库不可访问)。"; - return `操作失败:${t}`; - } - - /** - * Unified entry for add/edit/del, from either a slash command (source = link - * option) or a right-click message command (source = notification embed url). - */ - private async commentOp( - id: string, - token: string, - userId: string | null, - op: "add" | "edit" | "del", - source: string, - ): Promise { - if (!userId) return this.respond(id, token, "无法识别你的 Discord 账号。"); - const githubUserId = await getDiscordLink(this.env.KV, userId); - if (!githubUserId) { - return this.respond(id, token, "你还没有绑定 GitHub 账号,请先使用 `/gh login`。"); - } - - if (op === "add") { - const m = source.match(GITHUB_ISSUE_RE); - if (!m) - return this.respond( - id, - token, - "找不到 issue / PR 链接(右键 issue/PR 通知,或用 link 传入链接)。", - ); - return this.openCommentModal( - id, - token, - `${MODAL_ADD}${m[1]}|${m[2]}|${m[3]}`, - `评论 ${m[1]}/${m[2]}#${m[3]}`, - ); - } - - // edit / del both need a specific comment id. - const m = source.match(GITHUB_COMMENT_RE); - if (!m) { - return this.respond( - id, - token, - "找不到评论链接(需含 `#issuecomment-...`,请右键某条评论通知,或粘贴评论链接)。", - ); - } - const [, owner, repo, commentId] = m; - - if (op === "del") { - try { - await deleteCommentAsUser(this.env.KV, githubUserId, owner!, repo!, Number(commentId)); - return this.respond(id, token, `已删除评论 ${owner}/${repo}#issuecomment-${commentId}。`); - } catch (err) { - return this.respond(id, token, this.errText(err)); - } - } - - // edit: fetch current body to prefill the modal. - let prefill = ""; - try { - const { body } = await getCommentAsUser( - this.env.KV, - githubUserId, - owner!, - repo!, - Number(commentId), - ); - prefill = body; - } catch (err) { - return this.respond(id, token, this.errText(err)); - } - return this.openCommentModal( - id, - token, - `${MODAL_EDIT}${owner}|${repo}|${commentId}`, - `编辑评论 #${commentId}`, - prefill, - ); - } - - /** Open a modal to collect/edit comment body. */ - private async openCommentModal( - id: string, - token: string, - customId: string, - title: string, - prefill = "", - ): Promise { - await this.interactionCallback(id, token, { - type: CALLBACK_TYPE.MODAL, - data: { - custom_id: customId, - title: title.slice(0, 45), - components: [ - { - type: 1, - components: [ - { - type: 4, - custom_id: "body", - label: "评论内容", - style: 2, - required: true, - max_length: 2000, - value: prefill.slice(0, 2000) || undefined, - }, - ], - }, - ], - }, - }); - } - - private async modalSubmit( - id: string, - token: string, - userId: string | null, - data: unknown, - ): Promise { - const d = data as { - custom_id?: string; - components?: Array<{ components?: Array<{ custom_id?: string; value?: string }> }>; - }; - const customId = d.custom_id; - if (!userId || !customId) return; - - const body = d.components?.[0]?.components?.find((c) => c.custom_id === "body")?.value?.trim(); - if (!body) return this.respond(id, token, "评论内容不能为空。"); - - const githubUserId = await getDiscordLink(this.env.KV, userId); - if (!githubUserId) { - return this.respond(id, token, "你还没有绑定 GitHub 账号,请先使用 `/gh login`。"); - } - - // ghc|add|owner|repo|issueNumber - if (customId.startsWith(MODAL_ADD)) { - const [owner, repo, number] = customId.slice(MODAL_ADD.length).split("|"); - if (!owner || !repo || !number) - return this.respond(id, token, "内部错误:无法解析目标 issue。"); - try { - const { htmlUrl, login } = await commentAsUser( - this.env.KV, - githubUserId, - owner, - repo, - Number(number), - body, - ); - return this.respond(id, token, `已以 **@${login}** 身份评论:${htmlUrl}`); - } catch (err) { - return this.respond(id, token, this.errText(err)); - } - } - - // ghc|edit|owner|repo|commentId - if (customId.startsWith(MODAL_EDIT)) { - const [owner, repo, commentId] = customId.slice(MODAL_EDIT.length).split("|"); - if (!owner || !repo || !commentId) - return this.respond(id, token, "内部错误:无法解析目标评论。"); - try { - const { htmlUrl } = await editCommentAsUser( - this.env.KV, - githubUserId, - owner, - repo, - Number(commentId), - body, - ); - return this.respond(id, token, `已更新评论:${htmlUrl}`); - } catch (err) { - return this.respond(id, token, this.errText(err)); - } - } - } - - private reconnect(): void { - this.clearHeartbeat(); - if (this.socket) { - this.socket.close(); - this.socket = null; - } - this.connecting = false; - this.scheduleReconnect(); - } - - private async postMessage( - channelId: string, - message: unknown, - threadId?: string, - ): Promise<{ ok: boolean; error?: string }> { - const token = this.token ?? this.env.DISCORD_TOKEN; - if (!token) return { ok: false, error: "Discord token is not configured" }; - return sendMessage(token, channelId, message, threadId); - } - - async alarm(): Promise { - if (!this.token) { - this.token = (await this.state.storage.get("token")) ?? this.env.DISCORD_TOKEN ?? ""; - } - if (!this.socket || this.socket.readyState !== WebSocket.OPEN) { - log.info("Alarm: restarting Gateway connection"); - await this.connect(); - } - await this.state.storage.setAlarm(Date.now() + ALARM_INTERVAL * 1000); - } -} diff --git a/src/discord-interactions.ts b/src/discord-interactions.ts new file mode 100644 index 0000000..218f267 --- /dev/null +++ b/src/discord-interactions.ts @@ -0,0 +1,707 @@ +import { log } from "./log"; +import { + getOAuthURL, + commentAsUser, + getCommentAsUser, + editCommentAsUser, + deleteCommentAsUser, + mergePullRequestAsUser, + closePullRequestAsUser, +} from "./github-oauth"; +import { getDiscordLink, removeDiscordLink } from "./token-store"; +import type { Env } from "./types"; + +const DISCORD_API = "https://discord.com/api/v10"; + +// Discord interaction protocol constants +const INTERACTION_TYPE = { PING: 1, COMMAND: 2, BUTTON: 3, MODAL_SUBMIT: 5 } as const; +const CALLBACK_TYPE = { PONG: 1, MESSAGE: 4, DEFERRED_MESSAGE: 5, MODAL: 9 } as const; +const COMMAND_TYPE = { CHAT_INPUT: 1, MESSAGE: 3 } as const; +const OPTION_TYPE = { SUB_COMMAND: 1, SUB_COMMAND_GROUP: 2, STRING: 3 } as const; +const EPHEMERAL = 64; + +// Right-click (message context-menu) command names → operation. +const MSG_CMD_ADD = "GitHub: 添加评论"; +const MSG_CMD_EDIT = "GitHub: 编辑评论"; +const MSG_CMD_DEL = "GitHub: 删除评论"; + +// Modal custom_id encodings (delimiter '|' never appears in owner/repo). +const MODAL_ADD = "ghc|add|"; // ghc|add|owner|repo|issueNumber +const MODAL_EDIT = "ghc|edit|"; // ghc|edit|owner|repo|commentId + +// PR notification button custom_id encodings. +const BTN_MERGE = "ghpr|merge|"; // ghpr|merge|owner|repo|pullNumber +const BTN_CLOSE = "ghpr|close|"; // ghpr|close|owner|repo|pullNumber + +const APP_COMMANDS = [ + { + name: "gh", + type: COMMAND_TYPE.CHAT_INPUT, + description: "GitHub 集成", + options: [ + { + type: OPTION_TYPE.SUB_COMMAND, + name: "login", + description: "绑定你的 GitHub 账号以用本人身份评论", + }, + { type: OPTION_TYPE.SUB_COMMAND, name: "logout", description: "解绑你的 GitHub 账号" }, + { + type: OPTION_TYPE.SUB_COMMAND_GROUP, + name: "comment", + description: "对 issue/PR 评论进行增删改", + options: [ + { + type: OPTION_TYPE.SUB_COMMAND, + name: "add", + description: "在 issue/PR 下新增评论", + options: [ + { + type: OPTION_TYPE.STRING, + name: "link", + description: "issue/PR 链接", + required: true, + }, + ], + }, + { + type: OPTION_TYPE.SUB_COMMAND, + name: "edit", + description: "编辑一条评论", + options: [ + { + type: OPTION_TYPE.STRING, + name: "link", + description: "评论链接(含 #issuecomment-)", + required: true, + }, + ], + }, + { + type: OPTION_TYPE.SUB_COMMAND, + name: "del", + description: "删除一条评论", + options: [ + { + type: OPTION_TYPE.STRING, + name: "link", + description: "评论链接(含 #issuecomment-)", + required: true, + }, + ], + }, + ], + }, + ], + }, + { name: MSG_CMD_ADD, type: COMMAND_TYPE.MESSAGE }, + { name: MSG_CMD_EDIT, type: COMMAND_TYPE.MESSAGE }, + { name: MSG_CMD_DEL, type: COMMAND_TYPE.MESSAGE }, +]; + +// Comment link (has the comment id); check this BEFORE the plain issue regex. +const GITHUB_COMMENT_RE = + /github\.com\/([^/\s]+)\/([^/\s]+)\/(?:issues|pull)\/\d+#issuecomment-(\d+)/; +const GITHUB_ISSUE_RE = /github\.com\/([^/\s]+)\/([^/\s]+)\/(?:issues|pull)\/(\d+)/; + +interface Interaction { + id: string; + token: string; + type: number; + channel_id?: string; + member?: { user?: { id?: string } }; + user?: { id?: string }; + data?: Record; +} + +const MAX_BODY_SIZE = 1024 * 1024; +const TIMESTAMP_TOLERANCE_SECONDS = 180; + +function hexToBytes(hex: string): ArrayBuffer { + const buffer = new ArrayBuffer(hex.length / 2); + const bytes = new Uint8Array(buffer); + for (let i = 0; i < bytes.length; i++) { + bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16); + } + return buffer; +} + +/** + * Verify an interaction's Ed25519 signature (X-Signature-Ed25519 over + * timestamp + raw body, signed by the Discord application public key). + */ +export async function verifyDiscordSignature( + publicKey: string, + timestamp: string, + signatureHex: string, + rawBody: string, +): Promise { + try { + const key = await crypto.subtle.importKey( + "raw", + hexToBytes(publicKey), + { name: "Ed25519" }, + false, + ["verify"], + ); + return await crypto.subtle.verify( + { name: "Ed25519" }, + key, + hexToBytes(signatureHex), + new TextEncoder().encode(timestamp + rawBody), + ); + } catch (err) { + log.warn({ err: String(err) }, "Failed to verify Discord signature"); + return false; + } +} + +/** Handle a POST to the Discord Interactions Endpoint. */ +export async function handleInteractionRequest(request: Request, env: Env): Promise { + const contentLength = Number(request.headers.get("content-length") ?? 0); + if (contentLength > MAX_BODY_SIZE) { + return new Response("Request too large", { status: 413 }); + } + + const signature = request.headers.get("X-Signature-Ed25519"); + const timestamp = request.headers.get("X-Signature-Timestamp"); + if (!signature || !timestamp || !env.DISCORD_PUBLIC_KEY) { + log.warn({ hasSig: !!signature, hasTs: !!timestamp, hasKey: !!env.DISCORD_PUBLIC_KEY }, "Discord interaction missing signature"); + return new Response("Invalid signature", { status: 401 }); + } + if (Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp)) > TIMESTAMP_TOLERANCE_SECONDS) { + return new Response("Invalid signature", { status: 401 }); + } + + const rawBody = await request.text(); + if (rawBody.length > MAX_BODY_SIZE) { + return new Response("Request too large", { status: 413 }); + } + + const valid = await verifyDiscordSignature(env.DISCORD_PUBLIC_KEY, timestamp, signature, rawBody); + if (!valid) { + return new Response("Invalid signature", { status: 401 }); + } + + let interaction: Interaction; + try { + interaction = JSON.parse(rawBody) as Interaction; + } catch { + return new Response("Invalid JSON", { status: 400 }); + } + + // Discord's connection check. + if (interaction.type === INTERACTION_TYPE.PING) { + return new Response(JSON.stringify({ type: CALLBACK_TYPE.PONG }), { + headers: { "Content-Type": "application/json" }, + }); + } + + // Handle the interaction via the callback webhook; respond 202 with no body + // as required for interactions received over the HTTP endpoint. + await handleInteraction(env, interaction).catch((err) => + log.error({ err: String(err) }, "Interaction handler failed"), + ); + return new Response(null, { status: 202 }); +} + +async function handleInteraction(env: Env, interaction: Interaction): Promise { + const userId = interaction.member?.user?.id ?? interaction.user?.id ?? null; + const id = interaction.id; + const token = interaction.token; + + if (interaction.type === INTERACTION_TYPE.BUTTON) { + const data = interaction.data as { custom_id?: string; message?: { id?: string } }; + return handleButton( + env, + id, + token, + userId, + interaction.channel_id, + data.message?.id, + data.custom_id, + ); + } + + if (interaction.type === INTERACTION_TYPE.COMMAND) { + const data = interaction.data as { + name?: string; + type?: number; + target_id?: string; + options?: Array<{ + name: string; + options?: Array<{ + name: string; + value?: string; + options?: Array<{ name: string; value?: string }>; + }>; + }>; + resolved?: { + messages?: Record; content?: string }>; + }; + }; + + // Right-click (message context-menu) commands. + if (data.type === COMMAND_TYPE.MESSAGE) { + const op = + data.name === MSG_CMD_ADD + ? "add" + : data.name === MSG_CMD_EDIT + ? "edit" + : data.name === MSG_CMD_DEL + ? "del" + : null; + if (!op) return; + const target = data.target_id ? data.resolved?.messages?.[data.target_id] : undefined; + const source = target?.embeds?.[0]?.url ?? target?.content ?? ""; + return commentOp(env, id, token, userId, op, source); + } + + // Slash command /gh ... + if (data.name === "gh" && data.type === COMMAND_TYPE.CHAT_INPUT) { + const top = data.options?.[0]; + if (top?.name === "login") return cmdLogin(env, id, token, userId); + if (top?.name === "logout") return cmdLogout(env, id, token, userId); + if (top?.name === "comment") { + const sub = top.options?.[0]; + const op = + sub?.name === "add" + ? "add" + : sub?.name === "edit" + ? "edit" + : sub?.name === "del" + ? "del" + : null; + if (!op) return; + const link = sub?.options?.find((o) => o.name === "link")?.value ?? ""; + return commentOp(env, id, token, userId, op, link); + } + return; + } + return; + } + + if (interaction.type === INTERACTION_TYPE.MODAL_SUBMIT) { + return modalSubmit(env, id, token, userId, interaction.data); + } +} + +/** Respond to an interaction with an ephemeral text message. */ +async function respond(env: Env, id: string, token: string, content: string): Promise { + await interactionCallback(env, id, token, { + type: CALLBACK_TYPE.MESSAGE, + data: { content, flags: EPHEMERAL }, + }); +} + +async function interactionCallback(env: Env, id: string, token: string, body: unknown): Promise { + const res = await fetch(`${DISCORD_API}/interactions/${id}/${token}/callback`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + if (!res.ok) { + const err = await res.text(); + log.warn({ status: res.status, err }, "Interaction callback failed"); + } +} + +/** Replace the deferred (ephemeral) response body with the final result. */ +async function updateOriginal(env: Env, id: string, token: string, content: string): Promise { + const res = await fetch(`${DISCORD_API}/interactions/${id}/${token}/messages/@original`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ content }), + }); + if (!res.ok) { + const err = await res.text(); + log.warn({ status: res.status, err }, "Failed to update interaction response"); + } +} + +/** + * PR notification buttons: merge or close the PR as the clicker's linked + * GitHub account. The clicker must have run `/gh login` first. + */ +async function handleButton( + env: Env, + id: string, + token: string, + userId: string | null, + channelId: string | undefined, + messageId: string | undefined, + customId: string | undefined, +): Promise { + if (!userId) return respond(env, id, token, "无法识别你的 Discord 账号。"); + const githubUserId = await getDiscordLink(env.KV, userId); + if (!githubUserId) { + return respond(env, id, token, "你还没有绑定 GitHub 账号,请先使用 `/gh login`。"); + } + + let op: "merge" | "close"; + let rest: string; + if (customId?.startsWith(BTN_MERGE)) { + op = "merge"; + rest = customId.slice(BTN_MERGE.length); + } else if (customId?.startsWith(BTN_CLOSE)) { + op = "close"; + rest = customId.slice(BTN_CLOSE.length); + } else { + return; + } + + const [owner, repo, number] = rest.split("|"); + if (!owner || !repo || !number) return; + + // Acknowledge first (deferred, ephemeral) so the clicker sees a spinner + // while the GitHub API call runs. + await interactionCallback(env, id, token, { + type: CALLBACK_TYPE.DEFERRED_MESSAGE, + data: { flags: EPHEMERAL }, + }); + + try { + if (op === "merge") { + await mergePullRequestAsUser(env.KV, githubUserId, owner, repo, Number(number)); + } else { + await closePullRequestAsUser(env.KV, githubUserId, owner, repo, Number(number)); + } + // Remove the buttons from the notification so nobody double-clicks. + if (channelId && messageId) { + await fetch(`${DISCORD_API}/channels/${channelId}/messages/${messageId}`, { + method: "PATCH", + headers: { + Authorization: `Bot ${env.DISCORD_TOKEN ?? ""}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ components: [] }), + }).catch((err) => log.warn({ err: String(err) }, "Failed to strip PR buttons")); + } + const label = op === "merge" ? "合并" : "关闭"; + await updateOriginal(env, id, token, `✅ 已${label} PR ${owner}/${repo}#${number}`); + } catch (err) { + await updateOriginal(env, id, token, errText(err)); + } +} + +async function cmdLogin(env: Env, id: string, token: string, userId: string | null): Promise { + if (!userId) return respond(env, id, token, "无法识别你的 Discord 账号。"); + const clientId = env.GITHUB_CLIENT_ID; + if (!clientId) + return respond(env, id, token, "服务器未配置 GitHub OAuth(GITHUB_CLIENT_ID)。"); + + const state = crypto.randomUUID().replace(/-/g, ""); + await env.KV.put( + `state:${state}`, + JSON.stringify({ redirectTo: "/", discordUserId: userId, expiresAt: Date.now() + 600_000 }), + { expirationTtl: 600 }, + ); + const url = getOAuthURL(clientId, state); + await respond( + env, + id, + token, + `点击链接授权 GitHub,即可用**本人身份**评论(仅你可见,10 分钟内有效):\n${url}`, + ); +} + +async function cmdLogout(env: Env, id: string, token: string, userId: string | null): Promise { + if (!userId) return respond(env, id, token, "无法识别你的 Discord 账号。"); + await removeDiscordLink(env.KV, userId); + await respond(env, id, token, "已解绑你的 GitHub 账号。"); +} + +/** Map a GitHub op error code to a user-facing (Chinese) message. */ +function errText(err: unknown): string { + const t = err instanceof Error ? err.message : String(err); + if (t === "GITHUB_TOKEN_EXPIRED") + return "GitHub 授权已过期或无效,请重新使用 `/gh login` 绑定。"; + if (t === "GITHUB_FORBIDDEN") return "GitHub 拒绝了此操作:你的账号没有权限修改/删除这条评论。"; + if (t === "GITHUB_NOT_FOUND") return "找不到目标(可能评论已被删除或仓库不可访问)。"; + return `操作失败:${t}`; +} + +/** + * Unified entry for add/edit/del, from either a slash command (source = link + * option) or a right-click message command (source = notification embed url). + */ +async function commentOp( + env: Env, + id: string, + token: string, + userId: string | null, + op: "add" | "edit" | "del", + source: string, +): Promise { + if (!userId) return respond(env, id, token, "无法识别你的 Discord 账号。"); + const githubUserId = await getDiscordLink(env.KV, userId); + if (!githubUserId) { + return respond(env, id, token, "你还没有绑定 GitHub 账号,请先使用 `/gh login`。"); + } + + if (op === "add") { + const m = source.match(GITHUB_ISSUE_RE); + if (!m) + return respond( + env, + id, + token, + "找不到 issue / PR 链接(右键 issue/PR 通知,或用 link 传入链接)。", + ); + return openCommentModal( + env, + id, + token, + `${MODAL_ADD}${m[1]}|${m[2]}|${m[3]}`, + `评论 ${m[1]}/${m[2]}#${m[3]}`, + ); + } + + // edit / del both need a specific comment id. + const m = source.match(GITHUB_COMMENT_RE); + if (!m) { + return respond( + env, + id, + token, + "找不到评论链接(需含 `#issuecomment-...`,请右键某条评论通知,或粘贴评论链接)。", + ); + } + const [, owner, repo, commentId] = m; + + if (op === "del") { + try { + await deleteCommentAsUser(env.KV, githubUserId, owner!, repo!, Number(commentId)); + return respond(env, id, token, `已删除评论 ${owner}/${repo}#issuecomment-${commentId}。`); + } catch (err) { + return respond(env, id, token, errText(err)); + } + } + + // edit: fetch current body to prefill the modal. + let prefill = ""; + try { + const { body } = await getCommentAsUser( + env.KV, + githubUserId, + owner!, + repo!, + Number(commentId), + ); + prefill = body; + } catch (err) { + return respond(env, id, token, errText(err)); + } + return openCommentModal( + env, + id, + token, + `${MODAL_EDIT}${owner}|${repo}|${commentId}`, + `编辑评论 #${commentId}`, + prefill, + ); +} + +/** Open a modal to collect/edit comment body. */ +async function openCommentModal( + env: Env, + id: string, + token: string, + customId: string, + title: string, + prefill = "", +): Promise { + await interactionCallback(env, id, token, { + type: CALLBACK_TYPE.MODAL, + data: { + custom_id: customId, + title: title.slice(0, 45), + components: [ + { + type: 1, + components: [ + { + type: 4, + custom_id: "body", + label: "评论内容", + style: 2, + required: true, + max_length: 2000, + value: prefill.slice(0, 2000) || undefined, + }, + ], + }, + ], + }, + }); +} + +async function modalSubmit( + env: Env, + id: string, + token: string, + userId: string | null, + data: unknown, +): Promise { + const d = data as { + custom_id?: string; + components?: Array<{ components?: Array<{ custom_id?: string; value?: string }> }>; + }; + const customId = d.custom_id; + if (!userId || !customId) return; + + const body = d.components?.[0]?.components?.find((c) => c.custom_id === "body")?.value?.trim(); + if (!body) return respond(env, id, token, "评论内容不能为空。"); + + const githubUserId = await getDiscordLink(env.KV, userId); + if (!githubUserId) { + return respond(env, id, token, "你还没有绑定 GitHub 账号,请先使用 `/gh login`。"); + } + + // ghc|add|owner|repo|issueNumber + if (customId.startsWith(MODAL_ADD)) { + const [owner, repo, number] = customId.slice(MODAL_ADD.length).split("|"); + if (!owner || !repo || !number) + return respond(env, id, token, "内部错误:无法解析目标 issue。"); + try { + const { htmlUrl, login } = await commentAsUser( + env.KV, + githubUserId, + owner, + repo, + Number(number), + body, + ); + return respond(env, id, token, `已以 **@${login}** 身份评论:${htmlUrl}`); + } catch (err) { + return respond(env, id, token, errText(err)); + } + } + + // ghc|edit|owner|repo|commentId + if (customId.startsWith(MODAL_EDIT)) { + const [owner, repo, commentId] = customId.slice(MODAL_EDIT.length).split("|"); + if (!owner || !repo || !commentId) + return respond(env, id, token, "内部错误:无法解析目标评论。"); + try { + const { htmlUrl } = await editCommentAsUser( + env.KV, + githubUserId, + owner, + repo, + Number(commentId), + body, + ); + return respond(env, id, token, `已更新评论:${htmlUrl}`); + } catch (err) { + return respond(env, id, token, errText(err)); + } + } +} + +/** + * Resolve the Discord application id: env var → KV cache → Discord API + * (then cached in KV for later runs). + */ +export async function getApplicationId(env: Env): Promise { + if (env.DISCORD_APPLICATION_ID) return env.DISCORD_APPLICATION_ID; + try { + const cached = await env.KV.get("config:discord-app-id"); + if (cached) return cached; + } catch { + // fall through to the API + } + const token = env.DISCORD_TOKEN ?? ""; + if (!token) return null; + const res = await fetch(`${DISCORD_API}/oauth2/applications/@me`, { + headers: { Authorization: `Bot ${token}` }, + }); + if (!res.ok) { + log.warn({ status: res.status }, "Failed to fetch Discord application id"); + return null; + } + const app = (await res.json()) as { id?: string }; + if (app.id) { + try { + await env.KV.put("config:discord-app-id", app.id); + } catch { + // cache is best-effort + } + return app.id; + } + return null; +} + +/** Register commands globally (~1h propagation); dedup for a day. */ +export async function registerGlobalCommands(env: Env): Promise { + const token = env.DISCORD_TOKEN ?? ""; + if (!token) return; + try { + if (await env.KV.get("cmd:registered:global")) return; + } catch { + // fall through and register + } + const appId = await getApplicationId(env); + if (!appId) return; + const res = await fetch(`${DISCORD_API}/applications/${appId}/commands`, { + method: "PUT", + headers: { Authorization: `Bot ${token}`, "Content-Type": "application/json" }, + body: JSON.stringify(APP_COMMANDS), + }); + if (res.ok) { + try { + await env.KV.put("cmd:registered:global", "1", { expirationTtl: 86400 }); + } catch { + // best-effort + } + log.info("Registered global application commands"); + } else { + const err = await res.text(); + log.warn({ status: res.status, err }, "Global command registration failed"); + } +} + +/** Register commands per guild for instant availability (new guilds only). */ +export async function syncGuildCommands(env: Env): Promise { + const token = env.DISCORD_TOKEN ?? ""; + if (!token) return; + const appId = await getApplicationId(env); + if (!appId) return; + const res = await fetch(`${DISCORD_API}/users/@me/guilds`, { + headers: { Authorization: `Bot ${token}` }, + }); + if (!res.ok) { + const err = await res.text(); + log.warn({ status: res.status, err }, "Failed to list guilds"); + return; + } + const guilds = (await res.json()) as Array<{ id: string }>; + for (const guild of guilds) { + try { + if (await env.KV.get(`cmd:guild:${guild.id}`)) continue; + const r = await fetch( + `${DISCORD_API}/applications/${appId}/guilds/${guild.id}/commands`, + { + method: "PUT", + headers: { Authorization: `Bot ${token}`, "Content-Type": "application/json" }, + body: JSON.stringify(APP_COMMANDS), + }, + ); + if (r.ok) { + await env.KV.put(`cmd:guild:${guild.id}`, "1"); + log.info({ guildId: guild.id }, "Registered guild application commands"); + } else { + const err = await r.text(); + log.warn({ guildId: guild.id, status: r.status, err }, "Command registration failed"); + } + } catch (err) { + log.warn({ guildId: guild.id, err: String(err) }, "Command registration failed"); + } + } +} + +/** Entry point for the scheduled (cron) command sync. */ +export async function syncCommands(env: Env): Promise { + if (!env.DISCORD_TOKEN) return; + await registerGlobalCommands(env); + await syncGuildCommands(env); +} diff --git a/src/discord.ts b/src/discord.ts index 4f2b6d5..a14bc56 100644 --- a/src/discord.ts +++ b/src/discord.ts @@ -7,28 +7,6 @@ import { sendMessage } from "./discord-rest"; import { recordSend } from "./send-log"; import { loadGroups, groupAcceptsOwners } from "./groups"; -export function isGatewayEnabled(env: Env): boolean { - return env.DISCORD_GATEWAY_ENABLED === "true"; -} - -async function getGatewayProxy(env: Env): Promise { - const id = env.DISCORD_GATEWAY.idFromName("discord-gateway"); - return env.DISCORD_GATEWAY.get(id); -} - -export async function initGateway(env: Env): Promise { - if (!isGatewayEnabled(env)) return; - if (!env.DISCORD_TOKEN) return; - const stub = await getGatewayProxy(env); - await stub.fetch( - new Request("https://do.internal", { - method: "POST", - body: JSON.stringify({ action: "start", token: env.DISCORD_TOKEN }), - }), - ); - log.info("Discord Gateway DO started"); -} - export async function dispatchEvent(config: Config, event: WebhookEvent, env: Env): Promise { const langs = [...new Set(config.routes.map((r) => r.lang ?? "en"))]; const trMap = new Map(); @@ -105,21 +83,6 @@ async function sendToChannel( threadId?: string, ): Promise { const token = env.DISCORD_TOKEN ?? ""; - if (!isGatewayEnabled(env)) { - const result = await sendMessage(token, channelId, message, threadId); - if (!result.ok) throw new Error(result.error ?? "Send failed"); - return; - } - - const stub = await getGatewayProxy(env); - const res = await stub.fetch( - new Request("https://do.internal", { - method: "POST", - body: JSON.stringify({ action: "send", channelId, message, threadId }), - }), - ); - const result = (await res.json()) as { ok: boolean; error?: string }; - if (!result.ok) { - throw new Error(result.error ?? "Send failed"); - } + const result = await sendMessage(token, channelId, message, threadId); + if (!result.ok) throw new Error(result.error ?? "Send failed"); } diff --git a/src/index.ts b/src/index.ts index a3984c3..65ddd0c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,11 +1,8 @@ import { createServer } from "./server"; -import { initGateway } from "./discord"; -import { DiscordGateway } from "./discord-gateway"; +import { syncCommands } from "./discord-interactions"; import type { Env } from "./types"; import { log } from "./log"; -export { DiscordGateway }; - const app = createServer(); export default { @@ -15,9 +12,9 @@ export default { async scheduled(_event: ScheduledEvent, env: Env): Promise { try { - await initGateway(env); + await syncCommands(env); } catch (err) { - log.error({ err }, "Gateway init from cron failed"); + log.error({ err }, "Discord command sync from cron failed"); } }, }; diff --git a/src/server.ts b/src/server.ts index b4a2955..6dd51cd 100644 --- a/src/server.ts +++ b/src/server.ts @@ -2,6 +2,7 @@ import { Hono } from "hono"; import type { Env } from "./types"; import { verifySignature, parseEvent } from "./webhook"; import { dispatchEvent } from "./discord"; +import { handleInteractionRequest } from "./discord-interactions"; import { createOAuthRoutes } from "./oauth-routes"; import { createActionRoutes } from "./action-routes"; import { createAdminRoutes } from "./admin-routes"; @@ -68,6 +69,8 @@ export function createServer(): Hono<{ Bindings: Env }> { return c.json({ ok: true }); }); + app.post("/discord/interactions", (c) => handleInteractionRequest(c.req.raw, c.env)); + app.notFound((c) => { if (c.env.ASSETS) { return c.env.ASSETS.fetch(c.req.raw); diff --git a/src/types.ts b/src/types.ts index 54614f1..e67568c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -8,13 +8,13 @@ export interface Env { DISCORD_CHANNEL_ID?: string; BASE_URL?: string; ADMIN_USER_IDS?: string; - DISCORD_GATEWAY_ENABLED?: string; LEGAL_CONTACT?: string; DOCS_URL?: string; GITHUB_REPO_URL?: string; + DISCORD_PUBLIC_KEY?: string; + DISCORD_APPLICATION_ID?: string; ASSETS?: Fetcher; KV: KVNamespace; - DISCORD_GATEWAY: DurableObjectNamespace; } export interface Config { diff --git a/wrangler.jsonc b/wrangler.jsonc index da2b6ce..7fca57d 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -3,9 +3,6 @@ "main": "src/index.ts", "compatibility_date": "2025-01-01", "compatibility_flags": [], - "vars": { - "DISCORD_GATEWAY_ENABLED": "true" - }, "build": { "command": "cd admin && bun install --frozen-lockfile && bun run generate" }, @@ -20,18 +17,10 @@ "*/5 * * * *" ] }, - "durable_objects": { - "bindings": [ - { - "name": "DISCORD_GATEWAY", - "class_name": "DiscordGateway", - }, - ], - }, "migrations": [ { - "tag": "v1", - "new_sqlite_classes": [ + "tag": "v2", + "deleted_classes": [ "DiscordGateway" ], },