From 113e15e2f48fa9637684eb0b732d0549c7ec734e Mon Sep 17 00:00:00 2001 From: 7418 <7418@openclaw.ai> Date: Wed, 4 Mar 2026 23:32:46 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20Discord=20bridge=20adapter=20=E2=80=94?= =?UTF-8?q?=20=E5=AE=8C=E6=95=B4=E5=AE=9E=E7=8E=B0=20Discord=20=E4=BD=9C?= =?UTF-8?q?=E4=B8=BA=E7=AC=AC=E4=B8=89=E4=B8=AA=E6=A1=A5=E6=8E=A5=E6=B8=A0?= =?UTF-8?q?=E9=81=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增文件: - discord-adapter.ts: 基于 discord.js v14 的适配器,支持 DM/频道消息、 按钮交互、流式预览(edit-based)、@提及过滤(兼容角色 mention)、 图片附件下载、!→/ 命令归一化、消息去重、typing indicator - markdown/discord.ts: Discord 2000 字符分段,代码块围栏自动平衡 - API routes: settings CRUD(Token 掩码)+ verify(Discord REST 验证) - DiscordBridgeSection.tsx: 设置 UI(凭据、授权、服务器策略、配置指南含 ID 获取方法) - discord-bridge.test.ts: 22 项单元测试(chunking/auth/command/HTML 转换) 修改文件: - bridge-manager.ts: Discord 分段投递 + channel-aware stream config (Discord 默认 1500ms/40chars/1900max vs Telegram 700ms/20/3900) - BridgeLayout/BridgeSection: Discord 渠道开关和侧栏入口 - bridge/settings/route.ts: 新增 14 个 Discord 设置键 - next.config.ts: serverExternalPackages 添加 discord.js/zlib-sync - i18n: 中英文完整翻译(30+ keys) - docs/handover/bridge-system.md: Discord 架构文档 依赖:discord.js ^14.18.0, zlib-sync ^0.1.9 Co-Authored-By: Claude Opus 4.6 --- docs/handover/bridge-system.md | 32 +- next.config.ts | 2 +- package-lock.json | 269 ++++++- package.json | 6 +- src/__tests__/unit/discord-bridge.test.ts | 200 ++++++ src/app/api/bridge/settings/route.ts | 13 + src/app/api/settings/discord/route.ts | 73 ++ src/app/api/settings/discord/verify/route.ts | 61 ++ src/components/bridge/BridgeLayout.tsx | 8 +- src/components/bridge/BridgeSection.tsx | 29 +- .../bridge/DiscordBridgeSection.tsx | 384 ++++++++++ src/i18n/en.ts | 45 ++ src/i18n/zh.ts | 45 ++ src/lib/bridge/adapters/discord-adapter.ts | 662 ++++++++++++++++++ src/lib/bridge/adapters/index.ts | 1 + src/lib/bridge/bridge-manager.ts | 32 +- src/lib/bridge/markdown/discord.ts | 110 +++ 17 files changed, 1957 insertions(+), 15 deletions(-) create mode 100644 src/__tests__/unit/discord-bridge.test.ts create mode 100644 src/app/api/settings/discord/route.ts create mode 100644 src/app/api/settings/discord/verify/route.ts create mode 100644 src/components/bridge/DiscordBridgeSection.tsx create mode 100644 src/lib/bridge/adapters/discord-adapter.ts create mode 100644 src/lib/bridge/markdown/discord.ts diff --git a/docs/handover/bridge-system.md b/docs/handover/bridge-system.md index aa7cf7a..a8adb38 100644 --- a/docs/handover/bridge-system.md +++ b/docs/handover/bridge-system.md @@ -27,7 +27,10 @@ src/lib/bridge/ │ ├── telegram-adapter.ts # Telegram 长轮询 + offset 安全水位 + 图片/相册处理 + 自注册 │ ├── telegram-media.ts # Telegram 图片下载、尺寸选择、base64 转换 │ ├── telegram-utils.ts # callTelegramApi / sendMessageDraft / escapeHtml / splitMessage -│ └── feishu-adapter.ts # 飞书 WSClient + REST 消息收发 + typing 指示器 + 自注册 +│ ├── feishu-adapter.ts # 飞书 WSClient + REST 消息收发 + typing 指示器 + 自注册 +│ └── discord-adapter.ts # Discord.js Client + Gateway intents + 按钮交互 + 流式预览 + 自注册 +├── markdown/ +│ └── discord.ts # Discord 消息分片(2000 字符限制)+ 代码围栏平衡 └── security/ ├── rate-limiter.ts # 按 chat 滑动窗口限流(20 条/分钟) └── validators.ts # 路径/SessionID/危险输入校验 @@ -52,6 +55,33 @@ src/lib/bridge/ → 权限请求 → sendPermissionCard() [schema 2.0 卡片 + /perm 文本命令] ``` +### Discord + +``` +Discord 消息 → discord.js Client (Gateway WebSocket) + → messageCreate → processMessage() + → bot/self 过滤 → 去重(messageId Set 1000) → 授权检查(user+channel) + → guild 策略过滤(allowed_guilds + group_policy) → @提及检查 + → !command → /command 规范化 + → 图片附件 → fetch(url) → base64 FileAttachment + → enqueue() + → interactionCreate → handleInteraction() + → deferUpdate() (3s Discord 超时) → 存储 Interaction(60s TTL) → enqueue(callbackData) + → BridgeManager.runAdapterLoop() → handleMessage() + → deliverResponse(): + → markdownToDiscordChunks(2000 字符, 代码围栏平衡) → 逐块发送 + → 权限请求 → ActionRowBuilder + ButtonBuilder 组件 + → 流式预览 → channel.send() 首次 / message.edit() 后续 / delete 结束 + → typing → channel.sendTyping() 每 8s +``` + +**Discord 关键设计决策:** +- **原生 Markdown**:Discord 原生支持 Markdown,无需 IR→HTML 转换(不同于 Telegram) +- **保守流式默认值**:Discord 编辑限速 5/5s/channel,默认 interval 1500ms, minDelta 40 chars +- **按钮交互**:deferUpdate() 立即响应(3s Discord 超时),存储 Interaction 对象供 answerCallback 使用,60s TTL 清理 +- **授权默认拒绝**:空白允许列表 = 拒绝所有(安全优先,同飞书模式) +- **`!` 命令别名**:在 adapter 层规范化为 `/` 命令后入队——bridge-manager 命令处理器无需改动 + ### Telegram ``` diff --git a/next.config.ts b/next.config.ts index f9e67bc..6c0eeb3 100644 --- a/next.config.ts +++ b/next.config.ts @@ -6,7 +6,7 @@ const pkg = require("./package.json"); const nextConfig: NextConfig = { output: 'standalone', - serverExternalPackages: ['better-sqlite3'], + serverExternalPackages: ['better-sqlite3', 'discord.js', '@discordjs/ws', 'zlib-sync'], env: { NEXT_PUBLIC_APP_VERSION: pkg.version, }, diff --git a/package-lock.json b/package-lock.json index f479a75..4ea28f9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "codepilot", - "version": "0.25.1", + "version": "0.26.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "codepilot", - "version": "0.25.1", + "version": "0.26.0", "dependencies": { "@ai-sdk/anthropic": "^3.0.47", "@ai-sdk/google": "^3.0.31", @@ -28,6 +28,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", + "discord.js": "^14.25.1", "electron-updater": "^6.8.3", "lucide-react": "^0.563.0", "markdown-it": "^14.1.1", @@ -47,7 +48,8 @@ "streamdown": "^2.1.0", "tailwind-merge": "^3.4.0", "use-stick-to-bottom": "^1.1.2", - "uuid": "^13.0.0" + "uuid": "^13.0.0", + "zlib-sync": "^0.1.10" }, "devDependencies": { "@playwright/test": "^1.58.1", @@ -773,6 +775,136 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/@discordjs/builders": { + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/@discordjs/builders/-/builders-1.13.1.tgz", + "integrity": "sha512-cOU0UDHc3lp/5nKByDxkmRiNZBpdp0kx55aarbiAfakfKJHlxv/yFW1zmIqCAmwH5CRlrH9iMFKJMpvW4DPB+w==", + "license": "Apache-2.0", + "dependencies": { + "@discordjs/formatters": "^0.6.2", + "@discordjs/util": "^1.2.0", + "@sapphire/shapeshift": "^4.0.0", + "discord-api-types": "^0.38.33", + "fast-deep-equal": "^3.1.3", + "ts-mixer": "^6.0.4", + "tslib": "^2.6.3" + }, + "engines": { + "node": ">=16.11.0" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/collection": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-1.5.3.tgz", + "integrity": "sha512-SVb428OMd3WO1paV3rm6tSjM4wC+Kecaa1EUGX7vc6/fddvw/6lg90z4QtCqm21zvVe92vMMDt9+DkIvjXImQQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=16.11.0" + } + }, + "node_modules/@discordjs/formatters": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/@discordjs/formatters/-/formatters-0.6.2.tgz", + "integrity": "sha512-y4UPwWhH6vChKRkGdMB4odasUbHOUwy7KL+OVwF86PvT6QVOwElx+TiI1/6kcmcEe+g5YRXJFiXSXUdabqZOvQ==", + "license": "Apache-2.0", + "dependencies": { + "discord-api-types": "^0.38.33" + }, + "engines": { + "node": ">=16.11.0" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/rest": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@discordjs/rest/-/rest-2.6.0.tgz", + "integrity": "sha512-RDYrhmpB7mTvmCKcpj+pc5k7POKszS4E2O9TYc+U+Y4iaCP+r910QdO43qmpOja8LRr1RJ0b3U+CqVsnPqzf4w==", + "license": "Apache-2.0", + "dependencies": { + "@discordjs/collection": "^2.1.1", + "@discordjs/util": "^1.1.1", + "@sapphire/async-queue": "^1.5.3", + "@sapphire/snowflake": "^3.5.3", + "@vladfrangu/async_event_emitter": "^2.4.6", + "discord-api-types": "^0.38.16", + "magic-bytes.js": "^1.10.0", + "tslib": "^2.6.3", + "undici": "6.21.3" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/rest/node_modules/@discordjs/collection": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-2.1.1.tgz", + "integrity": "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==", + "license": "Apache-2.0", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/util": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@discordjs/util/-/util-1.2.0.tgz", + "integrity": "sha512-3LKP7F2+atl9vJFhaBjn4nOaSWahZ/yWjOvA4e5pnXkt2qyXRCHLxoBQy81GFtLGCq7K9lPm9R517M1U+/90Qg==", + "license": "Apache-2.0", + "dependencies": { + "discord-api-types": "^0.38.33" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/ws": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@discordjs/ws/-/ws-1.2.3.tgz", + "integrity": "sha512-wPlQDxEmlDg5IxhJPuxXr3Vy9AjYq5xCvFWGJyD7w7Np8ZGu+Mc+97LCoEc/+AYCo2IDpKioiH0/c/mj5ZR9Uw==", + "license": "Apache-2.0", + "dependencies": { + "@discordjs/collection": "^2.1.0", + "@discordjs/rest": "^2.5.1", + "@discordjs/util": "^1.1.0", + "@sapphire/async-queue": "^1.5.2", + "@types/ws": "^8.5.10", + "@vladfrangu/async_event_emitter": "^2.2.4", + "discord-api-types": "^0.38.1", + "tslib": "^2.6.2", + "ws": "^8.17.0" + }, + "engines": { + "node": ">=16.11.0" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/ws/node_modules/@discordjs/collection": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-2.1.1.tgz", + "integrity": "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==", + "license": "Apache-2.0", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, "node_modules/@dnd-kit/accessibility": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", @@ -6042,6 +6174,39 @@ "dev": true, "license": "MIT" }, + "node_modules/@sapphire/async-queue": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@sapphire/async-queue/-/async-queue-1.5.5.tgz", + "integrity": "sha512-cvGzxbba6sav2zZkH8GPf2oGk9yYoD5qrNWdu9fRehifgnFZJMV+nuy2nON2roRO4yQQ+v7MK/Pktl/HgfsUXg==", + "license": "MIT", + "engines": { + "node": ">=v14.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/@sapphire/shapeshift": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@sapphire/shapeshift/-/shapeshift-4.0.0.tgz", + "integrity": "sha512-d9dUmWVA7MMiKobL3VpLF8P2aeanRTu6ypG2OIaEv/ZHH/SUQ2iHOVyi5wAPjQ+HmnMuL0whK9ez8I/raWbtIg==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "lodash": "^4.17.21" + }, + "engines": { + "node": ">=v16" + } + }, + "node_modules/@sapphire/snowflake": { + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/@sapphire/snowflake/-/snowflake-3.5.3.tgz", + "integrity": "sha512-jjmJywLAFoWeBi1W7994zZyiNWPIiqRRNAmSERxyg93xRGzNYvGjlZ0gR6x0F4gPRi2+0O6S71kOZYyr3cxaIQ==", + "license": "MIT", + "engines": { + "node": ">=v14.0.0", + "npm": ">=7.0.0" + } + }, "node_modules/@shikijs/core": { "version": "3.22.0", "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-3.22.0.tgz", @@ -7132,6 +7297,15 @@ "license": "MIT", "optional": true }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/yauzl": { "version": "2.10.3", "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", @@ -7716,6 +7890,16 @@ "node": ">= 20" } }, + "node_modules/@vladfrangu/async_event_emitter": { + "version": "2.4.7", + "resolved": "https://registry.npmjs.org/@vladfrangu/async_event_emitter/-/async_event_emitter-2.4.7.tgz", + "integrity": "sha512-Xfe6rpCTxSxfbswi/W/Pz7zp1WWSNn4A0eW4mLkQUewCrXXtMj31lCg+iQyTkh/CkusZSq9eDflu7tjEDXUY6g==", + "license": "MIT", + "engines": { + "node": ">=v14.0.0", + "npm": ">=7.0.0" + } + }, "node_modules/@xmldom/xmldom": { "version": "0.8.11", "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.11.tgz", @@ -10324,6 +10508,42 @@ "p-limit": "^3.1.0 " } }, + "node_modules/discord-api-types": { + "version": "0.38.40", + "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.40.tgz", + "integrity": "sha512-P/His8cotqZgQqrt+hzrocp9L8RhQQz1GkrCnC9TMJ8Uw2q0tg8YyqJyGULxhXn/8kxHETN4IppmOv+P2m82lQ==", + "license": "MIT", + "workspaces": [ + "scripts/actions/documentation" + ] + }, + "node_modules/discord.js": { + "version": "14.25.1", + "resolved": "https://registry.npmjs.org/discord.js/-/discord.js-14.25.1.tgz", + "integrity": "sha512-2l0gsPOLPs5t6GFZfQZKnL1OJNYFcuC/ETWsW4VtKVD/tg4ICa9x+jb9bkPffkMdRpRpuUaO/fKkHCBeiCKh8g==", + "license": "Apache-2.0", + "dependencies": { + "@discordjs/builders": "^1.13.0", + "@discordjs/collection": "1.5.3", + "@discordjs/formatters": "^0.6.2", + "@discordjs/rest": "^2.6.0", + "@discordjs/util": "^1.2.0", + "@discordjs/ws": "^1.2.3", + "@sapphire/snowflake": "3.5.3", + "discord-api-types": "^0.38.33", + "fast-deep-equal": "3.1.3", + "lodash.snakecase": "4.1.1", + "magic-bytes.js": "^1.10.0", + "tslib": "^2.6.3", + "undici": "6.21.3" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, "node_modules/dmg-builder": { "version": "26.7.0", "resolved": "https://registry.npmjs.org/dmg-builder/-/dmg-builder-26.7.0.tgz", @@ -14922,6 +15142,12 @@ "integrity": "sha512-AZV+GsS/6ckvPOVQPXSiFFacKvKB4kOQu6ynt9wz0F3LO4R9Ij4K1ddYsIytDpSgLz88JHd9P+oaLeej5/Sl7Q==", "license": "MIT" }, + "node_modules/lodash.snakecase": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.snakecase/-/lodash.snakecase-4.1.1.tgz", + "integrity": "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==", + "license": "MIT" + }, "node_modules/log-symbols": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", @@ -15217,6 +15443,12 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/magic-bytes.js": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/magic-bytes.js/-/magic-bytes.js-1.13.0.tgz", + "integrity": "sha512-afO2mnxW7GDTXMm5/AoN1WuOcdoKhtgXjIvHmobqTD1grNplhGdv3PFOyjCVmrnOZBIT/gD/koDKpYG+0mvHcg==", + "license": "MIT" + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -16923,6 +17155,12 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/nan": { + "version": "2.25.0", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.25.0.tgz", + "integrity": "sha512-0M90Ag7Xn5KMLLZ7zliPWP3rT90P6PN+IzVFS0VqmnPktBk3700xUVv8Ikm9EUaUE5SDWdp/BIxdENzVznpm1g==", + "license": "MIT" + }, "node_modules/nanoid": { "version": "5.1.6", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.6.tgz", @@ -21122,6 +21360,12 @@ "node": ">=18" } }, + "node_modules/ts-mixer": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/ts-mixer/-/ts-mixer-6.0.4.tgz", + "integrity": "sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA==", + "license": "MIT" + }, "node_modules/tsconfig-paths": { "version": "3.15.0", "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", @@ -21385,6 +21629,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/undici": { + "version": "6.21.3", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.21.3.tgz", + "integrity": "sha512-gBLkYIlEnSp8pFbT64yFgGE6UIB9tAkhukC23PmMDCe5Nd+cRqKxSjw5y54MK2AZMgZfJWMaNE4nYUHgi1XEOw==", + "license": "MIT", + "engines": { + "node": ">=18.17" + } + }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", @@ -22220,6 +22473,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/zlib-sync": { + "version": "0.1.10", + "resolved": "https://registry.npmjs.org/zlib-sync/-/zlib-sync-0.1.10.tgz", + "integrity": "sha512-t7/pYg5tLBznL1RuhmbAt8rNp5tbhr+TSrJFnMkRtrGIaPJZ6Dc0uR4u3OoQI2d6cGlVI62E3Gy6gwkxyIqr/w==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "nan": "^2.18.0" + } + }, "node_modules/zod": { "version": "4.3.6", "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", diff --git a/package.json b/package.json index 67e6cc6..bb2194e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codepilot", - "version": "0.25.1", + "version": "0.26.0", "private": true, "author": { "name": "op7418", @@ -52,6 +52,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", + "discord.js": "^14.25.1", "electron-updater": "^6.8.3", "lucide-react": "^0.563.0", "markdown-it": "^14.1.1", @@ -71,7 +72,8 @@ "streamdown": "^2.1.0", "tailwind-merge": "^3.4.0", "use-stick-to-bottom": "^1.1.2", - "uuid": "^13.0.0" + "uuid": "^13.0.0", + "zlib-sync": "^0.1.10" }, "devDependencies": { "@playwright/test": "^1.58.1", diff --git a/src/__tests__/unit/discord-bridge.test.ts b/src/__tests__/unit/discord-bridge.test.ts new file mode 100644 index 0000000..632e184 --- /dev/null +++ b/src/__tests__/unit/discord-bridge.test.ts @@ -0,0 +1,200 @@ +/** + * Unit tests for Discord bridge adapter. + * + * Run with: npx tsx src/__tests__/unit/discord-bridge.test.ts + * + * Tests cover: + * - Markdown chunking (short text, line split, code fence balance, hard split) + * - Authorization logic (deny when empty, allow by user/channel) + * - Command normalization (! → /) + * - HTML to Discord markdown conversion + */ + +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { markdownToDiscordChunks } from '../../lib/bridge/markdown/discord'; + +// ── Discord chunking ───────────────────────────────────────── + +describe('markdownToDiscordChunks', () => { + it('returns empty array for empty input', () => { + assert.deepStrictEqual(markdownToDiscordChunks(''), []); + }); + + it('returns single chunk for short text', () => { + const result = markdownToDiscordChunks('Hello world'); + assert.strictEqual(result.length, 1); + assert.strictEqual(result[0].text, 'Hello world'); + }); + + it('returns single chunk when exactly at limit', () => { + const text = 'x'.repeat(2000); + const result = markdownToDiscordChunks(text, 2000); + assert.strictEqual(result.length, 1); + assert.strictEqual(result[0].text, text); + }); + + it('splits long text at line boundaries', () => { + const lines: string[] = []; + for (let i = 0; i < 50; i++) { + lines.push(`Line ${i}: ${'x'.repeat(80)}`); + } + const text = lines.join('\n'); + const result = markdownToDiscordChunks(text, 2000); + + assert.ok(result.length > 1, 'Should split into multiple chunks'); + for (const chunk of result) { + assert.ok(chunk.text.length <= 2000, `Chunk exceeds limit: ${chunk.text.length}`); + } + }); + + it('balances code fences at split points', () => { + const codePart = '```typescript\n' + 'const x = 1;\n'.repeat(200) + '```'; + const result = markdownToDiscordChunks(codePart, 2000); + + assert.ok(result.length > 1, 'Should split into multiple chunks'); + + // First chunk should end with closing ``` + assert.ok(result[0].text.includes('```typescript'), 'First chunk should contain opening fence'); + assert.ok(result[0].text.endsWith('```'), 'First chunk should end with closing fence'); + + // Second chunk should start with reopened fence + assert.ok(result[1].text.startsWith('```typescript'), 'Second chunk should start with reopened fence'); + }); + + it('hard splits when a single line exceeds limit', () => { + const text = 'x'.repeat(5000); + const result = markdownToDiscordChunks(text, 2000); + + assert.ok(result.length > 1, 'Should split into multiple chunks'); + assert.strictEqual(result[0].text.length, 2000); + }); + + it('preserves language tag when reopening fences', () => { + const text = '```python\n' + 'print("hello")\n'.repeat(200) + '```'; + const result = markdownToDiscordChunks(text, 2000); + + assert.ok(result.length > 1, 'Should split into multiple chunks'); + assert.ok(result[1].text.startsWith('```python'), 'Second chunk should have ```python'); + }); +}); + +// ── Authorization logic ────────────────────────────────────── + +describe('Discord authorization logic', () => { + function isAuthorized( + userId: string, + chatId: string, + allowedUsers: string, + allowedChannels: string, + ): boolean { + if (!allowedUsers && !allowedChannels) return false; + + const users = allowedUsers.split(',').map(s => s.trim()).filter(Boolean); + const channels = allowedChannels.split(',').map(s => s.trim()).filter(Boolean); + + if (users.length > 0 && users.includes(userId)) return true; + if (channels.length > 0 && channels.includes(chatId)) return true; + if (users.length > 0 && channels.length === 0) return false; + if (channels.length > 0 && users.length === 0) return false; + + return false; + } + + it('denies when both lists are empty', () => { + assert.strictEqual(isAuthorized('user1', 'chan1', '', ''), false); + }); + + it('allows by user ID', () => { + assert.strictEqual(isAuthorized('user1', 'chan1', 'user1,user2', ''), true); + }); + + it('denies unlisted user', () => { + assert.strictEqual(isAuthorized('user3', 'chan1', 'user1,user2', ''), false); + }); + + it('allows by channel ID', () => { + assert.strictEqual(isAuthorized('user1', 'chan1', '', 'chan1,chan2'), true); + }); + + it('denies unlisted channel', () => { + assert.strictEqual(isAuthorized('user1', 'chan3', '', 'chan1,chan2'), false); + }); + + it('allows when user matches in combined lists', () => { + assert.strictEqual(isAuthorized('user1', 'chan3', 'user1', 'chan1'), true); + }); + + it('allows when channel matches in combined lists', () => { + assert.strictEqual(isAuthorized('user3', 'chan1', 'user1', 'chan1'), true); + }); + + it('denies when neither matches in combined lists', () => { + assert.strictEqual(isAuthorized('user3', 'chan3', 'user1', 'chan1'), false); + }); +}); + +// ── Command normalization ──────────────────────────────────── + +describe('Discord command normalization', () => { + it('normalizes ! prefix to /', () => { + let text = '!new /path/to/project'; + if (text.startsWith('!')) text = '/' + text.slice(1); + assert.strictEqual(text, '/new /path/to/project'); + }); + + it('leaves / commands unchanged', () => { + let text = '/status'; + if (text.startsWith('!')) text = '/' + text.slice(1); + assert.strictEqual(text, '/status'); + }); + + it('does not touch regular text', () => { + let text = 'hello world'; + if (text.startsWith('!')) text = '/' + text.slice(1); + assert.strictEqual(text, 'hello world'); + }); +}); + +// ── HTML to Discord markdown conversion ────────────────────── + +describe('HTML to Discord markdown', () => { + function htmlToDiscordMarkdown(html: string): string { + return html + .replace(/(.*?)<\/b>/gi, '**$1**') + .replace(/(.*?)<\/strong>/gi, '**$1**') + .replace(/(.*?)<\/i>/gi, '*$1*') + .replace(/(.*?)<\/em>/gi, '*$1*') + .replace(/(.*?)<\/code>/gi, '`$1`') + .replace(/
([\s\S]*?)<\/pre>/gi, '```\n$1\n```')
+      .replace(/</g, '<')
+      .replace(/>/g, '>')
+      .replace(/&/g, '&')
+      .replace(//gi, '\n')
+      .replace(/<[^>]+>/g, '');
+  }
+
+  it('converts bold tags', () => {
+    assert.strictEqual(htmlToDiscordMarkdown('bold'), '**bold**');
+  });
+
+  it('converts italic tags', () => {
+    assert.strictEqual(htmlToDiscordMarkdown('italic'), '*italic*');
+  });
+
+  it('converts code tags', () => {
+    assert.strictEqual(htmlToDiscordMarkdown('code'), '`code`');
+  });
+
+  it('converts pre tags', () => {
+    assert.strictEqual(htmlToDiscordMarkdown('
block
'), '```\nblock\n```'); + }); + + it('decodes HTML entities', () => { + assert.strictEqual(htmlToDiscordMarkdown('A & B'), 'A & B'); + }); + + it('strips unknown HTML tags', () => { + assert.strictEqual(htmlToDiscordMarkdown('
text
'), 'text'); + }); +}); diff --git a/src/app/api/bridge/settings/route.ts b/src/app/api/bridge/settings/route.ts index f85dcd5..ccb66d1 100644 --- a/src/app/api/bridge/settings/route.ts +++ b/src/app/api/bridge/settings/route.ts @@ -22,6 +22,19 @@ const BRIDGE_SETTING_KEYS = [ 'bridge_feishu_group_policy', 'bridge_feishu_group_allow_from', 'bridge_feishu_require_mention', + 'bridge_discord_enabled', + 'bridge_discord_bot_token', + 'bridge_discord_allowed_users', + 'bridge_discord_allowed_channels', + 'bridge_discord_allowed_guilds', + 'bridge_discord_group_policy', + 'bridge_discord_require_mention', + 'bridge_discord_stream_enabled', + 'bridge_discord_stream_interval_ms', + 'bridge_discord_stream_min_delta_chars', + 'bridge_discord_stream_max_chars', + 'bridge_discord_max_attachment_size', + 'bridge_discord_image_enabled', ] as const; export async function GET() { diff --git a/src/app/api/settings/discord/route.ts b/src/app/api/settings/discord/route.ts new file mode 100644 index 0000000..67e3288 --- /dev/null +++ b/src/app/api/settings/discord/route.ts @@ -0,0 +1,73 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getSetting, setSetting } from '@/lib/db'; + +/** + * Discord Bot bridge settings. + * Stored in the SQLite settings table (same as other app settings). + */ + +const DISCORD_KEYS = [ + 'bridge_discord_enabled', + 'bridge_discord_bot_token', + 'bridge_discord_allowed_users', + 'bridge_discord_allowed_channels', + 'bridge_discord_allowed_guilds', + 'bridge_discord_group_policy', + 'bridge_discord_require_mention', + 'bridge_discord_stream_enabled', + 'bridge_discord_stream_interval_ms', + 'bridge_discord_stream_min_delta_chars', + 'bridge_discord_stream_max_chars', + 'bridge_discord_max_attachment_size', + 'bridge_discord_image_enabled', +] as const; + +export async function GET() { + try { + const result: Record = {}; + for (const key of DISCORD_KEYS) { + const value = getSetting(key); + if (value !== undefined) { + // Mask bot token for security (keep last 8 chars) + if (key === 'bridge_discord_bot_token' && value.length > 8) { + result[key] = '***' + value.slice(-8); + } else { + result[key] = value; + } + } + } + + return NextResponse.json({ settings: result }); + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to read Discord settings'; + return NextResponse.json({ error: message }, { status: 500 }); + } +} + +export async function PUT(request: NextRequest) { + try { + const body = await request.json(); + const { settings } = body; + + if (!settings || typeof settings !== 'object') { + return NextResponse.json({ error: 'Invalid settings data' }, { status: 400 }); + } + + for (const [key, value] of Object.entries(settings)) { + if (!DISCORD_KEYS.includes(key as typeof DISCORD_KEYS[number])) continue; + const strValue = String(value ?? '').trim(); + + // Don't overwrite token if user sent the masked version back + if (key === 'bridge_discord_bot_token' && strValue.startsWith('***')) { + continue; + } + + setSetting(key, strValue); + } + + return NextResponse.json({ success: true }); + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to save Discord settings'; + return NextResponse.json({ error: message }, { status: 500 }); + } +} diff --git a/src/app/api/settings/discord/verify/route.ts b/src/app/api/settings/discord/verify/route.ts new file mode 100644 index 0000000..64c239c --- /dev/null +++ b/src/app/api/settings/discord/verify/route.ts @@ -0,0 +1,61 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getSetting } from '@/lib/db'; + +/** + * POST /api/settings/discord/verify + * + * Verifies Discord bot token by calling the Discord API /users/@me endpoint. + * If bot_token starts with "***" (masked), falls back to the stored token. + */ +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + let { bot_token } = body; + + // Fall back to stored value if not provided or masked + if (!bot_token || bot_token.startsWith('***')) { + bot_token = getSetting('bridge_discord_bot_token') || ''; + } + + if (!bot_token) { + return NextResponse.json( + { verified: false, error: 'Bot token is required' }, + { status: 400 }, + ); + } + + // Call Discord API to verify the token + const res = await fetch('https://discord.com/api/v10/users/@me', { + method: 'GET', + headers: { + Authorization: `Bot ${bot_token}`, + }, + signal: AbortSignal.timeout(10_000), + }); + + if (!res.ok) { + const errorData = await res.json().catch(() => ({})); + return NextResponse.json({ + verified: false, + error: errorData.message || `HTTP ${res.status}: Token verification failed`, + }); + } + + const data = await res.json(); + + if (data.id) { + return NextResponse.json({ + verified: true, + botName: data.username ? `${data.username}#${data.discriminator || '0'}` : data.id, + }); + } + + return NextResponse.json({ + verified: false, + error: 'Could not retrieve bot info', + }); + } catch (error) { + const message = error instanceof Error ? error.message : 'Verification failed'; + return NextResponse.json({ verified: false, error: message }, { status: 500 }); + } +} diff --git a/src/components/bridge/BridgeLayout.tsx b/src/components/bridge/BridgeLayout.tsx index 78fe6ef..0009d26 100644 --- a/src/components/bridge/BridgeLayout.tsx +++ b/src/components/bridge/BridgeLayout.tsx @@ -3,15 +3,16 @@ import { useState, useCallback, useSyncExternalStore } from "react"; import { HugeiconsIcon } from "@hugeicons/react"; import type { IconSvgElement } from "@hugeicons/react"; -import { Wifi01Icon, TelegramIcon, BubbleChatIcon } from "@hugeicons/core-free-icons"; +import { Wifi01Icon, TelegramIcon, BubbleChatIcon, GameController01Icon } from "@hugeicons/core-free-icons"; import { cn } from "@/lib/utils"; import { BridgeSection } from "./BridgeSection"; import { TelegramBridgeSection } from "./TelegramBridgeSection"; import { FeishuBridgeSection } from "./FeishuBridgeSection"; +import { DiscordBridgeSection } from "./DiscordBridgeSection"; import { useTranslation } from "@/hooks/useTranslation"; import type { TranslationKey } from "@/i18n"; -type Section = "bridge" | "telegram" | "feishu"; +type Section = "bridge" | "telegram" | "feishu" | "discord"; interface SidebarItem { id: Section; @@ -23,6 +24,7 @@ const sidebarItems: SidebarItem[] = [ { id: "bridge", label: "Bridge", icon: Wifi01Icon }, { id: "telegram", label: "Telegram", icon: TelegramIcon }, { id: "feishu", label: "Feishu", icon: BubbleChatIcon }, + { id: "discord", label: "Discord", icon: GameController01Icon }, ]; function getSectionFromHash(): Section { @@ -50,6 +52,7 @@ export function BridgeLayout() { 'Bridge': 'bridge.title', 'Telegram': 'bridge.telegramSettings', 'Feishu': 'bridge.feishuSettings', + 'Discord': 'bridge.discordSettings', }; const handleSectionChange = useCallback((section: Section) => { @@ -90,6 +93,7 @@ export function BridgeLayout() { {activeSection === "bridge" && } {activeSection === "telegram" && } {activeSection === "feishu" && } + {activeSection === "discord" && } diff --git a/src/components/bridge/BridgeSection.tsx b/src/components/bridge/BridgeSection.tsx index 0116150..4de82cf 100644 --- a/src/components/bridge/BridgeSection.tsx +++ b/src/components/bridge/BridgeSection.tsx @@ -14,7 +14,7 @@ import { SelectValue, } from "@/components/ui/select"; import { HugeiconsIcon } from "@hugeicons/react"; -import { Loading02Icon, CheckmarkCircle02Icon, Alert02Icon, TelegramIcon, BubbleChatIcon } from "@hugeicons/core-free-icons"; +import { Loading02Icon, CheckmarkCircle02Icon, Alert02Icon, TelegramIcon, BubbleChatIcon, GameController01Icon } from "@hugeicons/core-free-icons"; import { useTranslation } from "@/hooks/useTranslation"; import type { ProviderModelGroup } from "@/types"; @@ -36,6 +36,7 @@ interface BridgeSettings { remote_bridge_enabled: string; bridge_telegram_enabled: string; bridge_feishu_enabled: string; + bridge_discord_enabled: string; bridge_auto_start: string; bridge_default_work_dir: string; bridge_default_model: string; @@ -46,6 +47,7 @@ const DEFAULT_SETTINGS: BridgeSettings = { remote_bridge_enabled: "", bridge_telegram_enabled: "", bridge_feishu_enabled: "", + bridge_discord_enabled: "", bridge_auto_start: "", bridge_default_work_dir: "", bridge_default_model: "", @@ -163,6 +165,10 @@ export function BridgeSection() { saveSettings({ bridge_feishu_enabled: checked ? "true" : "" }); }; + const handleToggleDiscord = (checked: boolean) => { + saveSettings({ bridge_discord_enabled: checked ? "true" : "" }); + }; + const handleSaveDefaults = () => { // Split composite "provider_id::model" value const parts = model.split("::"); @@ -233,6 +239,7 @@ export function BridgeSection() { const isEnabled = settings.remote_bridge_enabled === "true"; const isTelegramEnabled = settings.bridge_telegram_enabled === "true"; const isFeishuEnabled = settings.bridge_feishu_enabled === "true"; + const isDiscordEnabled = settings.bridge_discord_enabled === "true"; const isAutoStart = settings.bridge_auto_start === "true"; const isRunning = bridgeStatus?.running ?? false; const adapterCount = bridgeStatus?.adapters?.length ?? 0; @@ -382,6 +389,26 @@ export function BridgeSection() { /> +
+
+ +
+

{t("bridge.discordChannel")}

+

+ {t("bridge.discordChannelDesc")} +

+
+
+ +
+

{t("bridge.autoStart")}

diff --git a/src/components/bridge/DiscordBridgeSection.tsx b/src/components/bridge/DiscordBridgeSection.tsx new file mode 100644 index 0000000..e02f5e2 --- /dev/null +++ b/src/components/bridge/DiscordBridgeSection.tsx @@ -0,0 +1,384 @@ +"use client"; + +import { useState, useCallback, useEffect } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Switch } from "@/components/ui/switch"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { + Loading02Icon, + CheckmarkCircle02Icon, + Alert02Icon, +} from "@hugeicons/core-free-icons"; +import { useTranslation } from "@/hooks/useTranslation"; + +interface DiscordBridgeSettings { + bridge_discord_bot_token: string; + bridge_discord_allowed_users: string; + bridge_discord_allowed_channels: string; + bridge_discord_allowed_guilds: string; + bridge_discord_group_policy: string; + bridge_discord_require_mention: string; + bridge_discord_stream_enabled: string; + bridge_discord_max_attachment_size: string; + bridge_discord_image_enabled: string; +} + +const DEFAULT_SETTINGS: DiscordBridgeSettings = { + bridge_discord_bot_token: "", + bridge_discord_allowed_users: "", + bridge_discord_allowed_channels: "", + bridge_discord_allowed_guilds: "", + bridge_discord_group_policy: "open", + bridge_discord_require_mention: "false", + bridge_discord_stream_enabled: "true", + bridge_discord_max_attachment_size: "", + bridge_discord_image_enabled: "true", +}; + +export function DiscordBridgeSection() { + const [, setSettings] = + useState(DEFAULT_SETTINGS); + const [botToken, setBotToken] = useState(""); + const [allowedUsers, setAllowedUsers] = useState(""); + const [allowedChannels, setAllowedChannels] = useState(""); + const [allowedGuilds, setAllowedGuilds] = useState(""); + const [groupPolicy, setGroupPolicy] = useState("open"); + const [requireMention, setRequireMention] = useState(false); + const [streamEnabled, setStreamEnabled] = useState(true); + const [saving, setSaving] = useState(false); + const [verifying, setVerifying] = useState(false); + const [verifyResult, setVerifyResult] = useState<{ + ok: boolean; + message: string; + } | null>(null); + const { t } = useTranslation(); + + const fetchSettings = useCallback(async () => { + try { + const res = await fetch("/api/settings/discord"); + if (res.ok) { + const data = await res.json(); + const s = { ...DEFAULT_SETTINGS, ...data.settings }; + setSettings(s); + setBotToken(s.bridge_discord_bot_token); + setAllowedUsers(s.bridge_discord_allowed_users); + setAllowedChannels(s.bridge_discord_allowed_channels); + setAllowedGuilds(s.bridge_discord_allowed_guilds); + setGroupPolicy(s.bridge_discord_group_policy || "open"); + setRequireMention(s.bridge_discord_require_mention === "true"); + setStreamEnabled(s.bridge_discord_stream_enabled !== "false"); + } + } catch { + // ignore + } + }, []); + + useEffect(() => { + fetchSettings(); + }, [fetchSettings]); + + const saveSettings = async (updates: Partial) => { + setSaving(true); + try { + const res = await fetch("/api/settings/discord", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ settings: updates }), + }); + if (res.ok) { + setSettings((prev) => ({ ...prev, ...updates })); + } + } catch { + // ignore + } finally { + setSaving(false); + } + }; + + const handleSaveCredentials = () => { + const updates: Partial = {}; + if (botToken && !botToken.startsWith("***")) { + updates.bridge_discord_bot_token = botToken; + } + saveSettings(updates); + }; + + const handleSaveGroupSettings = () => { + saveSettings({ + bridge_discord_allowed_users: allowedUsers, + bridge_discord_allowed_channels: allowedChannels, + bridge_discord_allowed_guilds: allowedGuilds, + bridge_discord_group_policy: groupPolicy, + bridge_discord_require_mention: requireMention ? "true" : "false", + bridge_discord_stream_enabled: streamEnabled ? "true" : "false", + }); + }; + + const handleVerify = async () => { + setVerifying(true); + setVerifyResult(null); + try { + if (!botToken) { + setVerifyResult({ + ok: false, + message: t("discord.enterTokenFirst"), + }); + return; + } + + const res = await fetch("/api/settings/discord/verify", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ bot_token: botToken }), + }); + const data = await res.json(); + + if (data.verified) { + setVerifyResult({ + ok: true, + message: data.botName + ? t("discord.verifiedAs", { name: data.botName }) + : t("discord.verified"), + }); + } else { + setVerifyResult({ + ok: false, + message: data.error || t("discord.verifyFailed"), + }); + } + } catch { + setVerifyResult({ ok: false, message: t("discord.verifyFailed") }); + } finally { + setVerifying(false); + } + }; + + return ( +
+ {/* Bot Token */} +
+
+

{t("discord.credentials")}

+

+ {t("discord.credentialsDesc")} +

+
+ +
+
+ + setBotToken(e.target.value)} + placeholder="MTIzNDU2Nzg5MDEyMzQ1Njc4OQ.XXXXXX.XXXXXXXXXX" + className="font-mono text-sm" + /> +
+
+ +
+ + +
+ + {verifyResult && ( +
+ + {verifyResult.message} +
+ )} +
+ + {/* Allowed Users / Channels */} +
+
+

+ {t("discord.allowedUsers")} +

+

+ {t("discord.allowedUsersDesc")} +

+
+ +
+
+ + setAllowedUsers(e.target.value)} + placeholder="123456789012345678" + className="font-mono text-sm" + /> +

+ {t("discord.allowedUsersHint")} +

+
+ +
+ + setAllowedChannels(e.target.value)} + placeholder="123456789012345678" + className="font-mono text-sm" + /> +

+ {t("discord.allowedChannelsHint")} +

+
+
+
+ + {/* Guild & Group Settings */} +
+
+

+ {t("discord.guildSettings")} +

+

+ {t("discord.guildSettingsDesc")} +

+
+ +
+
+ + setAllowedGuilds(e.target.value)} + placeholder="123456789012345678" + className="font-mono text-sm" + /> +

+ {t("discord.allowedGuildsHint")} +

+
+ +
+ + +
+ +
+
+

{t("discord.requireMention")}

+

+ {t("discord.requireMentionDesc")} +

+
+ +
+ +
+
+

{t("discord.streamPreview")}

+

+ {t("discord.streamPreviewDesc")} +

+
+ +
+
+ + +
+ + {/* Setup Guide */} +
+

+ {t("discord.setupGuide")} +

+ +
+

+ {t("discord.setupBotTitle")} +

+
    +
  1. {t("discord.step1")}
  2. +
  3. {t("discord.step2")}
  4. +
  5. {t("discord.step3")}
  6. +
  7. {t("discord.step4")}
  8. +
  9. {t("discord.step5")}
  10. +
  11. {t("discord.step6")}
  12. +
  13. {t("discord.step7")}
  14. +
+
+ +
+

+ {t("discord.setupIdTitle")} +

+
    +
  1. {t("discord.stepDevMode")}
  2. +
  3. {t("discord.stepUserId")}
  4. +
  5. {t("discord.stepChannelId")}
  6. +
  7. {t("discord.stepGuildId")}
  8. +
+
+
+
+ ); +} diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 1f4386d..79e5944 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -579,6 +579,51 @@ const en = { 'bridge.feishuChannelDesc': 'Receive and respond to messages via Feishu Bot', 'bridge.feishuSettings': 'Feishu Settings', 'bridge.feishuSettingsDesc': 'Configure Feishu App credentials for bridge', + 'bridge.discordChannel': 'Discord', + 'bridge.discordChannelDesc': 'Receive and respond to messages via Discord Bot', + 'bridge.discordSettings': 'Discord Settings', + 'bridge.discordSettingsDesc': 'Configure Discord Bot credentials for bridge', + + // ── Settings: Discord Bridge ───────────────────────────────── + 'discord.credentials': 'Bot Credentials', + 'discord.credentialsDesc': 'Enter your Discord Bot token', + 'discord.botToken': 'Bot Token', + 'discord.verify': 'Test Connection', + 'discord.verified': 'Connection verified', + 'discord.verifiedAs': 'Connected as {name}', + 'discord.verifyFailed': 'Connection failed', + 'discord.enterTokenFirst': 'Enter a bot token first', + 'discord.allowedUsers': 'Authorization', + 'discord.allowedUsersDesc': 'Control which users and channels can interact with the bot', + 'discord.allowedUserIds': 'Allowed User IDs', + 'discord.allowedUsersHint': 'Discord user IDs, comma-separated for multiple. Denies all when both users and channels are empty.', + 'discord.allowedChannelIds': 'Allowed Channel IDs', + 'discord.allowedChannelsHint': 'Discord channel IDs, comma-separated for multiple.', + 'discord.guildSettings': 'Server & Group Settings', + 'discord.guildSettingsDesc': 'Control how the bot responds in Discord servers', + 'discord.allowedGuilds': 'Allowed Server (Guild) IDs', + 'discord.allowedGuildsHint': 'Guild IDs, comma-separated for multiple. Leave empty to allow all servers.', + 'discord.groupPolicy': 'Server Message Policy', + 'discord.groupPolicyOpen': 'Open — respond in all server channels', + 'discord.groupPolicyDisabled': 'Disabled — ignore all server messages (DM only)', + 'discord.requireMention': 'Require @mention', + 'discord.requireMentionDesc': 'Only respond in servers when the bot is @mentioned', + 'discord.streamPreview': 'Stream Preview', + 'discord.streamPreviewDesc': 'Show real-time response preview by editing messages', + 'discord.setupGuide': 'Configuration Guide', + 'discord.setupBotTitle': 'Create a Discord Bot', + 'discord.step1': 'Go to Discord Developer Portal (discord.com/developers), click "New Application" to create an app', + 'discord.step2': 'Navigate to "Bot" in the sidebar, click "Add Bot" to create the bot', + 'discord.step3': 'Scroll down to "Privileged Gateway Intents", enable the "MESSAGE CONTENT INTENT" toggle', + 'discord.step4': 'Click "Reset Token" to copy the Bot Token, paste it in the "Bot Credentials" section above and save', + 'discord.step5': 'Go to "OAuth2 → URL Generator", check "bot" under Scopes, then check "Send Messages" and "Read Message History" under Bot Permissions', + 'discord.step6': 'Copy the generated invite URL, open it in your browser to invite the Bot to your server', + 'discord.step7': 'Click "Test Connection" above — if the bot name appears, setup is complete', + 'discord.setupIdTitle': 'Getting IDs (Developer Mode required)', + 'discord.stepDevMode': 'Open Discord → User Settings → Advanced → enable "Developer Mode"', + 'discord.stepUserId': 'User ID: right-click your avatar or username → "Copy User ID"', + 'discord.stepChannelId': 'Channel ID: right-click a channel name in the sidebar → "Copy Channel ID"', + 'discord.stepGuildId': 'Server ID: right-click the server name at the top-left → "Copy Server ID"', } as const; export type TranslationKey = keyof typeof en; diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts index 54628e6..4ad1210 100644 --- a/src/i18n/zh.ts +++ b/src/i18n/zh.ts @@ -576,6 +576,51 @@ const zh: Record = { 'bridge.feishuChannelDesc': '通过飞书机器人接收和回复消息', 'bridge.feishuSettings': '飞书设置', 'bridge.feishuSettingsDesc': '配置桥接使用的飞书应用凭据', + 'bridge.discordChannel': 'Discord', + 'bridge.discordChannelDesc': '通过 Discord Bot 接收和回复消息', + 'bridge.discordSettings': 'Discord 设置', + 'bridge.discordSettingsDesc': '配置桥接使用的 Discord Bot 凭据', + + // ── Settings: Discord Bridge ───────────────────────────────── + 'discord.credentials': 'Bot 凭据', + 'discord.credentialsDesc': '输入您的 Discord Bot Token', + 'discord.botToken': 'Bot Token', + 'discord.verify': '测试连接', + 'discord.verified': '连接验证成功', + 'discord.verifiedAs': '已连接为 {name}', + 'discord.verifyFailed': '连接失败', + 'discord.enterTokenFirst': '请先输入 Bot Token', + 'discord.allowedUsers': '授权设置', + 'discord.allowedUsersDesc': '控制哪些用户和频道可以与 Bot 交互', + 'discord.allowedUserIds': '允许的用户 ID', + 'discord.allowedUsersHint': 'Discord 用户 ID,多个用逗号分隔。用户和频道均为空时拒绝所有请求。', + 'discord.allowedChannelIds': '允许的频道 ID', + 'discord.allowedChannelsHint': '允许的 Discord 频道 ID,多个用逗号分隔。', + 'discord.guildSettings': '服务器和群组设置', + 'discord.guildSettingsDesc': '控制 Bot 在 Discord 服务器中的响应方式', + 'discord.allowedGuilds': '允许的服务器 (Guild) ID', + 'discord.allowedGuildsHint': '服务器 ID,多个用逗号分隔。留空则允许所有服务器。', + 'discord.groupPolicy': '服务器消息策略', + 'discord.groupPolicyOpen': '开放 — 响应所有服务器频道消息', + 'discord.groupPolicyDisabled': '禁用 — 忽略所有服务器消息(仅私信)', + 'discord.requireMention': '需要 @提及', + 'discord.requireMentionDesc': '服务器中仅在 @机器人时响应', + 'discord.streamPreview': '流式预览', + 'discord.streamPreviewDesc': '通过编辑消息实时显示响应预览', + 'discord.setupGuide': '配置指南', + 'discord.setupBotTitle': '创建 Discord Bot', + 'discord.step1': '前往 Discord 开发者门户 (discord.com/developers),点击「New Application」创建应用', + 'discord.step2': '进入左侧「Bot」页面,点击「Add Bot」创建机器人', + 'discord.step3': '向下滚动到「Privileged Gateway Intents」,开启「MESSAGE CONTENT INTENT」开关', + 'discord.step4': '点击「Reset Token」复制 Bot Token,粘贴到上方「Bot 凭据」中保存', + 'discord.step5': '前往「OAuth2 → URL Generator」,Scopes 勾选「bot」,Bot Permissions 勾选「Send Messages」和「Read Message History」', + 'discord.step6': '复制生成的邀请链接,在浏览器中打开,将 Bot 邀请到你的服务器', + 'discord.step7': '回到上方点击「测试连接」,确认显示 Bot 名称即配置成功', + 'discord.setupIdTitle': '获取 ID(需开启开发者模式)', + 'discord.stepDevMode': '打开 Discord 客户端 →「用户设置」→「高级」→ 开启「开发者模式」', + 'discord.stepUserId': '获取用户 ID:右键点击自己的头像或用户名 →「复制用户 ID」', + 'discord.stepChannelId': '获取频道 ID:右键点击左侧频道名称 →「复制频道 ID」', + 'discord.stepGuildId': '获取服务器 ID:右键点击左上角服务器名称 →「复制服务器 ID」', }; export default zh; diff --git a/src/lib/bridge/adapters/discord-adapter.ts b/src/lib/bridge/adapters/discord-adapter.ts new file mode 100644 index 0000000..85ab11a --- /dev/null +++ b/src/lib/bridge/adapters/discord-adapter.ts @@ -0,0 +1,662 @@ +/** + * Discord Adapter — implements BaseChannelAdapter for Discord Bot API. + * + * Uses discord.js v14 Client with Gateway intents for real-time message + * consumption, and REST API for message sending. Routes messages through + * an internal async queue (same pattern as Telegram and Feishu). + * + * IMPORTANT: discord.js is loaded via dynamic import() to avoid Next.js + * bundler trying to resolve native modules (zlib-sync, bufferutil) at + * build time. All discord.js types are referenced via `any` at the class + * level and resolved at runtime in start(). + */ + +import crypto from 'crypto'; +import type { + ChannelType, + InboundMessage, + OutboundMessage, + PreviewCapabilities, + SendResult, +} from '../types'; +import type { FileAttachment } from '@/types'; +import { BaseChannelAdapter, registerAdapterFactory } from '../channel-adapter'; +import { insertAuditLog, getSetting } from '../../db'; + +/** Max number of message IDs to keep for dedup. */ +const DEDUP_MAX = 1000; + +/** Discord message character limit. */ +const DISCORD_CHAR_LIMIT = 2000; + +/** Default max attachment download size (20 MB). */ +const DEFAULT_MAX_ATTACHMENT_SIZE = 20 * 1024 * 1024; + +/** Typing indicator interval (8s, Discord typing lasts ~10s). */ +const TYPING_INTERVAL_MS = 8000; + +/** Interaction TTL for answerCallback (60s). */ +const INTERACTION_TTL_MS = 60_000; + +/** + * Lazily loaded discord.js module reference. + * Populated in start() via dynamic import to avoid bundler issues. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +let discordJs: any = null; + +async function loadDiscordJs() { + if (!discordJs) { + discordJs = await import('discord.js'); + } + return discordJs; +} + +export class DiscordAdapter extends BaseChannelAdapter { + readonly channelType: ChannelType = 'discord'; + + private running = false; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private client: any = null; + private queue: InboundMessage[] = []; + private waiters: Array<(msg: InboundMessage | null) => void> = []; + private seenMessageIds = new Set(); + private botUserId: string | null = null; + private typingIntervals = new Map>(); + /** Temporary storage for Interaction objects (for answerCallback). */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private pendingInteractions = new Map(); + /** Preview: store message IDs per chat for edit-based streaming. */ + private previewMessages = new Map(); + /** Chats where preview has permanently failed. */ + private previewDegraded = new Set(); + + // ── Lifecycle ─────────────────────────────────────────────── + + async start(): Promise { + if (this.running) return; + + const configError = this.validateConfig(); + if (configError) { + console.warn('[discord-adapter] Cannot start:', configError); + return; + } + + const token = getSetting('bridge_discord_bot_token') || ''; + + // Dynamic import to avoid bundler resolving native modules + const djs = await loadDiscordJs(); + const { Client, GatewayIntentBits, Partials } = djs; + + this.client = new Client({ + intents: [ + GatewayIntentBits.Guilds, + GatewayIntentBits.GuildMessages, + GatewayIntentBits.MessageContent, + GatewayIntentBits.DirectMessages, + ], + partials: [Partials.Channel], + }); + + // Register event handlers before login + // eslint-disable-next-line @typescript-eslint/no-explicit-any + this.client.on('messageCreate', (message: any) => { + this.handleMessageCreate(message).catch((err: unknown) => { + console.error('[discord-adapter] messageCreate error:', err instanceof Error ? err.message : err); + }); + }); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + this.client.on('interactionCreate', (interaction: any) => { + this.handleInteraction(interaction).catch((err: unknown) => { + console.error('[discord-adapter] interactionCreate error:', err instanceof Error ? err.message : err); + }); + }); + + // Login and wait for ready + await this.client.login(token); + + // Wait for the ready event + await new Promise((resolve) => { + if (this.client!.isReady()) { + resolve(); + } else { + this.client!.once('ready', () => resolve()); + } + }); + + this.botUserId = this.client.user?.id || null; + this.running = true; + + console.log('[discord-adapter] Started (botUserId:', this.botUserId || 'unknown', ')'); + } + + async stop(): Promise { + if (!this.running) return; + this.running = false; + + // Destroy client + if (this.client) { + try { + this.client.destroy(); + } catch (err) { + console.warn('[discord-adapter] Client destroy error:', err instanceof Error ? err.message : err); + } + this.client = null; + } + + // Reject all waiting consumers + for (const waiter of this.waiters) { + waiter(null); + } + this.waiters = []; + + // Stop all typing indicators + for (const [, interval] of this.typingIntervals) { + clearInterval(interval); + } + this.typingIntervals.clear(); + + // Clear state + this.seenMessageIds.clear(); + this.pendingInteractions.clear(); + this.previewMessages.clear(); + this.previewDegraded.clear(); + + console.log('[discord-adapter] Stopped'); + } + + isRunning(): boolean { + return this.running; + } + + // ── Queue ─────────────────────────────────────────────────── + + consumeOne(): Promise { + const queued = this.queue.shift(); + if (queued) return Promise.resolve(queued); + + if (!this.running) return Promise.resolve(null); + + return new Promise((resolve) => { + this.waiters.push(resolve); + }); + } + + private enqueue(msg: InboundMessage): void { + const waiter = this.waiters.shift(); + if (waiter) { + waiter(msg); + } else { + this.queue.push(msg); + } + } + + // ── Typing indicator ─────────────────────────────────────── + + onMessageStart(chatId: string): void { + this.stopTyping(chatId); + if (!this.client) return; + + const sendTyping = () => { + const channel = this.client?.channels.cache.get(chatId); + if (channel && 'sendTyping' in channel) { + channel.sendTyping().catch(() => {}); + } + }; + + // Send immediately + sendTyping(); + + // Repeat every 8s + const interval = setInterval(sendTyping, TYPING_INTERVAL_MS); + this.typingIntervals.set(chatId, interval); + } + + onMessageEnd(chatId: string): void { + this.stopTyping(chatId); + } + + private stopTyping(chatId: string): void { + const interval = this.typingIntervals.get(chatId); + if (interval) { + clearInterval(interval); + this.typingIntervals.delete(chatId); + } + } + + // ── Send ──────────────────────────────────────────────────── + + async send(message: OutboundMessage): Promise { + if (!this.client) { + return { ok: false, error: 'Discord client not initialized' }; + } + + let channel = this.client.channels.cache.get(message.address.chatId); + if (!channel || !('send' in channel)) { + // Try fetching the channel if not in cache + try { + channel = await this.client.channels.fetch(message.address.chatId); + if (!channel || !('send' in channel)) { + return { ok: false, error: 'Channel not found or not sendable' }; + } + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : 'Channel fetch failed' }; + } + } + + return this.sendToChannel(channel, message); + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private async sendToChannel(channel: any, message: OutboundMessage): Promise { + try { + let text = message.text; + + // Convert HTML to Discord markdown + if (message.parseMode === 'HTML') { + text = this.htmlToDiscordMarkdown(text); + } + // Markdown passes through natively + + // Build message options + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const options: { content: string; components?: any[] } = { + content: text.slice(0, DISCORD_CHAR_LIMIT), + }; + + // Build inline buttons as Discord components + if (message.inlineButtons && message.inlineButtons.length > 0 && discordJs) { + const { ActionRowBuilder, ButtonBuilder, ButtonStyle } = discordJs; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const rows: any[] = []; + for (const row of message.inlineButtons) { + const actionRow = new ActionRowBuilder(); + for (const btn of row) { + actionRow.addComponents( + new ButtonBuilder() + .setCustomId(btn.callbackData) + .setLabel(btn.text) + .setStyle(ButtonStyle.Primary), + ); + } + rows.push(actionRow); + } + options.components = rows; + } + + const sent = await channel.send(options); + return { ok: true, messageId: sent.id }; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : 'Send failed' }; + } + } + + async answerCallback(callbackQueryId: string, text?: string): Promise { + const entry = this.pendingInteractions.get(callbackQueryId); + if (!entry) return; + + this.pendingInteractions.delete(callbackQueryId); + + try { + const interaction = entry.interaction; + if (interaction.isButton() && !interaction.replied) { + await interaction.editReply({ content: text || 'OK' }); + } + } catch { + // Interaction may have expired — non-critical + } + } + + // ── Streaming preview ────────────────────────────────────── + + getPreviewCapabilities(chatId: string): PreviewCapabilities | null { + // Global kill switch + if (getSetting('bridge_discord_stream_enabled') === 'false') return null; + + // Already degraded for this chat + if (this.previewDegraded.has(chatId)) return null; + + return { supported: true, privateOnly: false }; + } + + async sendPreview(chatId: string, text: string, _draftId: number): Promise<'sent' | 'skip' | 'degrade'> { + if (!this.client) return 'skip'; + + const existingMsgId = this.previewMessages.get(chatId); + + try { + if (existingMsgId) { + // Edit existing preview message + const channel = await this.client.channels.fetch(chatId); + if (!channel || !('messages' in channel)) return 'skip'; + + const msg = await channel.messages.fetch(existingMsgId); + await msg.edit(text.slice(0, DISCORD_CHAR_LIMIT)); + return 'sent'; + } else { + // Send new preview message + const channel = await this.client.channels.fetch(chatId); + if (!channel || !('send' in channel)) return 'skip'; + + const sent = await channel.send(text.slice(0, DISCORD_CHAR_LIMIT)); + this.previewMessages.set(chatId, sent.id); + return 'sent'; + } + } catch (err) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const code = (err as any)?.httpStatus; + if (code === 403 || code === 404) { + this.previewDegraded.add(chatId); + return 'degrade'; + } + return 'skip'; + } + } + + endPreview(chatId: string, _draftId: number): void { + const msgId = this.previewMessages.get(chatId); + if (msgId && this.client) { + // Delete the preview message — the final response replaces it + const channel = this.client.channels.cache.get(chatId); + if (channel && 'messages' in channel) { + channel.messages.fetch(msgId).then((msg: { delete: () => void }) => msg.delete()).catch(() => {}); + } + } + this.previewMessages.delete(chatId); + } + + // ── Config & Auth ─────────────────────────────────────────── + + validateConfig(): string | null { + const enabled = getSetting('bridge_discord_enabled'); + if (enabled !== 'true') return 'bridge_discord_enabled is not true'; + + const token = getSetting('bridge_discord_bot_token'); + if (!token) return 'bridge_discord_bot_token not configured'; + + return null; + } + + isAuthorized(userId: string, chatId: string): boolean { + const allowedUsers = getSetting('bridge_discord_allowed_users') || ''; + const allowedChannels = getSetting('bridge_discord_allowed_channels') || ''; + + // If both are empty, deny all (security-first, default-deny) + if (!allowedUsers && !allowedChannels) return false; + + const users = allowedUsers.split(',').map(s => s.trim()).filter(Boolean); + const channels = allowedChannels.split(',').map(s => s.trim()).filter(Boolean); + + // If users list is configured, check if user is in it + if (users.length > 0 && users.includes(userId)) return true; + + // If channels list is configured, check if chat is in it + if (channels.length > 0 && channels.includes(chatId)) return true; + + // If only one list is configured and the other is empty, check only the configured one + if (users.length > 0 && channels.length === 0) return false; + if (channels.length > 0 && users.length === 0) return false; + + return false; + } + + // ── Incoming event handlers ──────────────────────────────── + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private async handleMessageCreate(message: any): Promise { + try { + await this.processMessage(message); + } catch (err) { + console.error( + '[discord-adapter] Unhandled error in message handler:', + err instanceof Error ? err.stack || err.message : err, + ); + } + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private async processMessage(message: any): Promise { + // Filter out bot messages (including self) + if (message.author.bot) return; + if (this.botUserId && message.author.id === this.botUserId) return; + + // Dedup by message ID + if (this.seenMessageIds.has(message.id)) return; + this.addToDedup(message.id); + + const chatId = message.channelId; + const userId = message.author.id; + const displayName = message.author.username || message.author.id; + const isGuild = !!message.guild; + + // Authorization check + if (!this.isAuthorized(userId, chatId)) return; + + // Guild (server) message policy + if (isGuild) { + const allowedGuilds = (getSetting('bridge_discord_allowed_guilds') || '') + .split(',').map(s => s.trim()).filter(Boolean); + + if (allowedGuilds.length > 0 && !allowedGuilds.includes(message.guild!.id)) { + return; + } + + const policy = getSetting('bridge_discord_group_policy') || 'open'; + + if (policy === 'disabled') { + return; + } + + // Require @mention check + const requireMention = getSetting('bridge_discord_require_mention') === 'true'; + if (requireMention && this.botUserId) { + // Check both user @mention and role @mention (bot's managed role) + const userMentioned = message.mentions.users.has(this.botUserId); + const botMember = message.guild?.members?.cache?.get(this.botUserId); + const botRoles = botMember?.roles?.cache; + const roleMentioned = botRoles + ? message.mentions.roles.some((r: { id: string }) => botRoles.has(r.id)) + : false; + // Also check raw content for <@botId> or <@&roleId> patterns + const rawContentMention = message.content?.includes(`<@${this.botUserId}>`) + || message.content?.includes(`<@!${this.botUserId}>`); + const mentioned = userMentioned || roleMentioned || rawContentMention; + if (!mentioned) { + try { + insertAuditLog({ + channelType: 'discord', + chatId, + direction: 'inbound', + messageId: message.id, + summary: '[FILTERED] Guild message dropped: bot not @mentioned (require_mention=true)', + }); + } catch { /* best effort */ } + return; + } + } + } + + // Extract text content + let text = message.content || ''; + + // Strip bot user mention and bot role mention from text + if (this.botUserId) { + text = text.replace(new RegExp(`<@!?${this.botUserId}>`, 'g'), '').trim(); + } + // Strip bot's managed role mentions + if (message.guild && this.botUserId) { + const botMember = message.guild.members?.cache?.get(this.botUserId); + if (botMember?.roles?.cache) { + for (const [roleId] of botMember.roles.cache) { + text = text.replace(new RegExp(`<@&${roleId}>`, 'g'), '').trim(); + } + } + } + + // Normalize ! commands to / commands + if (text.startsWith('!')) { + text = '/' + text.slice(1); + } + + // Handle attachments (images) + const attachments: FileAttachment[] = []; + const imageEnabled = getSetting('bridge_discord_image_enabled') !== 'false'; + const maxSize = parseInt(getSetting('bridge_discord_max_attachment_size') || '', 10) || DEFAULT_MAX_ATTACHMENT_SIZE; + + if (imageEnabled && message.attachments.size > 0) { + for (const [, attachment] of message.attachments) { + if (!attachment.contentType?.startsWith('image/')) continue; + if (attachment.size > maxSize) { + console.warn(`[discord-adapter] Attachment too large (${attachment.size} > ${maxSize}), skipping`); + continue; + } + + try { + const res = await fetch(attachment.url, { signal: AbortSignal.timeout(30_000) }); + if (!res.ok) continue; + + const buffer = Buffer.from(await res.arrayBuffer()); + const base64 = buffer.toString('base64'); + const id = crypto.randomUUID(); + + attachments.push({ + id, + name: attachment.name || `image.${attachment.contentType?.split('/')[1] || 'png'}`, + type: attachment.contentType || 'image/png', + size: buffer.length, + data: base64, + }); + } catch (err) { + console.warn('[discord-adapter] Attachment download failed:', err instanceof Error ? err.message : err); + } + } + } + + if (!text.trim() && attachments.length === 0) return; + + const address = { + channelType: 'discord' as const, + chatId, + userId, + displayName, + }; + + const inbound: InboundMessage = { + messageId: message.id, + address, + text: text.trim(), + timestamp: message.createdTimestamp, + attachments: attachments.length > 0 ? attachments : undefined, + }; + + // Audit log + try { + const summary = attachments.length > 0 + ? `[${attachments.length} attachment(s)] ${text.slice(0, 150)}` + : text.slice(0, 200); + insertAuditLog({ + channelType: 'discord', + chatId, + direction: 'inbound', + messageId: message.id, + summary, + }); + } catch { /* best effort */ } + + this.enqueue(inbound); + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private async handleInteraction(interaction: any): Promise { + if (!interaction.isButton()) return; + + try { + // Defer immediately to avoid 3s timeout + await interaction.deferUpdate(); + } catch { + // Interaction may already be handled + return; + } + + const callbackData = interaction.customId; + const chatId = interaction.channelId; + const userId = interaction.user.id; + const displayName = interaction.user.username; + + if (!this.isAuthorized(userId, chatId)) return; + + // Store interaction for answerCallback with TTL + const interactionId = `discord-${interaction.id}`; + this.pendingInteractions.set(interactionId, { + interaction, + expiresAt: Date.now() + INTERACTION_TTL_MS, + }); + + // Clean up expired interactions + this.cleanupExpiredInteractions(); + + const inbound: InboundMessage = { + messageId: interactionId, + address: { + channelType: 'discord', + chatId, + userId, + displayName, + }, + text: '', + timestamp: Date.now(), + callbackData, + callbackMessageId: interaction.message?.id, + }; + + this.enqueue(inbound); + } + + // ── Utilities ─────────────────────────────────────────────── + + private addToDedup(messageId: string): void { + this.seenMessageIds.add(messageId); + + if (this.seenMessageIds.size > DEDUP_MAX) { + const excess = this.seenMessageIds.size - DEDUP_MAX; + let removed = 0; + for (const id of this.seenMessageIds) { + if (removed >= excess) break; + this.seenMessageIds.delete(id); + removed++; + } + } + } + + private cleanupExpiredInteractions(): void { + const now = Date.now(); + for (const [id, entry] of this.pendingInteractions) { + if (entry.expiresAt < now) { + this.pendingInteractions.delete(id); + } + } + } + + /** + * Convert simple HTML tags to Discord markdown. + * Handles the common tags used in bridge-manager command responses. + */ + private htmlToDiscordMarkdown(html: string): string { + return html + .replace(/(.*?)<\/b>/gi, '**$1**') + .replace(/(.*?)<\/strong>/gi, '**$1**') + .replace(/(.*?)<\/i>/gi, '*$1*') + .replace(/(.*?)<\/em>/gi, '*$1*') + .replace(/(.*?)<\/code>/gi, '`$1`') + .replace(/
([\s\S]*?)<\/pre>/gi, '```\n$1\n```')
+      .replace(/</g, '<')
+      .replace(/>/g, '>')
+      .replace(/&/g, '&')
+      .replace(//gi, '\n')
+      .replace(/<[^>]+>/g, ''); // Strip remaining HTML tags
+  }
+}
+
+// Self-register so bridge-manager can create DiscordAdapter via the registry.
+registerAdapterFactory('discord', () => new DiscordAdapter());
diff --git a/src/lib/bridge/adapters/index.ts b/src/lib/bridge/adapters/index.ts
index a12f801..cf81750 100644
--- a/src/lib/bridge/adapters/index.ts
+++ b/src/lib/bridge/adapters/index.ts
@@ -11,3 +11,4 @@
 
 import './telegram-adapter';
 import './feishu-adapter';
+import './discord-adapter';
diff --git a/src/lib/bridge/bridge-manager.ts b/src/lib/bridge/bridge-manager.ts
index 5576fb8..d743796 100644
--- a/src/lib/bridge/bridge-manager.ts
+++ b/src/lib/bridge/bridge-manager.ts
@@ -17,6 +17,7 @@ import * as engine from './conversation-engine';
 import * as broker from './permission-broker';
 import { deliver, deliverRendered } from './delivery-layer';
 import { markdownToTelegramChunks } from './markdown/telegram';
+import { markdownToDiscordChunks } from './markdown/discord';
 import { getSetting, insertAuditLog, updateChannelBinding } from '../db';
 import { setBridgeModeActive } from '../telegram-bot';
 import { escapeHtml } from './adapters/telegram-utils';
@@ -43,10 +44,18 @@ interface StreamConfig {
   maxChars: number;
 }
 
-function getStreamConfig(): StreamConfig {
-  const intervalMs = parseInt(getSetting('bridge_telegram_stream_interval_ms') || '', 10) || 700;
-  const minDeltaChars = parseInt(getSetting('bridge_telegram_stream_min_delta_chars') || '', 10) || 20;
-  const maxChars = parseInt(getSetting('bridge_telegram_stream_max_chars') || '', 10) || 3900;
+/** Default stream config per channel type. */
+const STREAM_DEFAULTS: Record = {
+  telegram: { intervalMs: 700, minDeltaChars: 20, maxChars: 3900 },
+  discord: { intervalMs: 1500, minDeltaChars: 40, maxChars: 1900 },
+};
+
+function getStreamConfig(channelType = 'telegram'): StreamConfig {
+  const defaults = STREAM_DEFAULTS[channelType] || STREAM_DEFAULTS.telegram;
+  const prefix = `bridge_${channelType}_stream_`;
+  const intervalMs = parseInt(getSetting(`${prefix}interval_ms`) || '', 10) || defaults.intervalMs;
+  const minDeltaChars = parseInt(getSetting(`${prefix}min_delta_chars`) || '', 10) || defaults.minDeltaChars;
+  const maxChars = parseInt(getSetting(`${prefix}max_chars`) || '', 10) || defaults.maxChars;
   return { intervalMs, minDeltaChars, maxChars };
 }
 
@@ -95,6 +104,19 @@ async function deliverResponse(
     }
     return { ok: true };
   }
+  if (adapter.channelType === 'discord') {
+    // Discord: native markdown, chunk at 2000 chars with fence repair
+    const chunks = markdownToDiscordChunks(responseText, 2000);
+    for (let i = 0; i < chunks.length; i++) {
+      const result = await deliver(adapter, {
+        address,
+        text: chunks[i].text,
+        parseMode: 'Markdown',
+      }, { sessionId });
+      if (!result.ok) return result;
+    }
+    return { ok: true };
+  }
   if (adapter.channelType === 'feishu') {
     // Feishu: pass markdown through for adapter to format as post/card
     return deliver(adapter, {
@@ -461,7 +483,7 @@ async function handleMessage(
     };
   }
 
-  const streamCfg = previewState ? getStreamConfig() : null;
+  const streamCfg = previewState ? getStreamConfig(adapter.channelType) : null;
 
   // Build the onPartialText callback (or undefined if preview not supported)
   const onPartialText = (previewState && streamCfg) ? (fullText: string) => {
diff --git a/src/lib/bridge/markdown/discord.ts b/src/lib/bridge/markdown/discord.ts
new file mode 100644
index 0000000..5af5e69
--- /dev/null
+++ b/src/lib/bridge/markdown/discord.ts
@@ -0,0 +1,110 @@
+/**
+ * Discord markdown chunking — splits long markdown text into Discord-safe
+ * chunks (≤2000 chars) with code fence balancing.
+ *
+ * Discord supports native markdown, so no IR→HTML conversion is needed.
+ * The only concern is the 2000-char message limit.
+ */
+
+export interface DiscordChunk {
+  text: string;
+}
+
+/** Soft limit — leave room for fence repair overhead. */
+const SOFT_LIMIT = 1900;
+
+/**
+ * Split markdown into Discord-safe chunks.
+ * Splits at line boundaries and rebalances open code fences at split points.
+ */
+export function markdownToDiscordChunks(
+  markdown: string,
+  limit = 2000,
+): DiscordChunk[] {
+  if (!markdown) return [];
+
+  const softLimit = Math.min(limit - 100, SOFT_LIMIT);
+
+  // Fast path: fits in one message
+  if (markdown.length <= limit) {
+    return [{ text: markdown }];
+  }
+
+  const lines = markdown.split('\n');
+  const chunks: DiscordChunk[] = [];
+  let currentLines: string[] = [];
+  let currentLen = 0;
+  let openFence: string | null = null; // Tracks the opening fence (e.g., "```ts")
+
+  for (const line of lines) {
+    const lineLen = line.length + 1; // +1 for newline
+
+    // Check if this line toggles a fence
+    const fenceMatch = line.match(/^(`{3,})([\w]*)/);
+
+    if (currentLen + lineLen > softLimit && currentLines.length > 0) {
+      // Need to split here
+      let chunkText = currentLines.join('\n');
+
+      // If we're inside a fence, close it at the end of this chunk
+      if (openFence) {
+        chunkText += '\n```';
+      }
+
+      chunks.push({ text: chunkText });
+
+      // Start new chunk — if we were inside a fence, reopen it
+      currentLines = [];
+      currentLen = 0;
+
+      if (openFence) {
+        currentLines.push(openFence);
+        currentLen = openFence.length + 1;
+      }
+    }
+
+    currentLines.push(line);
+    currentLen += lineLen;
+
+    // Track fence state
+    if (fenceMatch) {
+      if (openFence) {
+        // Closing fence
+        openFence = null;
+      } else {
+        // Opening fence — remember the full opening line for re-opening
+        openFence = fenceMatch[0];
+      }
+    }
+  }
+
+  // Flush remaining
+  if (currentLines.length > 0) {
+    let chunkText = currentLines.join('\n');
+    // If still inside an unclosed fence, close it
+    if (openFence) {
+      chunkText += '\n```';
+    }
+    chunks.push({ text: chunkText });
+  }
+
+  // Hard split: if any chunk still exceeds limit, force-split by chars
+  const result: DiscordChunk[] = [];
+  for (const chunk of chunks) {
+    if (chunk.text.length <= limit) {
+      result.push(chunk);
+    } else {
+      // Hard split at limit boundaries
+      let remaining = chunk.text;
+      while (remaining.length > limit) {
+        result.push({ text: remaining.slice(0, limit) });
+        remaining = remaining.slice(limit);
+      }
+      if (remaining) {
+        result.push({ text: remaining });
+      }
+    }
+  }
+
+  return result;
+}