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:
1 parent
538cc1b7fd
commit
113e15e2f4
17 files changed
+1957
-15
No files matched your search
@@ -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() {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user