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:
1 parent
f7735e0b9c
commit
6ad821d3af
35 files changed
+5525
-8
No files matched your search
@@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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?.();
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user