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

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