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
@@ -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(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/&/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 & B'), 'A & B');
|
||||
});
|
||||
|
||||
it('strips unknown HTML tags', () => {
|
||||
assert.strictEqual(htmlToDiscordMarkdown('<div>text</div>'), 'text');
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/&/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());
|
||||
@@ -11,3 +11,4 @@
|
||||
|
||||
import './telegram-adapter';
|
||||
import './feishu-adapter';
|
||||
import './discord-adapter';
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user