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

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

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

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

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

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

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

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

chore: bump v0.21.0

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

No files matched your search

+1
View File
@@ -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
+127
View File
@@ -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 \<session_id\> | 绑定已有会话 |
| /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 命令菜单
+73
View File
@@ -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 只负责监控和通知(任务进度、错误告警),不做执行控制
+165 -1
View File
@@ -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<string, string> = {};
let isQuitting = false;
let tray: Tray | null = null;
// --- Install orchestrator ---
interface InstallStep {
@@ -85,6 +86,130 @@ function killServer(): Promise<void> {
});
}
/**
* Check if the remote bridge is currently active by querying the local API.
*/
async function isBridgeActive(): Promise<boolean> {
if (!serverPort) return false;
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const http = require('http');
return await new Promise<boolean>((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<void> {
if (!serverPort) return;
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const http = require('http');
await new Promise<void>((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();
}
+3
View File
@@ -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.
+2 -2
View File
@@ -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",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "codepilot",
"version": "0.20.1",
"version": "0.21.0",
"private": true,
"author": {
"name": "op7418",
+33
View File
@@ -0,0 +1,33 @@
import { listChannelBindings } from '@/lib/db';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
/**
* GET /api/bridge/channels — List channel bindings.
*
* Returns all bindings by default. Supports query parameters:
* ?active=true — return only active bindings
* ?active=false — return only inactive bindings
*/
export async function GET(request: Request) {
try {
const { searchParams } = new URL(request.url);
const activeParam = searchParams.get('active');
let bindings = listChannelBindings();
if (activeParam === 'true') {
bindings = bindings.filter(b => b.active);
} else if (activeParam === 'false') {
bindings = bindings.filter(b => !b.active);
}
return Response.json({ bindings });
} catch (error) {
return Response.json(
{ error: error instanceof Error ? error.message : 'Internal error' },
{ status: 500 },
);
}
}
+52
View File
@@ -0,0 +1,52 @@
import { NextRequest } from 'next/server';
import * as bridgeManager from '@/lib/bridge/bridge-manager';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
/**
* GET /api/bridge — Return bridge status (pure query, no side effects)
*/
export async function GET() {
try {
const status = bridgeManager.getStatus();
return Response.json(status);
} catch (error) {
return Response.json(
{ error: error instanceof Error ? error.message : 'Internal error' },
{ status: 500 },
);
}
}
/**
* POST /api/bridge — Start, stop, or auto-start the bridge
* Body: { action: 'start' | 'stop' | 'auto-start' }
*/
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { action } = body;
if (action === 'start') {
await bridgeManager.start();
return Response.json({ ok: true, status: bridgeManager.getStatus() });
} else if (action === 'stop') {
await bridgeManager.stop();
return Response.json({ ok: true, status: bridgeManager.getStatus() });
} else if (action === 'auto-start') {
bridgeManager.tryAutoStart();
return Response.json({ ok: true, status: bridgeManager.getStatus() });
} else {
return Response.json(
{ error: 'Invalid action. Use "start", "stop", or "auto-start".' },
{ status: 400 },
);
}
} catch (error) {
return Response.json(
{ error: error instanceof Error ? error.message : 'Internal error' },
{ status: 500 },
);
}
}
+54
View File
@@ -0,0 +1,54 @@
import { NextResponse } from 'next/server';
import { getSetting, setSetting } from '@/lib/db';
const BRIDGE_SETTING_KEYS = [
'remote_bridge_enabled',
'bridge_telegram_enabled',
'telegram_bridge_allowed_users',
'bridge_auto_start',
'bridge_default_work_dir',
'bridge_default_model',
'bridge_default_provider_id',
] as const;
export async function GET() {
try {
const settings: Record<string, string> = {};
for (const key of BRIDGE_SETTING_KEYS) {
settings[key] = getSetting(key) ?? '';
}
return NextResponse.json({ settings });
} catch {
return NextResponse.json(
{ error: 'Failed to read bridge settings' },
{ status: 500 },
);
}
}
export async function PUT(request: Request) {
try {
const body = await request.json();
const { settings } = body;
if (!settings || typeof settings !== 'object') {
return NextResponse.json(
{ error: 'Invalid settings data' },
{ status: 400 },
);
}
for (const [key, value] of Object.entries(settings)) {
if (BRIDGE_SETTING_KEYS.includes(key as typeof BRIDGE_SETTING_KEYS[number])) {
setSetting(key, String(value));
}
}
return NextResponse.json({ success: true });
} catch {
return NextResponse.json(
{ error: 'Failed to save bridge settings' },
{ status: 500 },
);
}
}
+46 -4
View File
@@ -1,6 +1,7 @@
import { NextRequest } from 'next/server';
import { streamClaude } from '@/lib/claude-client';
import { addMessage, getMessages, getSession, updateSessionTitle, updateSdkSessionId, updateSessionModel, updateSessionProvider, updateSessionProviderId, getSetting, getProvider, getDefaultProviderId, acquireSessionLock, releaseSessionLock, setSessionRuntimeStatus, syncSdkTasks } from '@/lib/db';
import { addMessage, getMessages, getSession, updateSessionTitle, updateSdkSessionId, updateSessionModel, updateSessionProvider, updateSessionProviderId, getSetting, getProvider, getDefaultProviderId, acquireSessionLock, renewSessionLock, releaseSessionLock, setSessionRuntimeStatus, syncSdkTasks } from '@/lib/db';
import { notifySessionStart, notifySessionComplete, notifySessionError } from '@/lib/telegram-bot';
import type { SendMessageRequest, SSEEvent, TokenUsage, MessageContentBlock, FileAttachment } from '@/types';
import crypto from 'crypto';
import fs from 'fs';
@@ -48,6 +49,14 @@ export async function POST(request: NextRequest) {
activeLockId = lockId;
setSessionRuntimeStatus(session_id, 'running');
// Telegram notification: session started (fire-and-forget)
const telegramNotifyOpts = {
sessionId: session_id,
sessionTitle: session.title !== 'New Chat' ? session.title : content.slice(0, 50),
workingDirectory: session.working_directory,
};
notifySessionStart(telegramNotifyOpts).catch(() => {});
// Save user message — persist file metadata so attachments survive page reload
let savedContent = content;
let fileMeta: Array<{ id: string; name: string; type: string; size: number; filePath: string }> | undefined;
@@ -201,8 +210,14 @@ export async function POST(request: NextRequest) {
// Tee the stream: one for client, one for collecting the response
const [streamForClient, streamForCollect] = stream.tee();
// Periodically renew the session lock so long-running tasks don't expire
const lockRenewalInterval = setInterval(() => {
try { renewSessionLock(session_id, lockId, 600); } catch { /* best effort */ }
}, 60_000);
// Save assistant message in background, with cleanup callback to release lock
collectStreamResponse(streamForCollect, session_id, () => {
collectStreamResponse(streamForCollect, session_id, telegramNotifyOpts, () => {
clearInterval(lockRenewalInterval);
releaseSessionLock(session_id, lockId);
setSessionRuntimeStatus(session_id, 'idle');
});
@@ -231,11 +246,18 @@ export async function POST(request: NextRequest) {
}
}
async function collectStreamResponse(stream: ReadableStream<string>, sessionId: string, onComplete?: () => void) {
async function collectStreamResponse(
stream: ReadableStream<string>,
sessionId: string,
telegramOpts: { sessionId?: string; sessionTitle?: string; workingDirectory?: string },
onComplete?: () => void,
) {
const reader = stream.getReader();
const contentBlocks: MessageContentBlock[] = [];
let currentText = '';
let tokenUsage: TokenUsage | null = null;
let hasError = false;
let errorMessage = '';
// Dedup layer: skip duplicate tool_result events by tool_use_id
const seenToolResultIds = new Set<string>();
@@ -318,12 +340,18 @@ async function collectStreamResponse(stream: ReadableStream<string>, sessionId:
} catch {
// skip malformed task_update data
}
} else if (event.type === 'error') {
hasError = true;
errorMessage = event.data || 'Unknown error';
} else if (event.type === 'result') {
try {
const resultData = JSON.parse(event.data);
if (resultData.usage) {
tokenUsage = resultData.usage;
}
if (resultData.is_error) {
hasError = true;
}
// Also capture session_id from result if we missed it from init
if (resultData.session_id) {
updateSdkSessionId(sessionId, resultData.session_id);
@@ -369,7 +397,9 @@ async function collectStreamResponse(stream: ReadableStream<string>, sessionId:
);
}
}
} catch {
} catch (e) {
hasError = true;
errorMessage = e instanceof Error ? e.message : 'Stream reading error';
// Stream reading error - best effort save
if (currentText.trim()) {
contentBlocks.push({ type: 'text', text: currentText });
@@ -390,6 +420,18 @@ async function collectStreamResponse(stream: ReadableStream<string>, sessionId:
}
}
} finally {
// Telegram notifications: completion or error (fire-and-forget)
if (hasError) {
notifySessionError(errorMessage, telegramOpts).catch(() => {});
} else {
// Extract text summary for the completion notification
const textSummary = contentBlocks
.filter((b): b is Extract<MessageContentBlock, { type: 'text' }> => b.type === 'text')
.map((b) => b.text)
.join('')
.trim();
notifySessionComplete(textSummary || undefined, telegramOpts).catch(() => {});
}
onComplete?.();
}
}
+87
View File
@@ -0,0 +1,87 @@
import { NextRequest, NextResponse } from 'next/server';
import { getSetting, setSetting } from '@/lib/db';
import { startPolling, stopPolling } from '@/lib/telegram-bot';
/**
* Telegram Bot notification settings.
* Stored in the SQLite settings table (same as other app settings).
*/
const TELEGRAM_KEYS = [
'telegram_bot_token',
'telegram_chat_id',
'telegram_enabled',
'telegram_notify_start',
'telegram_notify_complete',
'telegram_notify_error',
'telegram_notify_permission',
'telegram_bridge_allowed_users',
] as const;
export async function GET() {
try {
const result: Record<string, string> = {};
for (const key of TELEGRAM_KEYS) {
const value = getSetting(key);
if (value !== undefined) {
// Mask bot token for security
if (key === 'telegram_bot_token' && value.length > 8) {
result[key] = '***' + value.slice(-8);
} else {
result[key] = value;
}
}
}
// Auto-start polling if Telegram is configured (survives server restarts)
const enabled = getSetting('telegram_enabled') === 'true';
const hasToken = !!getSetting('telegram_bot_token');
const hasChatId = !!getSetting('telegram_chat_id');
if (enabled && hasToken && hasChatId) {
startPolling();
}
return NextResponse.json({ settings: result });
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to read Telegram settings';
return NextResponse.json({ error: message }, { status: 500 });
}
}
export async function PUT(request: NextRequest) {
try {
const body = await request.json();
const { settings } = body;
if (!settings || typeof settings !== 'object') {
return NextResponse.json({ error: 'Invalid settings data' }, { status: 400 });
}
for (const [key, value] of Object.entries(settings)) {
if (!TELEGRAM_KEYS.includes(key as typeof TELEGRAM_KEYS[number])) continue;
const strValue = String(value ?? '').trim();
// Don't overwrite token if user sent the masked version back
if (key === 'telegram_bot_token' && strValue.startsWith('***')) {
continue;
}
setSetting(key, strValue);
}
// Start or stop long-polling based on the enabled state
const enabled = getSetting('telegram_enabled') === 'true';
const hasToken = !!getSetting('telegram_bot_token');
const hasChatId = !!getSetting('telegram_chat_id');
if (enabled && hasToken && hasChatId) {
startPolling();
} else {
stopPolling();
}
return NextResponse.json({ success: true });
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to save Telegram settings';
return NextResponse.json({ error: message }, { status: 500 });
}
}
@@ -0,0 +1,77 @@
import { NextRequest, NextResponse } from 'next/server';
import { verifyBot, detectChatId } from '@/lib/telegram-bot';
import { getSetting } from '@/lib/db';
import { callTelegramApi } from '@/lib/bridge/adapters/telegram-utils';
/**
* POST /api/settings/telegram/verify
*
* Actions:
* - { action: "verify", bot_token, chat_id? } — Verify bot token, optionally send test message
* - { action: "detect_chat_id", bot_token } — Auto-detect chat ID from recent messages
* - { bot_token, chat_id? } — (Legacy) same as action: "verify"
*
* If bot_token starts with "***" (masked), falls back to the stored token in DB.
* If bot_token is omitted, also falls back to the stored token.
*/
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { action, chat_id } = body;
let bot_token: string = body.bot_token || '';
// If token is masked or missing, use the stored one
if (!bot_token || bot_token.startsWith('***')) {
const stored = getSetting('telegram_bot_token');
if (stored) {
bot_token = stored;
}
}
if (!bot_token) {
return NextResponse.json({ error: 'bot_token is required' }, { status: 400 });
}
// Auto-detect chat ID from recent bot messages
if (action === 'detect_chat_id') {
const result = await detectChatId(bot_token);
return NextResponse.json(result);
}
// Register bot commands menu with Telegram
if (action === 'register_commands') {
const res = await callTelegramApi(bot_token, 'setMyCommands', {
commands: [
{ command: 'new', description: 'Start new session (optionally specify path)' },
{ command: 'bind', description: 'Bind to existing session' },
{ command: 'cwd', description: 'Change working directory' },
{ command: 'mode', description: 'Switch mode: plan / code / ask' },
{ command: 'status', description: 'Show current session status' },
{ command: 'sessions', description: 'List recent sessions' },
{ command: 'stop', description: 'Stop current task' },
{ command: 'help', description: 'Show available commands' },
],
});
return NextResponse.json({ ok: res.ok, error: res.error });
}
// Default: verify bot token
const result = await verifyBot(bot_token, chat_id || undefined);
if (!result.ok) {
return NextResponse.json({
verified: false,
error: result.error,
botName: result.botName,
});
}
return NextResponse.json({
verified: true,
botName: result.botName,
});
} catch (error) {
const message = error instanceof Error ? error.message : 'Verification failed';
return NextResponse.json({ error: message }, { status: 500 });
}
}
+20
View File
@@ -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 (
<Suspense
fallback={
<div className="flex h-full items-center justify-center">
<HugeiconsIcon icon={Loading02Icon} className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
}
>
<BridgeLayout />
</Suspense>
);
}
+93
View File
@@ -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<Section | null>(null);
const activeSection = overrideSection ?? hashSection;
const { t } = useTranslation();
const bridgeLabelKeys: Record<string, TranslationKey> = {
'Bridge': 'bridge.title',
'Telegram': 'bridge.telegramSettings',
};
const handleSectionChange = useCallback((section: Section) => {
setOverrideSection(section);
window.history.replaceState(null, "", `/bridge#${section}`);
queueMicrotask(() => setOverrideSection(null));
}, []);
return (
<div className="flex h-full flex-col">
<div className="border-b border-border/50 px-6 pt-4 pb-4">
<h1 className="text-xl font-semibold">{t('bridge.title')}</h1>
<p className="text-sm text-muted-foreground">
{t('bridge.description')}
</p>
</div>
<div className="flex min-h-0 flex-1">
<nav className="flex w-52 shrink-0 flex-col gap-1 border-r border-border/50 p-3">
{sidebarItems.map((item) => (
<button
key={item.id}
onClick={() => handleSectionChange(item.id)}
className={cn(
"flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors text-left",
activeSection === item.id
? "bg-accent text-accent-foreground"
: "text-muted-foreground hover:bg-accent/50 hover:text-foreground"
)}
>
<HugeiconsIcon icon={item.icon} className="h-4 w-4 shrink-0" />
{t(bridgeLabelKeys[item.label])}
</button>
))}
</nav>
<div className="flex-1 overflow-auto p-6">
{activeSection === "bridge" && <BridgeSection />}
{activeSection === "telegram" && <TelegramBridgeSection />}
</div>
</div>
</div>
);
}
+509
View File
@@ -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<BridgeSettings>(DEFAULT_SETTINGS);
const [bridgeStatus, setBridgeStatus] = useState<BridgeStatus | null>(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<ProviderModelGroup[]>([]);
const pollRef = useRef<ReturnType<typeof setInterval> | 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<BridgeSettings>) => {
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<string, unknown>).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 (
<div className="max-w-3xl space-y-6">
{/* Enable/Disable Master Toggle */}
<div
className={`rounded-lg border p-4 transition-shadow hover:shadow-sm ${
isEnabled
? "border-blue-500/50 bg-blue-500/5"
: "border-border/50"
}`}
>
<div className="flex items-center justify-between">
<div>
<h2 className="text-sm font-medium">{t("bridge.title")}</h2>
<p className="text-xs text-muted-foreground">
{t("bridge.description")}
</p>
</div>
<Switch
checked={isEnabled}
onCheckedChange={handleToggleEnabled}
disabled={saving}
/>
</div>
{isEnabled && (
<div className="mt-3 flex items-center gap-2 rounded-md bg-blue-500/10 px-3 py-2 text-xs text-blue-600 dark:text-blue-400">
<span className="h-2 w-2 shrink-0 rounded-full bg-blue-500" />
{t("bridge.activeHint")}
</div>
)}
</div>
{/* Bridge Status + Start/Stop */}
{isEnabled && (
<div className="rounded-lg border border-border/50 p-4 space-y-4 transition-shadow hover:shadow-sm">
<div className="flex items-center justify-between">
<div>
<h2 className="text-sm font-medium">{t("bridge.status")}</h2>
<p className="text-xs text-muted-foreground">
{isRunning
? t("bridge.activeBindings", { count: String(adapterCount) })
: t("bridge.noBindings")}
</p>
</div>
<div className="flex items-center gap-3">
<div
className={`flex items-center gap-2 rounded-md px-3 py-1.5 text-xs ${
isRunning
? "bg-green-500/10 text-green-600 dark:text-green-400"
: "bg-muted text-muted-foreground"
}`}
>
<HugeiconsIcon
icon={isRunning ? CheckmarkCircle02Icon : Alert02Icon}
className="h-3.5 w-3.5 shrink-0"
/>
{isRunning
? t("bridge.statusConnected")
: t("bridge.statusDisconnected")}
</div>
{isRunning ? (
<Button
size="sm"
variant="outline"
onClick={handleStopBridge}
disabled={stopping}
>
{stopping ? (
<HugeiconsIcon
icon={Loading02Icon}
className="h-3.5 w-3.5 animate-spin mr-1.5"
/>
) : null}
{stopping ? t("bridge.stopping") : t("bridge.stop")}
</Button>
) : (
<Button
size="sm"
onClick={handleStartBridge}
disabled={starting}
>
{starting ? (
<HugeiconsIcon
icon={Loading02Icon}
className="h-3.5 w-3.5 animate-spin mr-1.5"
/>
) : null}
{starting ? t("bridge.starting") : t("bridge.start")}
</Button>
)}
</div>
</div>
</div>
)}
{/* Channel Toggles */}
{isEnabled && (
<div className="rounded-lg border border-border/50 p-4 space-y-4 transition-shadow hover:shadow-sm">
<div>
<h2 className="text-sm font-medium">{t("bridge.channels")}</h2>
<p className="text-xs text-muted-foreground">
{t("bridge.channelsDesc")}
</p>
</div>
<div className="space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<HugeiconsIcon
icon={TelegramIcon}
className="h-4 w-4 text-muted-foreground"
/>
<div>
<p className="text-sm">{t("bridge.telegramChannel")}</p>
<p className="text-xs text-muted-foreground">
{t("bridge.telegramChannelDesc")}
</p>
</div>
</div>
<Switch
checked={isTelegramEnabled}
onCheckedChange={handleToggleTelegram}
disabled={saving}
/>
</div>
<div className="flex items-center justify-between border-t border-border/30 pt-3">
<div>
<p className="text-sm">{t("bridge.autoStart")}</p>
<p className="text-xs text-muted-foreground">
{t("bridge.autoStartDesc")}
</p>
</div>
<Switch
checked={isAutoStart}
onCheckedChange={handleToggleAutoStart}
disabled={saving}
/>
</div>
</div>
</div>
)}
{/* Adapter Status */}
{isEnabled && isRunning && adapterCount > 0 && (
<div className="rounded-lg border border-border/50 p-4 space-y-4 transition-shadow hover:shadow-sm">
<div>
<h2 className="text-sm font-medium">{t("bridge.adapters")}</h2>
<p className="text-xs text-muted-foreground">
{t("bridge.adaptersDesc")}
</p>
</div>
<div className="space-y-2">
{bridgeStatus?.adapters.map((adapter) => (
<div
key={adapter.channelType}
className="rounded-md border border-border/30 px-3 py-2 space-y-1"
>
<div className="flex items-center justify-between">
<span className="text-xs font-medium capitalize">
{adapter.channelType}
</span>
<div
className={`rounded px-2 py-0.5 text-xs ${
adapter.running
? "bg-green-500/10 text-green-600 dark:text-green-400"
: "bg-muted text-muted-foreground"
}`}
>
{adapter.running
? t("bridge.adapterRunning")
: t("bridge.adapterStopped")}
</div>
</div>
{adapter.lastMessageAt && (
<p className="text-xs text-muted-foreground">
{t("bridge.adapterLastMessage")}: {new Date(adapter.lastMessageAt).toLocaleString()}
</p>
)}
{adapter.error && (
<p className="text-xs text-red-500">
{t("bridge.adapterLastError")}: {adapter.error}
</p>
)}
</div>
))}
</div>
</div>
)}
{/* Default Settings */}
{isEnabled && (
<div className="rounded-lg border border-border/50 p-4 space-y-4 transition-shadow hover:shadow-sm">
<div>
<h2 className="text-sm font-medium">{t("bridge.defaults")}</h2>
<p className="text-xs text-muted-foreground">
{t("bridge.defaultsDesc")}
</p>
</div>
<div className="space-y-3">
<div>
<label className="text-xs font-medium text-muted-foreground mb-1 block">
{t("bridge.defaultWorkDir")}
</label>
<div className="flex gap-2">
<Input
value={workDir}
onChange={(e) => setWorkDir(e.target.value)}
placeholder="/path/to/project"
className="font-mono text-sm"
/>
<Button
size="sm"
variant="outline"
onClick={handleBrowseFolder}
className="shrink-0"
>
{t("bridge.browse")}
</Button>
</div>
<p className="text-xs text-muted-foreground mt-1">
{t("bridge.defaultWorkDirHint")}
</p>
</div>
<div>
<label className="text-xs font-medium text-muted-foreground mb-1 block">
{t("bridge.defaultModel")}
</label>
{providerGroups.length > 0 ? (
<Select value={model} onValueChange={setModel}>
<SelectTrigger className="w-full text-sm font-mono">
<SelectValue placeholder={t("bridge.defaultModelHint")} />
</SelectTrigger>
<SelectContent>
{providerGroups.map((group) => (
<SelectGroup key={group.provider_id}>
<SelectLabel>{group.provider_name}</SelectLabel>
{group.models.map((m) => (
<SelectItem
key={`${group.provider_id}::${m.value}`}
value={`${group.provider_id}::${m.value}`}
>
{m.label}
</SelectItem>
))}
</SelectGroup>
))}
</SelectContent>
</Select>
) : (
<Input
value={model}
onChange={(e) => setModel(e.target.value)}
placeholder="sonnet"
className="font-mono text-sm"
/>
)}
<p className="text-xs text-muted-foreground mt-1">
{t("bridge.defaultModelHint")}
</p>
</div>
</div>
<Button
size="sm"
onClick={handleSaveDefaults}
disabled={saving}
>
{saving ? t("common.loading") : t("common.save")}
</Button>
</div>
)}
</div>
);
}
@@ -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<TelegramBridgeSettings>(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<TelegramBridgeSettings>) => {
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<TelegramBridgeSettings> = {};
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 (
<div className="max-w-3xl space-y-6">
{/* Bot Credentials */}
<div className="rounded-lg border border-border/50 p-4 space-y-4 transition-shadow hover:shadow-sm">
<div>
<h2 className="text-sm font-medium">{t("telegram.credentials")}</h2>
<p className="text-xs text-muted-foreground">
{t("telegram.credentialsDesc")}
</p>
</div>
<div className="space-y-3">
<div>
<label className="text-xs font-medium text-muted-foreground mb-1 block">
{t("telegram.botToken")}
</label>
<Input
type="password"
value={botToken}
onChange={(e) => setBotToken(e.target.value)}
placeholder="123456:ABC-DEF..."
className="font-mono text-sm"
/>
</div>
<div>
<label className="text-xs font-medium text-muted-foreground mb-1 block">
{t("telegram.chatId")}
</label>
<div className="flex gap-2">
<Input
value={chatId}
onChange={(e) => setChatId(e.target.value)}
placeholder="-1001234567890"
className="font-mono text-sm"
/>
<Button
size="sm"
variant="outline"
onClick={handleDetectChatId}
disabled={detecting || !botToken}
className="shrink-0"
>
{detecting ? (
<HugeiconsIcon
icon={Loading02Icon}
className="h-3.5 w-3.5 animate-spin mr-1.5"
/>
) : null}
{t("telegram.detectChatId")}
</Button>
</div>
<p className="text-xs text-muted-foreground mt-1">
{t("telegram.chatIdHint")}
</p>
</div>
</div>
<div className="flex items-center gap-2">
<Button
size="sm"
onClick={handleSaveCredentials}
disabled={saving}
>
{saving ? t("common.loading") : t("common.save")}
</Button>
<Button
size="sm"
variant="outline"
onClick={handleVerify}
disabled={verifying || !botToken}
>
{verifying ? (
<HugeiconsIcon
icon={Loading02Icon}
className="h-3.5 w-3.5 animate-spin mr-1.5"
/>
) : null}
{t("telegram.verify")}
</Button>
</div>
{verifyResult && (
<div
className={`flex items-center gap-2 rounded-md px-3 py-2 text-xs ${
verifyResult.ok
? "bg-green-500/10 text-green-600 dark:text-green-400"
: "bg-red-500/10 text-red-600 dark:text-red-400"
}`}
>
<HugeiconsIcon
icon={verifyResult.ok ? CheckmarkCircle02Icon : Alert02Icon}
className="h-4 w-4 shrink-0"
/>
{verifyResult.message}
</div>
)}
</div>
{/* Allowed Users */}
<div className="rounded-lg border border-border/50 p-4 space-y-4 transition-shadow hover:shadow-sm">
<div>
<h2 className="text-sm font-medium">{t("bridge.allowedUsers")}</h2>
<p className="text-xs text-muted-foreground">
{t("bridge.allowedUsersDesc")}
</p>
</div>
<div>
<Input
value={allowedUsers}
onChange={(e) => setAllowedUsers(e.target.value)}
placeholder="123456789, 987654321"
className="font-mono text-sm"
/>
<p className="text-xs text-muted-foreground mt-1">
{t("bridge.allowedUsersHint")}
</p>
</div>
</div>
{/* Setup Guide */}
<div className="rounded-lg border border-border/50 p-4 transition-shadow hover:shadow-sm">
<h2 className="text-sm font-medium mb-2">
{t("telegram.setupGuide")}
</h2>
<ol className="text-xs text-muted-foreground space-y-1.5 list-decimal pl-4">
<li>{t("telegram.step1")}</li>
<li>{t("telegram.step2")}</li>
<li>{t("telegram.step3")}</li>
<li>{t("telegram.step4")}</li>
<li>{t("telegram.step5")}</li>
<li>{t("telegram.step6")}</li>
</ol>
</div>
</div>
);
}
+3
View File
@@ -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(() => () => {}, []);
+73
View File
@@ -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;
+73
View File
@@ -9,6 +9,7 @@ const zh: Record<TranslationKey, string> = {
'nav.lightMode': '浅色模式',
'nav.darkMode': '深色模式',
'nav.toggleTheme': '切换主题',
'nav.bridge': '桥接',
// ── Chat list panel ─────────────────────────────────────────
'chatList.justNow': '刚刚',
@@ -464,6 +465,78 @@ const zh: Record<TranslationKey, string> = {
'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;
+12
View File
@@ -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';
+523
View File
@@ -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<string, ReturnType<typeof setInterval>>();
/** 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<number>();
/** 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<void> {
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<void> {
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<InboundMessage | null> {
// 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<InboundMessage | null>((resolve) => {
this.waiters.push(resolve);
});
}
async send(message: OutboundMessage): Promise<SendResult> {
const token = this.botToken;
if (!token) return { ok: false, error: 'No bot token configured' };
const params: Record<string, unknown> = {
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<void> {
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<void> {
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<void> {
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<void> {
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());
+123
View File
@@ -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<string, unknown>,
): Promise<TelegramSendResult> {
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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
/**
* 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(`<b>${escapeHtml(opts.sessionTitle)}</b>`);
}
if (opts?.workingDirectory) {
parts.push(`<code>${escapeHtml(opts.workingDirectory)}</code>`);
}
return parts.join('\n');
}
+602
View File
@@ -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<string, BaseChannelAdapter>;
adapterMeta: Map<string, AdapterMeta>;
running: boolean;
startedAt: string | null;
loopAborts: Map<string, AbortController>;
activeTasks: Map<string, AbortController>;
/** Per-session processing chains for concurrency control */
sessionLocks: Map<string, Promise<void>>;
autoStartChecked: boolean;
}
function getState(): BridgeManagerState {
const g = globalThis as unknown as Record<string, BridgeManagerState>;
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<void>): Promise<void> {
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<void> {
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<void> {
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<void> {
// 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: `<b>Error:</b> ${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<void> {
// 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 = [
'<b>CodePilot Bridge</b>',
'',
'Send any message to interact with Claude.',
'',
'<b>Commands:</b>',
'/new [path] - Start new session',
'/bind &lt;session_id&gt; - 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: <code>${binding.codepilotSessionId.slice(0, 8)}...</code>\nCWD: <code>${escapeHtml(binding.workingDirectory || '~')}</code>`;
break;
}
case '/bind': {
if (!args) {
response = 'Usage: /bind &lt;session_id&gt;';
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 <code>${args.slice(0, 8)}...</code>`;
} 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 <code>${escapeHtml(validatedPath)}</code>`;
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 <b>${args}</b>`;
break;
}
case '/status': {
const binding = router.resolve(msg.address);
response = [
'<b>Bridge Status</b>',
'',
`Session: <code>${binding.codepilotSessionId.slice(0, 8)}...</code>`,
`CWD: <code>${escapeHtml(binding.workingDirectory || '~')}</code>`,
`Mode: <b>${binding.mode}</b>`,
`Model: <code>${binding.model || 'default'}</code>`,
].join('\n');
break;
}
case '/sessions': {
const bindings = router.listBindings(adapter.channelType);
if (bindings.length === 0) {
response = 'No sessions found.';
} else {
const lines = ['<b>Sessions:</b>', ''];
for (const b of bindings.slice(0, 10)) {
const active = b.active ? 'active' : 'inactive';
lines.push(`<code>${b.codepilotSessionId.slice(0, 8)}...</code> [${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 = [
'<b>CodePilot Bridge Commands</b>',
'',
'/new [path] - Start new session',
'/bind &lt;session_id&gt; - 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',
});
}
}
+96
View File
@@ -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<void>;
/**
* Stop the adapter gracefully.
* Must be idempotent — calling stop() on an already-stopped adapter is a no-op.
*/
abstract stop(): Promise<void>;
/** 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<InboundMessage | null>;
/**
* Send an outbound message to the channel.
* Handles platform-specific formatting and API calls.
*/
abstract send(message: OutboundMessage): Promise<SendResult>;
/**
* 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<void> {
// 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<string, () => 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());
}
+106
View File
@@ -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<Pick<ChannelBinding, 'sdkSessionId' | 'workingDirectory' | 'model' | 'mode' | 'active'>>,
): void {
updateChannelBinding(id, updates);
}
/**
* List all bindings, optionally filtered by channel type.
*/
export function listBindings(channelType?: ChannelType): ChannelBinding[] {
return listChannelBindings(channelType);
}
+376
View File
@@ -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<string, unknown>;
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<void>;
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<ConversationResult> {
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<string>,
sessionId: string,
onPermissionRequest?: OnPermissionRequest,
): Promise<ConversationResult> {
const reader = stream.getReader();
const contentBlocks: MessageContentBlock[] = [];
let currentText = '';
let tokenUsage: TokenUsage | null = null;
let hasError = false;
let errorMessage = '';
const seenToolResultIds = new Set<string>();
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<MessageContentBlock, { type: 'text' }> => 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<MessageContentBlock, { type: 'text' }> => 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<MessageContentBlock, { type: 'text' }> => 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,
};
}
}
+262
View File
@@ -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<SendResult> {
// 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<SendResult> {
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' };
}
+175
View File
@@ -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<string, unknown>,
sessionId?: string,
suggestions?: unknown[],
): Promise<void> {
// Format the input summary (truncated)
const inputStr = JSON.stringify(toolInput, null, 2);
const truncatedInput = inputStr.length > 300
? inputStr.slice(0, 300) + '...'
: inputStr;
const text = [
`<b>Permission Required</b>`,
``,
`Tool: <code>${escapeHtml(toolName)}</code>`,
`<pre>${escapeHtml(truncatedInput)}</pre>`,
``,
`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;
}
+85
View File
@@ -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<string, BucketEntry>();
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<void> {
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<void>(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();
}
}
}
+127
View File
@@ -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]);
}
+153
View File
@@ -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<string, number> = {
telegram: 4096,
discord: 2000,
slack: 40000,
};
+13
View File
@@ -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<strin
data: JSON.stringify(permEvent),
}));
// Notify via Telegram (fire-and-forget)
notifyPermissionRequest(toolName, input as Record<string, unknown>, telegramOpts).catch(() => {});
// Notify runtime status change
onRuntimeStatusChange?.('waiting_permission');
@@ -502,6 +506,13 @@ export function streamClaude(options: ClaudeStreamOptions): ReadableStream<strin
return result;
};
// Telegram notification context for hooks
const telegramOpts = {
sessionId,
sessionTitle: undefined as string | undefined,
workingDirectory,
};
// Hooks: capture notifications and tool completion events
queryOptions.hooks = {
Notification: [{
@@ -515,6 +526,8 @@ export function streamClaude(options: ClaudeStreamOptions): ReadableStream<strin
message: notif.message,
}),
}));
// Forward to Telegram (fire-and-forget)
notifyGeneric(notif.title || '', notif.message || '', telegramOpts).catch(() => {});
return {};
}],
}],
+498
View File
@@ -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<typeof getPermissionRequest>;
}
// ==========================================
// 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<Pick<ChannelBinding, 'sdkSessionId' | 'workingDirectory' | 'model' | 'mode' | 'active'>>,
): 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
// ==========================================
+572
View File
@@ -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<string, boolean>)[BRIDGE_MODE_KEY];
}
export function setBridgeModeActive(active: boolean): void {
(globalThis as unknown as Record<string, boolean>)[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<void> {
const config = getTelegramConfig();
if (!config.enabled || !config.notifyStart) return;
const header = formatSessionHeader(opts);
const msg = `▶️ <b>Task Started</b>\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<void> {
const config = getTelegramConfig();
if (!config.enabled || !config.notifyComplete) return;
const header = formatSessionHeader(opts);
let msg = `✅ <b>Task Completed</b>\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<void> {
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 = `❌ <b>Task Error</b>\n${header}\n\n<pre>${escapeHtml(truncatedError)}</pre>`.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<string, unknown>,
opts?: TelegramNotifyOptions,
): Promise<void> {
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 = [
`🔐 <b>Permission Required</b>`,
header,
``,
`Tool: <code>${escapeHtml(toolName)}</code>`,
`<pre>${escapeHtml(truncatedInput)}</pre>`,
``,
`⚠️ 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<void> {
if (!isConfigured()) return;
const header = formatSessionHeader(opts);
const msg = [
`📢 <b>${escapeHtml(title)}</b>`,
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[] = [
`📊 <b>CodePilot Status</b>`,
``,
];
if (activeSessions.length === 0) {
lines.push(`No active tasks running.`);
} else {
lines.push(`<b>Active Tasks (${activeSessions.length}):</b>`);
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 ? ` — <code>${escapeHtml(s.working_directory)}</code>` : '';
lines.push(` ${status} ${escapeHtml(title)}${dir}`);
}
}
lines.push(``);
lines.push(`<b>Recent Sessions:</b>`);
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<string, RecentChat[]>;
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<string, PollerState>;
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<void> {
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: [
`👋 <b>CodePilot Bot</b>`,
``,
`I'll send you notifications about your CodePilot tasks.`,
``,
`<b>Commands:</b>`,
`/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: [
`<b>CodePilot Bot Commands</b>`,
``,
`/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));
}
}
}
}