diff --git a/CLAUDE.md b/CLAUDE.md index b48ab7e..2fb5fda 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -68,6 +68,7 @@ CodePilot — Claude Code 的桌面 GUI 客户端,基于 Electron + Next.js。 ## Architecture Docs - [Agent Tooling & TodoWrite Bridge](docs/agent-tooling-todo-bridge.md) — SDK → SSE → DB 事件流、TodoWrite 字段映射、tool_result 三层去重策略 +- [多 IM 远程会话桥接系统](docs/bridge-system.md) — Telegram 远程操控 Claude 会话,含目录结构、数据流、关键设计决策 ## Build Notes diff --git a/docs/bridge-system.md b/docs/bridge-system.md new file mode 100644 index 0000000..8ba2d42 --- /dev/null +++ b/docs/bridge-system.md @@ -0,0 +1,127 @@ +# 多 IM 远程会话桥接系统 + +## 核心思路 + +让用户通过 Telegram(后续可扩展 Discord/飞书等)远程操控 CodePilot 中的 Claude 会话。复用现有 `streamClaude()` 管线,在服务端消费 SSE 流,而非通过浏览器。 + +## 目录结构 + +``` +src/lib/bridge/ +├── types.ts # 共享类型(ChannelBinding, BridgeStatus, InboundMessage 等) +├── channel-adapter.ts # 抽象基类 + adapter 注册表(registerAdapterFactory/createAdapter) +├── channel-router.ts # (channel, user, thread) → session 映射,自动创建/绑定会话 +├── conversation-engine.ts # 服务端消费 streamClaude() SSE 流,保存消息到 DB +├── permission-broker.ts # 权限请求转发到 IM 内联按钮,处理回调审批 +├── delivery-layer.ts # 出站消息分片、限流、重试退避、HTML 降级 +├── bridge-manager.ts # 生命周期编排,adapter 事件循环,/stop abort,命令路由 +├── adapters/ +│ ├── index.ts # Adapter 目录文件(side-effect import 自注册所有 adapter) +│ ├── telegram-adapter.ts # Telegram 长轮询 + offset 安全水位 + 自注册 +│ └── telegram-utils.ts # callTelegramApi / escapeHtml / splitMessage +└── security/ + ├── rate-limiter.ts # 按 chat 滑动窗口限流(20 条/分钟) + └── validators.ts # 路径/SessionID/危险输入校验 +``` + +## 数据流 + +``` +Telegram 消息 → TelegramAdapter.pollLoop() → enqueue() + → BridgeManager.runAdapterLoop() → handleMessage() + → 命令? → handleCommand() 处理 /new /bind /cwd /mode /stop 等 + → 普通消息? → ChannelRouter.resolve() 获取 ChannelBinding + → ConversationEngine.processMessage() + → streamClaude() 获取 SSE 流 + → consumeStream() 服务端消费 + → permission_request → 立即回调 → PermissionBroker 转发到 IM + → text/tool_use/tool_result → 累积内容块 + → result → 捕获 tokenUsage + sdkSessionId + → addMessage() 保存到 DB + → DeliveryLayer.deliver() → 分片 + 限流 + 发送到 Telegram + → finally: adapter.acknowledgeUpdate(updateId) → 推进 committedOffset 并持久化 +``` + +## DB 表(在 db.ts migrateDb 中) + +| 表 | 用途 | +|---|------| +| channel_bindings | IM 地址 → CodePilot session 映射 | +| channel_offsets | 轮询 offset 持久化(key 为 bot user ID,通过 getMe API 获取) | +| channel_dedupe | 出站消息幂等去重 | +| channel_outbound_refs | 平台消息 ID 映射 | +| channel_audit_logs | 审计日志 | +| channel_permission_links | 权限请求 → IM 消息映射(含 resolved 标记) | + +## 关键设计决策 + +**1. 权限请求死锁解决** +SSE 流在 `permission_request` 事件处会阻塞等待审批。`consumeStream()` 通过 `onPermissionRequest` 回调在流消费过程中立即转发到 IM,而非等流结束后再转发。 + +**2. Offset 安全水位** +分离 `fetchOffset`(用于 getUpdates API)和 `committedOffset`(持久化到 DB)。消息入队时仅推进 fetchOffset,只有在 bridge-manager 完整处理完消息后(handleMessage 的 finally 块),才调用 `adapter.acknowledgeUpdate(updateId)` 推进 committedOffset 并持久化到 DB。这确保崩溃时未处理完的消息会被重新投递。内存 dedup set 防止重启后重复处理。 + +**2a. Bot 身份标识** +Offset 的 DB key 使用 Telegram `getMe` API 返回的 bot user ID(如 `telegram:bot123456`),而非 token hash。好处是 token 轮换后 offset 不丢失。首次迁移时自动将旧 token-hash key 的值复制到新 bot-ID key。 + +**3. 并发模型** +`processWithSessionLock()` 实现同会话串行、跨会话并行。不同用户的消息不互相阻塞。 + +**4. Adapter 注册式架构** +新 IM 只需实现 `BaseChannelAdapter` 并调用 `registerAdapterFactory()` 自注册,然后在 `adapters/index.ts` 中添加一行 side-effect import。bridge-manager 通过 `import './adapters'` 加载目录,registry 自动发现所有已注册的 adapter,无硬编码依赖。 + +**5. 权限回调安全** +PermissionBroker 在处理 IM 内联按钮回调时,验证 callbackData 中的 chatId 和 messageId 与存储的 permission_link 记录匹配,防止跨聊天伪造审批。`markPermissionLinkResolved()` 使用 `AND resolved = 0` 原子条件更新,确保同一权限请求不被重复审批。 + +**6. 输入校验** +`security/validators.ts` 对所有 IM 入站命令参数做校验:工作目录路径(拒绝 `..`、null 字节、shell 元字符)、session ID(hex/UUID 格式)、危险输入检测(命令注入、管道符)。`sanitizeInput()` 剥离控制字符并限制 32K 长度。 + +**7. runAdapterLoop 必须在 state.running = true 之后启动** +`runAdapterLoop` 内部是 fire-and-forget 的 async IIFE,循环条件 `while (state.running && ...)` 在第一个 `await` 之前同步求值。如果调用时 `state.running` 还是 `false`,循环直接跳过,消费者永远不会启动,消息入队后无人消费。`start()` 中必须先设 `state.running = true`,再调用 `runAdapterLoop`。 + +**8. 出站限流** +`security/rate-limiter.ts` 按 chatId 滑动窗口限流(默认 20 条/分钟)。`DeliveryLayer` 在每次发送前调用 `rateLimiter.acquire(chatId)` 阻塞等待配额,分片间额外加 300ms 节流。错误分类:429 尊重 `retry_after`、5xx 指数退避、4xx 不重试、解析错误降级纯文本。 + +## 设置项(settings 表) + +| Key | 说明 | +|-----|------| +| remote_bridge_enabled | 总开关 | +| bridge_telegram_enabled | Telegram 通道开关 | +| bridge_auto_start | 服务启动时自动拉起桥接 | +| bridge_default_work_dir | 新建会话默认工作目录 | +| bridge_default_model | 新建会话默认模型 | +| bridge_default_provider_id | 新建会话默认服务商 | +| telegram_bridge_allowed_users | 白名单用户 ID(逗号分隔) | + +## API 路由 + +| 路由 | 方法 | 功能 | +|------|------|------| +| /api/bridge | GET | 返回 BridgeStatus(纯查询,无副作用) | +| /api/bridge | POST | `{ action: 'start' \| 'stop' \| 'auto-start' }` | +| /api/bridge/channels | GET | 列出活跃通道(支持 `?active=true/false` 过滤) | +| /api/bridge/settings | GET/PUT | 读写 bridge 设置 | + +## Telegram 命令 + +| 命令 | 功能 | +|------|------| +| /new [path] | 新建会话 | +| /bind \ | 绑定已有会话 | +| /cwd /path | 切换工作目录 | +| /mode plan\|code\|ask | 切换模式 | +| /status | 当前状态 | +| /sessions | 列出会话 | +| /stop | 中止运行中任务 | +| /help | 帮助 | + +## 相关文件(bridge 之外) + +- `src/lib/telegram-bot.ts` — 通知模式(UI 发起会话的通知),与 bridge 模式互斥 +- `src/lib/permission-registry.ts` — 权限 Promise 注册表,bridge 和 UI 共用 +- `src/lib/claude-client.ts` — streamClaude(),bridge 和 UI 共用 +- `src/components/bridge/BridgeSection.tsx` — Bridge 设置 UI(一级导航 /bridge) +- `src/components/bridge/TelegramBridgeSection.tsx` — Telegram 凭据 + 白名单设置 UI(/bridge#telegram) +- `electron/main.ts` — 窗口关闭时 bridge 活跃则保持后台运行;启动时通过 POST `auto-start` 触发桥接恢复 +- `src/app/api/settings/telegram/verify/route.ts` — 支持 `register_commands` action 注册 Telegram 命令菜单 diff --git a/docs/chat-sdk-integration-feasibility.md b/docs/chat-sdk-integration-feasibility.md new file mode 100644 index 0000000..62a2d1a --- /dev/null +++ b/docs/chat-sdk-integration-feasibility.md @@ -0,0 +1,73 @@ +# Chat SDK 集成可行性调研报告 + +## 背景 + +调研 Vercel 最新推出的 Chat SDK(`npm install chat`)是否能集成到 CodePilot 中,实现通过 Telegram 等 IM 远程控制 AI 应用。 + +--- + +## 一、Chat SDK 是什么 + +Chat SDK(包名 `chat`)是 Vercel 推出的**独立于 AI SDK 的新包**,核心能力是:**一套代码部署到多个聊天平台**。 + +- 包名:`chat`(不是 `ai`) +- 已支持平台:Slack、Microsoft Teams、Google Chat、Discord、**Telegram**、GitHub、Linear +- 通过 Matrix 适配器(Beeper Cloud)还可桥接 WhatsApp、Instagram、Signal 等 +- 与 AI SDK 是互补关系:AI SDK 负责 LLM 调用,Chat SDK 负责多平台消息分发 + +## 二、CodePilot 当前架构 + +| 层级 | 技术 | 说明 | +|------|------|------| +| 核心引擎 | `@anthropic-ai/claude-agent-sdk` | 主要的 Claude Code 交互,工具执行、权限管理 | +| 辅助生成 | `ai` + `@ai-sdk/*` | 仅用于图片生成和文本批处理规划 | +| 通信协议 | 自定义 SSE | 12 种事件类型(text、tool_use、permission_request 等) | +| 前端 | Next.js + 自定义组件 | 不使用 useChat,完全自建的流管理和 UI | +| 运行环境 | Electron 桌面端 | 本地进程,操作用户本地文件系统 | + +**关键点**:CodePilot 的核心是 Claude Agent SDK 驱动的**本地代码执行引擎**,不是简单的 LLM 聊天。它涉及文件读写、终端命令、权限审批等本地操作。 + +## 三、能否集成?技术分析 + +### 可行的部分 + +Chat SDK 的 Telegram 适配器可以实现: +- 接收 Telegram 消息并转发给后端 +- 将文本回复推送回 Telegram +- 支持按钮交互(inline keyboard) +- AI SDK 流式输出直接推送到 Telegram + +### 不可行 / 困难的部分 + +| 问题 | 说明 | +|------|------| +| **权限审批** | Claude Code 执行工具前需要用户授权(文件写入、命令执行),Telegram 的交互能力有限(callback data 仅 64 字节),难以承载复杂的权限审批流 | +| **富内容展示** | CodePilot 的 UI 包含代码高亮、diff 预览、文件树、终端输出、思维链等,Telegram 仅支持纯文本 + Markdown | +| **本地文件操作** | Claude Code 需要操作本地文件系统,而 Chat SDK 是服务端部署的,需要额外的远程访问层 | +| **会话状态** | 当前的 SSE 流管理和 session 恢复机制是为 Electron 设计的,需要大幅改造才能适配 Chat SDK 的事件模型 | +| **部署模型不同** | Chat SDK 需要一个常驻服务端来接收 webhook,而 CodePilot 是桌面应用 | + +### 架构冲突 + +``` +当前:用户 ←→ Electron UI ←→ Next.js API ←→ Claude Agent SDK ←→ 本地文件系统 + ↑ +Chat SDK 需要:Telegram ←→ 云端服务 ←→ ??? ←→ 本地文件系统(断裂) +``` + +Chat SDK 本质是让 **服务端 bot** 部署到多个平台。而 CodePilot 是**本地桌面应用**直接操作用户机器上的文件。这两个模型之间存在根本性的架构鸿沟。 + +## 四、结论 + +**不建议集成 Chat SDK 到 CodePilot。** 原因: + +1. **用途不匹配**:Chat SDK 解决的是「一个 bot 部署到多个聊天平台」的问题,而 CodePilot 是本地代码执行工具,不是聊天 bot +2. **改造成本极高**:需要引入云端中转服务、远程文件访问、简化版权限审批、消息格式降级等大量基础设施 +3. **体验必然降级**:Telegram 无法承载 CodePilot 当前的富交互体验(代码 diff、文件树、工具审批等) +4. **安全风险**:通过 Telegram 远程触发本地文件操作和终端命令,需要非常谨慎的安全设计 + +### 如果确实想要远程控制能力 + +更合理的路径是: +- **方案 A**:为 CodePilot 加一个 Web 远程访问层(类似 VS Code Remote),保留完整 UI 体验 +- **方案 B**:做一个极简的 Telegram bot 只负责监控和通知(任务进度、错误告警),不做执行控制 diff --git a/electron/main.ts b/electron/main.ts index 16664cb..478b2db 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -1,4 +1,4 @@ -import { app, BrowserWindow, nativeImage, dialog, session, utilityProcess, ipcMain, shell } from 'electron'; +import { app, BrowserWindow, nativeImage, dialog, session, utilityProcess, ipcMain, shell, Tray, Menu } from 'electron'; import path from 'path'; import { execFileSync, spawn, ChildProcess } from 'child_process'; import fs from 'fs'; @@ -14,6 +14,7 @@ let serverExited = false; let serverExitCode: number | null = null; let userShellEnv: Record = {}; let isQuitting = false; +let tray: Tray | null = null; // --- Install orchestrator --- interface InstallStep { @@ -85,6 +86,130 @@ function killServer(): Promise { }); } +/** + * Check if the remote bridge is currently active by querying the local API. + */ +async function isBridgeActive(): Promise { + if (!serverPort) return false; + try { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const http = require('http'); + return await new Promise((resolve) => { + const req = http.get(`http://127.0.0.1:${serverPort}/api/bridge`, (res: { statusCode?: number; on: (event: string, cb: (data?: Buffer) => void) => void }) => { + let body = ''; + res.on('data', (chunk: Buffer) => { body += chunk.toString(); }); + res.on('end', () => { + try { + const data = JSON.parse(body); + resolve(data.running === true); + } catch { + resolve(false); + } + }); + }); + req.on('error', () => resolve(false)); + req.setTimeout(2000, () => { req.destroy(); resolve(false); }); + }); + } catch { + return false; + } +} + +/** + * Stop the remote bridge by posting to the local API. + */ +async function stopBridge(): Promise { + if (!serverPort) return; + try { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const http = require('http'); + await new Promise((resolve) => { + const postData = JSON.stringify({ action: 'stop' }); + const req = http.request({ + hostname: '127.0.0.1', + port: serverPort, + path: '/api/bridge', + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(postData), + }, + }, () => { resolve(); }); + req.on('error', () => resolve()); + req.setTimeout(3000, () => { req.destroy(); resolve(); }); + req.write(postData); + req.end(); + }); + } catch { + // ignore — bridge may already be stopped + } +} + +/** + * Create a system tray icon for background bridge mode. + * Called when all windows are closed but the bridge is still active. + */ +function createTray(): void { + if (tray) return; + + const iconPath = getIconPath(); + const trayIcon = nativeImage.createFromPath(iconPath).resize({ width: 16, height: 16 }); + tray = new Tray(trayIcon); + tray.setToolTip('CodePilot — Bridge Active'); + + const contextMenu = Menu.buildFromTemplate([ + { + label: 'Open CodePilot', + click: () => { + if (BrowserWindow.getAllWindows().length === 0) { + createWindow(`http://127.0.0.1:${serverPort || 3000}`); + if (!isDev && mainWindow) { + setUpdaterWindow(mainWindow); + } + } else { + mainWindow?.focus(); + } + }, + }, + { type: 'separator' }, + { + label: 'Bridge Status: Active', + enabled: false, + }, + { type: 'separator' }, + { + label: 'Stop Bridge & Quit', + click: async () => { + await stopBridge(); + destroyTray(); + await killServer(); + app.quit(); + }, + }, + ]); + + tray.setContextMenu(contextMenu); + + // Double-click on tray icon opens the window (macOS/Windows) + tray.on('double-click', () => { + if (BrowserWindow.getAllWindows().length === 0) { + createWindow(`http://127.0.0.1:${serverPort || 3000}`); + if (!isDev && mainWindow) { + setUpdaterWindow(mainWindow); + } + } else { + mainWindow?.focus(); + } + }); +} + +function destroyTray(): void { + if (tray) { + tray.destroy(); + tray = null; + } +} + /** * Verify that better_sqlite3.node in standalone resources is compatible * with this Electron runtime's ABI. If it was built for a different @@ -754,6 +879,11 @@ app.whenReady().then(async () => { return shell.openPath(folderPath); }); + // Bridge status IPC + ipcMain.handle('bridge:is-active', async () => { + return isBridgeActive(); + }); + // Native folder picker dialog ipcMain.handle('dialog:open-folder', async (_event, options?: { defaultPath?: string; title?: string }) => { if (!mainWindow) return { canceled: true, filePaths: [] }; @@ -788,6 +918,24 @@ app.whenReady().then(async () => { if (mainWindow) { mainWindow.loadURL(`http://127.0.0.1:${port}`); } + + // Trigger bridge auto-start via explicit POST (only checks setting once) + // eslint-disable-next-line @typescript-eslint/no-require-imports + const http = require('http'); + const autoStartData = JSON.stringify({ action: 'auto-start' }); + const autoStartReq = http.request({ + hostname: '127.0.0.1', + port, + path: '/api/bridge', + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(autoStartData), + }, + }, () => {}); + autoStartReq.on('error', () => {}); + autoStartReq.write(autoStartData); + autoStartReq.end(); } // Initialize auto-updater in packaged mode only @@ -805,6 +953,15 @@ app.whenReady().then(async () => { }); app.on('window-all-closed', async () => { + // If bridge is active, keep the server running and show a tray icon + const bridgeActive = await isBridgeActive(); + if (bridgeActive) { + console.log('Bridge is active — keeping server alive in background with tray icon'); + createTray(); + return; + } + + destroyTray(); await killServer(); if (process.platform !== 'darwin') { app.quit(); @@ -812,6 +969,9 @@ app.on('window-all-closed', async () => { }); app.on('activate', async () => { + // If tray is active (bridge background mode), destroy it when user re-opens + destroyTray(); + if (BrowserWindow.getAllWindows().length === 0) { try { if (!isDev && !serverProcess) { @@ -852,9 +1012,13 @@ app.on('before-quit', async (e) => { installProcess = null; } + destroyTray(); + if (serverProcess && !isQuitting) { isQuitting = true; e.preventDefault(); + // Stop bridge gracefully before killing the server + await stopBridge(); await killServer(); app.quit(); } diff --git a/electron/preload.ts b/electron/preload.ts index 7c97cc3..7a49425 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -25,6 +25,9 @@ contextBridge.exposeInMainWorld('electronAPI', { return () => { ipcRenderer.removeListener('install:progress', listener); }; }, }, + bridge: { + isActive: () => ipcRenderer.invoke('bridge:is-active'), + }, // Native auto-updater bridge — disabled (code signature issues on macOS). // Users are directed to download from GitHub Releases. // TODO: Re-enable after obtaining Apple Developer certificate. diff --git a/package-lock.json b/package-lock.json index ba1c154..66b0a3d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "codepilot", - "version": "0.20.1", + "version": "0.21.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "codepilot", - "version": "0.20.1", + "version": "0.21.0", "dependencies": { "@ai-sdk/anthropic": "^3.0.47", "@ai-sdk/google": "^3.0.31", diff --git a/package.json b/package.json index 96a99ec..9d75d5e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codepilot", - "version": "0.20.1", + "version": "0.21.0", "private": true, "author": { "name": "op7418", diff --git a/src/app/api/bridge/channels/route.ts b/src/app/api/bridge/channels/route.ts new file mode 100644 index 0000000..07e0fe0 --- /dev/null +++ b/src/app/api/bridge/channels/route.ts @@ -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 }, + ); + } +} diff --git a/src/app/api/bridge/route.ts b/src/app/api/bridge/route.ts new file mode 100644 index 0000000..c1021cc --- /dev/null +++ b/src/app/api/bridge/route.ts @@ -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 }, + ); + } +} diff --git a/src/app/api/bridge/settings/route.ts b/src/app/api/bridge/settings/route.ts new file mode 100644 index 0000000..ef564ac --- /dev/null +++ b/src/app/api/bridge/settings/route.ts @@ -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 = {}; + 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 }, + ); + } +} diff --git a/src/app/api/chat/route.ts b/src/app/api/chat/route.ts index 0d65a35..ce38af6 100644 --- a/src/app/api/chat/route.ts +++ b/src/app/api/chat/route.ts @@ -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, sessionId: string, onComplete?: () => void) { +async function collectStreamResponse( + stream: ReadableStream, + 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(); @@ -318,12 +340,18 @@ async function collectStreamResponse(stream: ReadableStream, 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, 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, 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 => b.type === 'text') + .map((b) => b.text) + .join('') + .trim(); + notifySessionComplete(textSummary || undefined, telegramOpts).catch(() => {}); + } onComplete?.(); } } diff --git a/src/app/api/settings/telegram/route.ts b/src/app/api/settings/telegram/route.ts new file mode 100644 index 0000000..be64f42 --- /dev/null +++ b/src/app/api/settings/telegram/route.ts @@ -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 = {}; + 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 }); + } +} diff --git a/src/app/api/settings/telegram/verify/route.ts b/src/app/api/settings/telegram/verify/route.ts new file mode 100644 index 0000000..674e414 --- /dev/null +++ b/src/app/api/settings/telegram/verify/route.ts @@ -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 }); + } +} diff --git a/src/app/bridge/page.tsx b/src/app/bridge/page.tsx new file mode 100644 index 0000000..fe8968c --- /dev/null +++ b/src/app/bridge/page.tsx @@ -0,0 +1,20 @@ +"use client"; + +import { Suspense } from "react"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { Loading02Icon } from "@hugeicons/core-free-icons"; +import { BridgeLayout } from "@/components/bridge/BridgeLayout"; + +export default function BridgePage() { + return ( + + + + } + > + + + ); +} diff --git a/src/components/bridge/BridgeLayout.tsx b/src/components/bridge/BridgeLayout.tsx new file mode 100644 index 0000000..b20d806 --- /dev/null +++ b/src/components/bridge/BridgeLayout.tsx @@ -0,0 +1,93 @@ +"use client"; + +import { useState, useCallback, useSyncExternalStore } from "react"; +import { HugeiconsIcon } from "@hugeicons/react"; +import type { IconSvgElement } from "@hugeicons/react"; +import { Wifi01Icon, TelegramIcon } from "@hugeicons/core-free-icons"; +import { cn } from "@/lib/utils"; +import { BridgeSection } from "./BridgeSection"; +import { TelegramBridgeSection } from "./TelegramBridgeSection"; +import { useTranslation } from "@/hooks/useTranslation"; +import type { TranslationKey } from "@/i18n"; + +type Section = "bridge" | "telegram"; + +interface SidebarItem { + id: Section; + label: string; + icon: IconSvgElement; +} + +const sidebarItems: SidebarItem[] = [ + { id: "bridge", label: "Bridge", icon: Wifi01Icon }, + { id: "telegram", label: "Telegram", icon: TelegramIcon }, +]; + +function getSectionFromHash(): Section { + if (typeof window === "undefined") return "bridge"; + const hash = window.location.hash.replace("#", ""); + if (sidebarItems.some((item) => item.id === hash)) { + return hash as Section; + } + return "bridge"; +} + +function subscribeToHash(callback: () => void) { + window.addEventListener("hashchange", callback); + return () => window.removeEventListener("hashchange", callback); +} + +export function BridgeLayout() { + const hashSection = useSyncExternalStore(subscribeToHash, getSectionFromHash, () => "bridge" as Section); + const [overrideSection, setOverrideSection] = useState
(null); + const activeSection = overrideSection ?? hashSection; + + const { t } = useTranslation(); + + const bridgeLabelKeys: Record = { + 'Bridge': 'bridge.title', + 'Telegram': 'bridge.telegramSettings', + }; + + const handleSectionChange = useCallback((section: Section) => { + setOverrideSection(section); + window.history.replaceState(null, "", `/bridge#${section}`); + queueMicrotask(() => setOverrideSection(null)); + }, []); + + return ( +
+
+

{t('bridge.title')}

+

+ {t('bridge.description')} +

+
+ +
+ + +
+ {activeSection === "bridge" && } + {activeSection === "telegram" && } +
+
+
+ ); +} diff --git a/src/components/bridge/BridgeSection.tsx b/src/components/bridge/BridgeSection.tsx new file mode 100644 index 0000000..73d357e --- /dev/null +++ b/src/components/bridge/BridgeSection.tsx @@ -0,0 +1,509 @@ +"use client"; + +import { useState, useCallback, useEffect, useRef } from "react"; +import { Button } from "@/components/ui/button"; +import { Switch } from "@/components/ui/switch"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { Loading02Icon, CheckmarkCircle02Icon, Alert02Icon, TelegramIcon } from "@hugeicons/core-free-icons"; +import { useTranslation } from "@/hooks/useTranslation"; +import type { ProviderModelGroup } from "@/types"; + +interface AdapterStatus { + channelType: string; + running: boolean; + connectedAt: string | null; + lastMessageAt: string | null; + error: string | null; +} + +interface BridgeStatus { + running: boolean; + startedAt: string | null; + adapters: AdapterStatus[]; +} + +interface BridgeSettings { + remote_bridge_enabled: string; + bridge_telegram_enabled: string; + bridge_auto_start: string; + bridge_default_work_dir: string; + bridge_default_model: string; + bridge_default_provider_id: string; +} + +const DEFAULT_SETTINGS: BridgeSettings = { + remote_bridge_enabled: "", + bridge_telegram_enabled: "", + bridge_auto_start: "", + bridge_default_work_dir: "", + bridge_default_model: "", + bridge_default_provider_id: "", +}; + +export function BridgeSection() { + const [settings, setSettings] = useState(DEFAULT_SETTINGS); + const [bridgeStatus, setBridgeStatus] = useState(null); + const [saving, setSaving] = useState(false); + const [starting, setStarting] = useState(false); + const [stopping, setStopping] = useState(false); + const [workDir, setWorkDir] = useState(""); + const [model, setModel] = useState(""); + const [providerGroups, setProviderGroups] = useState([]); + const pollRef = useRef | null>(null); + const { t } = useTranslation(); + + const fetchSettings = useCallback(async () => { + try { + const res = await fetch("/api/bridge/settings"); + if (res.ok) { + const data = await res.json(); + const s = { ...DEFAULT_SETTINGS, ...data.settings }; + setSettings(s); + setWorkDir(s.bridge_default_work_dir); + // Build composite value for Select: "provider_id::model" + if (s.bridge_default_provider_id && s.bridge_default_model) { + setModel(`${s.bridge_default_provider_id}::${s.bridge_default_model}`); + } else if (s.bridge_default_model) { + setModel(s.bridge_default_model); + } else { + setModel(""); + } + } + } catch { + // ignore + } + }, []); + + const fetchStatus = useCallback(async () => { + try { + const res = await fetch("/api/bridge"); + if (res.ok) { + const data = await res.json(); + setBridgeStatus(data); + } + } catch { + // ignore + } + }, []); + + const fetchModels = useCallback(async () => { + try { + const res = await fetch("/api/providers/models"); + if (res.ok) { + const data = await res.json(); + if (data.groups && data.groups.length > 0) { + setProviderGroups(data.groups); + } + } + } catch { + // ignore + } + }, []); + + useEffect(() => { + fetchSettings(); + fetchStatus(); + fetchModels(); + }, [fetchSettings, fetchStatus, fetchModels]); + + // Poll bridge status while bridge is running + useEffect(() => { + if (bridgeStatus?.running) { + pollRef.current = setInterval(fetchStatus, 5000); + } else if (pollRef.current) { + clearInterval(pollRef.current); + pollRef.current = null; + } + return () => { + if (pollRef.current) { + clearInterval(pollRef.current); + } + }; + }, [bridgeStatus?.running, fetchStatus]); + + const saveSettings = async (updates: Partial) => { + setSaving(true); + try { + const res = await fetch("/api/bridge/settings", { + 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 handleToggleEnabled = (checked: boolean) => { + saveSettings({ remote_bridge_enabled: checked ? "true" : "" }); + }; + + const handleToggleTelegram = (checked: boolean) => { + saveSettings({ bridge_telegram_enabled: checked ? "true" : "" }); + }; + + const handleSaveDefaults = () => { + // Split composite "provider_id::model" value + const parts = model.split("::"); + const providerId = parts.length === 2 ? parts[0] : ""; + const modelValue = parts.length === 2 ? parts[1] : model; + saveSettings({ + bridge_default_work_dir: workDir, + bridge_default_model: modelValue, + bridge_default_provider_id: providerId, + }); + }; + + const handleBrowseFolder = async () => { + try { + const api = (window as unknown as Record).electronAPI as + | { dialog: { openFolder: (opts?: { defaultPath?: string; title?: string }) => Promise<{ canceled: boolean; filePaths: string[] }> } } + | undefined; + if (api?.dialog?.openFolder) { + const result = await api.dialog.openFolder({ + defaultPath: workDir || undefined, + title: t("bridge.defaultWorkDir"), + }); + if (!result.canceled && result.filePaths[0]) { + setWorkDir(result.filePaths[0]); + } + } + } catch { + // Not in Electron or dialog unavailable + } + }; + + const handleStartBridge = async () => { + setStarting(true); + try { + await fetch("/api/bridge", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ action: "start" }), + }); + await fetchStatus(); + } catch { + // ignore + } finally { + setStarting(false); + } + }; + + const handleStopBridge = async () => { + setStopping(true); + try { + await fetch("/api/bridge", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ action: "stop" }), + }); + await fetchStatus(); + } catch { + // ignore + } finally { + setStopping(false); + } + }; + + const handleToggleAutoStart = (checked: boolean) => { + saveSettings({ bridge_auto_start: checked ? "true" : "" }); + }; + + const isEnabled = settings.remote_bridge_enabled === "true"; + const isTelegramEnabled = settings.bridge_telegram_enabled === "true"; + const isAutoStart = settings.bridge_auto_start === "true"; + const isRunning = bridgeStatus?.running ?? false; + const adapterCount = bridgeStatus?.adapters?.length ?? 0; + + return ( +
+ {/* Enable/Disable Master Toggle */} +
+
+
+

{t("bridge.title")}

+

+ {t("bridge.description")} +

+
+ +
+ {isEnabled && ( +
+ + {t("bridge.activeHint")} +
+ )} +
+ + {/* Bridge Status + Start/Stop */} + {isEnabled && ( +
+
+
+

{t("bridge.status")}

+

+ {isRunning + ? t("bridge.activeBindings", { count: String(adapterCount) }) + : t("bridge.noBindings")} +

+
+
+
+ + {isRunning + ? t("bridge.statusConnected") + : t("bridge.statusDisconnected")} +
+ {isRunning ? ( + + ) : ( + + )} +
+
+
+ )} + + {/* Channel Toggles */} + {isEnabled && ( +
+
+

{t("bridge.channels")}

+

+ {t("bridge.channelsDesc")} +

+
+ +
+
+
+ +
+

{t("bridge.telegramChannel")}

+

+ {t("bridge.telegramChannelDesc")} +

+
+
+ +
+ +
+
+

{t("bridge.autoStart")}

+

+ {t("bridge.autoStartDesc")} +

+
+ +
+
+
+ )} + + {/* Adapter Status */} + {isEnabled && isRunning && adapterCount > 0 && ( +
+
+

{t("bridge.adapters")}

+

+ {t("bridge.adaptersDesc")} +

+
+ +
+ {bridgeStatus?.adapters.map((adapter) => ( +
+
+ + {adapter.channelType} + +
+ {adapter.running + ? t("bridge.adapterRunning") + : t("bridge.adapterStopped")} +
+
+ {adapter.lastMessageAt && ( +

+ {t("bridge.adapterLastMessage")}: {new Date(adapter.lastMessageAt).toLocaleString()} +

+ )} + {adapter.error && ( +

+ {t("bridge.adapterLastError")}: {adapter.error} +

+ )} +
+ ))} +
+
+ )} + + {/* Default Settings */} + {isEnabled && ( +
+
+

{t("bridge.defaults")}

+

+ {t("bridge.defaultsDesc")} +

+
+ +
+
+ +
+ setWorkDir(e.target.value)} + placeholder="/path/to/project" + className="font-mono text-sm" + /> + +
+

+ {t("bridge.defaultWorkDirHint")} +

+
+ +
+ + {providerGroups.length > 0 ? ( + + ) : ( + setModel(e.target.value)} + placeholder="sonnet" + className="font-mono text-sm" + /> + )} +

+ {t("bridge.defaultModelHint")} +

+
+
+ + +
+ )} +
+ ); +} diff --git a/src/components/bridge/TelegramBridgeSection.tsx b/src/components/bridge/TelegramBridgeSection.tsx new file mode 100644 index 0000000..bcd1d0d --- /dev/null +++ b/src/components/bridge/TelegramBridgeSection.tsx @@ -0,0 +1,310 @@ +"use client"; + +import { useState, useCallback, useEffect } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { Loading02Icon, CheckmarkCircle02Icon, Alert02Icon } from "@hugeicons/core-free-icons"; +import { useTranslation } from "@/hooks/useTranslation"; + +interface TelegramBridgeSettings { + telegram_bot_token: string; + telegram_chat_id: string; + telegram_bridge_allowed_users: string; +} + +const DEFAULT_SETTINGS: TelegramBridgeSettings = { + telegram_bot_token: "", + telegram_chat_id: "", + telegram_bridge_allowed_users: "", +}; + +export function TelegramBridgeSection() { + const [settings, setSettings] = useState(DEFAULT_SETTINGS); + const [botToken, setBotToken] = useState(""); + const [chatId, setChatId] = useState(""); + const [allowedUsers, setAllowedUsers] = useState(""); + const [saving, setSaving] = useState(false); + const [verifying, setVerifying] = useState(false); + const [detecting, setDetecting] = 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/telegram"); + if (res.ok) { + const data = await res.json(); + const s = { ...DEFAULT_SETTINGS, ...data.settings }; + setSettings(s); + setBotToken(s.telegram_bot_token); + setChatId(s.telegram_chat_id); + setAllowedUsers(s.telegram_bridge_allowed_users); + } + } catch { + // ignore + } + }, []); + + useEffect(() => { + fetchSettings(); + }, [fetchSettings]); + + const saveSettings = async (updates: Partial) => { + setSaving(true); + try { + const res = await fetch("/api/settings/telegram", { + 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 = {}; + if (botToken && !botToken.startsWith("***")) { + updates.telegram_bot_token = botToken; + } + updates.telegram_chat_id = chatId; + updates.telegram_bridge_allowed_users = allowedUsers; + saveSettings(updates); + }; + + const handleDetectChatId = async () => { + if (!botToken) { + setVerifyResult({ + ok: false, + message: t("telegram.enterTokenFirst"), + }); + return; + } + + setDetecting(true); + setVerifyResult(null); + try { + const res = await fetch("/api/settings/telegram/verify", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + action: "detect_chat_id", + bot_token: botToken, + }), + }); + const data = await res.json(); + + if (data.ok && data.chatId) { + setChatId(data.chatId); + setVerifyResult({ + ok: true, + message: t("telegram.chatIdDetected", { + id: data.chatId, + name: data.chatTitle || data.chatId, + }), + }); + } else { + setVerifyResult({ + ok: false, + message: data.error || t("telegram.chatIdDetectFailed"), + }); + } + } catch { + setVerifyResult({ + ok: false, + message: t("telegram.chatIdDetectFailed"), + }); + } finally { + setDetecting(false); + } + }; + + const handleVerify = async () => { + setVerifying(true); + setVerifyResult(null); + try { + if (!botToken) { + setVerifyResult({ + ok: false, + message: t("telegram.enterTokenFirst"), + }); + return; + } + + const res = await fetch("/api/settings/telegram/verify", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + bot_token: botToken, + chat_id: chatId || undefined, + }), + }); + const data = await res.json(); + + if (data.verified) { + setVerifyResult({ + ok: true, + message: data.botName + ? t("telegram.verifiedAs", { name: data.botName }) + : t("telegram.verified"), + }); + } else { + setVerifyResult({ + ok: false, + message: data.error || t("telegram.verifyFailed"), + }); + } + } catch { + setVerifyResult({ ok: false, message: t("telegram.verifyFailed") }); + } finally { + setVerifying(false); + } + }; + + return ( +
+ {/* Bot Credentials */} +
+
+

{t("telegram.credentials")}

+

+ {t("telegram.credentialsDesc")} +

+
+ +
+
+ + setBotToken(e.target.value)} + placeholder="123456:ABC-DEF..." + className="font-mono text-sm" + /> +
+ +
+ +
+ setChatId(e.target.value)} + placeholder="-1001234567890" + className="font-mono text-sm" + /> + +
+

