feat: 多 IM 远程桥接系统 + Bridge 一级导航 + bug 修复

**Bridge 系统核心(src/lib/bridge/)**
- bridge-manager: 生命周期编排、adapter 事件循环、会话级并发控制
- channel-adapter: 抽象基类 + 注册式 adapter 工厂
- channel-router: IM 地址 → CodePilot session 自动映射
- conversation-engine: 服务端消费 streamClaude() SSE 流
- permission-broker: 权限请求转发到 IM 内联按钮
- delivery-layer: 出站分片、限流、重试退避、HTML 降级
- telegram-adapter: Telegram 长轮询 + offset 安全水位 + 幂等去重
- security/: 路径校验、危险输入检测、滑动窗口限流

**Telegram 通知 Bot(src/lib/telegram-bot.ts)**
- 独立通知模式,与 bridge 模式互斥
- bridgeModeActive 使用 globalThis 存储,避免 HMR 重置导致轮询冲突

**API 路由**
- /api/bridge: GET 状态查询 + POST start/stop/auto-start
- /api/bridge/channels: 活跃通道列表
- /api/bridge/settings: bridge 设置读写
- /api/settings/telegram: 凭据管理(含 allowed_users 白名单)
- /api/settings/telegram/verify: 连接验证 + chat ID 自动检测

**UI 重构:Bridge 提升为一级导航**
- NavRail 新增 Bridge 图标导航项
- /bridge 页面:BridgeLayout hash 路由,Bridge + Telegram 两个子 tab
- BridgeSection: 总开关、状态、启停、通道开关、适配器状态、默认设置
- TelegramBridgeSection: 凭据配置 + 白名单 + Setup Guide(6 步)
- Settings 移除 Telegram 和 Bridge tab,只保留 4 个核心 tab

**Bug 修复**
- bridge-manager: runAdapterLoop 在 state.running=true 之前被调用,
  while 条件同步求值为 false 导致消费者循环不启动。修复为先设 running
  再启动 loop
- telegram-bot: bridgeModeActive 改用 globalThis 防止 HMR 丢失状态
- settings/telegram route: TELEGRAM_KEYS 补充 telegram_bridge_allowed_users

**Electron**
- 窗口关闭时 bridge 活跃则最小化到系统托盘而非退出
- 启动时通过 auto-start API 恢复桥接
- preload 暴露 bridge 相关 IPC

**文档**
- docs/bridge-system.md: 完整架构文档
- docs/chat-sdk-integration-feasibility.md: Chat SDK 集成调研
- CLAUDE.md 添加 bridge 文档链接

chore: bump v0.21.0

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
7418andClaude Opus 4.6 committed 2026-03-01 00:18:19 +08:00
1 parent f7735e0b9c
commit 6ad821d3af
35 files changed
+5525 -8

No files matched your search

