feat: 多 IM 远程桥接系统 + Bridge 一级导航 + bug 修复
**Bridge 系统核心(src/lib/bridge/)** - bridge-manager: 生命周期编排、adapter 事件循环、会话级并发控制 - channel-adapter: 抽象基类 + 注册式 adapter 工厂 - channel-router: IM 地址 → CodePilot session 自动映射 - conversation-engine: 服务端消费 streamClaude() SSE 流 - permission-broker: 权限请求转发到 IM 内联按钮 - delivery-layer: 出站分片、限流、重试退避、HTML 降级 - telegram-adapter: Telegram 长轮询 + offset 安全水位 + 幂等去重 - security/: 路径校验、危险输入检测、滑动窗口限流 **Telegram 通知 Bot(src/lib/telegram-bot.ts)** - 独立通知模式,与 bridge 模式互斥 - bridgeModeActive 使用 globalThis 存储,避免 HMR 重置导致轮询冲突 **API 路由** - /api/bridge: GET 状态查询 + POST start/stop/auto-start - /api/bridge/channels: 活跃通道列表 - /api/bridge/settings: bridge 设置读写 - /api/settings/telegram: 凭据管理(含 allowed_users 白名单) - /api/settings/telegram/verify: 连接验证 + chat ID 自动检测 **UI 重构:Bridge 提升为一级导航** - NavRail 新增 Bridge 图标导航项 - /bridge 页面:BridgeLayout hash 路由,Bridge + Telegram 两个子 tab - BridgeSection: 总开关、状态、启停、通道开关、适配器状态、默认设置 - TelegramBridgeSection: 凭据配置 + 白名单 + Setup Guide(6 步) - Settings 移除 Telegram 和 Bridge tab,只保留 4 个核心 tab **Bug 修复** - bridge-manager: runAdapterLoop 在 state.running=true 之前被调用, while 条件同步求值为 false 导致消费者循环不启动。修复为先设 running 再启动 loop - telegram-bot: bridgeModeActive 改用 globalThis 防止 HMR 丢失状态 - settings/telegram route: TELEGRAM_KEYS 补充 telegram_bridge_allowed_users **Electron** - 窗口关闭时 bridge 活跃则最小化到系统托盘而非退出 - 启动时通过 auto-start API 恢复桥接 - preload 暴露 bridge 相关 IPC **文档** - docs/bridge-system.md: 完整架构文档 - docs/chat-sdk-integration-feasibility.md: Chat SDK 集成调研 - CLAUDE.md 添加 bridge 文档链接 chore: bump v0.21.0 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
f7735e0b9c
commit
6ad821d3af
35 files changed
+5525
-8
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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 命令菜单
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
Generated
+2
-2
@@ -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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "codepilot",
|
||||
"version": "0.20.1",
|
||||
"version": "0.21.0",
|
||||
"private": true,
|
||||
"author": {
|
||||
"name": "op7418",
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { listChannelBindings } from '@/lib/db';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/**
|
||||
* GET /api/bridge/channels — List channel bindings.
|
||||
*
|
||||
* Returns all bindings by default. Supports query parameters:
|
||||
* ?active=true — return only active bindings
|
||||
* ?active=false — return only inactive bindings
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const activeParam = searchParams.get('active');
|
||||
|
||||
let bindings = listChannelBindings();
|
||||
|
||||
if (activeParam === 'true') {
|
||||
bindings = bindings.filter(b => b.active);
|
||||
} else if (activeParam === 'false') {
|
||||
bindings = bindings.filter(b => !b.active);
|
||||
}
|
||||
|
||||
return Response.json({ bindings });
|
||||
} catch (error) {
|
||||
return Response.json(
|
||||
{ error: error instanceof Error ? error.message : 'Internal error' },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { NextRequest } from 'next/server';
|
||||
import * as bridgeManager from '@/lib/bridge/bridge-manager';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/**
|
||||
* GET /api/bridge — Return bridge status (pure query, no side effects)
|
||||
*/
|
||||
export async function GET() {
|
||||
try {
|
||||
const status = bridgeManager.getStatus();
|
||||
return Response.json(status);
|
||||
} catch (error) {
|
||||
return Response.json(
|
||||
{ error: error instanceof Error ? error.message : 'Internal error' },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/bridge — Start, stop, or auto-start the bridge
|
||||
* Body: { action: 'start' | 'stop' | 'auto-start' }
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { action } = body;
|
||||
|
||||
if (action === 'start') {
|
||||
await bridgeManager.start();
|
||||
return Response.json({ ok: true, status: bridgeManager.getStatus() });
|
||||
} else if (action === 'stop') {
|
||||
await bridgeManager.stop();
|
||||
return Response.json({ ok: true, status: bridgeManager.getStatus() });
|
||||
} else if (action === 'auto-start') {
|
||||
bridgeManager.tryAutoStart();
|
||||
return Response.json({ ok: true, status: bridgeManager.getStatus() });
|
||||
} else {
|
||||
return Response.json(
|
||||
{ error: 'Invalid action. Use "start", "stop", or "auto-start".' },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
return Response.json(
|
||||
{ error: error instanceof Error ? error.message : 'Internal error' },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getSetting, setSetting } from '@/lib/db';
|
||||
|
||||
const BRIDGE_SETTING_KEYS = [
|
||||
'remote_bridge_enabled',
|
||||
'bridge_telegram_enabled',
|
||||
'telegram_bridge_allowed_users',
|
||||
'bridge_auto_start',
|
||||
'bridge_default_work_dir',
|
||||
'bridge_default_model',
|
||||
'bridge_default_provider_id',
|
||||
] as const;
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const settings: Record<string, string> = {};
|
||||
for (const key of BRIDGE_SETTING_KEYS) {
|
||||
settings[key] = getSetting(key) ?? '';
|
||||
}
|
||||
return NextResponse.json({ settings });
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to read bridge settings' },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(request: Request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { settings } = body;
|
||||
|
||||
if (!settings || typeof settings !== 'object') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid settings data' },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(settings)) {
|
||||
if (BRIDGE_SETTING_KEYS.includes(key as typeof BRIDGE_SETTING_KEYS[number])) {
|
||||
setSetting(key, String(value));
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to save bridge settings' },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NextRequest } from 'next/server';
|
||||
import { streamClaude } from '@/lib/claude-client';
|
||||
import { addMessage, getMessages, getSession, updateSessionTitle, updateSdkSessionId, updateSessionModel, updateSessionProvider, updateSessionProviderId, getSetting, getProvider, getDefaultProviderId, acquireSessionLock, releaseSessionLock, setSessionRuntimeStatus, syncSdkTasks } from '@/lib/db';
|
||||
import { addMessage, getMessages, getSession, updateSessionTitle, updateSdkSessionId, updateSessionModel, updateSessionProvider, updateSessionProviderId, getSetting, getProvider, getDefaultProviderId, acquireSessionLock, renewSessionLock, releaseSessionLock, setSessionRuntimeStatus, syncSdkTasks } from '@/lib/db';
|
||||
import { notifySessionStart, notifySessionComplete, notifySessionError } from '@/lib/telegram-bot';
|
||||
import type { SendMessageRequest, SSEEvent, TokenUsage, MessageContentBlock, FileAttachment } from '@/types';
|
||||
import crypto from 'crypto';
|
||||
import fs from 'fs';
|
||||
@@ -48,6 +49,14 @@ export async function POST(request: NextRequest) {
|
||||
activeLockId = lockId;
|
||||
setSessionRuntimeStatus(session_id, 'running');
|
||||
|
||||
// Telegram notification: session started (fire-and-forget)
|
||||
const telegramNotifyOpts = {
|
||||
sessionId: session_id,
|
||||
sessionTitle: session.title !== 'New Chat' ? session.title : content.slice(0, 50),
|
||||
workingDirectory: session.working_directory,
|
||||
};
|
||||
notifySessionStart(telegramNotifyOpts).catch(() => {});
|
||||
|
||||
// Save user message — persist file metadata so attachments survive page reload
|
||||
let savedContent = content;
|
||||
let fileMeta: Array<{ id: string; name: string; type: string; size: number; filePath: string }> | undefined;
|
||||
@@ -201,8 +210,14 @@ export async function POST(request: NextRequest) {
|
||||
// Tee the stream: one for client, one for collecting the response
|
||||
const [streamForClient, streamForCollect] = stream.tee();
|
||||
|
||||
// Periodically renew the session lock so long-running tasks don't expire
|
||||
const lockRenewalInterval = setInterval(() => {
|
||||
try { renewSessionLock(session_id, lockId, 600); } catch { /* best effort */ }
|
||||
}, 60_000);
|
||||
|
||||
// Save assistant message in background, with cleanup callback to release lock
|
||||
collectStreamResponse(streamForCollect, session_id, () => {
|
||||
collectStreamResponse(streamForCollect, session_id, telegramNotifyOpts, () => {
|
||||
clearInterval(lockRenewalInterval);
|
||||
releaseSessionLock(session_id, lockId);
|
||||
setSessionRuntimeStatus(session_id, 'idle');
|
||||
});
|
||||
@@ -231,11 +246,18 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
}
|
||||
|
||||
async function collectStreamResponse(stream: ReadableStream<string>, sessionId: string, onComplete?: () => void) {
|
||||
async function collectStreamResponse(
|
||||
stream: ReadableStream<string>,
|
||||
sessionId: string,
|
||||
telegramOpts: { sessionId?: string; sessionTitle?: string; workingDirectory?: string },
|
||||
onComplete?: () => void,
|
||||
) {
|
||||
const reader = stream.getReader();
|
||||
const contentBlocks: MessageContentBlock[] = [];
|
||||
let currentText = '';
|
||||
let tokenUsage: TokenUsage | null = null;
|
||||
let hasError = false;
|
||||
let errorMessage = '';
|
||||
// Dedup layer: skip duplicate tool_result events by tool_use_id
|
||||
const seenToolResultIds = new Set<string>();
|
||||
|
||||
@@ -318,12 +340,18 @@ async function collectStreamResponse(stream: ReadableStream<string>, sessionId:
|
||||
} catch {
|
||||
// skip malformed task_update data
|
||||
}
|
||||
} else if (event.type === 'error') {
|
||||
hasError = true;
|
||||
errorMessage = event.data || 'Unknown error';
|
||||
} else if (event.type === 'result') {
|
||||
try {
|
||||
const resultData = JSON.parse(event.data);
|
||||
if (resultData.usage) {
|
||||
tokenUsage = resultData.usage;
|
||||
}
|
||||
if (resultData.is_error) {
|
||||
hasError = true;
|
||||
}
|
||||
// Also capture session_id from result if we missed it from init
|
||||
if (resultData.session_id) {
|
||||
updateSdkSessionId(sessionId, resultData.session_id);
|
||||
@@ -369,7 +397,9 @@ async function collectStreamResponse(stream: ReadableStream<string>, sessionId:
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
} catch (e) {
|
||||
hasError = true;
|
||||
errorMessage = e instanceof Error ? e.message : 'Stream reading error';
|
||||
// Stream reading error - best effort save
|
||||
if (currentText.trim()) {
|
||||
contentBlocks.push({ type: 'text', text: currentText });
|
||||
@@ -390,6 +420,18 @@ async function collectStreamResponse(stream: ReadableStream<string>, sessionId:
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
// Telegram notifications: completion or error (fire-and-forget)
|
||||
if (hasError) {
|
||||
notifySessionError(errorMessage, telegramOpts).catch(() => {});
|
||||
} else {
|
||||
// Extract text summary for the completion notification
|
||||
const textSummary = contentBlocks
|
||||
.filter((b): b is Extract<MessageContentBlock, { type: 'text' }> => b.type === 'text')
|
||||
.map((b) => b.text)
|
||||
.join('')
|
||||
.trim();
|
||||
notifySessionComplete(textSummary || undefined, telegramOpts).catch(() => {});
|
||||
}
|
||||
onComplete?.();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getSetting, setSetting } from '@/lib/db';
|
||||
import { startPolling, stopPolling } from '@/lib/telegram-bot';
|
||||
|
||||
/**
|
||||
* Telegram Bot notification settings.
|
||||
* Stored in the SQLite settings table (same as other app settings).
|
||||
*/
|
||||
|
||||
const TELEGRAM_KEYS = [
|
||||
'telegram_bot_token',
|
||||
'telegram_chat_id',
|
||||
'telegram_enabled',
|
||||
'telegram_notify_start',
|
||||
'telegram_notify_complete',
|
||||
'telegram_notify_error',
|
||||
'telegram_notify_permission',
|
||||
'telegram_bridge_allowed_users',
|
||||
] as const;
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const result: Record<string, string> = {};
|
||||
for (const key of TELEGRAM_KEYS) {
|
||||
const value = getSetting(key);
|
||||
if (value !== undefined) {
|
||||
// Mask bot token for security
|
||||
if (key === 'telegram_bot_token' && value.length > 8) {
|
||||
result[key] = '***' + value.slice(-8);
|
||||
} else {
|
||||
result[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-start polling if Telegram is configured (survives server restarts)
|
||||
const enabled = getSetting('telegram_enabled') === 'true';
|
||||
const hasToken = !!getSetting('telegram_bot_token');
|
||||
const hasChatId = !!getSetting('telegram_chat_id');
|
||||
if (enabled && hasToken && hasChatId) {
|
||||
startPolling();
|
||||
}
|
||||
|
||||
return NextResponse.json({ settings: result });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to read Telegram settings';
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { settings } = body;
|
||||
|
||||
if (!settings || typeof settings !== 'object') {
|
||||
return NextResponse.json({ error: 'Invalid settings data' }, { status: 400 });
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(settings)) {
|
||||
if (!TELEGRAM_KEYS.includes(key as typeof TELEGRAM_KEYS[number])) continue;
|
||||
const strValue = String(value ?? '').trim();
|
||||
|
||||
// Don't overwrite token if user sent the masked version back
|
||||
if (key === 'telegram_bot_token' && strValue.startsWith('***')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
setSetting(key, strValue);
|
||||
}
|
||||
|
||||
// Start or stop long-polling based on the enabled state
|
||||
const enabled = getSetting('telegram_enabled') === 'true';
|
||||
const hasToken = !!getSetting('telegram_bot_token');
|
||||
const hasChatId = !!getSetting('telegram_chat_id');
|
||||
if (enabled && hasToken && hasChatId) {
|
||||
startPolling();
|
||||
} else {
|
||||
stopPolling();
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to save Telegram settings';
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { verifyBot, detectChatId } from '@/lib/telegram-bot';
|
||||
import { getSetting } from '@/lib/db';
|
||||
import { callTelegramApi } from '@/lib/bridge/adapters/telegram-utils';
|
||||
|
||||
/**
|
||||
* POST /api/settings/telegram/verify
|
||||
*
|
||||
* Actions:
|
||||
* - { action: "verify", bot_token, chat_id? } — Verify bot token, optionally send test message
|
||||
* - { action: "detect_chat_id", bot_token } — Auto-detect chat ID from recent messages
|
||||
* - { bot_token, chat_id? } — (Legacy) same as action: "verify"
|
||||
*
|
||||
* If bot_token starts with "***" (masked), falls back to the stored token in DB.
|
||||
* If bot_token is omitted, also falls back to the stored token.
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { action, chat_id } = body;
|
||||
let bot_token: string = body.bot_token || '';
|
||||
|
||||
// If token is masked or missing, use the stored one
|
||||
if (!bot_token || bot_token.startsWith('***')) {
|
||||
const stored = getSetting('telegram_bot_token');
|
||||
if (stored) {
|
||||
bot_token = stored;
|
||||
}
|
||||
}
|
||||
|
||||
if (!bot_token) {
|
||||
return NextResponse.json({ error: 'bot_token is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Auto-detect chat ID from recent bot messages
|
||||
if (action === 'detect_chat_id') {
|
||||
const result = await detectChatId(bot_token);
|
||||
return NextResponse.json(result);
|
||||
}
|
||||
|
||||
// Register bot commands menu with Telegram
|
||||
if (action === 'register_commands') {
|
||||
const res = await callTelegramApi(bot_token, 'setMyCommands', {
|
||||
commands: [
|
||||
{ command: 'new', description: 'Start new session (optionally specify path)' },
|
||||
{ command: 'bind', description: 'Bind to existing session' },
|
||||
{ command: 'cwd', description: 'Change working directory' },
|
||||
{ command: 'mode', description: 'Switch mode: plan / code / ask' },
|
||||
{ command: 'status', description: 'Show current session status' },
|
||||
{ command: 'sessions', description: 'List recent sessions' },
|
||||
{ command: 'stop', description: 'Stop current task' },
|
||||
{ command: 'help', description: 'Show available commands' },
|
||||
],
|
||||
});
|
||||
return NextResponse.json({ ok: res.ok, error: res.error });
|
||||
}
|
||||
|
||||
// Default: verify bot token
|
||||
const result = await verifyBot(bot_token, chat_id || undefined);
|
||||
|
||||
if (!result.ok) {
|
||||
return NextResponse.json({
|
||||
verified: false,
|
||||
error: result.error,
|
||||
botName: result.botName,
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
verified: true,
|
||||
botName: result.botName,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Verification failed';
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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(() => () => {}, []);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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';
|
||||
@@ -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());
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a message into chunks that fit within Telegram's message size limit.
|
||||
* Tries to split at line boundaries when possible.
|
||||
*/
|
||||
export function splitMessage(text: string, maxLength: number): string[] {
|
||||
if (text.length <= maxLength) return [text];
|
||||
|
||||
const chunks: string[] = [];
|
||||
let remaining = text;
|
||||
|
||||
while (remaining.length > 0) {
|
||||
if (remaining.length <= maxLength) {
|
||||
chunks.push(remaining);
|
||||
break;
|
||||
}
|
||||
|
||||
let splitIdx = remaining.lastIndexOf('\n', maxLength);
|
||||
if (splitIdx <= 0 || splitIdx < maxLength * 0.5) {
|
||||
splitIdx = maxLength;
|
||||
}
|
||||
|
||||
chunks.push(remaining.slice(0, splitIdx));
|
||||
remaining = remaining.slice(splitIdx).replace(/^\n/, '');
|
||||
}
|
||||
|
||||
return chunks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a session header for notification messages.
|
||||
*/
|
||||
export function formatSessionHeader(opts?: {
|
||||
sessionId?: string;
|
||||
sessionTitle?: string;
|
||||
workingDirectory?: string;
|
||||
}): string {
|
||||
const parts: string[] = [];
|
||||
if (opts?.sessionTitle) {
|
||||
parts.push(`<b>${escapeHtml(opts.sessionTitle)}</b>`);
|
||||
}
|
||||
if (opts?.workingDirectory) {
|
||||
parts.push(`<code>${escapeHtml(opts.workingDirectory)}</code>`);
|
||||
}
|
||||
return parts.join('\n');
|
||||
}
|
||||
@@ -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 <session_id> - Bind to existing session',
|
||||
'/cwd /path - Change working directory',
|
||||
'/mode plan|code|ask - Change mode',
|
||||
'/status - Show current status',
|
||||
'/sessions - List recent sessions',
|
||||
'/stop - Stop current session',
|
||||
'/help - Show this help',
|
||||
].join('\n');
|
||||
break;
|
||||
|
||||
case '/new': {
|
||||
let workDir: string | undefined;
|
||||
if (args) {
|
||||
const validated = validateWorkingDirectory(args);
|
||||
if (!validated) {
|
||||
response = 'Invalid path. Must be an absolute path without traversal sequences.';
|
||||
break;
|
||||
}
|
||||
workDir = validated;
|
||||
}
|
||||
const binding = router.createBinding(msg.address, workDir);
|
||||
response = `New session created.\nSession: <code>${binding.codepilotSessionId.slice(0, 8)}...</code>\nCWD: <code>${escapeHtml(binding.workingDirectory || '~')}</code>`;
|
||||
break;
|
||||
}
|
||||
|
||||
case '/bind': {
|
||||
if (!args) {
|
||||
response = 'Usage: /bind <session_id>';
|
||||
break;
|
||||
}
|
||||
if (!validateSessionId(args)) {
|
||||
response = 'Invalid session ID format. Expected a 32-64 character hex/UUID string.';
|
||||
break;
|
||||
}
|
||||
const binding = router.bindToSession(msg.address, args);
|
||||
if (binding) {
|
||||
response = `Bound to session <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 <session_id> - Bind to existing session',
|
||||
'/cwd /path - Change working directory',
|
||||
'/mode plan|code|ask - Change mode',
|
||||
'/status - Show current status',
|
||||
'/sessions - List recent sessions',
|
||||
'/stop - Stop current session',
|
||||
'/help - Show this help',
|
||||
].join('\n');
|
||||
break;
|
||||
|
||||
default:
|
||||
response = `Unknown command: ${escapeHtml(command)}\nType /help for available commands.`;
|
||||
}
|
||||
|
||||
if (response) {
|
||||
await deliver(adapter, {
|
||||
address: msg.address,
|
||||
text: response,
|
||||
parseMode: 'HTML',
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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' };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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
@@ -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
|
||||
// ==========================================
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user