+ {t("telegram.chatIdHint")} +

+
+
+ +
+ + +
+ + {verifyResult && ( +
+ + {verifyResult.message} +
+ )} +
+ + {/* Allowed Users */} +
+
+

{t("bridge.allowedUsers")}

+

+ {t("bridge.allowedUsersDesc")} +

+
+ +
+ setAllowedUsers(e.target.value)} + placeholder="123456789, 987654321" + className="font-mono text-sm" + /> +

+ {t("bridge.allowedUsersHint")} +

+
+
+ + {/* Setup Guide */} +
+

+ {t("telegram.setupGuide")} +

+
    +
  1. {t("telegram.step1")}
  2. +
  3. {t("telegram.step2")}
  4. +
  5. {t("telegram.step3")}
  6. +
  7. {t("telegram.step4")}
  8. +
  9. {t("telegram.step5")}
  10. +
  11. {t("telegram.step6")}
  12. +
+
+
+ ); +} diff --git a/src/components/layout/NavRail.tsx b/src/components/layout/NavRail.tsx index 30c471d..cddbcd5 100644 --- a/src/components/layout/NavRail.tsx +++ b/src/components/layout/NavRail.tsx @@ -11,6 +11,7 @@ import { Plug01Icon, Image01Icon, Settings02Icon, + Wifi01Icon, Moon02Icon, Sun02Icon, } from "@hugeicons/core-free-icons"; @@ -38,6 +39,7 @@ const navItems = [ { href: "/skills", label: "Skills", icon: ZapIcon }, { href: "/mcp", label: "MCP", icon: Plug01Icon }, { href: "/gallery", label: "Gallery", icon: Image01Icon }, + { href: "/bridge", label: "Bridge", icon: Wifi01Icon }, { href: "/settings", label: "Settings", icon: Settings02Icon }, ] as const; @@ -51,6 +53,7 @@ export function NavRail({ onToggleChatList, hasUpdate, readyToInstall, skipPermi 'Skills': 'extensions.skills', 'MCP': 'extensions.mcpServers', 'Gallery': 'gallery.title', + 'Bridge': 'nav.bridge', 'Settings': 'nav.settings', }; const emptySubscribe = useCallback(() => () => {}, []); diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 254118f..f305aab 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -12,6 +12,7 @@ const en = { 'nav.lightMode': 'Light mode', 'nav.darkMode': 'Dark mode', 'nav.toggleTheme': 'Toggle theme', + 'nav.bridge': 'Bridge', // ── Chat list panel ───────────────────────────────────────── 'chatList.justNow': 'just now', @@ -467,6 +468,78 @@ const en = { 'split.closeSplit': 'Close Split', 'split.splitGroup': 'Split', 'chatList.splitScreen': 'Split Screen', + + // ── Telegram (Bridge) ────────────────────────────────────── + 'telegram.credentials': 'Bot Credentials', + 'telegram.credentialsDesc': 'Enter your Telegram Bot token and chat ID', + 'telegram.botToken': 'Bot Token', + 'telegram.chatId': 'Chat ID', + 'telegram.chatIdHint': 'Send /start to your bot first, then click "Auto Detect" to fill in your Chat ID', + 'telegram.detectChatId': 'Auto Detect', + 'telegram.chatIdDetected': 'Chat ID detected: {id} ({name})', + 'telegram.chatIdDetectFailed': 'Could not detect Chat ID. Send /start to the bot first, then try again.', + 'telegram.verify': 'Test Connection', + 'telegram.verified': 'Connection verified', + 'telegram.verifiedAs': 'Connected as @{name}', + 'telegram.verifyFailed': 'Connection failed', + 'telegram.enterTokenFirst': 'Enter a bot token first', + 'telegram.setupGuide': 'Setup Guide', + 'telegram.step1': 'Open Telegram and search for @BotFather', + 'telegram.step2': 'Send /newbot and follow the prompts to create a bot', + 'telegram.step3': 'Copy the bot token and paste it above', + 'telegram.step4': 'Click "Test Connection" to verify the token is valid', + 'telegram.step5': 'Send /start to your bot, then click "Auto Detect" next to the Chat ID field', + 'telegram.step6': 'Click "Save" to store your credentials', + + // ── Settings: Remote Bridge ──────────────────────────────── + 'settings.bridge': 'Remote Bridge', + 'bridge.title': 'Remote Bridge', + 'bridge.description': 'Control Claude from external channels like Telegram', + 'bridge.enabled': 'Enable Remote Bridge', + 'bridge.enabledDesc': 'Allow external messaging channels to interact with Claude', + 'bridge.activeHint': 'Bridge is active. External channels can send tasks to Claude.', + 'bridge.status': 'Bridge Status', + 'bridge.statusConnected': 'Connected', + 'bridge.statusDisconnected': 'Disconnected', + 'bridge.statusStarting': 'Starting...', + 'bridge.statusStopping': 'Stopping...', + 'bridge.activeBindings': '{count} active binding(s)', + 'bridge.noBindings': 'No active bindings', + 'bridge.channels': 'Channels', + 'bridge.channelsDesc': 'Enable or disable individual messaging channels', + 'bridge.telegramChannel': 'Telegram', + 'bridge.telegramChannelDesc': 'Receive and respond to messages via Telegram Bot', + 'bridge.bindings': 'Active Sessions', + 'bridge.bindingsDesc': 'Current session bindings from external channels', + 'bridge.bindingChat': 'Chat', + 'bridge.bindingChannel': 'Channel', + 'bridge.bindingCreated': 'Created', + 'bridge.bindingStatus': 'Status', + 'bridge.noActiveBindings': 'No active session bindings', + 'bridge.defaults': 'Defaults', + 'bridge.defaultsDesc': 'Default settings for bridge-initiated sessions', + 'bridge.defaultWorkDir': 'Working Directory', + 'bridge.defaultWorkDirHint': 'Default project folder for bridge sessions', + 'bridge.defaultModel': 'Model', + 'bridge.defaultModelHint': 'Default model for bridge sessions', + 'bridge.browse': 'Browse', + 'bridge.start': 'Start Bridge', + 'bridge.stop': 'Stop Bridge', + 'bridge.starting': 'Starting...', + 'bridge.stopping': 'Stopping...', + 'bridge.autoStart': 'Auto-start Bridge', + 'bridge.autoStartDesc': 'Automatically start the bridge when the application launches', + 'bridge.adapterRunning': 'Running', + 'bridge.adapterStopped': 'Stopped', + 'bridge.adapterLastMessage': 'Last message', + 'bridge.adapterLastError': 'Last error', + 'bridge.adapters': 'Adapter Status', + 'bridge.adaptersDesc': 'Real-time status of each channel adapter', + 'bridge.telegramSettings': 'Telegram Settings', + 'bridge.telegramSettingsDesc': 'Configure Telegram Bot credentials for bridge', + 'bridge.allowedUsers': 'Allowed Users', + 'bridge.allowedUsersDesc': 'Comma-separated Telegram user IDs allowed to use bridge', + 'bridge.allowedUsersHint': 'Leave empty to allow only the Chat ID configured above', } as const; export type TranslationKey = keyof typeof en; diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts index 4278602..848802e 100644 --- a/src/i18n/zh.ts +++ b/src/i18n/zh.ts @@ -9,6 +9,7 @@ const zh: Record = { 'nav.lightMode': '浅色模式', 'nav.darkMode': '深色模式', 'nav.toggleTheme': '切换主题', + 'nav.bridge': '桥接', // ── Chat list panel ───────────────────────────────────────── 'chatList.justNow': '刚刚', @@ -464,6 +465,78 @@ const zh: Record = { 'split.closeSplit': '关闭分屏', 'split.splitGroup': '分屏', 'chatList.splitScreen': '分屏', + + // ── Telegram (Bridge) ────────────────────────────────────── + 'telegram.credentials': 'Bot 凭据', + 'telegram.credentialsDesc': '输入您的 Telegram Bot Token 和 Chat ID', + 'telegram.botToken': 'Bot Token', + 'telegram.chatId': 'Chat ID', + 'telegram.chatIdHint': '先向您的 Bot 发送 /start,然后点击「自动检测」填入 Chat ID', + 'telegram.detectChatId': '自动检测', + 'telegram.chatIdDetected': '检测到 Chat ID:{id}({name})', + 'telegram.chatIdDetectFailed': '无法检测 Chat ID。请先向 Bot 发送 /start,然后重试。', + 'telegram.verify': '测试连接', + 'telegram.verified': '连接验证成功', + 'telegram.verifiedAs': '已连接为 @{name}', + 'telegram.verifyFailed': '连接失败', + 'telegram.enterTokenFirst': '请先输入 Bot Token', + 'telegram.setupGuide': '设置指南', + 'telegram.step1': '打开 Telegram 搜索 @BotFather', + 'telegram.step2': '发送 /newbot 并按提示创建 Bot', + 'telegram.step3': '复制 Bot Token 并粘贴到上方', + 'telegram.step4': '点击「测试连接」验证 Token 是否有效', + 'telegram.step5': '向您的 Bot 发送 /start,然后点击 Chat ID 旁的「自动检测」按钮', + 'telegram.step6': '点击「保存」存储凭据', + + // ── Settings: Remote Bridge ──────────────────────────────── + 'settings.bridge': '远程桥接', + 'bridge.title': '远程桥接', + 'bridge.description': '通过 Telegram 等外部渠道控制 Claude', + 'bridge.enabled': '启用远程桥接', + 'bridge.enabledDesc': '允许外部消息渠道与 Claude 交互', + 'bridge.activeHint': '桥接已激活。外部渠道可以向 Claude 发送任务。', + 'bridge.status': '桥接状态', + 'bridge.statusConnected': '已连接', + 'bridge.statusDisconnected': '未连接', + 'bridge.statusStarting': '启动中...', + 'bridge.statusStopping': '停止中...', + 'bridge.activeBindings': '{count} 个活跃绑定', + 'bridge.noBindings': '无活跃绑定', + 'bridge.channels': '渠道', + 'bridge.channelsDesc': '启用或禁用各消息渠道', + 'bridge.telegramChannel': 'Telegram', + 'bridge.telegramChannelDesc': '通过 Telegram Bot 接收和回复消息', + 'bridge.bindings': '活跃会话', + 'bridge.bindingsDesc': '来自外部渠道的当前会话绑定', + 'bridge.bindingChat': '对话', + 'bridge.bindingChannel': '渠道', + 'bridge.bindingCreated': '创建时间', + 'bridge.bindingStatus': '状态', + 'bridge.noActiveBindings': '无活跃会话绑定', + 'bridge.defaults': '默认设置', + 'bridge.defaultsDesc': '桥接发起会话的默认设置', + 'bridge.defaultWorkDir': '工作目录', + 'bridge.defaultWorkDirHint': '桥接会话的默认项目文件夹', + 'bridge.defaultModel': '模型', + 'bridge.defaultModelHint': '桥接会话的默认模型', + 'bridge.browse': '浏览', + 'bridge.start': '启动桥接', + 'bridge.stop': '停止桥接', + 'bridge.starting': '启动中...', + 'bridge.stopping': '停止中...', + 'bridge.autoStart': '自动启动桥接', + 'bridge.autoStartDesc': '应用启动时自动启动桥接', + 'bridge.adapterRunning': '运行中', + 'bridge.adapterStopped': '已停止', + 'bridge.adapterLastMessage': '最近消息', + 'bridge.adapterLastError': '最近错误', + 'bridge.adapters': '适配器状态', + 'bridge.adaptersDesc': '各渠道适配器的实时状态', + 'bridge.telegramSettings': 'Telegram 设置', + 'bridge.telegramSettingsDesc': '配置桥接使用的 Telegram Bot 凭据', + 'bridge.allowedUsers': '允许的用户', + 'bridge.allowedUsersDesc': '允许使用桥接的 Telegram 用户 ID,逗号分隔', + 'bridge.allowedUsersHint': '留空则仅允许上方配置的 Chat ID', }; export default zh; diff --git a/src/lib/bridge/adapters/index.ts b/src/lib/bridge/adapters/index.ts new file mode 100644 index 0000000..e48bddb --- /dev/null +++ b/src/lib/bridge/adapters/index.ts @@ -0,0 +1,12 @@ +/** + * Adapter catalog — side-effect imports that trigger self-registration + * of all available channel adapters. + * + * To add a new adapter: + * 1. Create the adapter file (e.g. `discord-adapter.ts`) with self-registration + * 2. Add a side-effect import line below + * + * bridge-manager.ts imports this module; it never needs to change for new adapters. + */ + +import './telegram-adapter'; diff --git a/src/lib/bridge/adapters/telegram-adapter.ts b/src/lib/bridge/adapters/telegram-adapter.ts new file mode 100644 index 0000000..2499029 --- /dev/null +++ b/src/lib/bridge/adapters/telegram-adapter.ts @@ -0,0 +1,523 @@ +/** + * Telegram Adapter — implements BaseChannelAdapter for Telegram Bot API. + * + * Uses long polling to consume updates, persists offset watermark to DB, + * and routes messages/callbacks through an internal async queue. + */ + +import crypto from 'crypto'; +import type { + ChannelType, + InboundMessage, + OutboundMessage, + SendResult, +} from '../types'; +import { BaseChannelAdapter, registerAdapterFactory } from '../channel-adapter'; +import { callTelegramApi, escapeHtml } from './telegram-utils'; +import { getChannelOffset, setChannelOffset, insertAuditLog } from '../../db'; +import { getSetting } from '../../db'; + +const TELEGRAM_API = 'https://api.telegram.org'; + +/** Max number of recent update_ids to keep for idempotency dedup on restart. */ +const DEDUP_SET_MAX = 1000; + +/** Derive a short token-specific hash for per-bot offset isolation. */ +function tokenShortHash(botToken: string): string { + return crypto.createHash('sha256').update(botToken).digest('hex').slice(0, 8); +} + +interface TelegramUpdate { + update_id: number; + message?: { + message_id: number; + chat: { id: number; first_name?: string; title?: string; username?: string }; + from?: { id: number; first_name: string; username?: string }; + text?: string; + date: number; + }; + callback_query?: { + id: string; + from: { id: number; first_name: string; username?: string }; + message?: { message_id: number; chat: { id: number } }; + data?: string; + }; +} + +/** + * Async queue entry — resolved by the poll loop, consumed by consumeOne(). + */ +interface QueueEntry { + message: InboundMessage; + resolve: () => void; +} + +export class TelegramAdapter extends BaseChannelAdapter { + readonly channelType: ChannelType = 'telegram'; + + private running = false; + private abortController: AbortController | null = null; + private queue: InboundMessage[] = []; + private waiters: Array<(msg: InboundMessage | null) => void> = []; + private typingIntervals = new Map>(); + + /** Committed offset — the highest update_id that has been safely enqueued or skipped. */ + private committedOffset = 0; + /** In-memory set of recently processed update_ids for idempotency on restart. */ + private recentUpdateIds = new Set(); + /** Stable bot user ID from Telegram's getMe, used for offset key identity. */ + private botUserId: string | null = null; + + get botToken(): string { + return getSetting('telegram_bot_token') || ''; + } + + async start(): Promise { + if (this.running) return; + + const configError = this.validateConfig(); + if (configError) { + console.warn('[telegram-adapter] Cannot start:', configError); + return; + } + + // Resolve bot identity via getMe before starting the poll loop. + // This provides a stable offset key that survives token rotation. + await this.resolveBotIdentity(); + + this.running = true; + this.abortController = new AbortController(); + + // Register bot commands menu with Telegram + this.registerCommands().catch(() => {}); + + // Start polling in background (no await — runs until stop()) + this.pollLoop().catch(err => { + console.error('[telegram-adapter] Poll loop error:', err); + }); + + console.log('[telegram-adapter] Started (botUserId:', this.botUserId || 'fallback-to-hash', ')'); + } + + async stop(): Promise { + if (!this.running) return; + this.running = false; + this.abortController?.abort(); + this.abortController = null; + + // Persist committed offset before shutdown + this.persistCommittedOffset(); + + // 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(); + + console.log('[telegram-adapter] Stopped'); + } + + isRunning(): boolean { + return this.running; + } + + consumeOne(): Promise { + // If there's a queued message, return it immediately + const queued = this.queue.shift(); + if (queued) return Promise.resolve(queued); + + // If not running, return null + if (!this.running) return Promise.resolve(null); + + // Otherwise, wait for the poll loop to enqueue a message + return new Promise((resolve) => { + this.waiters.push(resolve); + }); + } + + async send(message: OutboundMessage): Promise { + const token = this.botToken; + if (!token) return { ok: false, error: 'No bot token configured' }; + + const params: Record = { + chat_id: message.address.chatId, + text: message.text, + disable_web_page_preview: true, + }; + + if (message.parseMode === 'HTML') { + params.parse_mode = 'HTML'; + } else if (message.parseMode === 'Markdown') { + params.parse_mode = 'Markdown'; + } + + if (message.replyToMessageId) { + params.reply_to_message_id = message.replyToMessageId; + } + + // Inline keyboard buttons + if (message.inlineButtons && message.inlineButtons.length > 0) { + params.reply_markup = { + inline_keyboard: message.inlineButtons.map(row => + row.map(btn => ({ + text: btn.text, + callback_data: btn.callbackData, + })) + ), + }; + } + + return callTelegramApi(token, 'sendMessage', params); + } + + async answerCallback(callbackQueryId: string, text?: string): Promise { + const token = this.botToken; + if (!token) return; + + await callTelegramApi(token, 'answerCallbackQuery', { + callback_query_id: callbackQueryId, + text: text || 'OK', + }); + } + + validateConfig(): string | null { + const token = getSetting('telegram_bot_token'); + if (!token) return 'telegram_bot_token not configured'; + + const bridgeEnabled = getSetting('bridge_telegram_enabled'); + if (bridgeEnabled !== 'true') return 'bridge_telegram_enabled is not true'; + + return null; + } + + isAuthorized(userId: string, chatId: string): boolean { + // Check bridge-specific allowed users first + const allowedUsers = getSetting('telegram_bridge_allowed_users') || ''; + if (allowedUsers) { + const allowed = allowedUsers.split(',').map(s => s.trim()).filter(Boolean); + if (allowed.length > 0) { + return allowed.includes(userId) || allowed.includes(chatId); + } + } + + // Fallback: check notification bot's chat_id + const notifyChatId = getSetting('telegram_chat_id') || ''; + if (notifyChatId) { + return chatId === notifyChatId; + } + + // No auth configured — deny by default + return false; + } + + /** + * Start a typing indicator that fires every 5 seconds. + */ + startTyping(chatId: string): void { + this.stopTyping(chatId); // Clear any existing + const token = this.botToken; + if (!token) return; + + // Send immediately + callTelegramApi(token, 'sendChatAction', { chat_id: chatId, action: 'typing' }).catch(() => {}); + + // Repeat every 5s + const interval = setInterval(() => { + callTelegramApi(token, 'sendChatAction', { chat_id: chatId, action: 'typing' }).catch(() => {}); + }, 5000); + this.typingIntervals.set(chatId, interval); + } + + /** + * Stop the typing indicator for a chat. + */ + stopTyping(chatId: string): void { + const interval = this.typingIntervals.get(chatId); + if (interval) { + clearInterval(interval); + this.typingIntervals.delete(chatId); + } + } + + /** + * Acknowledge that an update has been fully processed by the bridge-manager. + * Only at this point do we advance the committed offset and persist it. + * This ensures no message is lost if the process crashes between enqueue and processing. + */ + acknowledgeUpdate(updateId: number): void { + this.markUpdateProcessed(updateId); + this.persistCommittedOffset(); + } + + // ── Lifecycle hooks (called generically by bridge-manager) ─── + + onMessageStart(chatId: string): void { + this.startTyping(chatId); + } + + onMessageEnd(chatId: string): void { + this.stopTyping(chatId); + } + + // ── Private ────────────────────────────────────────────────── + + /** + * Register slash commands with Telegram Bot API so they appear in the menu. + */ + private async registerCommands(): Promise { + const token = this.botToken; + if (!token) return; + + await callTelegramApi(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' }, + ], + }); + } + + private enqueue(msg: InboundMessage): void { + const waiter = this.waiters.shift(); + if (waiter) { + waiter(msg); + } else { + this.queue.push(msg); + } + } + + /** + * Return the DB key used to store the offset, scoped to the bot's stable identity. + * Uses the bot user ID (from getMe) which survives token rotation. + * Falls back to the token hash if getMe was not successful. + */ + private offsetKey(): string { + if (this.botUserId) { + return 'telegram:bot' + this.botUserId; + } + const token = this.botToken; + if (!token) return 'telegram'; + return 'telegram:' + tokenShortHash(token); + } + + /** + * Resolve the bot's stable user ID via Telegram's getMe API. + * On first startup with bot-ID-based key, migrates the offset from the + * old token-hash-based key so no messages are re-fetched. + */ + private async resolveBotIdentity(): Promise { + const token = this.botToken; + if (!token) return; + + try { + const url = `${TELEGRAM_API}/bot${token}/getMe`; + const res = await fetch(url, { + method: 'GET', + signal: AbortSignal.timeout(10_000), + }); + const data = await res.json(); + if (data.ok && data.result?.id) { + this.botUserId = String(data.result.id); + + // Migrate offset from old token-hash key to new bot-ID key + const newKey = 'telegram:bot' + this.botUserId; + const oldKey = 'telegram:' + tokenShortHash(token); + const existingNew = getChannelOffset(newKey); + if (!existingNew || existingNew === '0') { + const existingOld = getChannelOffset(oldKey); + if (existingOld && existingOld !== '0') { + setChannelOffset(newKey, existingOld); + console.log(`[telegram-adapter] Migrated offset from ${oldKey} to ${newKey}: ${existingOld}`); + } + } + } else { + console.warn('[telegram-adapter] getMe did not return a valid bot ID, falling back to token hash'); + } + } catch (err) { + console.warn('[telegram-adapter] getMe failed, falling back to token hash:', err instanceof Error ? err.message : err); + } + } + + /** + * Mark an update as safely processed (enqueued or intentionally skipped). + * Advances committedOffset and prunes the dedup set when it exceeds capacity. + */ + private markUpdateProcessed(updateId: number): void { + if (updateId >= this.committedOffset) { + this.committedOffset = updateId + 1; + } + this.recentUpdateIds.add(updateId); + if (this.recentUpdateIds.size > DEDUP_SET_MAX) { + // Remove oldest entries (Set iterates in insertion order) + const excess = this.recentUpdateIds.size - DEDUP_SET_MAX; + let removed = 0; + for (const id of this.recentUpdateIds) { + if (removed >= excess) break; + this.recentUpdateIds.delete(id); + removed++; + } + } + } + + /** + * Persist the committed offset to DB. Safe to call at any time. + */ + private persistCommittedOffset(): void { + if (this.committedOffset <= 0) return; + try { + setChannelOffset(this.offsetKey(), String(this.committedOffset)); + } catch { /* best effort */ } + } + + private async pollLoop(): Promise { + const key = this.offsetKey(); + + // Load persisted committed offset + this.committedOffset = parseInt(getChannelOffset(key), 10) || 0; + + // fetchOffset is used for the getUpdates API call; starts at committed offset + let fetchOffset = this.committedOffset; + + while (this.running) { + try { + const token = this.botToken; + if (!token) { + console.warn('[telegram-adapter] No bot token, waiting...'); + await new Promise(r => setTimeout(r, 5000)); + continue; + } + + const url = `${TELEGRAM_API}/bot${token}/getUpdates`; + const res = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + offset: fetchOffset, + timeout: 30, + allowed_updates: ['message', 'callback_query'], + }), + signal: this.abortController?.signal, + }); + + if (!this.running) break; + + const data = await res.json(); + if (!data.ok || !Array.isArray(data.result)) { + console.warn('[telegram-adapter] getUpdates failed:', JSON.stringify(data).slice(0, 200)); + continue; + } + const updates: TelegramUpdate[] = data.result; + for (const update of updates) { + // Advance fetchOffset so the next getUpdates call skips this update + if (update.update_id >= fetchOffset) { + fetchOffset = update.update_id + 1; + } + + // Idempotency: skip updates already processed (dedup on restart) + if (this.recentUpdateIds.has(update.update_id)) { + this.markUpdateProcessed(update.update_id); + continue; + } + + if (update.callback_query) { + const cb = update.callback_query; + const chatId = cb.message?.chat.id ? String(cb.message.chat.id) : ''; + const userId = String(cb.from.id); + + if (!this.isAuthorized(userId, chatId)) { + console.warn('[telegram-adapter] Unauthorized callback from userId:', userId, 'chatId:', chatId); + this.markUpdateProcessed(update.update_id); + continue; + } + + const msg: InboundMessage = { + messageId: cb.id, + address: { + channelType: 'telegram', + chatId, + userId, + displayName: cb.from.username || cb.from.first_name, + }, + text: '', + timestamp: Date.now(), + callbackData: cb.data, + callbackMessageId: cb.message?.message_id ? String(cb.message.message_id) : undefined, + raw: update, + updateId: update.update_id, + }; + + this.enqueue(msg); + + // Answer callback to dismiss the loading state + this.answerCallback(cb.id).catch(() => {}); + } else if (update.message?.text) { + const m = update.message; + const chatId = String(m.chat.id); + const userId = m.from ? String(m.from.id) : chatId; + + if (!this.isAuthorized(userId, chatId)) { + console.warn('[telegram-adapter] Unauthorized message from userId:', userId, 'chatId:', chatId); + this.markUpdateProcessed(update.update_id); + continue; + } + + const msg: InboundMessage = { + messageId: String(m.message_id), + address: { + channelType: 'telegram', + chatId, + userId, + displayName: m.from?.username || m.from?.first_name || chatId, + }, + text: m.text!, + timestamp: m.date * 1000, + raw: update, + updateId: update.update_id, + }; + + // Audit log + try { + insertAuditLog({ + channelType: 'telegram', + chatId, + direction: 'inbound', + messageId: String(m.message_id), + summary: m.text!.slice(0, 200), + }); + } catch { /* best effort */ } + + this.enqueue(msg); + } else { + // Unhandled update type — still safe to advance past it + this.markUpdateProcessed(update.update_id); + } + } + + // Persist committed offset after processing the batch + this.persistCommittedOffset(); + } catch (err) { + if (err instanceof Error && err.name === 'AbortError') break; + console.warn('[telegram-adapter] Polling error:', err instanceof Error ? err.message : err); + // Persist whatever we've safely committed before backing off + this.persistCommittedOffset(); + if (this.running) { + await new Promise(r => setTimeout(r, 5000)); + } + } + } + } +} + +// Self-register so bridge-manager can create TelegramAdapter via the registry. +registerAdapterFactory('telegram', () => new TelegramAdapter()); diff --git a/src/lib/bridge/adapters/telegram-utils.ts b/src/lib/bridge/adapters/telegram-utils.ts new file mode 100644 index 0000000..76d804f --- /dev/null +++ b/src/lib/bridge/adapters/telegram-utils.ts @@ -0,0 +1,123 @@ +/** + * Telegram utility functions — shared between the notification bot + * (telegram-bot.ts) and the bridge adapter (telegram-adapter.ts). + * + * Extracted from telegram-bot.ts to avoid duplication. + */ + +const TELEGRAM_API = 'https://api.telegram.org'; + +export interface TelegramSendResult { + ok: boolean; + messageId?: string; + error?: string; + /** HTTP status code from the Telegram API response. */ + httpStatus?: number; + /** Retry-after seconds returned by Telegram on 429 responses. */ + retryAfter?: number; +} + +export interface TelegramApiResponse { + ok: boolean; + result?: { + message_id?: number; + [key: string]: unknown; + }; + description?: string; + /** Telegram returns retry_after (seconds) on 429 rate limit responses. */ + parameters?: { + retry_after?: number; + [key: string]: unknown; + }; +} + +/** + * Call a Telegram Bot API method. + */ +export async function callTelegramApi( + botToken: string, + method: string, + params: Record, +): Promise { + try { + const url = `${TELEGRAM_API}/bot${botToken}/${method}`; + const res = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(params), + }); + const httpStatus = res.status; + const data: TelegramApiResponse = await res.json(); + if (!data.ok) { + return { + ok: false, + error: data.description || 'Unknown Telegram API error', + httpStatus, + retryAfter: data.parameters?.retry_after, + }; + } + return { + ok: true, + messageId: data.result?.message_id != null ? String(data.result.message_id) : undefined, + httpStatus, + }; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : 'Network error' }; + } +} + +/** + * Escape special HTML characters for Telegram HTML mode. + */ +export function escapeHtml(text: string): string { + return text + .replace(/&/g, '&') + .replace(//g, '>'); +} + +/** + * Split a message into chunks that fit within Telegram's message size limit. + * Tries to split at line boundaries when possible. + */ +export function splitMessage(text: string, maxLength: number): string[] { + if (text.length <= maxLength) return [text]; + + const chunks: string[] = []; + let remaining = text; + + while (remaining.length > 0) { + if (remaining.length <= maxLength) { + chunks.push(remaining); + break; + } + + let splitIdx = remaining.lastIndexOf('\n', maxLength); + if (splitIdx <= 0 || splitIdx < maxLength * 0.5) { + splitIdx = maxLength; + } + + chunks.push(remaining.slice(0, splitIdx)); + remaining = remaining.slice(splitIdx).replace(/^\n/, ''); + } + + return chunks; +} + +/** + * Format a session header for notification messages. + */ +export function formatSessionHeader(opts?: { + sessionId?: string; + sessionTitle?: string; + workingDirectory?: string; +}): string { + const parts: string[] = []; + if (opts?.sessionTitle) { + parts.push(`${escapeHtml(opts.sessionTitle)}`); + } + if (opts?.workingDirectory) { + parts.push(`${escapeHtml(opts.workingDirectory)}`); + } + return parts.join('\n'); +} diff --git a/src/lib/bridge/bridge-manager.ts b/src/lib/bridge/bridge-manager.ts new file mode 100644 index 0000000..0ff198d --- /dev/null +++ b/src/lib/bridge/bridge-manager.ts @@ -0,0 +1,602 @@ +/** + * Bridge Manager — singleton orchestrator for the multi-IM bridge system. + * + * Manages adapter lifecycles, routes inbound messages through the + * conversation engine, and coordinates permission handling. + * + * Uses globalThis to survive Next.js HMR in development. + */ + +import type { BridgeStatus, InboundMessage, OutboundMessage } from './types'; +import { createAdapter, getRegisteredTypes } from './channel-adapter'; +import type { BaseChannelAdapter } from './channel-adapter'; +// Side-effect import: triggers self-registration of all adapter factories +import './adapters'; +import * as router from './channel-router'; +import * as engine from './conversation-engine'; +import * as broker from './permission-broker'; +import { deliver } from './delivery-layer'; +import { getSetting, insertAuditLog, updateChannelBinding } from '../db'; +import { setBridgeModeActive } from '../telegram-bot'; +import { escapeHtml } from './adapters/telegram-utils'; +import { + validateWorkingDirectory, + validateSessionId, + isDangerousInput, + sanitizeInput, + validateMode, +} from './security/validators'; + +const GLOBAL_KEY = '__bridge_manager__'; + +interface AdapterMeta { + lastMessageAt: string | null; + lastError: string | null; +} + +interface BridgeManagerState { + adapters: Map; + adapterMeta: Map; + running: boolean; + startedAt: string | null; + loopAborts: Map; + activeTasks: Map; + /** Per-session processing chains for concurrency control */ + sessionLocks: Map>; + autoStartChecked: boolean; +} + +function getState(): BridgeManagerState { + const g = globalThis as unknown as Record; + if (!g[GLOBAL_KEY]) { + g[GLOBAL_KEY] = { + adapters: new Map(), + adapterMeta: new Map(), + running: false, + startedAt: null, + loopAborts: new Map(), + activeTasks: new Map(), + sessionLocks: new Map(), + autoStartChecked: false, + }; + } + // Backfill sessionLocks for states created before this field existed + if (!g[GLOBAL_KEY].sessionLocks) { + g[GLOBAL_KEY].sessionLocks = new Map(); + } + return g[GLOBAL_KEY]; +} + +/** + * Process a function with per-session serialization. + * Different sessions run concurrently; same-session requests are serialized. + */ +function processWithSessionLock(sessionId: string, fn: () => Promise): Promise { + const state = getState(); + const prev = state.sessionLocks.get(sessionId) || Promise.resolve(); + const current = prev.then(fn, fn); + state.sessionLocks.set(sessionId, current); + // Cleanup when the chain completes + current.finally(() => { + if (state.sessionLocks.get(sessionId) === current) { + state.sessionLocks.delete(sessionId); + } + }); + return current; +} + +/** + * Start the bridge system. + * Checks feature flags, registers enabled adapters, starts polling loops. + */ +export async function start(): Promise { + const state = getState(); + if (state.running) return; + + const bridgeEnabled = getSetting('remote_bridge_enabled') === 'true'; + if (!bridgeEnabled) { + console.log('[bridge-manager] Bridge not enabled (remote_bridge_enabled != true)'); + return; + } + + // Iterate all registered adapter types and create those that are enabled + for (const channelType of getRegisteredTypes()) { + const settingKey = `bridge_${channelType}_enabled`; + if (getSetting(settingKey) !== 'true') continue; + + const adapter = createAdapter(channelType); + if (!adapter) continue; + + const configError = adapter.validateConfig(); + if (!configError) { + registerAdapter(adapter); + } else { + console.warn(`[bridge-manager] ${channelType} adapter not valid:`, configError); + } + } + + // Start all registered adapters, track how many succeeded + let startedCount = 0; + for (const [type, adapter] of state.adapters) { + try { + await adapter.start(); + console.log(`[bridge-manager] Started adapter: ${type}`); + startedCount++; + } catch (err) { + console.error(`[bridge-manager] Failed to start adapter ${type}:`, err); + } + } + + // Only mark as running if at least one adapter started successfully + if (startedCount === 0) { + console.warn('[bridge-manager] No adapters started successfully, bridge not activated'); + state.adapters.clear(); + state.adapterMeta.clear(); + return; + } + + // Mark running BEFORE starting consumer loops — runAdapterLoop checks + // state.running in its while-condition, so it must be true first. + state.running = true; + state.startedAt = new Date().toISOString(); + + // Suppress notification bot polling to avoid conflicts + setBridgeModeActive(true); + + // Now start the consumer loops (state.running is already true) + for (const [, adapter] of state.adapters) { + if (adapter.isRunning()) { + runAdapterLoop(adapter); + } + } + + console.log(`[bridge-manager] Bridge started with ${startedCount} adapter(s)`); +} + +/** + * Stop the bridge system gracefully. + */ +export async function stop(): Promise { + const state = getState(); + if (!state.running) return; + + state.running = false; + + // Abort all event loops + for (const [, abort] of state.loopAborts) { + abort.abort(); + } + state.loopAborts.clear(); + + // Stop all adapters + for (const [type, adapter] of state.adapters) { + try { + await adapter.stop(); + console.log(`[bridge-manager] Stopped adapter: ${type}`); + } catch (err) { + console.error(`[bridge-manager] Error stopping adapter ${type}:`, err); + } + } + + state.adapters.clear(); + state.adapterMeta.clear(); + state.startedAt = null; + + // Re-enable notification bot polling + setBridgeModeActive(false); + + console.log('[bridge-manager] Bridge stopped'); +} + +/** + * Lazy auto-start: checks bridge_auto_start setting once and starts if enabled. + * Called from POST /api/bridge with action 'auto-start' (triggered by Electron on startup). + */ +export function tryAutoStart(): void { + const state = getState(); + if (state.autoStartChecked) return; + state.autoStartChecked = true; + + if (state.running) return; + + const autoStart = getSetting('bridge_auto_start'); + if (autoStart !== 'true') return; + + start().catch(err => { + console.error('[bridge-manager] Auto-start failed:', err); + }); +} + +/** + * Get the current bridge status. + */ +export function getStatus(): BridgeStatus { + const state = getState(); + return { + running: state.running, + startedAt: state.startedAt, + adapters: Array.from(state.adapters.entries()).map(([type, adapter]) => { + const meta = state.adapterMeta.get(type); + return { + channelType: adapter.channelType, + running: adapter.isRunning(), + connectedAt: state.startedAt, + lastMessageAt: meta?.lastMessageAt ?? null, + error: meta?.lastError ?? null, + }; + }), + }; +} + +/** + * Register a channel adapter. + */ +export function registerAdapter(adapter: BaseChannelAdapter): void { + const state = getState(); + state.adapters.set(adapter.channelType, adapter); +} + +/** + * Run the event loop for a single adapter. + * Messages for different sessions are dispatched concurrently; + * messages for the same session are serialized via session locks. + */ +function runAdapterLoop(adapter: BaseChannelAdapter): void { + const state = getState(); + const abort = new AbortController(); + state.loopAborts.set(adapter.channelType, abort); + + (async () => { + while (state.running && adapter.isRunning()) { + try { + const msg = await adapter.consumeOne(); + if (!msg) continue; // Adapter stopped + + // Callback queries and commands are lightweight — process inline. + // Regular messages use per-session locking for concurrency. + if (msg.callbackData || msg.text.trim().startsWith('/')) { + await handleMessage(adapter, msg); + } else { + const binding = router.resolve(msg.address); + // Fire-and-forget into session lock — loop continues to accept + // messages for other sessions immediately. + processWithSessionLock(binding.codepilotSessionId, () => + handleMessage(adapter, msg), + ).catch(err => { + console.error(`[bridge-manager] Session ${binding.codepilotSessionId.slice(0, 8)} error:`, err); + }); + } + } catch (err) { + if (abort.signal.aborted) break; + const errMsg = err instanceof Error ? err.message : String(err); + console.error(`[bridge-manager] Error in ${adapter.channelType} loop:`, err); + // Track last error per adapter + const meta = state.adapterMeta.get(adapter.channelType) || { lastMessageAt: null, lastError: null }; + meta.lastError = errMsg; + state.adapterMeta.set(adapter.channelType, meta); + // Brief delay to prevent tight error loops + await new Promise(r => setTimeout(r, 1000)); + } + } + })().catch(err => { + if (!abort.signal.aborted) { + const errMsg = err instanceof Error ? err.message : String(err); + console.error(`[bridge-manager] ${adapter.channelType} loop crashed:`, err); + const meta = state.adapterMeta.get(adapter.channelType) || { lastMessageAt: null, lastError: null }; + meta.lastError = errMsg; + state.adapterMeta.set(adapter.channelType, meta); + } + }); +} + +/** + * Handle a single inbound message. + */ +async function handleMessage( + adapter: BaseChannelAdapter, + msg: InboundMessage, +): Promise { + // Update lastMessageAt for this adapter + const adapterState = getState(); + const meta = adapterState.adapterMeta.get(adapter.channelType) || { lastMessageAt: null, lastError: null }; + meta.lastMessageAt = new Date().toISOString(); + adapterState.adapterMeta.set(adapter.channelType, meta); + + // Acknowledge the update offset after processing completes (or fails). + // This ensures the adapter only advances its committed offset once the + // message has been fully handled, preventing message loss on crash. + const ack = () => { + if (msg.updateId != null && adapter.acknowledgeUpdate) { + adapter.acknowledgeUpdate(msg.updateId); + } + }; + + // Handle callback queries (permission buttons) + if (msg.callbackData) { + const handled = broker.handlePermissionCallback(msg.callbackData, msg.address.chatId, msg.callbackMessageId); + if (handled) { + // Send confirmation + const confirmMsg: OutboundMessage = { + address: msg.address, + text: 'Permission response recorded.', + parseMode: 'plain', + }; + await deliver(adapter, confirmMsg); + } + ack(); + return; + } + + const rawText = msg.text.trim(); + if (!rawText) { ack(); return; } + + // Check for IM commands (before sanitization — commands are validated individually) + if (rawText.startsWith('/')) { + await handleCommand(adapter, msg, rawText); + ack(); + return; + } + + // Sanitize general message text before routing to conversation engine + const { text, truncated } = sanitizeInput(rawText); + if (truncated) { + console.warn(`[bridge-manager] Input truncated from ${rawText.length} to ${text.length} chars for chat ${msg.address.chatId}`); + insertAuditLog({ + channelType: adapter.channelType, + chatId: msg.address.chatId, + direction: 'inbound', + messageId: msg.messageId, + summary: `[TRUNCATED] Input truncated from ${rawText.length} chars`, + }); + } + + if (!text) { ack(); return; } + + // Regular message — route to conversation engine + const binding = router.resolve(msg.address); + + // Notify adapter that message processing is starting (e.g., typing indicator) + adapter.onMessageStart?.(msg.address.chatId); + + // Create an AbortController so /stop can cancel this task externally + const taskAbort = new AbortController(); + const state = getState(); + state.activeTasks.set(binding.codepilotSessionId, taskAbort); + + try { + // Pass permission callback so requests are forwarded to IM immediately + // during streaming (the stream blocks until permission is resolved). + const result = await engine.processMessage(binding, text, async (perm) => { + await broker.forwardPermissionRequest( + adapter, + msg.address, + perm.permissionRequestId, + perm.toolName, + perm.toolInput, + binding.codepilotSessionId, + perm.suggestions, + ); + }, taskAbort.signal); + + // Send response text + if (result.responseText) { + const response: OutboundMessage = { + address: msg.address, + text: escapeHtml(result.responseText), + parseMode: 'HTML', + }; + await deliver(adapter, response, { + sessionId: binding.codepilotSessionId, + }); + } else if (result.hasError) { + const errorResponse: OutboundMessage = { + address: msg.address, + text: `Error: ${escapeHtml(result.errorMessage)}`, + parseMode: 'HTML', + }; + await deliver(adapter, errorResponse); + } + + // Persist the actual SDK session ID for future resume. + // If the result has an error and no session ID was captured, clear the + // stale ID so the next message starts fresh instead of retrying a broken resume. + if (binding.id) { + try { + if (result.sdkSessionId) { + updateChannelBinding(binding.id, { sdkSessionId: result.sdkSessionId }); + } else if (result.hasError && binding.sdkSessionId) { + updateChannelBinding(binding.id, { sdkSessionId: '' }); + } + } catch { /* best effort */ } + } + } finally { + state.activeTasks.delete(binding.codepilotSessionId); + // Notify adapter that message processing ended + adapter.onMessageEnd?.(msg.address.chatId); + // Commit the offset only after full processing (success or failure) + ack(); + } +} + +/** + * Handle IM slash commands. + */ +async function handleCommand( + adapter: BaseChannelAdapter, + msg: InboundMessage, + text: string, +): Promise { + // Extract command and args (handle /command@botname format) + const parts = text.split(/\s+/); + const command = parts[0].split('@')[0].toLowerCase(); + const args = parts.slice(1).join(' ').trim(); + + // Run dangerous-input detection on the full command text + const dangerCheck = isDangerousInput(text); + if (dangerCheck.dangerous) { + insertAuditLog({ + channelType: adapter.channelType, + chatId: msg.address.chatId, + direction: 'inbound', + messageId: msg.messageId, + summary: `[BLOCKED] Dangerous input detected: ${dangerCheck.reason}`, + }); + console.warn(`[bridge-manager] Blocked dangerous command input from chat ${msg.address.chatId}: ${dangerCheck.reason}`); + await deliver(adapter, { + address: msg.address, + text: `Command rejected: invalid input detected.`, + parseMode: 'plain', + }); + return; + } + + let response = ''; + + switch (command) { + case '/start': + response = [ + 'CodePilot Bridge', + '', + 'Send any message to interact with Claude.', + '', + 'Commands:', + '/new [path] - Start new session', + '/bind <session_id> - Bind to existing session', + '/cwd /path - Change working directory', + '/mode plan|code|ask - Change mode', + '/status - Show current status', + '/sessions - List recent sessions', + '/stop - Stop current session', + '/help - Show this help', + ].join('\n'); + break; + + case '/new': { + let workDir: string | undefined; + if (args) { + const validated = validateWorkingDirectory(args); + if (!validated) { + response = 'Invalid path. Must be an absolute path without traversal sequences.'; + break; + } + workDir = validated; + } + const binding = router.createBinding(msg.address, workDir); + response = `New session created.\nSession: ${binding.codepilotSessionId.slice(0, 8)}...\nCWD: ${escapeHtml(binding.workingDirectory || '~')}`; + break; + } + + case '/bind': { + if (!args) { + response = 'Usage: /bind <session_id>'; + break; + } + if (!validateSessionId(args)) { + response = 'Invalid session ID format. Expected a 32-64 character hex/UUID string.'; + break; + } + const binding = router.bindToSession(msg.address, args); + if (binding) { + response = `Bound to session ${args.slice(0, 8)}...`; + } else { + response = 'Session not found.'; + } + break; + } + + case '/cwd': { + if (!args) { + response = 'Usage: /cwd /path/to/directory'; + break; + } + const validatedPath = validateWorkingDirectory(args); + if (!validatedPath) { + response = 'Invalid path. Must be an absolute path without traversal sequences or special characters.'; + break; + } + const binding = router.resolve(msg.address); + router.updateBinding(binding.id, { workingDirectory: validatedPath }); + response = `Working directory set to ${escapeHtml(validatedPath)}`; + break; + } + + case '/mode': { + if (!validateMode(args)) { + response = 'Usage: /mode plan|code|ask'; + break; + } + const binding = router.resolve(msg.address); + router.updateBinding(binding.id, { mode: args }); + response = `Mode set to ${args}`; + break; + } + + case '/status': { + const binding = router.resolve(msg.address); + response = [ + 'Bridge Status', + '', + `Session: ${binding.codepilotSessionId.slice(0, 8)}...`, + `CWD: ${escapeHtml(binding.workingDirectory || '~')}`, + `Mode: ${binding.mode}`, + `Model: ${binding.model || 'default'}`, + ].join('\n'); + break; + } + + case '/sessions': { + const bindings = router.listBindings(adapter.channelType); + if (bindings.length === 0) { + response = 'No sessions found.'; + } else { + const lines = ['Sessions:', '']; + for (const b of bindings.slice(0, 10)) { + const active = b.active ? 'active' : 'inactive'; + lines.push(`${b.codepilotSessionId.slice(0, 8)}... [${active}] ${escapeHtml(b.workingDirectory || '~')}`); + } + response = lines.join('\n'); + } + break; + } + + case '/stop': { + const binding = router.resolve(msg.address); + const st = getState(); + const taskAbort = st.activeTasks.get(binding.codepilotSessionId); + if (taskAbort) { + taskAbort.abort(); + st.activeTasks.delete(binding.codepilotSessionId); + response = 'Stopping current task...'; + } else { + response = 'No task is currently running.'; + } + break; + } + + case '/help': + response = [ + 'CodePilot Bridge Commands', + '', + '/new [path] - Start new session', + '/bind <session_id> - Bind to existing session', + '/cwd /path - Change working directory', + '/mode plan|code|ask - Change mode', + '/status - Show current status', + '/sessions - List recent sessions', + '/stop - Stop current session', + '/help - Show this help', + ].join('\n'); + break; + + default: + response = `Unknown command: ${escapeHtml(command)}\nType /help for available commands.`; + } + + if (response) { + await deliver(adapter, { + address: msg.address, + text: response, + parseMode: 'HTML', + }); + } +} diff --git a/src/lib/bridge/channel-adapter.ts b/src/lib/bridge/channel-adapter.ts new file mode 100644 index 0000000..8fbbcda --- /dev/null +++ b/src/lib/bridge/channel-adapter.ts @@ -0,0 +1,96 @@ +/** + * Abstract base class for IM channel adapters. + * + * Each adapter (Telegram, Discord, Slack, ...) extends this class to provide + * platform-specific message consumption and delivery. + */ + +import type { + ChannelType, + InboundMessage, + OutboundMessage, + SendResult, +} from './types'; + +export abstract class BaseChannelAdapter { + /** Which channel type this adapter handles */ + abstract readonly channelType: ChannelType; + + /** + * Start the adapter (connect, begin polling/websocket, etc.). + * Must be idempotent — calling start() on an already-running adapter is a no-op. + */ + abstract start(): Promise; + + /** + * Stop the adapter gracefully. + * Must be idempotent — calling stop() on an already-stopped adapter is a no-op. + */ + abstract stop(): Promise; + + /** Whether the adapter is currently running and consuming messages */ + abstract isRunning(): boolean; + + /** + * Consume the next inbound message from the internal queue. + * Blocks until a message is available or the adapter is stopped. + * Returns null if the adapter was stopped while waiting. + */ + abstract consumeOne(): Promise; + + /** + * Send an outbound message to the channel. + * Handles platform-specific formatting and API calls. + */ + abstract send(message: OutboundMessage): Promise; + + /** + * Answer a callback query (e.g. Telegram inline button press). + * Not all platforms support this — default implementation is a no-op. + */ + async answerCallback(_callbackQueryId: string, _text?: string): Promise { + // No-op by default; override in adapters that support callback queries + } + + /** + * Validate that the adapter's configuration is complete. + * Returns null if valid, or an error message string if invalid. + */ + abstract validateConfig(): string | null; + + /** + * Check whether a user is authorized to use this bridge. + * Returns true if authorized, false otherwise. + */ + abstract isAuthorized(userId: string, chatId: string): boolean; + + /** Called when message processing starts (e.g., typing indicator). */ + onMessageStart?(_chatId: string): void; + + /** Called when message processing ends. */ + onMessageEnd?(_chatId: string): void; + + /** + * Acknowledge that an update has been fully processed. + * Adapters that defer offset commits until after handleMessage should implement this. + * Default is a no-op; override in adapters that need deferred offset tracking. + */ + acknowledgeUpdate?(_updateId: number): void; +} + +// ── Adapter Registry ──────────────────────────────────────────── + +const adapterFactories = new Map BaseChannelAdapter>(); + +export function registerAdapterFactory(channelType: string, factory: () => BaseChannelAdapter): void { + adapterFactories.set(channelType, factory); +} + +export function createAdapter(channelType: string): BaseChannelAdapter | null { + const factory = adapterFactories.get(channelType); + return factory ? factory() : null; +} + +export function getRegisteredTypes(): string[] { + return Array.from(adapterFactories.keys()); +} diff --git a/src/lib/bridge/channel-router.ts b/src/lib/bridge/channel-router.ts new file mode 100644 index 0000000..03fd191 --- /dev/null +++ b/src/lib/bridge/channel-router.ts @@ -0,0 +1,106 @@ +/** + * Channel Router — resolves IM addresses to CodePilot sessions. + * + * When a message arrives from an IM channel, the router finds or creates + * the corresponding ChannelBinding (and underlying chat_session). + */ + +import type { ChannelAddress, ChannelBinding, ChannelType } from './types'; +import { + getChannelBinding, + upsertChannelBinding, + updateChannelBinding, + listChannelBindings, + getSession, + createSession, + getSetting, + updateSessionProviderId, +} from '../db'; + +/** + * Resolve an inbound address to a ChannelBinding. + * If no binding exists, auto-creates a new session and binding. + */ +export function resolve(address: ChannelAddress): ChannelBinding { + const existing = getChannelBinding(address.channelType, address.chatId); + if (existing) { + // Verify the linked session still exists; if not, create a new one + const session = getSession(existing.codepilotSessionId); + if (session) return existing; + // Session was deleted — recreate + return createBinding(address); + } + return createBinding(address); +} + +/** + * Create a new binding with a fresh CodePilot session. + */ +export function createBinding( + address: ChannelAddress, + workingDirectory?: string, +): ChannelBinding { + const defaultCwd = workingDirectory + || getSetting('bridge_default_work_dir') + || process.env.HOME + || ''; + const defaultModel = getSetting('bridge_default_model') || ''; + const defaultProviderId = getSetting('bridge_default_provider_id') || ''; + + const displayName = address.displayName || address.chatId; + const session = createSession( + `Bridge: ${displayName}`, + defaultModel, + undefined, + defaultCwd, + 'code', + ); + + if (defaultProviderId) { + updateSessionProviderId(session.id, defaultProviderId); + } + + return upsertChannelBinding({ + channelType: address.channelType, + chatId: address.chatId, + codepilotSessionId: session.id, + workingDirectory: defaultCwd, + model: defaultModel, + }); +} + +/** + * Bind an IM chat to an existing CodePilot session. + */ +export function bindToSession( + address: ChannelAddress, + codepilotSessionId: string, +): ChannelBinding | null { + const session = getSession(codepilotSessionId); + if (!session) return null; + + return upsertChannelBinding({ + channelType: address.channelType, + chatId: address.chatId, + codepilotSessionId, + workingDirectory: session.working_directory, + model: session.model, + }); +} + +/** + * Update properties of an existing binding. + */ +export function updateBinding( + id: string, + updates: Partial>, +): void { + updateChannelBinding(id, updates); +} + +/** + * List all bindings, optionally filtered by channel type. + */ +export function listBindings(channelType?: ChannelType): ChannelBinding[] { + return listChannelBindings(channelType); +} diff --git a/src/lib/bridge/conversation-engine.ts b/src/lib/bridge/conversation-engine.ts new file mode 100644 index 0000000..705e46d --- /dev/null +++ b/src/lib/bridge/conversation-engine.ts @@ -0,0 +1,376 @@ +/** + * Conversation Engine — processes inbound IM messages through Claude. + * + * Takes a ChannelBinding + inbound message, calls streamClaude(), + * consumes the SSE stream server-side, saves messages to DB, + * and returns the response text for delivery. + */ + +import type { ChannelBinding } from './types'; +import type { SSEEvent, TokenUsage, MessageContentBlock } from '@/types'; +import { streamClaude } from '../claude-client'; +import { + addMessage, + getMessages, + acquireSessionLock, + renewSessionLock, + releaseSessionLock, + setSessionRuntimeStatus, + updateSdkSessionId, + updateSessionModel, + syncSdkTasks, + getSession, + getProvider, + getDefaultProviderId, + getSetting, +} from '../db'; +import crypto from 'crypto'; + +export interface PermissionRequestInfo { + permissionRequestId: string; + toolName: string; + toolInput: Record; + suggestions?: unknown[]; +} + +/** + * Callback invoked immediately when a permission_request SSE event arrives. + * This breaks the deadlock: the stream blocks until the permission is resolved, + * so we must forward the request to the IM *during* stream consumption, + * not after it returns. + */ +export type OnPermissionRequest = (perm: PermissionRequestInfo) => Promise; + +export interface ConversationResult { + responseText: string; + tokenUsage: TokenUsage | null; + hasError: boolean; + errorMessage: string; + /** Permission request events that were forwarded during streaming */ + permissionRequests: PermissionRequestInfo[]; + /** SDK session ID captured from status/result events, for session resume */ + sdkSessionId: string | null; +} + +/** + * Process an inbound message: send to Claude, consume the response stream, + * save to DB, and return the result. + */ +export async function processMessage( + binding: ChannelBinding, + text: string, + onPermissionRequest?: OnPermissionRequest, + abortSignal?: AbortSignal, +): Promise { + const sessionId = binding.codepilotSessionId; + + // Acquire session lock + const lockId = crypto.randomBytes(8).toString('hex'); + const lockAcquired = acquireSessionLock(sessionId, lockId, `bridge-${binding.channelType}`, 600); + if (!lockAcquired) { + return { + responseText: '', + tokenUsage: null, + hasError: true, + errorMessage: 'Session is busy processing another request', + permissionRequests: [], + sdkSessionId: null, + }; + } + + setSessionRuntimeStatus(sessionId, 'running'); + + // Lock renewal interval + const renewalInterval = setInterval(() => { + try { renewSessionLock(sessionId, lockId, 600); } catch { /* best effort */ } + }, 60_000); + + try { + // Save user message + addMessage(sessionId, 'user', text); + + // Resolve provider + const session = getSession(sessionId); + let resolvedProvider: import('@/types').ApiProvider | undefined; + const providerId = session?.provider_id || ''; + if (providerId && providerId !== 'env') { + resolvedProvider = getProvider(providerId); + } + if (!resolvedProvider) { + const defaultId = getDefaultProviderId(); + if (defaultId) resolvedProvider = getProvider(defaultId); + } + + // Effective model + const effectiveModel = binding.model || session?.model || getSetting('default_model') || undefined; + + // Permission mode from binding mode + let permissionMode: string; + switch (binding.mode) { + case 'plan': permissionMode = 'plan'; break; + case 'ask': permissionMode = 'default'; break; + default: permissionMode = 'acceptEdits'; break; + } + + // Load conversation history for context + const { messages: recentMsgs } = getMessages(sessionId, { limit: 50 }); + const historyMsgs = recentMsgs.slice(0, -1).map(m => ({ + role: m.role as 'user' | 'assistant', + content: m.content, + })); + + const abortController = new AbortController(); + if (abortSignal) { + if (abortSignal.aborted) { + abortController.abort(); + } else { + abortSignal.addEventListener('abort', () => abortController.abort(), { once: true }); + } + } + + const stream = streamClaude({ + prompt: text, + sessionId, + sdkSessionId: binding.sdkSessionId || undefined, + model: effectiveModel, + systemPrompt: session?.system_prompt || undefined, + workingDirectory: binding.workingDirectory || session?.working_directory || undefined, + abortController, + permissionMode, + provider: resolvedProvider, + conversationHistory: historyMsgs, + onRuntimeStatusChange: (status: string) => { + try { setSessionRuntimeStatus(sessionId, status); } catch { /* best effort */ } + }, + }); + + // Consume the stream server-side (replicate collectStreamResponse pattern). + // Permission requests are forwarded immediately via the callback during streaming + // because the stream blocks until permission is resolved — we can't wait until after. + return await consumeStream(stream, sessionId, onPermissionRequest); + } finally { + clearInterval(renewalInterval); + releaseSessionLock(sessionId, lockId); + setSessionRuntimeStatus(sessionId, 'idle'); + } +} + +/** + * Consume an SSE stream and extract response data. + * Mirrors the collectStreamResponse() logic from chat/route.ts. + */ +async function consumeStream( + stream: ReadableStream, + sessionId: string, + onPermissionRequest?: OnPermissionRequest, +): Promise { + const reader = stream.getReader(); + const contentBlocks: MessageContentBlock[] = []; + let currentText = ''; + let tokenUsage: TokenUsage | null = null; + let hasError = false; + let errorMessage = ''; + const seenToolResultIds = new Set(); + const permissionRequests: PermissionRequestInfo[] = []; + let capturedSdkSessionId: string | null = null; + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + const lines = value.split('\n'); + for (const line of lines) { + if (!line.startsWith('data: ')) continue; + + let event: SSEEvent; + try { + event = JSON.parse(line.slice(6)); + } catch { + continue; + } + + switch (event.type) { + case 'text': + currentText += event.data; + break; + + case 'tool_use': { + if (currentText.trim()) { + contentBlocks.push({ type: 'text', text: currentText }); + currentText = ''; + } + try { + const toolData = JSON.parse(event.data); + contentBlocks.push({ + type: 'tool_use', + id: toolData.id, + name: toolData.name, + input: toolData.input, + }); + } catch { /* skip */ } + break; + } + + case 'tool_result': { + try { + const resultData = JSON.parse(event.data); + const newBlock = { + type: 'tool_result' as const, + tool_use_id: resultData.tool_use_id, + content: resultData.content, + is_error: resultData.is_error || false, + }; + if (seenToolResultIds.has(resultData.tool_use_id)) { + const idx = contentBlocks.findIndex( + (b) => b.type === 'tool_result' && 'tool_use_id' in b && b.tool_use_id === resultData.tool_use_id + ); + if (idx >= 0) contentBlocks[idx] = newBlock; + } else { + seenToolResultIds.add(resultData.tool_use_id); + contentBlocks.push(newBlock); + } + } catch { /* skip */ } + break; + } + + case 'permission_request': { + try { + const permData = JSON.parse(event.data); + const perm: PermissionRequestInfo = { + permissionRequestId: permData.permissionRequestId, + toolName: permData.toolName, + toolInput: permData.toolInput, + suggestions: permData.suggestions, + }; + permissionRequests.push(perm); + // Forward immediately — the stream blocks until the permission is + // resolved, so we must send the IM prompt *now*, not after the stream ends. + if (onPermissionRequest) { + onPermissionRequest(perm).catch((err) => { + console.error('[conversation-engine] Failed to forward permission request:', err); + }); + } + } catch { /* skip */ } + break; + } + + case 'status': { + try { + const statusData = JSON.parse(event.data); + if (statusData.session_id) { + capturedSdkSessionId = statusData.session_id; + updateSdkSessionId(sessionId, statusData.session_id); + } + if (statusData.model) { + updateSessionModel(sessionId, statusData.model); + } + } catch { /* skip */ } + break; + } + + case 'task_update': { + try { + const taskData = JSON.parse(event.data); + if (taskData.session_id && taskData.todos) { + syncSdkTasks(taskData.session_id, taskData.todos); + } + } catch { /* skip */ } + break; + } + + case 'error': + hasError = true; + errorMessage = event.data || 'Unknown error'; + break; + + case 'result': { + try { + const resultData = JSON.parse(event.data); + if (resultData.usage) tokenUsage = resultData.usage; + if (resultData.is_error) hasError = true; + if (resultData.session_id) { + capturedSdkSessionId = resultData.session_id; + updateSdkSessionId(sessionId, resultData.session_id); + } + } catch { /* skip */ } + break; + } + + // tool_output, tool_timeout, mode_changed, done — ignored for bridge + } + } + } + + // Flush remaining text + if (currentText.trim()) { + contentBlocks.push({ type: 'text', text: currentText }); + } + + // Save assistant message + if (contentBlocks.length > 0) { + const hasToolBlocks = contentBlocks.some( + (b) => b.type === 'tool_use' || b.type === 'tool_result' + ); + const content = hasToolBlocks + ? JSON.stringify(contentBlocks) + : contentBlocks + .filter((b): b is Extract => b.type === 'text') + .map((b) => b.text) + .join('') + .trim(); + + if (content) { + addMessage(sessionId, 'assistant', content, tokenUsage ? JSON.stringify(tokenUsage) : null); + } + } + + // Extract text-only response for IM delivery + const responseText = contentBlocks + .filter((b): b is Extract => b.type === 'text') + .map((b) => b.text) + .join('') + .trim(); + + return { + responseText, + tokenUsage, + hasError, + errorMessage, + permissionRequests, + sdkSessionId: capturedSdkSessionId, + }; + } catch (e) { + // Best-effort save on stream error + if (currentText.trim()) { + contentBlocks.push({ type: 'text', text: currentText }); + } + if (contentBlocks.length > 0) { + const hasToolBlocks = contentBlocks.some( + (b) => b.type === 'tool_use' || b.type === 'tool_result' + ); + const content = hasToolBlocks + ? JSON.stringify(contentBlocks) + : contentBlocks + .filter((b): b is Extract => b.type === 'text') + .map((b) => b.text) + .join('') + .trim(); + if (content) { + addMessage(sessionId, 'assistant', content); + } + } + + const isAbort = e instanceof DOMException && e.name === 'AbortError' + || e instanceof Error && e.name === 'AbortError'; + + return { + responseText: '', + tokenUsage, + hasError: true, + errorMessage: isAbort ? 'Task stopped by user' : (e instanceof Error ? e.message : 'Stream consumption error'), + permissionRequests, + sdkSessionId: capturedSdkSessionId, + }; + } +} diff --git a/src/lib/bridge/delivery-layer.ts b/src/lib/bridge/delivery-layer.ts new file mode 100644 index 0000000..a860901 --- /dev/null +++ b/src/lib/bridge/delivery-layer.ts @@ -0,0 +1,262 @@ +/** + * Delivery Layer — reliable outbound message delivery with chunking, + * dedup, retry, error classification, and reference tracking. + */ + +import type { + ChannelType, + OutboundMessage, + SendResult, + PLATFORM_LIMITS, +} from './types'; +import { PLATFORM_LIMITS as limits } from './types'; +import type { BaseChannelAdapter } from './channel-adapter'; +import { + checkDedup, + insertDedup, + cleanupExpiredDedup, + insertOutboundRef, + insertAuditLog, +} from '../db'; +import { ChatRateLimiter } from './security/rate-limiter'; + +const MAX_RETRIES = 3; +const BASE_DELAY_MS = 1000; +const JITTER_MAX_MS = 500; +/** Delay between sending multiple chunks to avoid rate limits. */ +const INTER_CHUNK_DELAY_MS = 300; + +/** Shared rate limiter instance (20 messages/minute per chat). */ +const rateLimiter = new ChatRateLimiter(); + +// Periodically clean up idle rate limiter buckets (every 5 minutes). +setInterval(() => { rateLimiter.cleanup(); }, 5 * 60_000); + +/** + * Split text into chunks that fit within a platform's message size limit. + * Tries to split at line boundaries when possible. + */ +function chunkText(text: string, maxLength: number): string[] { + if (text.length <= maxLength) return [text]; + + const chunks: string[] = []; + let remaining = text; + + while (remaining.length > 0) { + if (remaining.length <= maxLength) { + chunks.push(remaining); + break; + } + + // Try to split at a newline within the limit + let splitIdx = remaining.lastIndexOf('\n', maxLength); + if (splitIdx <= 0 || splitIdx < maxLength * 0.5) { + splitIdx = maxLength; + } + + chunks.push(remaining.slice(0, splitIdx)); + remaining = remaining.slice(splitIdx).replace(/^\n/, ''); + } + + return chunks; +} + +/** + * Compute exponential backoff delay with jitter. + */ +function backoffDelay(attempt: number): number { + const base = BASE_DELAY_MS * Math.pow(2, attempt); + const jitter = Math.random() * JITTER_MAX_MS; + return base + jitter; +} + +// ── Error classification ────────────────────────────────────── + +type ErrorCategory = 'rate_limit' | 'server_error' | 'client_error' | 'parse_error' | 'network'; + +/** + * Classify a SendResult failure into an error category. + * Uses httpStatus when available, falls back to error string matching. + */ +function classifyError(result: SendResult): ErrorCategory { + const status = (result as { httpStatus?: number }).httpStatus; + const error = result.error ?? ''; + + if (status === 429) return 'rate_limit'; + if (status && status >= 500) return 'server_error'; + if (status && status >= 400 && status < 500) { + // Check for HTML parse errors even though they are 400 + if (/can't parse entities|parse entities|find end of the entity/i.test(error)) { + return 'parse_error'; + } + return 'client_error'; + } + + // No HTTP status — fall back to string matching + if (/can't parse entities|parse entities|find end of the entity/i.test(error)) { + return 'parse_error'; + } + if (/too many requests|rate limit|retry.after/i.test(error)) { + return 'rate_limit'; + } + + return 'network'; +} + +/** + * Determine if a failed SendResult should be retried. + */ +function shouldRetry(category: ErrorCategory): boolean { + switch (category) { + case 'rate_limit': + case 'server_error': + case 'network': + return true; + case 'client_error': + case 'parse_error': + // Client errors won't succeed on retry; parse errors are handled + // by the HTML fallback path, not the retry loop. + return false; + } +} + +/** + * Compute retry delay. For 429 responses, honor Telegram's retry_after field. + */ +function retryDelay(result: SendResult, attempt: number): number { + const retryAfter = (result as { retryAfter?: number }).retryAfter; + if (retryAfter && retryAfter > 0) { + // Telegram gives seconds; add a small buffer + return retryAfter * 1000 + 200; + } + return backoffDelay(attempt); +} + +// ── Public API ──────────────────────────────────────────────── + +/** + * Send a message through an adapter with chunking, dedup, retry, and auditing. + */ +export async function deliver( + adapter: BaseChannelAdapter, + message: OutboundMessage, + opts?: { + sessionId?: string; + dedupKey?: string; + }, +): Promise { + // Dedup check + if (opts?.dedupKey) { + if (checkDedup(opts.dedupKey)) { + return { ok: true, messageId: undefined }; + } + } + + // Periodically clean up expired dedup entries (1 in 100 chance) + if (Math.random() < 0.01) { + try { cleanupExpiredDedup(); } catch { /* best effort */ } + } + + const limit = limits[adapter.channelType] || 4096; + const chunks = chunkText(message.text, limit); + let lastMessageId: string | undefined; + + for (let i = 0; i < chunks.length; i++) { + // Rate limit: wait if this chat is sending too fast + await rateLimiter.acquire(message.address.chatId); + + // Inter-chunk delay to avoid hitting rate limits on multi-chunk messages + if (i > 0) { + await new Promise(r => setTimeout(r, INTER_CHUNK_DELAY_MS)); + } + + const chunkMessage: OutboundMessage = { + ...message, + text: chunks[i], + // Only attach inline buttons to the last chunk + inlineButtons: i === chunks.length - 1 ? message.inlineButtons : undefined, + }; + + const result = await sendWithRetry(adapter, chunkMessage); + if (!result.ok) { + return result; + } + lastMessageId = result.messageId; + + // Track outbound reference + if (result.messageId && opts?.sessionId) { + try { + insertOutboundRef({ + channelType: adapter.channelType, + chatId: message.address.chatId, + codepilotSessionId: opts.sessionId, + platformMessageId: result.messageId, + purpose: message.inlineButtons ? 'permission' : 'response', + }); + } catch { /* best effort */ } + } + } + + // Mark as delivered for dedup + if (opts?.dedupKey) { + try { insertDedup(opts.dedupKey); } catch { /* best effort */ } + } + + // Audit log + try { + insertAuditLog({ + channelType: adapter.channelType, + chatId: message.address.chatId, + direction: 'outbound', + messageId: lastMessageId || '', + summary: message.text.slice(0, 200), + }); + } catch { /* best effort */ } + + return { ok: true, messageId: lastMessageId }; +} + +/** + * Send a single message with retry, error classification, and HTML fallback. + */ +async function sendWithRetry( + adapter: BaseChannelAdapter, + message: OutboundMessage, +): Promise { + let lastError: string | undefined; + + for (let attempt = 0; attempt < MAX_RETRIES; attempt++) { + const result = await adapter.send(message); + if (result.ok) return result; + + lastError = result.error; + const category = classifyError(result); + + // HTML parse error: immediately fallback to plain text (no retry needed) + if (category === 'parse_error' && message.parseMode === 'HTML') { + const plainResult = await adapter.send({ + ...message, + parseMode: 'plain', + }); + if (plainResult.ok) return plainResult; + lastError = plainResult.error; + // If plain text also fails, classify that error and continue + const plainCategory = classifyError(plainResult); + if (!shouldRetry(plainCategory)) { + return plainResult; + } + } + + // Don't retry client errors (except 429 which is rate_limit) + if (!shouldRetry(category)) { + return result; + } + + // Wait before next retry — honor retry_after for 429 + if (attempt < MAX_RETRIES - 1) { + await new Promise(r => setTimeout(r, retryDelay(result, attempt))); + } + } + + return { ok: false, error: lastError || 'Max retries exceeded' }; +} diff --git a/src/lib/bridge/permission-broker.ts b/src/lib/bridge/permission-broker.ts new file mode 100644 index 0000000..c194ad2 --- /dev/null +++ b/src/lib/bridge/permission-broker.ts @@ -0,0 +1,175 @@ +/** + * Permission Broker — forwards Claude permission requests to IM channels + * and handles user responses via inline buttons. + * + * When Claude needs tool approval, the broker: + * 1. Formats a permission prompt with inline keyboard buttons + * 2. Sends it via the delivery layer + * 3. Records the link between permission ID and IM message + * 4. When a callback arrives, resolves the permission via the existing registry + */ + +import type { PermissionUpdate } from '@anthropic-ai/claude-agent-sdk'; +import type { ChannelAddress, OutboundMessage } from './types'; +import type { BaseChannelAdapter } from './channel-adapter'; +import { deliver } from './delivery-layer'; +import { insertPermissionLink, getPermissionLink, markPermissionLinkResolved } from '../db'; +import { resolvePendingPermission } from '../permission-registry'; +import { escapeHtml } from './adapters/telegram-utils'; + +/** + * Forward a permission request to an IM channel as an interactive message. + */ +export async function forwardPermissionRequest( + adapter: BaseChannelAdapter, + address: ChannelAddress, + permissionRequestId: string, + toolName: string, + toolInput: Record, + sessionId?: string, + suggestions?: unknown[], +): Promise { + // Format the input summary (truncated) + const inputStr = JSON.stringify(toolInput, null, 2); + const truncatedInput = inputStr.length > 300 + ? inputStr.slice(0, 300) + '...' + : inputStr; + + const text = [ + `Permission Required`, + ``, + `Tool: ${escapeHtml(toolName)}`, + `
${escapeHtml(truncatedInput)}
`, + ``, + `Choose an action:`, + ].join('\n'); + + const message: OutboundMessage = { + address, + text, + parseMode: 'HTML', + inlineButtons: [ + [ + { text: 'Allow', callbackData: `perm:allow:${permissionRequestId}` }, + { text: 'Allow Session', callbackData: `perm:allow_session:${permissionRequestId}` }, + { text: 'Deny', callbackData: `perm:deny:${permissionRequestId}` }, + ], + ], + }; + + const result = await deliver(adapter, message, { sessionId }); + + // Record the link so we can match callback queries back to this permission + if (result.ok && result.messageId) { + try { + insertPermissionLink({ + permissionRequestId, + channelType: adapter.channelType, + chatId: address.chatId, + messageId: result.messageId, + toolName, + suggestions: suggestions ? JSON.stringify(suggestions) : '', + }); + } catch { /* best effort */ } + } +} + +/** + * Handle a permission callback from an inline button press. + * Validates that the callback came from the same chat AND same message that + * received the permission request, prevents duplicate resolution via atomic + * DB check-and-set, and implements real allow_session semantics by passing + * updatedPermissions (suggestions). + * + * Returns true if the callback was recognized and handled. + */ +export function handlePermissionCallback( + callbackData: string, + callbackChatId: string, + callbackMessageId?: string, +): boolean { + // Parse callback data: perm:action:permId + const parts = callbackData.split(':'); + if (parts.length < 3 || parts[0] !== 'perm') return false; + + const action = parts[1]; + const permissionRequestId = parts.slice(2).join(':'); // permId might contain colons + + // Look up the permission link to validate origin and check dedup + const link = getPermissionLink(permissionRequestId); + if (!link) { + console.warn(`[permission-broker] No permission link found for ${permissionRequestId}`); + return false; + } + + // Security: verify the callback came from the same chat that received the request + if (link.chatId !== callbackChatId) { + console.warn(`[permission-broker] Chat ID mismatch: expected ${link.chatId}, got ${callbackChatId}`); + return false; + } + + // Security: verify the callback came from the original permission message + if (callbackMessageId && link.messageId !== callbackMessageId) { + console.warn(`[permission-broker] Message ID mismatch: expected ${link.messageId}, got ${callbackMessageId}`); + return false; + } + + // Dedup: reject if already resolved (fast path before expensive resolution) + if (link.resolved) { + console.warn(`[permission-broker] Permission ${permissionRequestId} already resolved`); + return false; + } + + // Atomically mark as resolved BEFORE calling resolvePendingPermission + // to prevent race conditions with concurrent button clicks + let claimed: boolean; + try { + claimed = markPermissionLinkResolved(permissionRequestId); + } catch { + return false; + } + + if (!claimed) { + // Another concurrent handler already resolved this permission + console.warn(`[permission-broker] Permission ${permissionRequestId} already claimed by concurrent handler`); + return false; + } + + let resolved: boolean; + + switch (action) { + case 'allow': + resolved = resolvePendingPermission(permissionRequestId, { + behavior: 'allow', + }); + break; + + case 'allow_session': { + // Parse stored suggestions so subsequent same-tool calls auto-approve + let updatedPermissions: PermissionUpdate[] | undefined; + if (link.suggestions) { + try { + updatedPermissions = JSON.parse(link.suggestions) as PermissionUpdate[]; + } catch { /* fall through without updatedPermissions */ } + } + + resolved = resolvePendingPermission(permissionRequestId, { + behavior: 'allow', + ...(updatedPermissions ? { updatedPermissions } : {}), + }); + break; + } + + case 'deny': + resolved = resolvePendingPermission(permissionRequestId, { + behavior: 'deny', + message: 'Denied via IM bridge', + }); + break; + + default: + return false; + } + + return resolved; +} diff --git a/src/lib/bridge/security/rate-limiter.ts b/src/lib/bridge/security/rate-limiter.ts new file mode 100644 index 0000000..b355961 --- /dev/null +++ b/src/lib/bridge/security/rate-limiter.ts @@ -0,0 +1,85 @@ +/** + * Per-chat sliding window rate limiter. + * + * Prevents sending more than `maxMessages` messages per `windowMs` to the + * same chat. When the limit is hit, `acquire()` delays until the window + * slides enough to allow the next message. + */ + +const DEFAULT_MAX_MESSAGES = 20; +const DEFAULT_WINDOW_MS = 60_000; // 1 minute + +interface BucketEntry { + /** Timestamps (ms) of recent sends within the current window. */ + timestamps: number[]; +} + +export class ChatRateLimiter { + private buckets = new Map(); + private maxMessages: number; + private windowMs: number; + + constructor(opts?: { maxMessages?: number; windowMs?: number }) { + this.maxMessages = opts?.maxMessages ?? DEFAULT_MAX_MESSAGES; + this.windowMs = opts?.windowMs ?? DEFAULT_WINDOW_MS; + } + + /** + * Wait until sending a message to `chatId` is allowed. + * Registers the send timestamp upon returning. + */ + async acquire(chatId: string): Promise { + const now = Date.now(); + const bucket = this.getOrCreate(chatId); + this.pruneOld(bucket, now); + + if (bucket.timestamps.length < this.maxMessages) { + bucket.timestamps.push(now); + return; + } + + // Window is full — wait until the oldest entry expires + const oldest = bucket.timestamps[0]; + const waitMs = oldest + this.windowMs - now; + if (waitMs > 0) { + await new Promise(r => setTimeout(r, waitMs)); + } + + // Prune again after waiting and record + const afterWait = Date.now(); + this.pruneOld(bucket, afterWait); + bucket.timestamps.push(afterWait); + } + + /** + * Remove buckets that have been idle for longer than 2x the window. + * Call periodically to prevent memory leaks for long-running processes. + */ + cleanup(): void { + const now = Date.now(); + const expiry = this.windowMs * 2; + for (const [chatId, bucket] of this.buckets) { + const latest = bucket.timestamps[bucket.timestamps.length - 1]; + if (!latest || now - latest > expiry) { + this.buckets.delete(chatId); + } + } + } + + private getOrCreate(chatId: string): BucketEntry { + let bucket = this.buckets.get(chatId); + if (!bucket) { + bucket = { timestamps: [] }; + this.buckets.set(chatId, bucket); + } + return bucket; + } + + private pruneOld(bucket: BucketEntry, now: number): void { + const cutoff = now - this.windowMs; + // timestamps are in chronological order; drop from front + while (bucket.timestamps.length > 0 && bucket.timestamps[0] <= cutoff) { + bucket.timestamps.shift(); + } + } +} diff --git a/src/lib/bridge/security/validators.ts b/src/lib/bridge/security/validators.ts new file mode 100644 index 0000000..e013a32 --- /dev/null +++ b/src/lib/bridge/security/validators.ts @@ -0,0 +1,127 @@ +/** + * Input validation and sanitization for bridge IM commands. + * + * Prevents path traversal, command injection, and other dangerous inputs + * from reaching the conversation engine or file system operations. + */ + +import * as path from 'path'; + +// ── Constants ──────────────────────────────────────────────────── + +const MAX_INPUT_LENGTH = 32_000; // Claude's effective context limit +const MAX_PATH_LENGTH = 1024; +const SESSION_ID_PATTERN = /^[0-9a-f-]{32,64}$/i; +const VALID_MODES = ['plan', 'code', 'ask'] as const; + +/** + * Patterns that indicate shell injection or dangerous input. + * Each entry has a regex and a human-readable reason. + */ +const DANGEROUS_PATTERNS: Array<{ pattern: RegExp; reason: string }> = [ + { pattern: /\x00/, reason: 'null byte' }, + { pattern: /\.\.[/\\]/, reason: 'path traversal (../)' }, + { pattern: /\$\(/, reason: 'command substitution $()' }, + { pattern: /`[^`]*`/, reason: 'backtick command substitution' }, + { pattern: /;\s*(rm|cat|curl|wget|chmod|chown|mv|cp|dd|mkfs|shutdown|reboot)\b/, reason: 'chained dangerous command' }, + { pattern: /\|\s*(bash|sh|zsh|exec)\b/, reason: 'pipe to shell' }, + { pattern: />\s*\//, reason: 'redirect to absolute path' }, +]; + +// ── Validators ─────────────────────────────────────────────────── + +/** + * Validate a working directory path. + * Must be an absolute path without traversal or shell metacharacters. + * Returns sanitized path or null if invalid. + */ +export function validateWorkingDirectory(rawPath: string): string | null { + if (!rawPath || !rawPath.trim()) return null; + + const trimmed = rawPath.trim(); + + // Must be absolute + if (!path.isAbsolute(trimmed)) return null; + + // Reject null bytes + if (trimmed.includes('\0')) return null; + + // Reject path traversal segments + const segments = trimmed.split(/[/\\]/); + if (segments.some(s => s === '..')) return null; + + // Reject if too long + if (trimmed.length > MAX_PATH_LENGTH) return null; + + // Reject shell metacharacters that have no place in a directory path + if (/[$`;|&><(){}\x00-\x1f]/.test(trimmed)) return null; + + // Normalize the path (resolves redundant slashes, etc.) + return path.normalize(trimmed); +} + +/** + * Validate a session ID format. + * Must be a hex string or UUID, 32-64 characters. + */ +export function validateSessionId(id: string): boolean { + if (!id || !id.trim()) return false; + return SESSION_ID_PATTERN.test(id.trim()); +} + +/** + * Check if input contains dangerous patterns (path traversal, command injection, etc.). + * Returns { dangerous: false } for safe inputs or { dangerous: true, reason } for threats. + */ +export function isDangerousInput(input: string): { dangerous: boolean; reason?: string } { + if (!input) return { dangerous: false }; + + // Excessively long input + if (input.length > MAX_INPUT_LENGTH * 2) { + return { dangerous: true, reason: `excessively long input (${input.length} chars)` }; + } + + // Null bytes + if (input.includes('\0')) { + return { dangerous: true, reason: 'null byte detected' }; + } + + // Check known dangerous patterns + for (const { pattern, reason } of DANGEROUS_PATTERNS) { + if (pattern.test(input)) { + return { dangerous: true, reason }; + } + } + + return { dangerous: false }; +} + +/** + * Sanitize general text input: strip control characters (except newline/tab) + * and enforce max length. + * Returns { text, truncated } — truncated is true if the input was shortened. + */ +export function sanitizeInput( + text: string, + maxLength: number = MAX_INPUT_LENGTH, +): { text: string; truncated: boolean } { + if (!text) return { text: '', truncated: false }; + + // Strip control characters except \n (0x0A) and \t (0x09) + // eslint-disable-next-line no-control-regex + let sanitized = text.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ''); + + const truncated = sanitized.length > maxLength; + if (truncated) { + sanitized = sanitized.slice(0, maxLength); + } + + return { text: sanitized, truncated }; +} + +/** + * Validate /mode parameter. + */ +export function validateMode(mode: string): mode is 'plan' | 'code' | 'ask' { + return VALID_MODES.includes(mode as typeof VALID_MODES[number]); +} diff --git a/src/lib/bridge/types.ts b/src/lib/bridge/types.ts new file mode 100644 index 0000000..ceb2a43 --- /dev/null +++ b/src/lib/bridge/types.ts @@ -0,0 +1,153 @@ +/** + * Bridge system types — shared across all bridge modules. + * + * The bridge connects external IM channels (Telegram, Discord, Slack) + * to CodePilot chat sessions, allowing users to interact with Claude + * from their preferred messaging platform. + */ + +// ── Channel Types ────────────────────────────────────────────── + +/** + * Channel type identifier. + * Extensible — any string is valid so new adapters can register without + * modifying this definition. Well-known values: 'telegram', 'discord', 'slack'. + */ +export type ChannelType = string; + +/** Unique address of a user within a channel */ +export interface ChannelAddress { + channelType: ChannelType; + chatId: string; // Platform-specific chat/channel identifier + userId?: string; // Platform-specific user identifier (optional for group chats) + displayName?: string; // Human-readable name for audit logs +} + +/** Composite key for routing: channelType + chatId */ +export interface SessionKey { + channelType: ChannelType; + chatId: string; +} + +// ── Messages ─────────────────────────────────────────────────── + +/** Inbound message from an IM channel */ +export interface InboundMessage { + /** Platform-specific message ID (for dedup and reference) */ + messageId: string; + /** Address of the sender */ + address: ChannelAddress; + /** Plain text content of the message */ + text: string; + /** Timestamp of the message (ISO string or unix epoch ms) */ + timestamp: number; + /** If this is a callback query (inline button press), the callback data */ + callbackData?: string; + /** For callback queries: the message ID of the original message that triggered the callback */ + callbackMessageId?: string; + /** Platform-specific raw update object (for adapter-specific handling) */ + raw?: unknown; + /** Adapter-specific update ID for deferred offset acknowledgement */ + updateId?: number; +} + +/** Outbound message to send to an IM channel */ +export interface OutboundMessage { + /** Target address */ + address: ChannelAddress; + /** Message text (may contain HTML for Telegram) */ + text: string; + /** Parse mode for the text */ + parseMode?: 'HTML' | 'Markdown' | 'plain'; + /** Inline keyboard buttons */ + inlineButtons?: InlineButton[][]; + /** If replying to a specific message */ + replyToMessageId?: string; +} + +/** Inline keyboard button for permission prompts */ +export interface InlineButton { + text: string; + callbackData: string; +} + +/** Result of sending a message via an adapter */ +export interface SendResult { + ok: boolean; + /** Platform-specific message ID of the sent message */ + messageId?: string; + error?: string; +} + +// ── Bindings ─────────────────────────────────────────────────── + +/** Links an IM chat to a CodePilot session */ +export interface ChannelBinding { + id: string; + channelType: ChannelType; + chatId: string; + /** CodePilot session ID this chat is bound to */ + codepilotSessionId: string; + /** SDK session ID for resume (cached from last conversation) */ + sdkSessionId: string; + /** Working directory for this binding */ + workingDirectory: string; + /** Model override for this binding */ + model: string; + /** Chat mode */ + mode: 'code' | 'plan' | 'ask'; + /** Whether this binding is currently active */ + active: boolean; + createdAt: string; + updatedAt: string; +} + +// ── Bridge Status ────────────────────────────────────────────── + +/** Overall bridge system status */ +export interface BridgeStatus { + running: boolean; + startedAt: string | null; + adapters: AdapterStatus[]; +} + +/** Status of a single channel adapter */ +export interface AdapterStatus { + channelType: ChannelType; + running: boolean; + connectedAt: string | null; + lastMessageAt: string | null; + error: string | null; +} + +// ── Audit & Dedup ────────────────────────────────────────────── + +/** Audit log entry */ +export interface AuditLogEntry { + id: string; + channelType: ChannelType; + chatId: string; + direction: 'inbound' | 'outbound'; + messageId: string; + summary: string; + createdAt: string; +} + +/** Permission link: maps permissionRequestId to an IM message for callback handling */ +export interface PermissionLink { + id: string; + permissionRequestId: string; + channelType: ChannelType; + chatId: string; + messageId: string; + createdAt: string; +} + +// ── Config ───────────────────────────────────────────────────── + +/** Platform-specific message length limits */ +export const PLATFORM_LIMITS: Record = { + telegram: 4096, + discord: 2000, + slack: 40000, +}; diff --git a/src/lib/claude-client.ts b/src/lib/claude-client.ts index c623af2..852860d 100644 --- a/src/lib/claude-client.ts +++ b/src/lib/claude-client.ts @@ -21,6 +21,7 @@ import { registerPendingPermission } from './permission-registry'; import { registerConversation, unregisterConversation } from './conversation-registry'; import { getSetting, getActiveProvider, updateSdkSessionId, createPermissionRequest } from './db'; import { findClaudeBinary, findGitBash, getExpandedPath } from './platform'; +import { notifyPermissionRequest, notifyGeneric } from './telegram-bot'; import os from 'os'; import fs from 'fs'; import path from 'path'; @@ -489,6 +490,9 @@ export function streamClaude(options: ClaudeStreamOptions): ReadableStream, telegramOpts).catch(() => {}); + // Notify runtime status change onRuntimeStatusChange?.('waiting_permission'); @@ -502,6 +506,13 @@ export function streamClaude(options: ClaudeStreamOptions): ReadableStream {}); return {}; }], }], diff --git a/src/lib/db.ts b/src/lib/db.ts index 5e0ca16..dc89482 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -4,6 +4,7 @@ import crypto from 'crypto'; import fs from 'fs'; import os from 'os'; import type { ChatSession, Message, SettingsMap, TaskItem, TaskStatus, ApiProvider, CreateProviderRequest, UpdateProviderRequest, MediaJob, MediaJobStatus, MediaJobItem, MediaJobItemStatus, MediaContextEvent, BatchConfig } from '@/types'; +import type { ChannelType, ChannelBinding } from './bridge/types'; const dataDir = process.env.CLAUDE_GUI_DATA_DIR || path.join(os.homedir(), '.codepilot'); const DB_PATH = path.join(dataDir, 'codepilot.db'); @@ -200,6 +201,79 @@ function initDb(db: Database.Database): void { CREATE INDEX IF NOT EXISTS idx_media_job_items_job_id ON media_job_items(job_id); CREATE INDEX IF NOT EXISTS idx_media_job_items_status ON media_job_items(status); CREATE INDEX IF NOT EXISTS idx_media_context_events_job_id ON media_context_events(job_id); + + -- Bridge: IM channel bindings + CREATE TABLE IF NOT EXISTS channel_bindings ( + id TEXT PRIMARY KEY, + channel_type TEXT NOT NULL, + chat_id TEXT NOT NULL, + codepilot_session_id TEXT NOT NULL, + sdk_session_id TEXT NOT NULL DEFAULT '', + working_directory TEXT NOT NULL DEFAULT '', + model TEXT NOT NULL DEFAULT '', + mode TEXT NOT NULL DEFAULT 'code' CHECK(mode IN ('code', 'plan', 'ask')), + active INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY (codepilot_session_id) REFERENCES chat_sessions(id) ON DELETE CASCADE, + UNIQUE(channel_type, chat_id) + ); + CREATE INDEX IF NOT EXISTS idx_channel_bindings_session ON channel_bindings(codepilot_session_id); + CREATE INDEX IF NOT EXISTS idx_channel_bindings_lookup ON channel_bindings(channel_type, chat_id); + + -- Bridge: polling offset watermarks per adapter + CREATE TABLE IF NOT EXISTS channel_offsets ( + channel_type TEXT PRIMARY KEY, + offset_value TEXT NOT NULL DEFAULT '0', + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + + -- Bridge: idempotent message dedup + CREATE TABLE IF NOT EXISTS channel_dedupe ( + dedup_key TEXT PRIMARY KEY, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + expires_at TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_channel_dedupe_expires ON channel_dedupe(expires_at); + + -- Bridge: outbound message references (for editing/deleting sent messages) + CREATE TABLE IF NOT EXISTS channel_outbound_refs ( + id TEXT PRIMARY KEY, + channel_type TEXT NOT NULL, + chat_id TEXT NOT NULL, + codepilot_session_id TEXT NOT NULL, + platform_message_id TEXT NOT NULL, + purpose TEXT NOT NULL DEFAULT 'response', + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE INDEX IF NOT EXISTS idx_outbound_refs_session ON channel_outbound_refs(codepilot_session_id); + + -- Bridge: audit log + CREATE TABLE IF NOT EXISTS channel_audit_logs ( + id TEXT PRIMARY KEY, + channel_type TEXT NOT NULL, + chat_id TEXT NOT NULL, + direction TEXT NOT NULL CHECK(direction IN ('inbound', 'outbound')), + message_id TEXT NOT NULL DEFAULT '', + summary TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE INDEX IF NOT EXISTS idx_audit_logs_chat ON channel_audit_logs(channel_type, chat_id); + CREATE INDEX IF NOT EXISTS idx_audit_logs_created ON channel_audit_logs(created_at); + + -- Bridge: permission request → IM message links + CREATE TABLE IF NOT EXISTS channel_permission_links ( + id TEXT PRIMARY KEY, + permission_request_id TEXT NOT NULL, + channel_type TEXT NOT NULL, + chat_id TEXT NOT NULL, + message_id TEXT NOT NULL, + tool_name TEXT NOT NULL DEFAULT '', + suggestions TEXT NOT NULL DEFAULT '', + resolved INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE INDEX IF NOT EXISTS idx_perm_links_request ON channel_permission_links(permission_request_id); `); // Run migrations for existing databases @@ -497,6 +571,89 @@ function migrateDb(db: Database.Database): void { ).run(id, 'Default', 'anthropic', baseUrlRow?.value || '', tokenRow?.value || '', 1, 0, '{}', 'Migrated from settings', now, now); } } + + // Ensure bridge tables exist for databases created before bridge feature + db.exec(` + CREATE TABLE IF NOT EXISTS channel_bindings ( + id TEXT PRIMARY KEY, + channel_type TEXT NOT NULL, + chat_id TEXT NOT NULL, + codepilot_session_id TEXT NOT NULL, + sdk_session_id TEXT NOT NULL DEFAULT '', + working_directory TEXT NOT NULL DEFAULT '', + model TEXT NOT NULL DEFAULT '', + mode TEXT NOT NULL DEFAULT 'code' CHECK(mode IN ('code', 'plan', 'ask')), + active INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY (codepilot_session_id) REFERENCES chat_sessions(id) ON DELETE CASCADE, + UNIQUE(channel_type, chat_id) + ); + CREATE INDEX IF NOT EXISTS idx_channel_bindings_session ON channel_bindings(codepilot_session_id); + CREATE INDEX IF NOT EXISTS idx_channel_bindings_lookup ON channel_bindings(channel_type, chat_id); + + CREATE TABLE IF NOT EXISTS channel_offsets ( + channel_type TEXT PRIMARY KEY, + offset_value TEXT NOT NULL DEFAULT '0', + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + + CREATE TABLE IF NOT EXISTS channel_dedupe ( + dedup_key TEXT PRIMARY KEY, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + expires_at TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_channel_dedupe_expires ON channel_dedupe(expires_at); + + CREATE TABLE IF NOT EXISTS channel_outbound_refs ( + id TEXT PRIMARY KEY, + channel_type TEXT NOT NULL, + chat_id TEXT NOT NULL, + codepilot_session_id TEXT NOT NULL, + platform_message_id TEXT NOT NULL, + purpose TEXT NOT NULL DEFAULT 'response', + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE INDEX IF NOT EXISTS idx_outbound_refs_session ON channel_outbound_refs(codepilot_session_id); + + CREATE TABLE IF NOT EXISTS channel_audit_logs ( + id TEXT PRIMARY KEY, + channel_type TEXT NOT NULL, + chat_id TEXT NOT NULL, + direction TEXT NOT NULL CHECK(direction IN ('inbound', 'outbound')), + message_id TEXT NOT NULL DEFAULT '', + summary TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE INDEX IF NOT EXISTS idx_audit_logs_chat ON channel_audit_logs(channel_type, chat_id); + CREATE INDEX IF NOT EXISTS idx_audit_logs_created ON channel_audit_logs(created_at); + + CREATE TABLE IF NOT EXISTS channel_permission_links ( + id TEXT PRIMARY KEY, + permission_request_id TEXT NOT NULL, + channel_type TEXT NOT NULL, + chat_id TEXT NOT NULL, + message_id TEXT NOT NULL, + tool_name TEXT NOT NULL DEFAULT '', + suggestions TEXT NOT NULL DEFAULT '', + resolved INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE INDEX IF NOT EXISTS idx_perm_links_request ON channel_permission_links(permission_request_id); + `); + + // Migrate channel_permission_links for databases created before these columns were added + const permLinkCols = db.prepare("PRAGMA table_info(channel_permission_links)").all() as { name: string }[]; + const permLinkColNames = permLinkCols.map(c => c.name); + if (permLinkColNames.length > 0 && !permLinkColNames.includes('tool_name')) { + db.exec("ALTER TABLE channel_permission_links ADD COLUMN tool_name TEXT NOT NULL DEFAULT ''"); + } + if (permLinkColNames.length > 0 && !permLinkColNames.includes('suggestions')) { + db.exec("ALTER TABLE channel_permission_links ADD COLUMN suggestions TEXT NOT NULL DEFAULT ''"); + } + if (permLinkColNames.length > 0 && !permLinkColNames.includes('resolved')) { + db.exec("ALTER TABLE channel_permission_links ADD COLUMN resolved INTEGER NOT NULL DEFAULT 0"); + } } // ========================================== @@ -508,6 +665,16 @@ export function getAllSessions(): ChatSession[] { return db.prepare('SELECT * FROM chat_sessions ORDER BY updated_at DESC').all() as ChatSession[]; } +/** + * Get sessions that are currently running or waiting for permission. + */ +export function getActiveSessions(): ChatSession[] { + const db = getDb(); + return db.prepare( + "SELECT * FROM chat_sessions WHERE runtime_status IN ('running', 'waiting_permission') ORDER BY runtime_updated_at DESC" + ).all() as ChatSession[]; +} + export function getSession(id: string): ChatSession | undefined { const db = getDb(); return db.prepare('SELECT * FROM chat_sessions WHERE id = ?').get(id) as ChatSession | undefined; @@ -1375,6 +1542,337 @@ export function getPermissionRequest(id: string): { return db.prepare('SELECT * FROM permission_requests WHERE id = ?').get(id) as ReturnType; } +// ========================================== +// Bridge: Channel Binding Operations +// ========================================== + +export function getChannelBinding(channelType: ChannelType, chatId: string): ChannelBinding | undefined { + const db = getDb(); + const row = db.prepare( + 'SELECT * FROM channel_bindings WHERE channel_type = ? AND chat_id = ?' + ).get(channelType, chatId) as { + id: string; channel_type: string; chat_id: string; codepilot_session_id: string; + sdk_session_id: string; working_directory: string; model: string; mode: string; + active: number; created_at: string; updated_at: string; + } | undefined; + if (!row) return undefined; + return { + id: row.id, + channelType: row.channel_type as ChannelType, + chatId: row.chat_id, + codepilotSessionId: row.codepilot_session_id, + sdkSessionId: row.sdk_session_id, + workingDirectory: row.working_directory, + model: row.model, + mode: row.mode as 'code' | 'plan' | 'ask', + active: row.active === 1, + createdAt: row.created_at, + updatedAt: row.updated_at, + }; +} + +export function upsertChannelBinding(params: { + channelType: ChannelType; + chatId: string; + codepilotSessionId: string; + sdkSessionId?: string; + workingDirectory?: string; + model?: string; + mode?: 'code' | 'plan' | 'ask'; +}): ChannelBinding { + const db = getDb(); + const now = new Date().toISOString().replace('T', ' ').split('.')[0]; + const existing = getChannelBinding(params.channelType, params.chatId); + + if (existing) { + db.prepare( + `UPDATE channel_bindings SET codepilot_session_id = ?, sdk_session_id = ?, working_directory = ?, model = ?, mode = ?, updated_at = ? + WHERE channel_type = ? AND chat_id = ?` + ).run( + params.codepilotSessionId, + params.sdkSessionId ?? existing.sdkSessionId, + params.workingDirectory ?? existing.workingDirectory, + params.model ?? existing.model, + params.mode ?? existing.mode, + now, + params.channelType, + params.chatId, + ); + } else { + const id = crypto.randomBytes(16).toString('hex'); + db.prepare( + `INSERT INTO channel_bindings (id, channel_type, chat_id, codepilot_session_id, sdk_session_id, working_directory, model, mode, active, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?)` + ).run( + id, + params.channelType, + params.chatId, + params.codepilotSessionId, + params.sdkSessionId || '', + params.workingDirectory || '', + params.model || '', + params.mode || 'code', + now, + now, + ); + } + + return getChannelBinding(params.channelType, params.chatId)!; +} + +export function listChannelBindings(channelType?: ChannelType): ChannelBinding[] { + const db = getDb(); + let rows: Array<{ + id: string; channel_type: string; chat_id: string; codepilot_session_id: string; + sdk_session_id: string; working_directory: string; model: string; mode: string; + active: number; created_at: string; updated_at: string; + }>; + + if (channelType) { + rows = db.prepare('SELECT * FROM channel_bindings WHERE channel_type = ? ORDER BY updated_at DESC').all(channelType) as typeof rows; + } else { + rows = db.prepare('SELECT * FROM channel_bindings ORDER BY updated_at DESC').all() as typeof rows; + } + + return rows.map(row => ({ + id: row.id, + channelType: row.channel_type as ChannelType, + chatId: row.chat_id, + codepilotSessionId: row.codepilot_session_id, + sdkSessionId: row.sdk_session_id, + workingDirectory: row.working_directory, + model: row.model, + mode: row.mode as 'code' | 'plan' | 'ask', + active: row.active === 1, + createdAt: row.created_at, + updatedAt: row.updated_at, + })); +} + +export function updateChannelBinding( + id: string, + updates: Partial>, +): void { + const db = getDb(); + const now = new Date().toISOString().replace('T', ' ').split('.')[0]; + const sets: string[] = ['updated_at = ?']; + const values: unknown[] = [now]; + + if (updates.sdkSessionId !== undefined) { sets.push('sdk_session_id = ?'); values.push(updates.sdkSessionId); } + if (updates.workingDirectory !== undefined) { sets.push('working_directory = ?'); values.push(updates.workingDirectory); } + if (updates.model !== undefined) { sets.push('model = ?'); values.push(updates.model); } + if (updates.mode !== undefined) { sets.push('mode = ?'); values.push(updates.mode); } + if (updates.active !== undefined) { sets.push('active = ?'); values.push(updates.active ? 1 : 0); } + + values.push(id); + db.prepare(`UPDATE channel_bindings SET ${sets.join(', ')} WHERE id = ?`).run(...values); +} + +// ========================================== +// Bridge: Channel Offset Operations +// ========================================== + +export function getChannelOffset(channelType: ChannelType | string): string { + const db = getDb(); + const row = db.prepare('SELECT offset_value FROM channel_offsets WHERE channel_type = ?').get(channelType) as { offset_value: string } | undefined; + return row?.offset_value || '0'; +} + +export function setChannelOffset(channelType: ChannelType | string, offsetValue: string): void { + const db = getDb(); + const now = new Date().toISOString().replace('T', ' ').split('.')[0]; + db.prepare( + `INSERT INTO channel_offsets (channel_type, offset_value, updated_at) + VALUES (?, ?, ?) + ON CONFLICT(channel_type) DO UPDATE SET offset_value = excluded.offset_value, updated_at = excluded.updated_at` + ).run(channelType, offsetValue, now); +} + +// ========================================== +// Bridge: Dedup Operations +// ========================================== + +export function checkDedup(dedupKey: string): boolean { + const db = getDb(); + const row = db.prepare('SELECT 1 FROM channel_dedupe WHERE dedup_key = ?').get(dedupKey); + return !!row; +} + +export function insertDedup(dedupKey: string, ttlMs: number = 24 * 60 * 60 * 1000): void { + const db = getDb(); + const now = new Date().toISOString().replace('T', ' ').split('.')[0]; + const expiresAt = new Date(Date.now() + ttlMs).toISOString().replace('T', ' ').split('.')[0]; + db.prepare( + `INSERT OR IGNORE INTO channel_dedupe (dedup_key, created_at, expires_at) VALUES (?, ?, ?)` + ).run(dedupKey, now, expiresAt); +} + +export function cleanupExpiredDedup(): number { + const db = getDb(); + const now = new Date().toISOString().replace('T', ' ').split('.')[0]; + const result = db.prepare('DELETE FROM channel_dedupe WHERE expires_at < ?').run(now); + return result.changes; +} + +// ========================================== +// Bridge: Outbound Ref Operations +// ========================================== + +export function insertOutboundRef(params: { + channelType: ChannelType; + chatId: string; + codepilotSessionId: string; + platformMessageId: string; + purpose?: string; +}): void { + const db = getDb(); + const id = crypto.randomBytes(16).toString('hex'); + const now = new Date().toISOString().replace('T', ' ').split('.')[0]; + db.prepare( + `INSERT INTO channel_outbound_refs (id, channel_type, chat_id, codepilot_session_id, platform_message_id, purpose, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?)` + ).run(id, params.channelType, params.chatId, params.codepilotSessionId, params.platformMessageId, params.purpose || 'response', now); +} + +export function getOutboundRefs(codepilotSessionId: string): Array<{ + id: string; + channelType: ChannelType; + chatId: string; + platformMessageId: string; + purpose: string; + createdAt: string; +}> { + const db = getDb(); + const rows = db.prepare( + 'SELECT * FROM channel_outbound_refs WHERE codepilot_session_id = ? ORDER BY created_at DESC' + ).all(codepilotSessionId) as Array<{ + id: string; channel_type: string; chat_id: string; codepilot_session_id: string; + platform_message_id: string; purpose: string; created_at: string; + }>; + return rows.map(r => ({ + id: r.id, + channelType: r.channel_type as ChannelType, + chatId: r.chat_id, + platformMessageId: r.platform_message_id, + purpose: r.purpose, + createdAt: r.created_at, + })); +} + +// ========================================== +// Bridge: Audit Log Operations +// ========================================== + +export function insertAuditLog(params: { + channelType: ChannelType; + chatId: string; + direction: 'inbound' | 'outbound'; + messageId?: string; + summary?: string; +}): void { + const db = getDb(); + const id = crypto.randomBytes(16).toString('hex'); + const now = new Date().toISOString().replace('T', ' ').split('.')[0]; + db.prepare( + `INSERT INTO channel_audit_logs (id, channel_type, chat_id, direction, message_id, summary, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?)` + ).run(id, params.channelType, params.chatId, params.direction, params.messageId || '', params.summary || '', now); +} + +export function getAuditLogs(channelType: ChannelType, chatId: string, limit: number = 50): Array<{ + id: string; + channelType: ChannelType; + chatId: string; + direction: 'inbound' | 'outbound'; + messageId: string; + summary: string; + createdAt: string; +}> { + const db = getDb(); + const rows = db.prepare( + 'SELECT * FROM channel_audit_logs WHERE channel_type = ? AND chat_id = ? ORDER BY created_at DESC LIMIT ?' + ).all(channelType, chatId, limit) as Array<{ + id: string; channel_type: string; chat_id: string; direction: string; + message_id: string; summary: string; created_at: string; + }>; + return rows.map(r => ({ + id: r.id, + channelType: r.channel_type as ChannelType, + chatId: r.chat_id, + direction: r.direction as 'inbound' | 'outbound', + messageId: r.message_id, + summary: r.summary, + createdAt: r.created_at, + })); +} + +// ========================================== +// Bridge: Permission Link Operations +// ========================================== + +export function insertPermissionLink(params: { + permissionRequestId: string; + channelType: ChannelType; + chatId: string; + messageId: string; + toolName?: string; + suggestions?: string; +}): void { + const db = getDb(); + const id = crypto.randomBytes(16).toString('hex'); + const now = new Date().toISOString().replace('T', ' ').split('.')[0]; + db.prepare( + `INSERT INTO channel_permission_links (id, permission_request_id, channel_type, chat_id, message_id, tool_name, suggestions, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)` + ).run(id, params.permissionRequestId, params.channelType, params.chatId, params.messageId, params.toolName || '', params.suggestions || '', now); +} + +export function getPermissionLink(permissionRequestId: string): { + id: string; + permissionRequestId: string; + channelType: ChannelType; + chatId: string; + messageId: string; + toolName: string; + suggestions: string; + resolved: boolean; + createdAt: string; +} | undefined { + const db = getDb(); + const row = db.prepare( + 'SELECT * FROM channel_permission_links WHERE permission_request_id = ?' + ).get(permissionRequestId) as { + id: string; permission_request_id: string; channel_type: string; + chat_id: string; message_id: string; tool_name: string; + suggestions: string; resolved: number; created_at: string; + } | undefined; + if (!row) return undefined; + return { + id: row.id, + permissionRequestId: row.permission_request_id, + channelType: row.channel_type as ChannelType, + chatId: row.chat_id, + messageId: row.message_id, + toolName: row.tool_name, + suggestions: row.suggestions, + resolved: row.resolved === 1, + createdAt: row.created_at, + }; +} + +/** + * Atomically mark a permission link as resolved. + * Uses `resolved = 0` in the WHERE clause to prevent double-resolution races. + * Returns true if the row was actually updated (i.e., it was not already resolved). + */ +export function markPermissionLinkResolved(permissionRequestId: string): boolean { + const db = getDb(); + const result = db.prepare( + 'UPDATE channel_permission_links SET resolved = 1 WHERE permission_request_id = ? AND resolved = 0' + ).run(permissionRequestId); + return result.changes > 0; +} + // ========================================== // Graceful Shutdown // ========================================== diff --git a/src/lib/telegram-bot.ts b/src/lib/telegram-bot.ts new file mode 100644 index 0000000..d9fe5c6 --- /dev/null +++ b/src/lib/telegram-bot.ts @@ -0,0 +1,572 @@ +/** + * Telegram Bot notification module for CodePilot. + * + * Sends task status notifications (start, complete, error, permission requests) + * to a configured Telegram chat. Optionally listens for /status commands + * via long polling. + * + * Configuration is stored in the SQLite settings table: + * - telegram_bot_token: Bot API token from @BotFather + * - telegram_chat_id: Target chat/group/channel ID + * - telegram_enabled: 'true' | '' (empty = disabled) + * - telegram_notify_start: 'true' | '' — notify on session start + * - telegram_notify_complete: 'true' | '' — notify on session complete + * - telegram_notify_error: 'true' | '' — notify on errors + * - telegram_notify_permission: 'true' | '' — notify on permission requests + */ + +import { getSetting, getActiveSessions, getAllSessions } from './db'; +import { + callTelegramApi, + escapeHtml, + splitMessage, + formatSessionHeader, +} from './bridge/adapters/telegram-utils'; + +// ── Types ────────────────────────────────────────────────────── + +interface TelegramConfig { + botToken: string; + chatId: string; + enabled: boolean; + notifyStart: boolean; + notifyComplete: boolean; + notifyError: boolean; + notifyPermission: boolean; +} + +interface TelegramUser { + id: number; + first_name: string; + username?: string; +} + +interface TelegramBotInfo { + ok: boolean; + result?: TelegramUser; + description?: string; +} + +export interface TelegramNotifyOptions { + sessionId?: string; + sessionTitle?: string; + workingDirectory?: string; +} + +// ── Constants ────────────────────────────────────────────────── + +const TELEGRAM_API = 'https://api.telegram.org'; +const MAX_MESSAGE_LENGTH = 4000; // Telegram limit is 4096, leave buffer + +// ── Bridge Mode Guard ───────────────────────────────────────── + +/** + * When the bridge adapter is active, the notification bot's polling + * must be suppressed to avoid conflicts (both would consume updates). + * + * Stored on globalThis so the flag survives Next.js HMR in development. + * Without this, a hot-reload resets the flag to false and the notification + * bot restarts polling, stealing updates from the bridge adapter. + */ +const BRIDGE_MODE_KEY = '__telegram_bridge_mode_active__'; + +function isBridgeModeActive(): boolean { + return !!(globalThis as unknown as Record)[BRIDGE_MODE_KEY]; +} + +export function setBridgeModeActive(active: boolean): void { + (globalThis as unknown as Record)[BRIDGE_MODE_KEY] = active; + if (active) { + stopPolling(); + } +} + +// ── Config ───────────────────────────────────────────────────── + +export function getTelegramConfig(): TelegramConfig { + return { + botToken: getSetting('telegram_bot_token') || '', + chatId: getSetting('telegram_chat_id') || '', + enabled: getSetting('telegram_enabled') === 'true', + notifyStart: getSetting('telegram_notify_start') === 'true', + notifyComplete: getSetting('telegram_notify_complete') === 'true', + notifyError: getSetting('telegram_notify_error') === 'true', + notifyPermission: getSetting('telegram_notify_permission') === 'true', + }; +} + +function isConfigured(): boolean { + const config = getTelegramConfig(); + return config.enabled && !!config.botToken && !!config.chatId; +} + +/** + * Ensure the long-polling loop is started if Telegram is configured. + * Called lazily on the first notification attempt. + */ +let pollingInitialized = false; +function ensurePollingStarted(): void { + if (pollingInitialized || isBridgeModeActive()) return; + pollingInitialized = true; + if (isConfigured()) { + startPolling(); + } +} + +// ── Core API ─────────────────────────────────────────────────── + +/** + * Send a message to the configured Telegram chat. + * Automatically splits messages that exceed the Telegram limit. + */ +async function sendMessage(text: string, parseMode: 'HTML' | 'Markdown' = 'HTML'): Promise<{ ok: boolean; error?: string }> { + const config = getTelegramConfig(); + if (!config.enabled || !config.botToken || !config.chatId) { + return { ok: false, error: 'Telegram not configured' }; + } + + // Lazily start the command listener on first outgoing message + ensurePollingStarted(); + + const chunks = splitMessage(text, MAX_MESSAGE_LENGTH); + for (const chunk of chunks) { + const result = await callTelegramApi(config.botToken, 'sendMessage', { + chat_id: config.chatId, + text: chunk, + parse_mode: parseMode, + disable_web_page_preview: true, + }); + if (!result.ok) return result; + } + return { ok: true }; +} + +// ── Public Notification Functions ────────────────────────────── + +/** + * Notify that a session has started processing. + */ +export async function notifySessionStart(opts?: TelegramNotifyOptions): Promise { + const config = getTelegramConfig(); + if (!config.enabled || !config.notifyStart) return; + + const header = formatSessionHeader(opts); + const msg = `▶️ Task Started\n${header}`.trim(); + + const result = await sendMessage(msg); + if (!result.ok) { + console.warn('[telegram] Failed to send start notification:', result.error); + } +} + +/** + * Notify that a session has completed successfully. + */ +export async function notifySessionComplete( + summary?: string, + opts?: TelegramNotifyOptions, +): Promise { + const config = getTelegramConfig(); + if (!config.enabled || !config.notifyComplete) return; + + const header = formatSessionHeader(opts); + let msg = `✅ Task Completed\n${header}`.trim(); + if (summary) { + const truncated = summary.length > 500 + ? summary.slice(0, 500) + '...' + : summary; + msg += `\n\n${escapeHtml(truncated)}`; + } + + const result = await sendMessage(msg); + if (!result.ok) { + console.warn('[telegram] Failed to send completion notification:', result.error); + } +} + +/** + * Notify that an error occurred during a session. + */ +export async function notifySessionError( + errorMessage: string, + opts?: TelegramNotifyOptions, +): Promise { + const config = getTelegramConfig(); + if (!config.enabled || !config.notifyError) return; + + const header = formatSessionHeader(opts); + const truncatedError = errorMessage.length > 500 + ? errorMessage.slice(0, 500) + '...' + : errorMessage; + const msg = `❌ Task Error\n${header}\n\n
${escapeHtml(truncatedError)}
`.trim(); + + const result = await sendMessage(msg); + if (!result.ok) { + console.warn('[telegram] Failed to send error notification:', result.error); + } +} + +/** + * Notify that a permission request is pending. + */ +export async function notifyPermissionRequest( + toolName: string, + toolInput: Record, + opts?: TelegramNotifyOptions, +): Promise { + const config = getTelegramConfig(); + if (!config.enabled || !config.notifyPermission) return; + + const header = formatSessionHeader(opts); + const inputStr = JSON.stringify(toolInput, null, 2); + const truncatedInput = inputStr.length > 300 + ? inputStr.slice(0, 300) + '...' + : inputStr; + + const msg = [ + `🔐 Permission Required`, + header, + ``, + `Tool: ${escapeHtml(toolName)}`, + `
${escapeHtml(truncatedInput)}
`, + ``, + `⚠️ Please approve or deny in CodePilot.`, + ].filter(Boolean).join('\n'); + + const result = await sendMessage(msg); + if (!result.ok) { + console.warn('[telegram] Failed to send permission notification:', result.error); + } +} + +/** + * Send a generic SDK notification (from Notification hook). + */ +export async function notifyGeneric( + title: string, + message: string, + opts?: TelegramNotifyOptions, +): Promise { + if (!isConfigured()) return; + + const header = formatSessionHeader(opts); + const msg = [ + `📢 ${escapeHtml(title)}`, + header, + message ? `\n${escapeHtml(message)}` : '', + ].filter(Boolean).join('\n'); + + const result = await sendMessage(msg); + if (!result.ok) { + console.warn('[telegram] Failed to send notification:', result.error); + } +} + +// ── Bot Verification ─────────────────────────────────────────── + +/** + * Verify a bot token and optionally send a test message. + * Returns bot info on success. + */ +export async function verifyBot( + botToken: string, + chatId?: string, +): Promise<{ ok: boolean; botName?: string; error?: string }> { + try { + const url = `${TELEGRAM_API}/bot${botToken}/getMe`; + const res = await fetch(url); + const data: TelegramBotInfo = await res.json(); + + if (!data.ok || !data.result) { + return { ok: false, error: data.description || 'Invalid bot token' }; + } + + const botName = data.result.username || data.result.first_name; + + // Send a test message if chat_id provided + if (chatId) { + const testResult = await callTelegramApi(botToken, 'sendMessage', { + chat_id: chatId, + text: `✅ CodePilot connected successfully!\n\nBot: @${botName}\nNotifications will be sent to this chat.`, + parse_mode: 'HTML', + }); + if (!testResult.ok) { + return { ok: false, botName, error: `Bot verified but cannot send to chat: ${testResult.error}` }; + } + } + + return { ok: true, botName }; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : 'Network error' }; + } +} + +/** + * Auto-detect chat ID by reading recent messages sent to the bot. + * The user should send /start (or any message) to the bot before calling this. + * Returns the chat ID of the most recent private message sender. + */ +export async function detectChatId( + botToken: string, +): Promise<{ ok: boolean; chatId?: string; chatTitle?: string; error?: string }> { + try { + // Try getUpdates first (works when polling hasn't consumed the message) + const url = `${TELEGRAM_API}/bot${botToken}/getUpdates`; + const res = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ limit: 100, timeout: 0, allowed_updates: ['message'] }), + }); + const data = await res.json(); + + if (data.ok && Array.isArray(data.result) && data.result.length > 0) { + // Find the most recent message with a chat id + for (let i = data.result.length - 1; i >= 0; i--) { + const update = data.result[i]; + const msg = update.message; + if (msg?.chat?.id) { + const chatId = String(msg.chat.id); + const chatTitle = msg.chat.first_name + || msg.chat.title + || msg.chat.username + || chatId; + return { ok: true, chatId, chatTitle }; + } + } + } + + // Fallback: check chat IDs recorded during polling + const recentChats = getRecentChats(); + if (recentChats.length > 0) { + const latest = recentChats[recentChats.length - 1]; + return { ok: true, chatId: latest.chatId, chatTitle: latest.chatTitle }; + } + + return { + ok: false, + error: 'No messages found. Please send /start to the bot first, then try again.', + }; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : 'Network error' }; + } +} + +// ── Status Query ─────────────────────────────────────────────── + +/** + * Build a status summary of current CodePilot sessions. + * Used by the /status command handler. + */ +export function buildStatusMessage(): string { + const activeSessions = getActiveSessions(); + const allSessions = getAllSessions(); + const recentSessions = allSessions.slice(0, 5); + + const lines: string[] = [ + `📊 CodePilot Status`, + ``, + ]; + + if (activeSessions.length === 0) { + lines.push(`No active tasks running.`); + } else { + lines.push(`Active Tasks (${activeSessions.length}):`); + for (const s of activeSessions) { + const status = s.runtime_status === 'waiting_permission' ? '🔐 Waiting Permission' : '⚡ Running'; + const title = s.title || 'Untitled'; + const dir = s.working_directory ? ` — ${escapeHtml(s.working_directory)}` : ''; + lines.push(` ${status} ${escapeHtml(title)}${dir}`); + } + } + + lines.push(``); + lines.push(`Recent Sessions:`); + if (recentSessions.length === 0) { + lines.push(` No sessions yet.`); + } else { + for (const s of recentSessions) { + const statusIcon = s.runtime_status === 'running' ? '⚡' + : s.runtime_status === 'waiting_permission' ? '🔐' + : '💤'; + const title = s.title || 'Untitled'; + lines.push(` ${statusIcon} ${escapeHtml(title)}`); + } + } + + lines.push(``); + lines.push(`Total sessions: ${allSessions.length}`); + + return lines.join('\n'); +} + +// ── Long Polling ─────────────────────────────────────────────── + +interface TelegramUpdate { + update_id: number; + message?: { + chat: { id: number; first_name?: string; title?: string; username?: string }; + text?: string; + from?: TelegramUser; + }; +} + +// ── Recently Seen Chats (for auto-detect fallback) ──────────── + +interface RecentChat { + chatId: string; + chatTitle: string; + timestamp: number; +} + +const RECENT_CHATS_KEY = '__telegram_recent_chats__'; + +function getRecentChats(): RecentChat[] { + const g = globalThis as unknown as Record; + if (!g[RECENT_CHATS_KEY]) g[RECENT_CHATS_KEY] = []; + return g[RECENT_CHATS_KEY]; +} + +function recordRecentChat(chatId: string, chatTitle: string): void { + const chats = getRecentChats(); + const idx = chats.findIndex(c => c.chatId === chatId); + if (idx >= 0) chats.splice(idx, 1); + chats.push({ chatId, chatTitle, timestamp: Date.now() }); + while (chats.length > 10) chats.shift(); +} + +// Singleton poller state stored on globalThis to survive Next.js HMR +const POLLER_KEY = '__telegram_poller__'; + +interface PollerState { + running: boolean; + abortController: AbortController | null; + lastOffset: number; +} + +function getPollerState(): PollerState { + const g = globalThis as unknown as Record; + if (!g[POLLER_KEY]) { + g[POLLER_KEY] = { running: false, abortController: null, lastOffset: 0 }; + } + return g[POLLER_KEY]; +} + +/** + * Start the long-polling loop to listen for incoming Telegram commands. + * Supports: /status — replies with current CodePilot session status. + * Only processes messages from the configured chat_id for security. + * + * This is idempotent — calling it multiple times won't create duplicate pollers. + */ +export function startPolling(): void { + if (isBridgeModeActive()) return; // Bridge adapter handles polling + const state = getPollerState(); + if (state.running) return; + + const config = getTelegramConfig(); + if (!config.enabled || !config.botToken || !config.chatId) return; + + state.running = true; + state.abortController = new AbortController(); + + console.log('[telegram] Starting long-polling for commands'); + pollLoop(config.botToken, config.chatId, state); +} + +/** + * Stop the long-polling loop. + */ +export function stopPolling(): void { + const state = getPollerState(); + state.running = false; + state.abortController?.abort(); + state.abortController = null; + console.log('[telegram] Stopped long-polling'); +} + +async function pollLoop(botToken: string, chatId: string, state: PollerState): Promise { + while (state.running) { + try { + const url = `${TELEGRAM_API}/bot${botToken}/getUpdates`; + const res = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + offset: state.lastOffset, + timeout: 30, + allowed_updates: ['message'], + }), + signal: state.abortController?.signal, + }); + + if (!state.running) break; + + const data = await res.json(); + if (!data.ok || !Array.isArray(data.result)) continue; + + for (const update of data.result as TelegramUpdate[]) { + state.lastOffset = update.update_id + 1; + + const msg = update.message; + if (!msg || !msg.text) continue; + + // Record chat ID for auto-detect (before security filter) + const incomingChatId = String(msg.chat.id); + const incomingChatTitle = msg.chat.first_name + || msg.chat.title + || msg.chat.username + || incomingChatId; + recordRecentChat(incomingChatId, incomingChatTitle); + + // Security: only respond to messages from the configured chat + if (incomingChatId !== chatId) continue; + + // Extract command (handle /command@botname format) + const command = msg.text.trim().split(/[\s@]/)[0].toLowerCase(); + + if (command === '/start') { + await callTelegramApi(botToken, 'sendMessage', { + chat_id: chatId, + text: [ + `👋 CodePilot Bot`, + ``, + `I'll send you notifications about your CodePilot tasks.`, + ``, + `Commands:`, + `/status — Show current task status`, + `/help — Show available commands`, + ].join('\n'), + parse_mode: 'HTML', + }); + } else if (command === '/status') { + const statusMsg = buildStatusMessage(); + await callTelegramApi(botToken, 'sendMessage', { + chat_id: chatId, + text: statusMsg, + parse_mode: 'HTML', + disable_web_page_preview: true, + }); + } else if (command === '/help') { + await callTelegramApi(botToken, 'sendMessage', { + chat_id: chatId, + text: [ + `CodePilot Bot Commands`, + ``, + `/status — Show current task status`, + `/help — Show this help message`, + ].join('\n'), + parse_mode: 'HTML', + }); + } + } + } catch (err) { + // AbortError is expected when stopping + if (err instanceof Error && err.name === 'AbortError') break; + console.warn('[telegram] Polling error:', err instanceof Error ? err.message : err); + // Back off on errors + if (state.running) { + await new Promise(resolve => setTimeout(resolve, 5000)); + } + } + } +}