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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user