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

+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 });
}
}