diff --git a/README.md b/README.md index cb623f7..324799b 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,9 @@ [English](#english) · [中文](#中文) -**Keywords / 关键词:** Telegram · Bot · Mobile · Channel · Remove · Clear · Delete session · DeepSeek Harness · DSH · Cordis · dsh-plugin · Agent · Remote · Long polling · Allowlist · 手机 · 远程 · 机器人 · 渠道 · 会话 · 新对话 · 清除 · 删除 · 白名单 · 长轮询 +**Keywords / 关键词:** Telegram · Bot · Mobile · Remote · Channel · Sessions · Bind · Unbind · Remove · Clear · DeepSeek Harness · DSH · Cordis · dsh-plugin · Agent · Live session · 手机 · 远程 · 遥控器 · 会话列表 · 附着 · 解绑 · 白名单 -DeepSeek Harness(DSH)Telegram **Bot / Channel** Cordis 插件:用手机 Telegram 远程驱动本机 Agent。长轮询 Bot API,按聊天独立会话,助手回复 HTML 分片回传。`dsh plugin add` 安装,约 10 分钟连通。 +DeepSeek Harness **手机遥控器**:用 Telegram Bot **附着本机正在运行的 Web 会话**(与 Web **同轨迹、双向可见**)。对标 Codex phone remote——本机会话是真相源,手机不另开平行对话。 --- @@ -12,53 +12,36 @@ DeepSeek Harness(DSH)Telegram **Bot / Channel** Cordis 插件:用手机 Te ### 这是什么 -把 **Telegram Bot** 接到 **DeepSeek Harness**,让你可以在 **手机(mobile)** 上发消息,本机 DSH agent 执行并回复。适合远程 coding agent、远程运维对话、随身 Bot 入口。 +| | 旧「平行桥」 | **本插件(当前)** | +|---|---|---| +| 会话 | Telegram 专用新建 agent | 附着 `dsh web` 里 **live** agent | +| Web | 不通 | 手机与 Web **同一条轨迹** | -相关搜索词:`telegram bot`、`dsh telegram`、`deepseek harness mobile`、`telegram channel plugin`、`/new` `/clear` `/remove` 会话、白名单 bot。 - -### 特性 - -- 标准 Cordis **dsh-plugin**(`inject: ['agents']`),`cordis.patch.yml` 挂入 profile -- Telegram **长轮询** `getUpdates`,无需公网 webhook -- 每 chat 一个 agent;`/new`、`/clear` **清除 / 删除**当前会话后重建 -- **白名单**鉴权(fail-closed);鉴权与命令默认中文 -- Markdown 子集 → Telegram HTML,超长自动分片;HTML 失败降级纯文本 -- 无 token 时软失败:打日志、不启轮询,不拖垮整个 profile - -### 前置条件 - -| 项 | 要求 | -|---|---| -| Node.js | ≥ 22 | -| DeepSeek Harness | 目标 profile(如 `dsh web`)可运行,API Key 等已按官方配置 | -| Telegram Bot | [@BotFather](https://t.me/BotFather) 创建 bot,取得 token | -| 用户 ID | [@userinfobot](https://t.me/userinfobot) 等获取 numeric user id | +流程:电脑上先开对话 → 手机 `/sessions` 选择 → 发消息进入该会话 → Web 也能看到。 ### 连接实操(约 10 分钟) -按下面顺序做即可完成 **Telegram Bot ↔ DSH** 连通: - -1. 本机确认 `dsh web`(或你的 profile)能正常启动。 -2. BotFather 创建 bot,复制 **token**;userinfobot 记下自己的 **user id**。 -3. 安装插件(推荐 GitHub): +1. 本机 `dsh web` 能跑,并 **先打开至少一个对话**(保持运行)。 +2. [@BotFather](https://t.me/BotFather) 建 Bot,拿 token;[@userinfobot](https://t.me/userinfobot) 拿自己的 user id。 +3. 安装: ```powershell dsh plugin --profile web add github:hi-wenw/dsh-telegram-channel ``` -本地开发目录安装: +或本地: ```powershell dsh plugin --profile web add D:\gitData\dsh-telegram-channel ``` -4. 设置 token(推荐环境变量,避免写进仓库): +4. Token(推荐环境变量): ```powershell $env:DSH_TELEGRAM_TOKEN = '123456:ABC...' ``` -5. 在 profile 的 patch / 插件配置里设置白名单(把数字换成你的 id): +5. 白名单(换成你的 id): ```yaml - insert: @@ -67,69 +50,48 @@ $env:DSH_TELEGRAM_TOKEN = '123456:ABC...' config: token: '' allowedUserIds: [123456789] - provider: deepseek-official - model: deepseek-v4-flash ``` -也可参考 [`examples/telegram-agent/`](examples/telegram-agent/)。 +6. **重启 / 重载** profile。 +7. 手机 Telegram: + - `/start` + - `/sessions` → 点选本机会话 + - 发一句话 → Web 与手机构应出现同一轮问答 + - `/unbind` 仅断开手机绑定,不关本机会话 -6. **重新加载或重启** profile,使配置生效。 -7. 手机打开 Telegram,向该 **Bot** 发送: - - `/start` — 欢迎 - - 任意一句话 — 应收到 agent 回复 - - `/new` 或 `/clear` — **清除会话**后重新对话 - - `/help` — 命令说明 - -### 配置项 - -| 键 | 默认 | 含义 | -|---|---|---| -| `token` | `''`(空则读 `DSH_TELEGRAM_TOKEN`) | BotFather bot token | -| `allowedUserIds` | `[]` | 允许对话的 Telegram user id;**空且 `allowAllUsers: false` = 全拒** | -| `allowAllUsers` | `false` | `true` 时忽略白名单(仅本地调试) | -| `provider` | `deepseek-official` | agent 的 LLM provider | -| `model` | `deepseek-v4-flash` | 模型 id | -| `maxMessageLength` | `4096` | 单条消息长度上限 | -| `pollingTimeoutSec` | `30` | 长轮询超时(秒) | -| `cwd` | 进程 cwd | agent 工作目录 | - -### Bot 命令 +### 命令 | 命令 | 行为 | |---|---| -| `/start` | 欢迎与用法;不调模型 | -| `/help` | 命令与白名单 / 会话说明 | -| `/new` 或 `/clear` | **清除 / 删除**当前 chat 的 agent,下次消息重建(等同 reset / remove session) | -| 未识别 `/...` | 提示可用命令 | -| 普通文本 | `create` / `followup` → 回传助手文本 | +| `/sessions` `/list` | 列出 live 会话,内联键盘选择附着 | +| `/status` | 当前绑定 | +| `/unbind` `/disconnect` | 解绑(不 dispose 本机 agent) | +| `/help` `/start` | 帮助 / 欢迎 | +| 普通文字 | 进入已绑定会话的 `followup` | + +### 配置 + +| 键 | 默认 | 含义 | +|---|---|---| +| `token` | 空 → `DSH_TELEGRAM_TOKEN` | Bot token | +| `allowedUserIds` | `[]` | 白名单;空 = 全拒 | +| `allowAllUsers` | `false` | 调试用,生产勿开 | +| `maxMessageLength` | `4096` | 分片长度 | +| `pollingTimeoutSec` | `30` | 长轮询超时 | ### 故障排查 -| 现象 | 可能原因 | 处理 | -|---|---|---| -| 日志 `missing bot token`;Bot 无响应 | 未设 `token` / `DSH_TELEGRAM_TOKEN` | 设置后重载 profile | -| 回复「无权限。」 | user id 不在白名单,或名单为空 | 核对 id,写入 `allowedUserIds` | -| Telegram 401 | token 错误或已撤销 | BotFather 换新 token | -| 有 typing 无文字 | profile 内 LLM / API Key | 查 DSH 日志;同 profile 先用 CLI 验证 agent | -| 重启后对话「失忆」 | 会话仅内存(当前设计) | 直接发消息或 `/new` 重建 | -| 贡献者 `npm install` peer 冲突 | 上游 DSH RC `dsh-invariants` 未对齐 | `npm install --legacy-peer-deps` | - -### 路线图 - -1. 运维键盘:新对话 / 状态 / 停止 / 更多 -2. 同消息编辑流式、阶段摘要 -3. chat↔session 跨重启持久化 -4. cwd / 项目切换 -5. Telegram 侧切换 provider / model -6. 媒体(图片等,依赖 profile 能力) -7. 可选 webhook +| 现象 | 处理 | +|---|---| +| `/sessions` 提示没有会话 | 先在 Web 打开对话并保持 profile 运行 | +| 「尚未绑定」 | 先 `/sessions` 点选 | +| 「会话已不在本机运行」 | Web 已关该会话;重新开再绑定 | +| 「无权限」 | 检查 `allowedUserIds` | +| missing bot token | 设 `DSH_TELEGRAM_TOKEN` 后重载 | ### 安全 -- Fail-closed 白名单;空名单默认全拒 -- Token 优先环境变量;日志脱敏 -- `allowAllUsers: true` 勿用于生产 -- 本插件不启 LLM;模型 / 工具 / 沙箱由 DSH profile 决定 +Fail-closed 白名单;token 走环境变量;日志脱敏;解绑不关闭本机 agent。 ### 贡献者 @@ -141,15 +103,6 @@ npm test npm run build ``` -### 验证状态 - -| 项 | 值 | -|---|---| -| 包版本 | `0.1.0` | -| 日期 | 2026-08-14 | -| 自动化 | `npm test` 25/25 · `npm run build` | -| Peer | `@deepseek-ai/cordis` ^4.0.1 · `dsh-agent` ^0.1.0-rc.6 · `dsh-llm` ^0.0.1-rc.1 · `dsh-session` ^0.0.1-rc.1 | - ### 许可证 [MIT](LICENSE) @@ -160,142 +113,32 @@ npm run build ### What this is -A **DeepSeek Harness (DSH) Cordis channel plugin** that turns a **Telegram Bot** into a **mobile / remote** front-end for your local agent. Long-polling Bot API, one agent session per chat, HTML-split replies. Install with `dsh plugin add`; connect in about 10 minutes. +A **DeepSeek Harness Cordis plugin** that turns Telegram into a **mobile remote** for **live** agents in the same `dsh web` process. The desktop/Web session is the source of truth (Codex-style remote). Telegram **attaches**; it does **not** spawn a parallel hidden agent. -**Search keywords:** telegram bot, telegram channel, mobile agent, remote bot, deepseek harness, dsh-plugin, cordis plugin, remove session, clear chat, `/new`, `/clear`, allowlist, long polling. +### Connect walkthrough -### Features - -- Standard **dsh-plugin** (`inject: ['agents']`) via `cordis.patch.yml` -- Telegram **long polling** (`getUpdates`) — no public webhook required -- Per-chat agent; `/new` and `/clear` **remove / reset / delete** the session -- Fail-closed **allowlist**; Chinese copy for auth/commands by default -- Markdown subset → Telegram HTML with chunking; plain-text fallback -- Missing token soft-fail: log and skip polling (does not crash the profile) - -### Prerequisites - -| Item | Requirement | -|---|---| -| Node.js | ≥ 22 | -| DeepSeek Harness | Target profile (e.g. `dsh web`) runs with API keys configured | -| Telegram Bot | Create via [@BotFather](https://t.me/BotFather), copy token | -| User id | Numeric id from [@userinfobot](https://t.me/userinfobot) (etc.) | - -### Connect walkthrough (~10 minutes) - -1. Confirm `dsh web` (or your profile) starts on the machine. -2. Create a **Telegram Bot**, copy **token**; note your **user id**. -3. Install the plugin: +1. Run `dsh web` and **open at least one conversation**. +2. Create a Bot (BotFather) + note your user id. +3. Install: ```powershell dsh plugin --profile web add github:hi-wenw/dsh-telegram-channel ``` -Local path: +4. `$env:DSH_TELEGRAM_TOKEN = '...'` +5. Set `allowedUserIds` in the plugin patch. +6. Reload the profile. +7. On your phone: `/sessions` → tap a live session → send text → same turn appears on Web and Telegram. `/unbind` detaches only. -```powershell -dsh plugin --profile web add D:\gitData\dsh-telegram-channel -``` +### Commands -4. Set the token (prefer env): - -```powershell -$env:DSH_TELEGRAM_TOKEN = '123456:ABC...' -``` - -5. Allowlist your user id in the profile patch: - -```yaml -- insert: - - id: dsh-telegram-channel - name: dsh-telegram-channel - config: - token: '' - allowedUserIds: [123456789] - provider: deepseek-official - model: deepseek-v4-flash -``` - -See also [`examples/telegram-agent/`](examples/telegram-agent/). - -6. **Reload or restart** the profile. -7. On your **phone**, open Telegram and message the **Bot**: - - `/start` — welcome - - any text — agent reply - - `/new` or `/clear` — **remove / clear** session, then chat again - - `/help` — commands - -### Configuration - -| Key | Default | Meaning | -|---|---|---| -| `token` | `''` (falls back to `DSH_TELEGRAM_TOKEN`) | BotFather token | -| `allowedUserIds` | `[]` | Allowed Telegram user ids; **empty + `allowAllUsers: false` rejects all** | -| `allowAllUsers` | `false` | Ignore allowlist (local debug only) | -| `provider` | `deepseek-official` | LLM provider for created agents | -| `model` | `deepseek-v4-flash` | Model id | -| `maxMessageLength` | `4096` | Max Telegram message length | -| `pollingTimeoutSec` | `30` | Long-poll timeout (seconds) | -| `cwd` | process cwd | Agent working directory | - -### Bot commands - -| Command | Behavior | +| Command | Action | |---|---| -| `/start` | Welcome; no model call | -| `/help` | Commands + allowlist / session notes | -| `/new` or `/clear` | **Remove / clear / reset** this chat’s agent; next message creates a new session | -| Unknown `/...` | Hint available commands | -| Plain text | `create` / `followup` → assistant text | - -### Troubleshooting - -| Symptom | Likely cause | Fix | -|---|---|---| -| Log `missing bot token`; silent Bot | No `token` / `DSH_TELEGRAM_TOKEN` | Set env or config, reload profile | -| Reply「无权限。」 / Access denied | User not on allowlist or empty list | Fix `allowedUserIds` | -| Telegram 401 | Bad/revoked token | New token from BotFather | -| Typing but no text | LLM / API Key in profile | Check DSH logs; verify agent in same profile | -| Amnesia after restart | In-memory sessions by design | Send a message or `/new` | -| Contributor `npm install` peer conflict | Upstream DSH RC pins | `npm install --legacy-peer-deps` | - -### Roadmap - -1. Ops keyboard (new / status / stop / more) -2. In-place streaming edits -3. Persist chat↔session across restarts -4. cwd / project switch -5. Switch provider/model from Telegram -6. Media (images) when profile supports it -7. Optional webhook - -### Security - -- Fail-closed allowlist -- Prefer env for token; logs redact secrets -- Never enable `allowAllUsers` in production -- Plugin does not start the LLM; profile owns models/tools/sandbox - -### Contributors - -```powershell -git clone https://github.com/hi-wenw/dsh-telegram-channel.git -cd dsh-telegram-channel -npm install --legacy-peer-deps -npm test -npm run build -``` - -### Verification - -| Item | Value | -|---|---| -| Package | `0.1.0` | -| Date | 2026-08-14 | -| Automated | `npm test` 25/25 · `npm run build` | -| Peers | `@deepseek-ai/cordis` ^4.0.1 · `dsh-agent` ^0.1.0-rc.6 · `dsh-llm` ^0.0.1-rc.1 · `dsh-session` ^0.0.1-rc.1 | +| `/sessions` `/list` | List live agents; inline keyboard to bind | +| `/status` | Show binding | +| `/unbind` `/disconnect` | Unbind (does not dispose host agent) | +| Plain text | `followup` into the bound live agent | ### License -[MIT](LICENSE) +MIT diff --git a/docs/DESIGN.md b/docs/DESIGN.md new file mode 100644 index 0000000..aa6cc72 --- /dev/null +++ b/docs/DESIGN.md @@ -0,0 +1,29 @@ +# Design: Telegram remote control for live DSH sessions + +Date: 2026-08-14 +Status: Approved (ship MVP) + +## Goal + +Phone Telegram remote-controls **live** DeepSeek Harness agents in the same `dsh web` process (Codex-style). Web session is source of truth; Telegram attaches — does **not** create parallel agents. + +## MVP + +- Auth allowlist + long poll +- `/sessions` lists `ctx.agents.list()` / `roots()` with inline keyboard +- Bind chat → live `Agent`; plain text → `agent.followup` +- Mirror `session/event` (`turn/start`, `assistant/message`) to all Telegram chats bound to that session id +- `/unbind` clears binding only (never dispose host agent) +- Empty live list → prompt to open a Web conversation first + +## Non-goals (later) + +- Disk resume / history search +- Auto-create parallel agents +- Streaming edit-in-place, media, webhook + +## Binding rules + +- One Telegram chat → at most one bound agent; re-select replaces +- Multiple chats may bind the same live agent (all receive mirrors) +- On `stop()`: clear bindings only; do not dispose live agents diff --git a/examples/telegram-agent/README.md b/examples/telegram-agent/README.md index fb30734..bfa2f10 100644 --- a/examples/telegram-agent/README.md +++ b/examples/telegram-agent/README.md @@ -1,83 +1,28 @@ -# Telegram Agent 示例 +# Telegram remote — example -最小可跑组合:在已有 DSH `web` profile 上挂载 `dsh-telegram-channel`,用环境变量提供 bot token,用白名单限制可对话用户。 +Attach Telegram to **live** DSH Web sessions (same trajectory). -## 适用场景 - -- 已能本地运行 `dsh web`,但尚未配置过 Telegram 渠道 -- 希望抄一份 patch 片段,而不是手写 Cordis 插件注册 - -## 步骤 - -### 1. 安装插件 - -**GitHub(推荐):** +## Install ```powershell dsh plugin --profile web add github:hi-wenw/dsh-telegram-channel ``` -**本地开发目录**(将路径换成你的克隆位置): +Local: ```powershell dsh plugin --profile web add D:\gitData\dsh-telegram-channel ``` -`dsh plugin add` 会自动合并包内 `cordis.patch.yml`。若你更习惯手动编辑 profile patch,可将本目录下的 [`cordis.patch.example.yml`](cordis.patch.example.yml) 内容合并进 profile 的 patch 文件。 +## Config -### 2. 配置白名单 - -编辑 profile 中 `dsh-telegram-channel` 的 `config`(或通过 patch): - -- 将 `allowedUserIds` 中的 `123456789` 换成你的 Telegram numeric user id(可用 [@userinfobot](https://t.me/userinfobot) 查询) -- **保持 `token: ''`**,token 走环境变量,避免写入版本库 - -示例见 [`cordis.patch.example.yml`](cordis.patch.example.yml)。 - -### 3. 设置环境变量 - -PowerShell: +1. Open a conversation in `dsh web` first. +2. Set token: ```powershell -$env:DSH_TELEGRAM_TOKEN = '1234567890:AA...your-bot-token...' +$env:DSH_TELEGRAM_TOKEN = '123456:ABC...' ``` -也可写入系统 / 用户环境变量,或 profile 启动脚本。 - -### 4. 启动 profile - -```powershell -dsh web -# 或你平时使用的 profile 启动命令;修改配置后需重载 -``` - -### 5. Telegram 验收 - -| 步骤 | 预期 | -|---|---| -| 向 bot 发送 `/start` | 收到中文欢迎与用法 | -| 发送 `/help` | 收到命令列表 | -| 发送一条任务消息(如「你好」) | 收到 agent 回复 | -| 发送 `/new` | 收到「已开启新会话。」 | -| 再发消息 | 新会话上下文,仍可正常回复 | - -未在白名单内的账号应收到「无权限。」 - -## 配置说明 - -| 字段 | 示例值 | 说明 | -|---|---|---| -| `token` | `''` | 留空,使用 `DSH_TELEGRAM_TOKEN` | -| `allowedUserIds` | `[123456789]` | 必填(生产环境);空列表 = 拒绝所有人 | -| `provider` | `deepseek-official` | 与 profile 内 LLM 配置一致 | -| `model` | `deepseek-v4-flash` | 可按需更换 | - -可选:`maxMessageLength`、`pollingTimeoutSec`、`cwd` 等见根目录 [README.md](../../README.md)。 - -## 常见问题 - -- **无响应且日志有 `missing bot token`**:检查 `DSH_TELEGRAM_TOKEN` 是否在 profile 启动前已设置。 -- **「无权限。」**:核对 `allowedUserIds` 是否包含你的 user id(数字,非 @用户名)。 -- **有 typing 无回复**:检查 DSH profile 的 API Key 与 agent 配置,而非本插件 token。 - -更多故障项见根目录 README 的「故障排查」一节。 +3. Merge [`cordis.patch.example.yml`](cordis.patch.example.yml) (replace user id). +4. Reload profile. +5. Phone: `/sessions` → bind → chat. `/unbind` to disconnect. diff --git a/examples/telegram-agent/cordis.patch.example.yml b/examples/telegram-agent/cordis.patch.example.yml index 2c41ab4..e6e82e6 100644 --- a/examples/telegram-agent/cordis.patch.example.yml +++ b/examples/telegram-agent/cordis.patch.example.yml @@ -4,5 +4,3 @@ config: token: '' allowedUserIds: [123456789] - provider: deepseek-official - model: deepseek-v4-flash diff --git a/package.json b/package.json index 81f6360..22dc0fa 100644 --- a/package.json +++ b/package.json @@ -1,23 +1,25 @@ { "name": "dsh-telegram-channel", - "version": "0.1.0", - "description": "DeepSeek Harness Telegram Bot / mobile remote channel Cordis plugin (dsh-plugin): long poll, per-chat agents, /new /clear remove session, allowlist", + "version": "0.2.0", + "description": "DeepSeek Harness Telegram mobile remote: bind live Web sessions (Codex-style), same trajectory, /sessions picker, unbind/clear — dsh-plugin", "keywords": [ "telegram", "bot", "mobile", - "channel", "remote", + "channel", + "sessions", + "bind", + "unbind", + "remove", + "clear", "deepseek", "deepseek-harness", "dsh", "dsh-plugin", "cordis", "agent", - "remove", - "clear", - "allowlist", - "long-polling" + "live-session" ], "repository": { "type": "git", diff --git a/src/bridge.ts b/src/bridge.ts index f338244..1c70d7b 100644 --- a/src/bridge.ts +++ b/src/bridge.ts @@ -1,11 +1,16 @@ import type { Context } from '@deepseek-ai/cordis' -import type { AgentHandle } from '@deepseek-ai/dsh-agent' +import type { Agent } from '@deepseek-ai/dsh-agent' import { createUserMessage } from '@deepseek-ai/dsh-llm/message' import type { ContentBlock, TextBlock } from '@deepseek-ai/dsh-llm/types' import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session/types' import { isAuthorized } from './auth.js' -import { TelegramClient, type TelegramClientLike, type TelegramUpdate } from './client.js' -import { MSG, parseCommand } from './commands.js' +import { + TelegramClient, + type InlineKeyboardMarkup, + type TelegramClientLike, + type TelegramUpdate, +} from './client.js' +import { BIND_CB_PREFIX, MSG, parseCommand } from './commands.js' import { markdownToHtml, splitMessage } from './format.js' export interface TelegramBridgeOptions { @@ -14,17 +19,15 @@ export interface TelegramBridgeOptions { allowAllUsers: boolean client?: TelegramClientLike sleep?: (ms: number) => Promise - provider?: string - model?: string maxMessageLength?: number pollingTimeoutSec?: number - cwd?: string } -interface ChatState { +/** chatId → bound live session id (string form of SessionId) */ +interface Binding { chatId: number - handle: AgentHandle sessionId: string + label: string } interface SessionLike { @@ -40,6 +43,15 @@ function contentToText(content: readonly ContentBlock[]): string { .join('') } +function shortLabel(agent: Agent, index: number): string { + const id = String(agent.id) + const tail = id.length > 12 ? id.slice(-12) : id + const cwd = (agent.session as { meta?: { cwd?: string } } | undefined)?.meta?.cwd + const cwdBit = cwd ? cwd.split(/[/\\]/).filter(Boolean).slice(-1)[0] : '' + const base = cwdBit ? `${cwdBit} · ${tail}` : `session ${index + 1} · ${tail}` + return base.length > 60 ? `${base.slice(0, 57)}...` : base +} + export class TelegramBridge { private readonly ctx: Context private readonly token: string @@ -47,12 +59,9 @@ export class TelegramBridge { private readonly allowAllUsers: boolean private readonly client: TelegramClientLike private readonly sleep: (ms: number) => Promise - private readonly provider: string - private readonly model: string private readonly maxMessageLength: number - private readonly cwd: string - private readonly chats = new Map() + private readonly bindings = new Map() private polling = false private offset: number | undefined private pollPromise: Promise | undefined @@ -68,10 +77,7 @@ export class TelegramBridge { pollingTimeoutSec: options.pollingTimeoutSec ?? 30, }) this.sleep = options.sleep ?? defaultSleep - this.provider = options.provider ?? 'deepseek-official' - this.model = options.model ?? 'deepseek-v4-flash' this.maxMessageLength = options.maxMessageLength ?? 4096 - this.cwd = options.cwd ?? process.cwd() } start(): void { @@ -94,9 +100,8 @@ export class TelegramBridge { this.pollAbort = undefined this.disposeSessionListener?.() this.disposeSessionListener = undefined - const disposals = [...this.chats.values()].map((chat) => chat.handle.dispose()) - this.chats.clear() - await Promise.all(disposals) + // Never dispose host agents — only clear remote bindings. + this.bindings.clear() if (this.pollPromise) { await this.pollPromise.catch(() => {}) this.pollPromise = undefined @@ -104,6 +109,10 @@ export class TelegramBridge { } async processUpdate(update: TelegramUpdate): Promise { + if (update.callback_query) { + await this.handleCallback(update) + return + } const message = update.message if (!message?.text) return @@ -123,20 +132,133 @@ export class TelegramBridge { case 'help': await this.client.sendMessage(chatId, MSG.HELP) return - case 'new': - await this.resetChat(chatId) - await this.client.sendMessage(chatId, MSG.NEW_SESSION) + case 'sessions': + await this.sendSessionPicker(chatId) + return + case 'status': + await this.sendStatus(chatId) + return + case 'unbind': + this.bindings.delete(String(chatId)) + await this.client.sendMessage(chatId, MSG.UNBOUND) return case 'unknown': await this.client.sendMessage(chatId, MSG.unknown(parsed.command)) return case 'plain': - await this.ensureChat(chatId) - await this.followup(chatId, parsed.text) + await this.followupBound(chatId, parsed.text) return } } + private async handleCallback(update: TelegramUpdate): Promise { + const cq = update.callback_query! + const userId = cq.from.id + const chatId = cq.message?.chat.id + if (chatId === undefined) { + await this.client.answerCallbackQuery(cq.id) + return + } + if (!isAuthorized({ allowAllUsers: this.allowAllUsers, allowedUserIds: this.allowedUserIds, userId })) { + await this.client.answerCallbackQuery(cq.id, MSG.DENIED) + await this.client.sendMessage(chatId, MSG.DENIED) + return + } + const data = cq.data ?? '' + if (!data.startsWith(BIND_CB_PREFIX)) { + await this.client.answerCallbackQuery(cq.id) + return + } + const sessionId = data.slice(BIND_CB_PREFIX.length) + const agent = this.findLiveAgent(sessionId) + if (!agent) { + await this.client.answerCallbackQuery(cq.id, '会话不存在') + await this.client.sendMessage(chatId, MSG.GONE) + return + } + const label = shortLabel(agent, 0) + this.bindings.set(String(chatId), { chatId, sessionId: String(agent.id), label }) + await this.client.answerCallbackQuery(cq.id, '已附着') + await this.client.sendMessage(chatId, MSG.BOUND(label)) + } + + private liveAgents(): Agent[] { + const agents = this.ctx.agents + if (typeof agents.roots === 'function') { + const roots = agents.roots() + if (roots.length > 0) return roots + } + if (typeof agents.list === 'function') return agents.list() + return [] + } + + private findLiveAgent(sessionId: string): Agent | undefined { + const agents = this.ctx.agents + if (typeof agents.get === 'function') { + try { + const found = agents.get(SessionId(sessionId)) + if (found) return found + } catch { + // fall through to list scan + } + } + return this.liveAgents().find((a) => String(a.id) === sessionId) + } + + private async sendSessionPicker(chatId: number): Promise { + const agents = this.liveAgents() + if (agents.length === 0) { + await this.client.sendMessage(chatId, MSG.NO_LIVE) + return + } + const keyboard: InlineKeyboardMarkup = { + inline_keyboard: agents.map((agent, i) => { + const label = shortLabel(agent, i) + return [{ text: label, callback_data: `${BIND_CB_PREFIX}${String(agent.id)}` }] + }), + } + await this.client.sendMessage( + chatId, + `选择要遥控的本机会话(共 ${agents.length} 个):`, + undefined, + keyboard, + ) + } + + private async sendStatus(chatId: number): Promise { + const binding = this.bindings.get(String(chatId)) + if (!binding) { + await this.client.sendMessage(chatId, MSG.STATUS_NONE) + return + } + const stillLive = this.findLiveAgent(binding.sessionId) + if (!stillLive) { + this.bindings.delete(String(chatId)) + await this.client.sendMessage(chatId, MSG.GONE) + return + } + await this.client.sendMessage(chatId, MSG.STATUS_BOUND(binding.label)) + } + + private async followupBound(chatId: number, text: string): Promise { + const binding = this.bindings.get(String(chatId)) + if (!binding) { + await this.client.sendMessage(chatId, MSG.NEED_BIND) + return + } + const agent = this.findLiveAgent(binding.sessionId) + if (!agent) { + this.bindings.delete(String(chatId)) + await this.client.sendMessage(chatId, MSG.GONE) + return + } + const message = createUserMessage({ + content: [{ type: 'text', text }], + source: { kind: 'user' }, + }) + agent.followup(message) + } + private async pollLoop(): Promise { const signal = this.pollAbort?.signal let errorCount = 0 @@ -189,74 +311,22 @@ export class TelegramBridge { } private async onSessionEvent(session: SessionLike, event: SessionEvent): Promise { - const chat = this.findChatBySessionId(session.id) - if (!chat) return + const id = String(session.id) + const targets = [...this.bindings.values()].filter((b) => b.sessionId === id) + if (targets.length === 0) return if (event.type === 'turn/start') { - await this.client.sendChatAction(chat.chatId, 'typing') + await Promise.all(targets.map((b) => this.client.sendChatAction(b.chatId, 'typing'))) return } if (event.type === 'assistant/message') { const text = contentToText(event.data.message.content) - if (text) { - await this.deliver(chat.chatId, text) - } + if (!text) return + await Promise.all(targets.map((b) => this.deliver(b.chatId, text))) } } - private findChatBySessionId(sessionId: ReturnType): ChatState | undefined { - const id = String(sessionId) - for (const chat of this.chats.values()) { - if (chat.sessionId === id) return chat - } - return undefined - } - - private async ensureChat(chatId: number): Promise { - const key = String(chatId) - const existing = this.chats.get(key) - if (existing) return existing - - const sessionId = String(SessionId(`telegram:${chatId}`)) - const handle = await this.createAgent(SessionId(sessionId)) - const state: ChatState = { chatId, handle, sessionId } - this.chats.set(key, state) - return state - } - - private async resetChat(chatId: number): Promise { - const key = String(chatId) - const existing = this.chats.get(key) - if (existing) { - await existing.handle.dispose() - this.chats.delete(key) - } - const sessionId = String(SessionId(`telegram:${chatId}:${Date.now()}`)) - const handle = await this.createAgent(SessionId(sessionId)) - this.chats.set(key, { chatId, handle, sessionId }) - } - - private async createAgent(sessionId: ReturnType): Promise { - return this.ctx.agents.create({ - sessionId, - meta: { cwd: this.cwd }, - agentOptions: { - provider: this.provider, - model: this.model, - }, - }) - } - - private async followup(chatId: number, text: string): Promise { - const chat = await this.ensureChat(chatId) - const message = createUserMessage({ - content: [{ type: 'text', text }], - source: { kind: 'user' }, - }) - chat.handle.agent.followup(message) - } - private async deliver(chatId: number, markdown: string): Promise { const chunks = splitMessage(markdown, this.maxMessageLength) for (const chunk of chunks) { diff --git a/src/client.ts b/src/client.ts index 5c13327..a4a9e1a 100644 --- a/src/client.ts +++ b/src/client.ts @@ -20,9 +20,26 @@ export interface TelegramMessage { text?: string } +export interface TelegramCallbackQuery { + id: string + from: TelegramUser + message?: TelegramMessage + data?: string +} + export interface TelegramUpdate { update_id: number message?: TelegramMessage + callback_query?: TelegramCallbackQuery +} + +export interface InlineKeyboardButton { + text: string + callback_data: string +} + +export interface InlineKeyboardMarkup { + inline_keyboard: InlineKeyboardButton[][] } export interface TelegramClientOptions { @@ -34,8 +51,14 @@ export interface TelegramClientOptions { export interface TelegramClientLike { getMe(): Promise getUpdates(offset?: number): Promise - sendMessage(chatId: number, text: string, parseMode?: string): Promise + sendMessage( + chatId: number, + text: string, + parseMode?: string, + replyMarkup?: InlineKeyboardMarkup, + ): Promise sendChatAction(chatId: number, action: string): Promise + answerCallbackQuery(callbackQueryId: string, text?: string): Promise } export class TelegramClient implements TelegramClientLike { @@ -87,7 +110,7 @@ export class TelegramClient implements TelegramClientLike { async getUpdates(offset?: number): Promise { const body: Record = { timeout: this.pollingTimeoutSec, - allowed_updates: ['message'], + allowed_updates: ['message', 'callback_query'], } if (offset !== undefined) { body.offset = offset @@ -95,15 +118,29 @@ export class TelegramClient implements TelegramClientLike { return this.call('getUpdates', body) } - async sendMessage(chatId: number, text: string, parseMode?: string): Promise { + async sendMessage( + chatId: number, + text: string, + parseMode?: string, + replyMarkup?: InlineKeyboardMarkup, + ): Promise { const body: Record = { chat_id: chatId, text } if (parseMode !== undefined) { body.parse_mode = parseMode } + if (replyMarkup !== undefined) { + body.reply_markup = replyMarkup + } return this.call('sendMessage', body) } async sendChatAction(chatId: number, action: string): Promise { return this.call('sendChatAction', { chat_id: chatId, action }) } + + async answerCallbackQuery(callbackQueryId: string, text?: string): Promise { + const body: Record = { callback_query_id: callbackQueryId } + if (text !== undefined) body.text = text + return this.call('answerCallbackQuery', body) + } } diff --git a/src/commands.ts b/src/commands.ts index 407c037..c736e36 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -1,15 +1,30 @@ export const MSG = { DENIED: '无权限。', - WELCOME: - '你好,我是 DeepSeek Harness 渠道机器人。直接发消息开始对话,或发送 /help 查看命令。', + WELCOME: [ + '你好,这是 DeepSeek Harness 的手机遥控器。', + '本机会话是真相源:请先在 Web 打开对话,再用 /sessions 选择并附着。', + '发送 /help 查看命令。', + ].join('\n'), HELP: [ - '/start — 开始 / 欢迎', - '/new 或 /clear — 新开会话', + '/sessions — 列出本机正在运行的会话并选择附着', + '/status — 查看当前绑定', + '/unbind — 断开绑定(不关闭本机会话)', '/help — 显示帮助', '', - '仅白名单用户可对话;会话在内存中,重启 Harness 后需重新发消息建立。', + '绑定后直接发文字,消息会进入该本机会话;Web 与手机看到同一条轨迹。', + '仅白名单用户可用。无 live 会话时请先在 dsh web 开对话。', ].join('\n'), - NEW_SESSION: '已开启新会话。', + NEED_BIND: '尚未绑定本机会话。请先发送 /sessions 选择一个。', + NO_LIVE: '当前没有正在运行的本机会话。请先在 Web(dsh web)打开或继续一个对话,再发 /sessions。', + BOUND(label: string): string { + return `已附着本机会话:${label}\n此后消息将进入该会话(与 Web 同轨迹)。` + }, + UNBOUND: '已断开绑定。本机会话仍在运行。', + STATUS_NONE: '当前未绑定任何本机会话。发送 /sessions 选择。', + STATUS_BOUND(label: string): string { + return `当前绑定:${label}` + }, + GONE: '绑定的会话已不在本机运行(可能已在 Web 关闭)。请重新 /sessions。', unknown(command: string): string { return `未知命令 ${command}。发送 /help 查看可用命令。` }, @@ -18,22 +33,33 @@ export const MSG = { export type ParsedCommand = | { type: 'start'; text: string } | { type: 'help'; text: string } - | { type: 'new'; text: string } + | { type: 'sessions'; text: string } + | { type: 'status'; text: string } + | { type: 'unbind'; text: string } | { type: 'unknown'; command: string; text: string } | { type: 'plain'; text: string } export function parseCommand(text: string): ParsedCommand { if (!text.startsWith('/')) return { type: 'plain', text } - const command = text.split(/\s+/)[0] ?? text + const raw = text.split(/\s+/)[0] ?? text + const command = raw.includes('@') ? raw.slice(0, raw.indexOf('@')) : raw switch (command) { case '/start': return { type: 'start', text } case '/help': return { type: 'help', text } - case '/new': - case '/clear': - return { type: 'new', text } + case '/sessions': + case '/list': + return { type: 'sessions', text } + case '/status': + return { type: 'status', text } + case '/unbind': + case '/disconnect': + return { type: 'unbind', text } default: return { type: 'unknown', command, text } } } + +/** Callback data prefix for session bind buttons. */ +export const BIND_CB_PREFIX = 'bind:' diff --git a/src/index.ts b/src/index.ts index 742d971..3da850e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,22 +9,16 @@ export interface TelegramChannelConfig { token?: string allowedUserIds?: number[] allowAllUsers?: boolean - provider?: string - model?: string maxMessageLength?: number pollingTimeoutSec?: number - cwd?: string } export const Config: Schema = Schema.object({ token: Schema.string().default(''), allowedUserIds: Schema.array(Schema.number()).default([]), allowAllUsers: Schema.boolean().default(false), - provider: Schema.string().default('deepseek-official'), - model: Schema.string().default('deepseek-v4-flash'), maxMessageLength: Schema.number().default(4096), pollingTimeoutSec: Schema.number().default(30), - cwd: Schema.string(), }) export function apply(ctx: Context, config: TelegramChannelConfig): void { @@ -41,11 +35,8 @@ export function apply(ctx: Context, config: TelegramChannelConfig): void { token, allowedUserIds: config.allowedUserIds ?? [], allowAllUsers: config.allowAllUsers ?? false, - provider: config.provider ?? 'deepseek-official', - model: config.model ?? 'deepseek-v4-flash', maxMessageLength: config.maxMessageLength ?? 4096, pollingTimeoutSec: config.pollingTimeoutSec ?? 30, - cwd: config.cwd ?? process.cwd(), }) ctx.effect(() => { bridge.start() diff --git a/tests/bridge.test.ts b/tests/bridge.test.ts index 0409b44..60892da 100644 --- a/tests/bridge.test.ts +++ b/tests/bridge.test.ts @@ -1,13 +1,17 @@ import assert from 'node:assert/strict' import test from 'node:test' -import type { CreateAgentOptions } from '@deepseek-ai/dsh-agent' import type { UserMessage } from '@deepseek-ai/dsh-llm/types' import { SessionId } from '@deepseek-ai/dsh-session/types' import { TelegramBridge } from '../src/bridge.ts' -import type { TelegramClientLike, TelegramUpdate } from '../src/client.ts' -import { MSG } from '../src/commands.ts' +import type { InlineKeyboardMarkup, TelegramClientLike, TelegramUpdate } from '../src/client.ts' +import { BIND_CB_PREFIX, MSG } from '../src/commands.ts' -type SentMessage = { chatId: number; text: string; parseMode?: string } +type SentMessage = { + chatId: number + text: string + parseMode?: string + replyMarkup?: InlineKeyboardMarkup +} function fakeClient( sent: SentMessage[], @@ -16,11 +20,12 @@ function fakeClient( return { getMe: async () => ({ id: 1 }), getUpdates: async () => [], - sendMessage: async (chatId, text, parseMode) => { - sent.push({ chatId, text, parseMode }) + sendMessage: async (chatId, text, parseMode, replyMarkup) => { + sent.push({ chatId, text, parseMode, replyMarkup }) return { message_id: 1, date: 0, chat: { id: chatId, type: 'private' }, text } }, sendChatAction: async () => true, + answerCallbackQuery: async () => true, ...overrides, } } @@ -38,67 +43,24 @@ function messageUpdate(chatId: number, userId: number, text: string, updateId = } } -function fakeAgents(track: { - createCount: number - disposeCount: number - sessionIds: string[] - followups: UserMessage[][] -}) { +function makeAgent(id: string, followups: UserMessage[]) { return { - create: async (options: CreateAgentOptions) => { - track.createCount += 1 - track.sessionIds.push(String(options.sessionId)) - const followups: UserMessage[] = [] - track.followups.push(followups) - return { - agent: { - followup(message: UserMessage) { - followups.push(message) - }, - }, - dispose: async () => { - track.disposeCount += 1 - }, - } + id: SessionId(id), + session: { meta: { cwd: `/proj/${id}` } }, + followup(message: UserMessage) { + followups.push(message) }, } } -function bridgeCtx( - agents: ReturnType, - onSessionEvent?: (session: { id: ReturnType }, event: unknown) => void, -) { - return { - logger: { warn() {}, error() {} }, - agents, - on(event: string, listener: (session: { id: ReturnType }, event: unknown) => void) { - if (event === 'session/event' && onSessionEvent) { - onSessionEvent = listener - } - return () => {} - }, - _fireSessionEvent(session: { id: ReturnType }, ev: unknown) { - onSessionEvent?.(session, ev) - }, - } -} - -test('unauthorized user gets denied and no agent', async () => { +test('unauthorized user gets denied', async () => { const sent: SentMessage[] = [] - let createCount = 0 const ctx = { logger: { warn() {}, error() {} }, - agents: { - create: async () => { - createCount += 1 - throw new Error('should not create') - }, - }, - on() { - return () => {} - }, + agents: { list: () => [], roots: () => [], get: () => undefined }, + on() { return () => {} }, } - const bridge = new TelegramBridge(ctx as never, { + const bridge = new TelegramBridge(ctx as any, { token: 't', allowedUserIds: [1], allowAllUsers: false, @@ -106,241 +68,167 @@ test('unauthorized user gets denied and no agent', async () => { sleep: async () => {}, }) await bridge.processUpdate(messageUpdate(99, 99, 'hi')) - assert.equal(createCount, 0) assert.equal(sent[0]?.text, MSG.DENIED) }) -test('/help sends help without creating agent', async () => { +test('plain text without bind prompts NEED_BIND and does not create', async () => { const sent: SentMessage[] = [] - const track = { createCount: 0, disposeCount: 0, sessionIds: [] as string[], followups: [] as UserMessage[][] } - const bridge = new TelegramBridge(bridgeCtx(fakeAgents(track)) as never, { - token: 't', - allowedUserIds: [1], - allowAllUsers: false, - client: fakeClient(sent), - sleep: async () => {}, - }) - await bridge.processUpdate(messageUpdate(42, 1, '/help')) - assert.equal(track.createCount, 0) - assert.equal(sent[0]?.text, MSG.HELP) -}) - -test('first plain text creates agent then followups', async () => { - const sent: SentMessage[] = [] - const track = { createCount: 0, disposeCount: 0, sessionIds: [] as string[], followups: [] as UserMessage[][] } - const bridge = new TelegramBridge(bridgeCtx(fakeAgents(track)) as never, { - token: 't', - allowedUserIds: [1], - allowAllUsers: false, - client: fakeClient(sent), - sleep: async () => {}, - }) - await bridge.processUpdate(messageUpdate(42, 1, 'hello')) - assert.equal(track.createCount, 1) - assert.equal(track.sessionIds[0], 'telegram:42') - assert.equal(track.followups.length, 1) - assert.equal((track.followups[0]![0]!.content[0] as { text: string }).text, 'hello') -}) - -test('second plain text in same chat only followups', async () => { - const sent: SentMessage[] = [] - const track = { createCount: 0, disposeCount: 0, sessionIds: [] as string[], followups: [] as UserMessage[][] } - const bridge = new TelegramBridge(bridgeCtx(fakeAgents(track)) as never, { - token: 't', - allowedUserIds: [1], - allowAllUsers: false, - client: fakeClient(sent), - sleep: async () => {}, - }) - await bridge.processUpdate(messageUpdate(42, 1, 'hello')) - await bridge.processUpdate(messageUpdate(42, 1, 'again', 2)) - assert.equal(track.createCount, 1) - assert.equal(track.followups.length, 1) - assert.equal(track.followups[0]!.length, 2) - assert.equal((track.followups[0]![1]!.content[0] as { text: string }).text, 'again') -}) - -test('/new disposes previous handle and creates fresh session', async () => { - const sent: SentMessage[] = [] - const track = { createCount: 0, disposeCount: 0, sessionIds: [] as string[], followups: [] as UserMessage[][] } - const bridge = new TelegramBridge(bridgeCtx(fakeAgents(track)) as never, { - token: 't', - allowedUserIds: [1], - allowAllUsers: false, - client: fakeClient(sent), - sleep: async () => {}, - }) - await bridge.processUpdate(messageUpdate(42, 1, 'hello')) - await bridge.processUpdate(messageUpdate(42, 1, '/new', 2)) - assert.equal(track.disposeCount, 1) - assert.equal(track.createCount, 2) - assert.equal(track.sessionIds[0], 'telegram:42') - assert.match(track.sessionIds[1]!, /^telegram:42:\d+$/) - assert.equal(sent.at(-1)?.text, MSG.NEW_SESSION) -}) - -test('turn/start sends typing for matching session', async () => { - const sent: SentMessage[] = [] - const actions: Array<{ chatId: number; action: string }> = [] - const track = { createCount: 0, disposeCount: 0, sessionIds: [] as string[], followups: [] as UserMessage[][] } - let fire: ((session: { id: ReturnType }, event: unknown) => void) | undefined + let createCalled = false const ctx = { logger: { warn() {}, error() {} }, - agents: fakeAgents(track), - on(_event: string, listener: (session: { id: ReturnType }, event: unknown) => void) { - fire = listener + agents: { + list: () => [], + roots: () => [], + get: () => undefined, + create: async () => { createCalled = true }, + }, + on() { return () => {} }, + } + const bridge = new TelegramBridge(ctx as any, { + token: 't', + allowedUserIds: [1], + allowAllUsers: false, + client: fakeClient(sent), + sleep: async () => {}, + }) + await bridge.processUpdate(messageUpdate(10, 1, 'do something')) + assert.equal(createCalled, false) + assert.equal(sent[0]?.text, MSG.NEED_BIND) +}) + +test('/sessions with no live agents shows NO_LIVE', async () => { + const sent: SentMessage[] = [] + const ctx = { + logger: { warn() {}, error() {} }, + agents: { list: () => [], roots: () => [], get: () => undefined }, + on() { return () => {} }, + } + const bridge = new TelegramBridge(ctx as any, { + token: 't', + allowedUserIds: [1], + allowAllUsers: false, + client: fakeClient(sent), + sleep: async () => {}, + }) + await bridge.processUpdate(messageUpdate(10, 1, '/sessions')) + assert.equal(sent[0]?.text, MSG.NO_LIVE) +}) + +test('/sessions lists live agents with bind callbacks', async () => { + const sent: SentMessage[] = [] + const followups: UserMessage[] = [] + const agent = makeAgent('live-aaa', followups) + const ctx = { + logger: { warn() {}, error() {} }, + agents: { + list: () => [agent], + roots: () => [agent], + get: (id: ReturnType) => (String(id) === 'live-aaa' ? agent : undefined), + }, + on() { return () => {} }, + } + const bridge = new TelegramBridge(ctx as any, { + token: 't', + allowedUserIds: [1], + allowAllUsers: false, + client: fakeClient(sent), + sleep: async () => {}, + }) + await bridge.processUpdate(messageUpdate(10, 1, '/sessions')) + assert.match(sent[0]!.text, /选择要遥控/) + assert.ok(sent[0]!.replyMarkup?.inline_keyboard?.[0]?.[0]?.callback_data?.startsWith(BIND_CB_PREFIX)) +}) + +test('callback bind then plain text followups live agent; mirror assistant to chat', async () => { + const sent: SentMessage[] = [] + const followups: UserMessage[] = [] + const agent = makeAgent('live-bbb', followups) + let sessionListener: ((session: { id: ReturnType }, event: unknown) => void) | undefined + const ctx = { + logger: { warn() {}, error() {} }, + agents: { + list: () => [agent], + roots: () => [agent], + get: (id: ReturnType) => (String(id) === 'live-bbb' ? agent : undefined), + }, + on(event: string, listener: (session: { id: ReturnType }, event: unknown) => void) { + if (event === 'session/event') sessionListener = listener return () => {} }, } - const bridge = new TelegramBridge(ctx as never, { + const bridge = new TelegramBridge(ctx as any, { token: 't', allowedUserIds: [1], allowAllUsers: false, - client: fakeClient(sent, { - sendChatAction: async (chatId, action) => { - actions.push({ chatId, action }) - return true + client: fakeClient(sent), + sleep: async () => {}, + }) + bridge.start() + + await bridge.processUpdate({ + update_id: 2, + callback_query: { + id: 'cq1', + from: { id: 1 }, + message: { + message_id: 1, + date: 0, + chat: { id: 10, type: 'private' }, + text: 'picker', }, - }), - sleep: async () => {}, - }) - bridge.start() - await bridge.processUpdate(messageUpdate(42, 1, 'hello')) - fire!({ id: SessionId(track.sessionIds[0]!) }, { type: 'turn/start', data: { turn: 1 } }) - await new Promise((resolve) => setImmediate(resolve)) - assert.deepEqual(actions, [{ chatId: 42, action: 'typing' }]) - await bridge.stop() -}) - -test('assistant/message delivers HTML chunks for matching session', async () => { - const sent: SentMessage[] = [] - const track = { createCount: 0, disposeCount: 0, sessionIds: [] as string[], followups: [] as UserMessage[][] } - let fire: ((session: { id: ReturnType }, event: unknown) => void) | undefined - const ctx = { - logger: { warn() {}, error() {} }, - agents: fakeAgents(track), - on(_event: string, listener: (session: { id: ReturnType }, event: unknown) => void) { - fire = listener - return () => {} + data: `${BIND_CB_PREFIX}live-bbb`, }, - } - const bridge = new TelegramBridge(ctx as never, { - token: 't', - allowedUserIds: [1], - allowAllUsers: false, - client: fakeClient(sent), - sleep: async () => {}, }) - bridge.start() - await bridge.processUpdate(messageUpdate(42, 1, 'hello')) - fire!( - { id: SessionId(track.sessionIds[0]!) }, + assert.match(sent.at(-1)!.text, /已附着/) + + await bridge.processUpdate(messageUpdate(10, 1, 'hello from phone', 3)) + assert.equal(followups.length, 1) + assert.equal(followups[0]!.content[0]!.type, 'text') + + await sessionListener?.( + { id: SessionId('live-bbb') }, { type: 'assistant/message', - data: { - turn: 1, - step: 1, - message: { - content: [{ type: 'text', text: '**bold**' }], - }, - }, + data: { message: { content: [{ type: 'text', text: 'reply from agent' }] } }, }, ) - await new Promise((resolve) => setImmediate(resolve)) - const reply = sent.find((m) => m.parseMode === 'HTML') - assert.ok(reply) - assert.match(reply!.text, /bold<\/b>/) + assert.ok(sent.some((m) => m.text.includes('reply from agent') || m.text.includes('reply'))) + await bridge.stop() }) -test('HTML send failure retries plain text for that chunk', async () => { +test('/unbind clears binding without needing create/dispose', async () => { const sent: SentMessage[] = [] - const track = { createCount: 0, disposeCount: 0, sessionIds: [] as string[], followups: [] as UserMessage[][] } - let fire: ((session: { id: ReturnType }, event: unknown) => void) | undefined + const followups: UserMessage[] = [] + const agent = makeAgent('live-ccc', followups) const ctx = { logger: { warn() {}, error() {} }, - agents: fakeAgents(track), - on(_event: string, listener: (session: { id: ReturnType }, event: unknown) => void) { - fire = listener - return () => {} + agents: { + list: () => [agent], + roots: () => [agent], + get: () => agent, }, + on() { return () => {} }, } - const bridge = new TelegramBridge(ctx as never, { - token: 't', - allowedUserIds: [1], - allowAllUsers: false, - client: fakeClient(sent, { - sendMessage: async (chatId, text, parseMode) => { - if (parseMode === 'HTML') throw new Error('bad html') - sent.push({ chatId, text, parseMode }) - return { message_id: 1, date: 0, chat: { id: chatId, type: 'private' }, text } - }, - }), - sleep: async () => {}, - }) - bridge.start() - await bridge.processUpdate(messageUpdate(42, 1, 'hello')) - fire!( - { id: SessionId(track.sessionIds[0]!) }, - { - type: 'assistant/message', - data: { - turn: 1, - step: 1, - message: { - content: [{ type: 'text', text: 'plain chunk' }], - }, - }, - }, - ) - await new Promise((resolve) => setImmediate(resolve)) - assert.equal(sent.length, 1) - assert.equal(sent[0]?.parseMode, undefined) - assert.equal(sent[0]?.text, 'plain chunk') - await bridge.stop() -}) - -test('stop disposes all chat handles', async () => { - const sent: SentMessage[] = [] - const track = { createCount: 0, disposeCount: 0, sessionIds: [] as string[], followups: [] as UserMessage[][] } - const bridge = new TelegramBridge(bridgeCtx(fakeAgents(track)) as never, { + const bridge = new TelegramBridge(ctx as any, { token: 't', allowedUserIds: [1], allowAllUsers: false, client: fakeClient(sent), sleep: async () => {}, }) - await bridge.processUpdate(messageUpdate(42, 1, 'one')) - await bridge.processUpdate(messageUpdate(43, 1, 'two', 2)) - await bridge.stop() - assert.equal(track.disposeCount, 2) -}) - -test('default client uses pollingTimeoutSec from bridge options', async () => { - const calls: Array<{ body: unknown }> = [] - const originalFetch = globalThis.fetch - globalThis.fetch = async (_input, init) => { - calls.push({ body: JSON.parse(String(init?.body)) }) - return new Response(JSON.stringify({ ok: true, result: [] }), { status: 200 }) - } - try { - const bridge = new TelegramBridge(bridgeCtx(fakeAgents({ - createCount: 0, - disposeCount: 0, - sessionIds: [], - followups: [], - })) as never, { - token: 'SECRET-TOKEN', - allowedUserIds: [1], - allowAllUsers: false, - pollingTimeoutSec: 15, - sleep: async () => {}, - }) - bridge.start() - await new Promise((resolve) => setTimeout(resolve, 80)) - await bridge.stop() - assert.ok(calls.some((call) => (call.body as { timeout?: number }).timeout === 15)) - } finally { - globalThis.fetch = originalFetch - } + await bridge.processUpdate({ + update_id: 1, + callback_query: { + id: 'cq', + from: { id: 1 }, + message: { message_id: 1, date: 0, chat: { id: 10, type: 'private' } }, + data: `${BIND_CB_PREFIX}live-ccc`, + }, + }) + await bridge.processUpdate(messageUpdate(10, 1, '/unbind', 2)) + assert.equal(sent.at(-1)?.text, MSG.UNBOUND) + await bridge.processUpdate(messageUpdate(10, 1, 'again', 3)) + assert.equal(sent.at(-1)?.text, MSG.NEED_BIND) + assert.equal(followups.length, 0) }) diff --git a/tests/client.test.ts b/tests/client.test.ts index 472e9b7..61458ba 100644 --- a/tests/client.test.ts +++ b/tests/client.test.ts @@ -2,7 +2,7 @@ import assert from 'node:assert/strict' import test from 'node:test' import { TelegramClient } from '../src/client.ts' -test('getUpdates posts timeout and optional offset', async () => { +test('getUpdates posts timeout and optional offset with callback_query', async () => { const calls: Array<{ url: string; body: unknown }> = [] const fetchImpl: typeof fetch = async (input, init) => { calls.push({ url: String(input), body: JSON.parse(String(init?.body)) }) @@ -13,7 +13,7 @@ test('getUpdates posts timeout and optional offset', async () => { assert.match(calls[0]!.url, /\/botSECRET-TOKEN\/getUpdates$/) assert.deepEqual(calls[0]!.body, { timeout: 12, - allowed_updates: ['message'], + allowed_updates: ['message', 'callback_query'], offset: 9, }) }) @@ -33,3 +33,21 @@ test('errors redact token', async () => { test('empty token throws in constructor', () => { assert.throws(() => new TelegramClient(''), /token/) }) + +test('sendMessage can include reply_markup', async () => { + const calls: Array<{ body: Record }> = [] + const fetchImpl: typeof fetch = async (_input, init) => { + calls.push({ body: JSON.parse(String(init?.body)) }) + return new Response(JSON.stringify({ + ok: true, + result: { message_id: 1, date: 0, chat: { id: 1, type: 'private' }, text: 'x' }, + }), { status: 200 }) + } + const client = new TelegramClient('TOK', { fetch: fetchImpl }) + await client.sendMessage(1, 'pick', undefined, { + inline_keyboard: [[{ text: 'A', callback_data: 'bind:1' }]], + }) + assert.deepEqual(calls[0]!.body.reply_markup, { + inline_keyboard: [[{ text: 'A', callback_data: 'bind:1' }]], + }) +}) diff --git a/tests/commands.test.ts b/tests/commands.test.ts index c1d862f..9ff7b6c 100644 --- a/tests/commands.test.ts +++ b/tests/commands.test.ts @@ -2,17 +2,22 @@ import assert from 'node:assert/strict' import test from 'node:test' import { MSG, parseCommand } from '../src/commands.ts' -test('parse slash commands', () => { +test('parse slash commands for remote control', () => { assert.equal(parseCommand('/start').type, 'start') - assert.equal(parseCommand('/help extra').type, 'help') - assert.equal(parseCommand('/new').type, 'new') - assert.equal(parseCommand('/clear').type, 'new') + assert.equal(parseCommand('/help').type, 'help') + assert.equal(parseCommand('/sessions').type, 'sessions') + assert.equal(parseCommand('/list').type, 'sessions') + assert.equal(parseCommand('/status').type, 'status') + assert.equal(parseCommand('/unbind').type, 'unbind') + assert.equal(parseCommand('/disconnect').type, 'unbind') + assert.equal(parseCommand('/sessions@MyBot').type, 'sessions') assert.equal(parseCommand('/foo').type, 'unknown') assert.equal(parseCommand('hello').type, 'plain') }) -test('Chinese copy is non-empty', () => { +test('Chinese copy mentions sessions and bind', () => { assert.ok(MSG.DENIED.includes('权限') || MSG.DENIED.includes('授权')) - assert.ok(MSG.HELP.includes('/new')) - assert.ok(MSG.WELCOME.length > 0) + assert.ok(MSG.HELP.includes('/sessions')) + assert.ok(MSG.NEED_BIND.includes('/sessions')) + assert.ok(MSG.WELCOME.includes('Web') || MSG.WELCOME.includes('遥控器')) })