feat: Discord bridge adapter — 完整实现 Discord 作为第三个桥接渠道

新增文件:
- 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 <noreply@anthropic.com>
This commit is contained in:
7418andClaude Opus 4.6 committed 2026-03-04 23:32:46 +08:00
1 parent 538cc1b7fd
commit 113e15e2f4
17 files changed
+1957 -15

No files matched your search

+31 -1
View File
@@ -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
```
+1 -1
View File
@@ -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,
},
+266 -3
View File
@@ -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",
+4 -2
View File
@@ -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",
+200
View File
@@ -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>(.*?)<\/b>/gi, '**$1**')
.replace(/<strong>(.*?)<\/strong>/gi, '**$1**')
.replace(/<i>(.*?)<\/i>/gi, '*$1*')
.replace(/<em>(.*?)<\/em>/gi, '*$1*')
.replace(/<code>(.*?)<\/code>/gi, '`$1`')
.replace(/<pre>([\s\S]*?)<\/pre>/gi, '```\n$1\n```')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&amp;/g, '&')
.replace(/<br\s*\/?>/gi, '\n')
.replace(/<[^>]+>/g, '');
}
it('converts bold tags', () => {
assert.strictEqual(htmlToDiscordMarkdown('<b>bold</b>'), '**bold**');
});
it('converts italic tags', () => {
assert.strictEqual(htmlToDiscordMarkdown('<i>italic</i>'), '*italic*');
});
it('converts code tags', () => {
assert.strictEqual(htmlToDiscordMarkdown('<code>code</code>'), '`code`');
});
it('converts pre tags', () => {
assert.strictEqual(htmlToDiscordMarkdown('<pre>block</pre>'), '```\nblock\n```');
});
it('decodes HTML entities', () => {
assert.strictEqual(htmlToDiscordMarkdown('A &amp; B'), 'A & B');
});
it('strips unknown HTML tags', () => {
assert.strictEqual(htmlToDiscordMarkdown('<div>text</div>'), 'text');
});
});
+13
View File
@@ -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() {
+73
View File
@@ -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<string, string> = {};
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 });
}
}
@@ -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 });
}
}
+6 -2
View File
@@ -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" && <BridgeSection />}
{activeSection === "telegram" && <TelegramBridgeSection />}
{activeSection === "feishu" && <FeishuBridgeSection />}
{activeSection === "discord" && <DiscordBridgeSection />}
</div>
</div>
</div>
+28 -1
View File
@@ -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() {
/>
</div>
<div className="flex items-center justify-between border-t border-border/30 pt-3">
<div className="flex items-center gap-3">
<HugeiconsIcon
icon={GameController01Icon}
className="h-4 w-4 text-muted-foreground"
/>
<div>
<p className="text-sm">{t("bridge.discordChannel")}</p>
<p className="text-xs text-muted-foreground">
{t("bridge.discordChannelDesc")}
</p>
</div>
</div>
<Switch
checked={isDiscordEnabled}
onCheckedChange={handleToggleDiscord}
disabled={saving}
/>
</div>
<div className="flex items-center justify-between border-t border-border/30 pt-3">
<div>
<p className="text-sm">{t("bridge.autoStart")}</p>
@@ -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<DiscordBridgeSettings>(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<DiscordBridgeSettings>) => {
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<DiscordBridgeSettings> = {};
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 (
<div className="max-w-3xl space-y-6">
{/* Bot Token */}
<div className="rounded-lg border border-border/50 p-4 space-y-4 transition-shadow hover:shadow-sm">
<div>
<h2 className="text-sm font-medium">{t("discord.credentials")}</h2>
<p className="text-xs text-muted-foreground">
{t("discord.credentialsDesc")}
</p>
</div>
<div className="space-y-3">
<div>
<label className="text-xs font-medium text-muted-foreground mb-1 block">
{t("discord.botToken")}
</label>
<Input
type="password"
value={botToken}
onChange={(e) => setBotToken(e.target.value)}
placeholder="MTIzNDU2Nzg5MDEyMzQ1Njc4OQ.XXXXXX.XXXXXXXXXX"
className="font-mono text-sm"
/>
</div>
</div>
<div className="flex items-center gap-2">
<Button size="sm" onClick={handleSaveCredentials} disabled={saving}>
{saving ? t("common.loading") : t("common.save")}
</Button>
<Button
size="sm"
variant="outline"
onClick={handleVerify}
disabled={verifying || !botToken}
>
{verifying ? (
<HugeiconsIcon
icon={Loading02Icon}
className="h-3.5 w-3.5 animate-spin mr-1.5"
/>
) : null}
{t("discord.verify")}
</Button>
</div>
{verifyResult && (
<div
className={`flex items-center gap-2 rounded-md px-3 py-2 text-xs ${
verifyResult.ok
? "bg-green-500/10 text-green-600 dark:text-green-400"
: "bg-red-500/10 text-red-600 dark:text-red-400"
}`}
>
<HugeiconsIcon
icon={verifyResult.ok ? CheckmarkCircle02Icon : Alert02Icon}
className="h-4 w-4 shrink-0"
/>
{verifyResult.message}
</div>
)}
</div>
{/* Allowed Users / Channels */}
<div className="rounded-lg border border-border/50 p-4 space-y-4 transition-shadow hover:shadow-sm">
<div>
<h2 className="text-sm font-medium">
{t("discord.allowedUsers")}
</h2>
<p className="text-xs text-muted-foreground">
{t("discord.allowedUsersDesc")}
</p>
</div>
<div className="space-y-3">
<div>
<label className="text-xs font-medium text-muted-foreground mb-1 block">
{t("discord.allowedUserIds")}
</label>
<Input
value={allowedUsers}
onChange={(e) => setAllowedUsers(e.target.value)}
placeholder="123456789012345678"
className="font-mono text-sm"
/>
<p className="text-xs text-muted-foreground mt-1">
{t("discord.allowedUsersHint")}
</p>
</div>
<div>
<label className="text-xs font-medium text-muted-foreground mb-1 block">
{t("discord.allowedChannelIds")}
</label>
<Input
value={allowedChannels}
onChange={(e) => setAllowedChannels(e.target.value)}
placeholder="123456789012345678"
className="font-mono text-sm"
/>
<p className="text-xs text-muted-foreground mt-1">
{t("discord.allowedChannelsHint")}
</p>
</div>
</div>
</div>
{/* Guild & Group Settings */}
<div className="rounded-lg border border-border/50 p-4 space-y-4 transition-shadow hover:shadow-sm">
<div>
<h2 className="text-sm font-medium">
{t("discord.guildSettings")}
</h2>
<p className="text-xs text-muted-foreground">
{t("discord.guildSettingsDesc")}
</p>
</div>
<div className="space-y-3">
<div>
<label className="text-xs font-medium text-muted-foreground mb-1 block">
{t("discord.allowedGuilds")}
</label>
<Input
value={allowedGuilds}
onChange={(e) => setAllowedGuilds(e.target.value)}
placeholder="123456789012345678"
className="font-mono text-sm"
/>
<p className="text-xs text-muted-foreground mt-1">
{t("discord.allowedGuildsHint")}
</p>
</div>
<div>
<label className="text-xs font-medium text-muted-foreground mb-1 block">
{t("discord.groupPolicy")}
</label>
<Select value={groupPolicy} onValueChange={setGroupPolicy}>
<SelectTrigger className="w-full text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="open">
{t("discord.groupPolicyOpen")}
</SelectItem>
<SelectItem value="disabled">
{t("discord.groupPolicyDisabled")}
</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center justify-between">
<div>
<p className="text-sm">{t("discord.requireMention")}</p>
<p className="text-xs text-muted-foreground">
{t("discord.requireMentionDesc")}
</p>
</div>
<Switch
checked={requireMention}
onCheckedChange={setRequireMention}
/>
</div>
<div className="flex items-center justify-between border-t border-border/30 pt-3">
<div>
<p className="text-sm">{t("discord.streamPreview")}</p>
<p className="text-xs text-muted-foreground">
{t("discord.streamPreviewDesc")}
</p>
</div>
<Switch
checked={streamEnabled}
onCheckedChange={setStreamEnabled}
/>
</div>
</div>
<Button size="sm" onClick={handleSaveGroupSettings} disabled={saving}>
{saving ? t("common.loading") : t("common.save")}
</Button>
</div>
{/* Setup Guide */}
<div className="rounded-lg border border-border/50 p-4 space-y-4 transition-shadow hover:shadow-sm">
<h2 className="text-sm font-medium">
{t("discord.setupGuide")}
</h2>
<div>
<h3 className="text-xs font-medium mb-1.5">
{t("discord.setupBotTitle")}
</h3>
<ol className="text-xs text-muted-foreground space-y-1.5 list-decimal pl-4">
<li>{t("discord.step1")}</li>
<li>{t("discord.step2")}</li>
<li>{t("discord.step3")}</li>
<li>{t("discord.step4")}</li>
<li>{t("discord.step5")}</li>
<li>{t("discord.step6")}</li>
<li>{t("discord.step7")}</li>
</ol>
</div>
<div className="border-t border-border/30 pt-3">
<h3 className="text-xs font-medium mb-1.5">
{t("discord.setupIdTitle")}
</h3>
<ol className="text-xs text-muted-foreground space-y-1.5 list-decimal pl-4">
<li>{t("discord.stepDevMode")}</li>
<li>{t("discord.stepUserId")}</li>
<li>{t("discord.stepChannelId")}</li>
<li>{t("discord.stepGuildId")}</li>
</ol>
</div>
</div>
</div>
);
}
+45
View File
@@ -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;
+45
View File
@@ -576,6 +576,51 @@ const zh: Record<TranslationKey, string> = {
'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;
+662
View File
@@ -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<string>();
private botUserId: string | null = null;
private typingIntervals = new Map<string, ReturnType<typeof setInterval>>();
/** Temporary storage for Interaction objects (for answerCallback). */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
private pendingInteractions = new Map<string, { interaction: any; expiresAt: number }>();
/** Preview: store message IDs per chat for edit-based streaming. */
private previewMessages = new Map<string, string>();
/** Chats where preview has permanently failed. */
private previewDegraded = new Set<string>();
// ── Lifecycle ───────────────────────────────────────────────
async start(): Promise<void> {
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<void>((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<void> {
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<InboundMessage | null> {
const queued = this.queue.shift();
if (queued) return Promise.resolve(queued);
if (!this.running) return Promise.resolve(null);
return new Promise<InboundMessage | null>((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<SendResult> {
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<SendResult> {
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<void> {
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<void> {
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<void> {
// 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<void> {
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>(.*?)<\/b>/gi, '**$1**')
.replace(/<strong>(.*?)<\/strong>/gi, '**$1**')
.replace(/<i>(.*?)<\/i>/gi, '*$1*')
.replace(/<em>(.*?)<\/em>/gi, '*$1*')
.replace(/<code>(.*?)<\/code>/gi, '`$1`')
.replace(/<pre>([\s\S]*?)<\/pre>/gi, '```\n$1\n```')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&amp;/g, '&')
.replace(/<br\s*\/?>/gi, '\n')
.replace(/<[^>]+>/g, ''); // Strip remaining HTML tags
}
}
// Self-register so bridge-manager can create DiscordAdapter via the registry.
registerAdapterFactory('discord', () => new DiscordAdapter());
+1
View File
@@ -11,3 +11,4 @@
import './telegram-adapter';
import './feishu-adapter';
import './discord-adapter';
+27 -5
View File
@@ -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<string, StreamConfig> = {
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) => {
+110
View File
@@ -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;
}