+33
View File
@@ -0,0 +1,33 @@
import { listChannelBindings } from '@/lib/db';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
/**
* GET /api/bridge/channels — List channel bindings.
*
* Returns all bindings by default. Supports query parameters:
* ?active=true — return only active bindings
* ?active=false — return only inactive bindings
*/
export async function GET(request: Request) {
try {
const { searchParams } = new URL(request.url);
const activeParam = searchParams.get('active');
let bindings = listChannelBindings();
if (activeParam === 'true') {
bindings = bindings.filter(b => b.active);
} else if (activeParam === 'false') {
bindings = bindings.filter(b => !b.active);
}
return Response.json({ bindings });
} catch (error) {
return Response.json(
{ error: error instanceof Error ? error.message : 'Internal error' },
{ status: 500 },
);
}
}
+52
View File
@@ -0,0 +1,52 @@
import { NextRequest } from 'next/server';
import * as bridgeManager from '@/lib/bridge/bridge-manager';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
/**
* GET /api/bridge — Return bridge status (pure query, no side effects)
*/
export async function GET() {
try {
const status = bridgeManager.getStatus();
return Response.json(status);
} catch (error) {
return Response.json(
{ error: error instanceof Error ? error.message : 'Internal error' },
{ status: 500 },
);
}
}
/**
* POST /api/bridge — Start, stop, or auto-start the bridge
* Body: { action: 'start' | 'stop' | 'auto-start' }
*/
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { action } = body;
if (action === 'start') {
await bridgeManager.start();
return Response.json({ ok: true, status: bridgeManager.getStatus() });
} else if (action === 'stop') {
await bridgeManager.stop();
return Response.json({ ok: true, status: bridgeManager.getStatus() });
} else if (action === 'auto-start') {
bridgeManager.tryAutoStart();
return Response.json({ ok: true, status: bridgeManager.getStatus() });
} else {
return Response.json(
{ error: 'Invalid action. Use "start", "stop", or "auto-start".' },
{ status: 400 },
);
}
} catch (error) {
return Response.json(
{ error: error instanceof Error ? error.message : 'Internal error' },
{ status: 500 },
);
}
}
+54
View File
@@ -0,0 +1,54 @@
import { NextResponse } from 'next/server';
import { getSetting, setSetting } from '@/lib/db';
const BRIDGE_SETTING_KEYS = [
'remote_bridge_enabled',
'bridge_telegram_enabled',
'telegram_bridge_allowed_users',
'bridge_auto_start',
'bridge_default_work_dir',
'bridge_default_model',
'bridge_default_provider_id',
] as const;
export async function GET() {
try {
const settings: Record<string, string> = {};
for (const key of BRIDGE_SETTING_KEYS) {
settings[key] = getSetting(key) ?? '';
}
return NextResponse.json({ settings });
} catch {
return NextResponse.json(
{ error: 'Failed to read bridge settings' },
{ status: 500 },
);
}
}
export async function PUT(request: Request) {
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 (BRIDGE_SETTING_KEYS.includes(key as typeof BRIDGE_SETTING_KEYS[number])) {
setSetting(key, String(value));
}
}
return NextResponse.json({ success: true });
} catch {
return NextResponse.json(
{ error: 'Failed to save bridge settings' },
{ status: 500 },
);
}
}
+46 -4
View File
@@ -1,6 +1,7 @@
import { NextRequest } from 'next/server';
import { streamClaude } from '@/lib/claude-client';
import { addMessage, getMessages, getSession, updateSessionTitle, updateSdkSessionId, updateSessionModel, updateSessionProvider, updateSessionProviderId, getSetting, getProvider, getDefaultProviderId, acquireSessionLock, releaseSessionLock, setSessionRuntimeStatus, syncSdkTasks } from '@/lib/db';
import { addMessage, getMessages, getSession, updateSessionTitle, updateSdkSessionId, updateSessionModel, updateSessionProvider, updateSessionProviderId, getSetting, getProvider, getDefaultProviderId, acquireSessionLock, renewSessionLock, releaseSessionLock, setSessionRuntimeStatus, syncSdkTasks } from '@/lib/db';
import { notifySessionStart, notifySessionComplete, notifySessionError } from '@/lib/telegram-bot';
import type { SendMessageRequest, SSEEvent, TokenUsage, MessageContentBlock, FileAttachment } from '@/types';
import crypto from 'crypto';
import fs from 'fs';
@@ -48,6 +49,14 @@ export async function POST(request: NextRequest) {
activeLockId = lockId;
setSessionRuntimeStatus(session_id, 'running');
// Telegram notification: session started (fire-and-forget)
const telegramNotifyOpts = {
sessionId: session_id,
sessionTitle: session.title !== 'New Chat' ? session.title : content.slice(0, 50),
workingDirectory: session.working_directory,
};
notifySessionStart(telegramNotifyOpts).catch(() => {});
// Save user message — persist file metadata so attachments survive page reload
let savedContent = content;
let fileMeta: Array<{ id: string; name: string; type: string; size: number; filePath: string }> | undefined;
@@ -201,8 +210,14 @@ export async function POST(request: NextRequest) {
// Tee the stream: one for client, one for collecting the response
const [streamForClient, streamForCollect] = stream.tee();
// Periodically renew the session lock so long-running tasks don't expire
const lockRenewalInterval = setInterval(() => {
try { renewSessionLock(session_id, lockId, 600); } catch { /* best effort */ }
}, 60_000);
// Save assistant message in background, with cleanup callback to release lock
collectStreamResponse(streamForCollect, session_id, () => {
collectStreamResponse(streamForCollect, session_id, telegramNotifyOpts, () => {
clearInterval(lockRenewalInterval);
releaseSessionLock(session_id, lockId);
setSessionRuntimeStatus(session_id, 'idle');
});
@@ -231,11 +246,18 @@ export async function POST(request: NextRequest) {
}
}
async function collectStreamResponse(stream: ReadableStream<string>, sessionId: string, onComplete?: () => void) {
async function collectStreamResponse(
stream: ReadableStream<string>,
sessionId: string,
telegramOpts: { sessionId?: string; sessionTitle?: string; workingDirectory?: string },
onComplete?: () => void,
) {
const reader = stream.getReader();
const contentBlocks: MessageContentBlock[] = [];
let currentText = '';
let tokenUsage: TokenUsage | null = null;
let hasError = false;
let errorMessage = '';
// Dedup layer: skip duplicate tool_result events by tool_use_id
const seenToolResultIds = new Set<string>();
@@ -318,12 +340,18 @@ async function collectStreamResponse(stream: ReadableStream<string>, sessionId:
} catch {
// skip malformed task_update data
}
} else if (event.type === 'error') {
hasError = true;
errorMessage = event.data || 'Unknown error';
} else if (event.type === 'result') {
try {
const resultData = JSON.parse(event.data);
if (resultData.usage) {
tokenUsage = resultData.usage;
}
if (resultData.is_error) {
hasError = true;
}
// Also capture session_id from result if we missed it from init
if (resultData.session_id) {
updateSdkSessionId(sessionId, resultData.session_id);
@@ -369,7 +397,9 @@ async function collectStreamResponse(stream: ReadableStream<string>, sessionId:
);
}
}
} catch {
} catch (e) {
hasError = true;
errorMessage = e instanceof Error ? e.message : 'Stream reading error';
// Stream reading error - best effort save
if (currentText.trim()) {
contentBlocks.push({ type: 'text', text: currentText });
@@ -390,6 +420,18 @@ async function collectStreamResponse(stream: ReadableStream<string>, sessionId:
}
}
} finally {
// Telegram notifications: completion or error (fire-and-forget)
if (hasError) {
notifySessionError(errorMessage, telegramOpts).catch(() => {});
} else {
// Extract text summary for the completion notification
const textSummary = contentBlocks
.filter((b): b is Extract<MessageContentBlock, { type: 'text' }> => b.type === 'text')
.map((b) => b.text)
.join('')
.trim();
notifySessionComplete(textSummary || undefined, telegramOpts).catch(() => {});
}
onComplete?.();
}
}
+87
View File
@@ -0,0 +1,87 @@
import { NextRequest, NextResponse } from 'next/server';
import { getSetting, setSetting } from '@/lib/db';
import { startPolling, stopPolling } from '@/lib/telegram-bot';
/**
* Telegram Bot notification settings.
* Stored in the SQLite settings table (same as other app settings).
*/
const TELEGRAM_KEYS = [
'telegram_bot_token',
'telegram_chat_id',
'telegram_enabled',
'telegram_notify_start',
'telegram_notify_complete',
'telegram_notify_error',
'telegram_notify_permission',
'telegram_bridge_allowed_users',
] as const;
export async function GET() {
try {
const result: Record<string, string> = {};
for (const key of TELEGRAM_KEYS) {
const value = getSetting(key);
if (value !== undefined) {
// Mask bot token for security
if (key === 'telegram_bot_token' && value.length > 8) {
result[key] = '***' + value.slice(-8);
} else {
result[key] = value;
}
}
}
// Auto-start polling if Telegram is configured (survives server restarts)
const enabled = getSetting('telegram_enabled') === 'true';
const hasToken = !!getSetting('telegram_bot_token');
const hasChatId = !!getSetting('telegram_chat_id');
if (enabled && hasToken && hasChatId) {
startPolling();
}
return NextResponse.json({ settings: result });
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to read Telegram 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 (!TELEGRAM_KEYS.includes(key as typeof TELEGRAM_KEYS[number])) continue;
const strValue = String(value ?? '').trim();
// Don't overwrite token if user sent the masked version back
if (key === 'telegram_bot_token' && strValue.startsWith('***')) {
continue;
}
setSetting(key, strValue);
}
// Start or stop long-polling based on the enabled state
const enabled = getSetting('telegram_enabled') === 'true';
const hasToken = !!getSetting('telegram_bot_token');
const hasChatId = !!getSetting('telegram_chat_id');
if (enabled && hasToken && hasChatId) {
startPolling();
} else {
stopPolling();
}
return NextResponse.json({ success: true });
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to save Telegram settings';
return NextResponse.json({ error: message }, { status: 500 });
}
}
@@ -0,0 +1,77 @@
import { NextRequest, NextResponse } from 'next/server';
import { verifyBot, detectChatId } from '@/lib/telegram-bot';
import { getSetting } from '@/lib/db';
import { callTelegramApi } from '@/lib/bridge/adapters/telegram-utils';
/**
* POST /api/settings/telegram/verify
*
* Actions:
* - { action: "verify", bot_token, chat_id? } — Verify bot token, optionally send test message
* - { action: "detect_chat_id", bot_token } — Auto-detect chat ID from recent messages
* - { bot_token, chat_id? } — (Legacy) same as action: "verify"
*
* If bot_token starts with "***" (masked), falls back to the stored token in DB.
* If bot_token is omitted, also falls back to the stored token.
*/
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { action, chat_id } = body;
let bot_token: string = body.bot_token || '';
// If token is masked or missing, use the stored one
if (!bot_token || bot_token.startsWith('***')) {
const stored = getSetting('telegram_bot_token');
if (stored) {
bot_token = stored;
}
}
if (!bot_token) {
return NextResponse.json({ error: 'bot_token is required' }, { status: 400 });
}
// Auto-detect chat ID from recent bot messages
if (action === 'detect_chat_id') {
const result = await detectChatId(bot_token);
return NextResponse.json(result);
}
// Register bot commands menu with Telegram
if (action === 'register_commands') {
const res = await callTelegramApi(bot_token, 'setMyCommands', {
commands: [
{ command: 'new', description: 'Start new session (optionally specify path)' },
{ command: 'bind', description: 'Bind to existing session' },
{ command: 'cwd', description: 'Change working directory' },
{ command: 'mode', description: 'Switch mode: plan / code / ask' },
{ command: 'status', description: 'Show current session status' },
{ command: 'sessions', description: 'List recent sessions' },
{ command: 'stop', description: 'Stop current task' },
{ command: 'help', description: 'Show available commands' },
],
});
return NextResponse.json({ ok: res.ok, error: res.error });
}
// Default: verify bot token
const result = await verifyBot(bot_token, chat_id || undefined);
if (!result.ok) {
return NextResponse.json({
verified: false,
error: result.error,
botName: result.botName,
});
}
return NextResponse.json({
verified: true,
botName: result.botName,
});
} catch (error) {
const message = error instanceof Error ? error.message : 'Verification failed';
return NextResponse.json({ error: message }, { status: 500 });
}
}