feat: remote-control live DSH Web sessions from Telegram

Attach via /sessions picker instead of creating parallel agents; mirror the same session trajectory to phone and Web (Codex-style remote).

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
owo
2026-08-14 17:11:13 +08:00
co-authored by Cursor
parent dd72f134fb
commit c2c0db7597
12 changed files with 518 additions and 666 deletions
+59 -216
View File
@@ -2,9 +2,9 @@
[English](#english) · [中文](#中文) [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。 流程:电脑上先开对话 → 手机 `/sessions` 选择 → 发消息进入该会话 → Web 也能看到。
### 特性
- 标准 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 |
### 连接实操(约 10 分钟) ### 连接实操(约 10 分钟)
按下面顺序做即可完成 **Telegram Bot ↔ DSH** 连通: 1. 本机 `dsh web` 能跑,并 **先打开至少一个对话**(保持运行)。
2. [@BotFather](https://t.me/BotFather) 建 Bot,拿 token;[@userinfobot](https://t.me/userinfobot) 拿自己的 user id。
1. 本机确认 `dsh web`(或你的 profile)能正常启动。 3. 安装:
2. BotFather 创建 bot,复制 **token**;userinfobot 记下自己的 **user id**。
3. 安装插件(推荐 GitHub):
```powershell ```powershell
dsh plugin --profile web add github:hi-wenw/dsh-telegram-channel dsh plugin --profile web add github:hi-wenw/dsh-telegram-channel
``` ```
本地开发目录安装: 或本地:
```powershell ```powershell
dsh plugin --profile web add D:\gitData\dsh-telegram-channel dsh plugin --profile web add D:\gitData\dsh-telegram-channel
``` ```
4. 设置 token(推荐环境变量,避免写进仓库): 4. Token(推荐环境变量):
```powershell ```powershell
$env:DSH_TELEGRAM_TOKEN = '123456:ABC...' $env:DSH_TELEGRAM_TOKEN = '123456:ABC...'
``` ```
5. 在 profile 的 patch / 插件配置里设置白名单(把数字换成你的 id): 5. 白名单(换成你的 id):
```yaml ```yaml
- insert: - insert:
@@ -67,69 +50,48 @@ $env:DSH_TELEGRAM_TOKEN = '123456:ABC...'
config: config:
token: '' token: ''
allowedUserIds: [123456789] 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` | 欢迎与用法;不调模型 | | `/sessions` `/list` | 列出 live 会话,内联键盘选择附着 |
| `/help` | 命令与白名单 / 会话说明 | | `/status` | 当前绑定 |
| `/new` 或 `/clear` | **清除 / 删除**当前 chat 的 agent,下次消息重建(等同 reset / remove session) | | `/unbind` `/disconnect` | 解绑(不 dispose 本机 agent) |
| 未识别 `/...` | 提示可用命令 | | `/help` `/start` | 帮助 / 欢迎 |
| 普通文本 | `create` / `followup` → 回传助手文本 | | 普通文字 | 进入已绑定会话的 `followup` |
### 配置
| 键 | 默认 | 含义 |
|---|---|---|
| `token` | 空 → `DSH_TELEGRAM_TOKEN` | Bot token |
| `allowedUserIds` | `[]` | 白名单;空 = 全拒 |
| `allowAllUsers` | `false` | 调试用,生产勿开 |
| `maxMessageLength` | `4096` | 分片长度 |
| `pollingTimeoutSec` | `30` | 长轮询超时 |
### 故障排查 ### 故障排查
| 现象 | 可能原因 | 处理 | | 现象 | 处理 |
|---|---|---| |---|---|
| 日志 `missing bot token`;Bot 无响应 | 未设 `token` / `DSH_TELEGRAM_TOKEN` | 设置后重载 profile | | `/sessions` 提示没有会话 | 先在 Web 打开对话并保持 profile 运行 |
| 回复「无权限。」 | user id 不在白名单,或名单为空 | 核对 id,写入 `allowedUserIds` | | 「尚未绑定」 | 先 `/sessions` 点选 |
| Telegram 401 | token 错误或已撤销 | BotFather 换新 token | | 「会话已不在本机运行」 | Web 已关该会话;重新开再绑定 |
| 有 typing 无文字 | profile 内 LLM / API Key | 查 DSH 日志;同 profile 先用 CLI 验证 agent | | 「无权限」 | 检查 `allowedUserIds` |
| 重启后对话「失忆」 | 会话仅内存(当前设计) | 直接发消息或 `/new` 重建 | | missing bot token | 设 `DSH_TELEGRAM_TOKEN` 后重载 |
| 贡献者 `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
### 安全 ### 安全
- Fail-closed 白名单;空名单默认全拒 Fail-closed 白名单;token 走环境变量;日志脱敏;解绑不关闭本机 agent。
- Token 优先环境变量;日志脱敏
- `allowAllUsers: true` 勿用于生产
- 本插件不启 LLM;模型 / 工具 / 沙箱由 DSH profile 决定
### 贡献者 ### 贡献者
@@ -141,15 +103,6 @@ npm test
npm run build 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) [MIT](LICENSE)
@@ -160,142 +113,32 @@ npm run build
### What this is ### 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 1. Run `dsh web` and **open at least one conversation**.
2. Create a Bot (BotFather) + note your user id.
- Standard **dsh-plugin** (`inject: ['agents']`) via `cordis.patch.yml` 3. Install:
- 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:
```powershell ```powershell
dsh plugin --profile web add github:hi-wenw/dsh-telegram-channel 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 ### Commands
dsh plugin --profile web add D:\gitData\dsh-telegram-channel
```
4. Set the token (prefer env): | Command | Action |
```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 |
|---|---| |---|---|
| `/start` | Welcome; no model call | | `/sessions` `/list` | List live agents; inline keyboard to bind |
| `/help` | Commands + allowlist / session notes | | `/status` | Show binding |
| `/new` or `/clear` | **Remove / clear / reset** this chat’s agent; next message creates a new session | | `/unbind` `/disconnect` | Unbind (does not dispose host agent) |
| Unknown `/...` | Hint available commands | | Plain text | `followup` into the bound live agent |
| 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 |
### License ### License
[MIT](LICENSE) MIT
+29
View File
@@ -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
+11 -66
View File
@@ -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).
## 适用场景 ## Install
- 已能本地运行 `dsh web`,但尚未配置过 Telegram 渠道
- 希望抄一份 patch 片段,而不是手写 Cordis 插件注册
## 步骤
### 1. 安装插件
**GitHub(推荐):**
```powershell ```powershell
dsh plugin --profile web add github:hi-wenw/dsh-telegram-channel dsh plugin --profile web add github:hi-wenw/dsh-telegram-channel
``` ```
**本地开发目录**(将路径换成你的克隆位置): Local:
```powershell ```powershell
dsh plugin --profile web add D:\gitData\dsh-telegram-channel 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. 配置白名单 1. Open a conversation in `dsh web` first.
2. Set token:
编辑 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:
```powershell ```powershell
$env:DSH_TELEGRAM_TOKEN = '1234567890:AA...your-bot-token...' $env:DSH_TELEGRAM_TOKEN = '123456:ABC...'
``` ```
也可写入系统 / 用户环境变量,或 profile 启动脚本。 3. Merge [`cordis.patch.example.yml`](cordis.patch.example.yml) (replace user id).
4. Reload profile.
### 4. 启动 profile 5. Phone: `/sessions` → bind → chat. `/unbind` to disconnect.
```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 的「故障排查」一节。
@@ -4,5 +4,3 @@
config: config:
token: '' token: ''
allowedUserIds: [123456789] allowedUserIds: [123456789]
provider: deepseek-official
model: deepseek-v4-flash
+9 -7
View File
@@ -1,23 +1,25 @@
{ {
"name": "dsh-telegram-channel", "name": "dsh-telegram-channel",
"version": "0.1.0", "version": "0.2.0",
"description": "DeepSeek Harness Telegram Bot / mobile remote channel Cordis plugin (dsh-plugin): long poll, per-chat agents, /new /clear remove session, allowlist", "description": "DeepSeek Harness Telegram mobile remote: bind live Web sessions (Codex-style), same trajectory, /sessions picker, unbind/clear — dsh-plugin",
"keywords": [ "keywords": [
"telegram", "telegram",
"bot", "bot",
"mobile", "mobile",
"channel",
"remote", "remote",
"channel",
"sessions",
"bind",
"unbind",
"remove",
"clear",
"deepseek", "deepseek",
"deepseek-harness", "deepseek-harness",
"dsh", "dsh",
"dsh-plugin", "dsh-plugin",
"cordis", "cordis",
"agent", "agent",
"remove", "live-session"
"clear",
"allowlist",
"long-polling"
], ],
"repository": { "repository": {
"type": "git", "type": "git",
+151 -81
View File
@@ -1,11 +1,16 @@
import type { Context } from '@deepseek-ai/cordis' 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 { createUserMessage } from '@deepseek-ai/dsh-llm/message'
import type { ContentBlock, TextBlock } from '@deepseek-ai/dsh-llm/types' import type { ContentBlock, TextBlock } from '@deepseek-ai/dsh-llm/types'
import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session/types' import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session/types'
import { isAuthorized } from './auth.js' import { isAuthorized } from './auth.js'
import { TelegramClient, type TelegramClientLike, type TelegramUpdate } from './client.js' import {
import { MSG, parseCommand } from './commands.js' 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' import { markdownToHtml, splitMessage } from './format.js'
export interface TelegramBridgeOptions { export interface TelegramBridgeOptions {
@@ -14,17 +19,15 @@ export interface TelegramBridgeOptions {
allowAllUsers: boolean allowAllUsers: boolean
client?: TelegramClientLike client?: TelegramClientLike
sleep?: (ms: number) => Promise<void> sleep?: (ms: number) => Promise<void>
provider?: string
model?: string
maxMessageLength?: number maxMessageLength?: number
pollingTimeoutSec?: number pollingTimeoutSec?: number
cwd?: string
} }
interface ChatState { /** chatId → bound live session id (string form of SessionId) */
interface Binding {
chatId: number chatId: number
handle: AgentHandle
sessionId: string sessionId: string
label: string
} }
interface SessionLike { interface SessionLike {
@@ -40,6 +43,15 @@ function contentToText(content: readonly ContentBlock[]): string {
.join('') .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 { export class TelegramBridge {
private readonly ctx: Context private readonly ctx: Context
private readonly token: string private readonly token: string
@@ -47,12 +59,9 @@ export class TelegramBridge {
private readonly allowAllUsers: boolean private readonly allowAllUsers: boolean
private readonly client: TelegramClientLike private readonly client: TelegramClientLike
private readonly sleep: (ms: number) => Promise<void> private readonly sleep: (ms: number) => Promise<void>
private readonly provider: string
private readonly model: string
private readonly maxMessageLength: number private readonly maxMessageLength: number
private readonly cwd: string
private readonly chats = new Map<string, ChatState>() private readonly bindings = new Map<string, Binding>()
private polling = false private polling = false
private offset: number | undefined private offset: number | undefined
private pollPromise: Promise<void> | undefined private pollPromise: Promise<void> | undefined
@@ -68,10 +77,7 @@ export class TelegramBridge {
pollingTimeoutSec: options.pollingTimeoutSec ?? 30, pollingTimeoutSec: options.pollingTimeoutSec ?? 30,
}) })
this.sleep = options.sleep ?? defaultSleep this.sleep = options.sleep ?? defaultSleep
this.provider = options.provider ?? 'deepseek-official'
this.model = options.model ?? 'deepseek-v4-flash'
this.maxMessageLength = options.maxMessageLength ?? 4096 this.maxMessageLength = options.maxMessageLength ?? 4096
this.cwd = options.cwd ?? process.cwd()
} }
start(): void { start(): void {
@@ -94,9 +100,8 @@ export class TelegramBridge {
this.pollAbort = undefined this.pollAbort = undefined
this.disposeSessionListener?.() this.disposeSessionListener?.()
this.disposeSessionListener = undefined this.disposeSessionListener = undefined
const disposals = [...this.chats.values()].map((chat) => chat.handle.dispose()) // Never dispose host agents — only clear remote bindings.
this.chats.clear() this.bindings.clear()
await Promise.all(disposals)
if (this.pollPromise) { if (this.pollPromise) {
await this.pollPromise.catch(() => {}) await this.pollPromise.catch(() => {})
this.pollPromise = undefined this.pollPromise = undefined
@@ -104,6 +109,10 @@ export class TelegramBridge {
} }
async processUpdate(update: TelegramUpdate): Promise<void> { async processUpdate(update: TelegramUpdate): Promise<void> {
if (update.callback_query) {
await this.handleCallback(update)
return
}
const message = update.message const message = update.message
if (!message?.text) return if (!message?.text) return
@@ -123,20 +132,133 @@ export class TelegramBridge {
case 'help': case 'help':
await this.client.sendMessage(chatId, MSG.HELP) await this.client.sendMessage(chatId, MSG.HELP)
return return
case 'new': case 'sessions':
await this.resetChat(chatId) await this.sendSessionPicker(chatId)
await this.client.sendMessage(chatId, MSG.NEW_SESSION) return
case 'status':
await this.sendStatus(chatId)
return
case 'unbind':
this.bindings.delete(String(chatId))
await this.client.sendMessage(chatId, MSG.UNBOUND)
return return
case 'unknown': case 'unknown':
await this.client.sendMessage(chatId, MSG.unknown(parsed.command)) await this.client.sendMessage(chatId, MSG.unknown(parsed.command))
return return
case 'plain': case 'plain':
await this.ensureChat(chatId) await this.followupBound(chatId, parsed.text)
await this.followup(chatId, parsed.text)
return return
} }
} }
private async handleCallback(update: TelegramUpdate): Promise<void> {
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<void> {
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<void> {
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<void> {
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<void> { private async pollLoop(): Promise<void> {
const signal = this.pollAbort?.signal const signal = this.pollAbort?.signal
let errorCount = 0 let errorCount = 0
@@ -189,74 +311,22 @@ export class TelegramBridge {
} }
private async onSessionEvent(session: SessionLike, event: SessionEvent): Promise<void> { private async onSessionEvent(session: SessionLike, event: SessionEvent): Promise<void> {
const chat = this.findChatBySessionId(session.id) const id = String(session.id)
if (!chat) return const targets = [...this.bindings.values()].filter((b) => b.sessionId === id)
if (targets.length === 0) return
if (event.type === 'turn/start') { 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 return
} }
if (event.type === 'assistant/message') { if (event.type === 'assistant/message') {
const text = contentToText(event.data.message.content) const text = contentToText(event.data.message.content)
if (text) { if (!text) return
await this.deliver(chat.chatId, text) await Promise.all(targets.map((b) => this.deliver(b.chatId, text)))
}
} }
} }
private findChatBySessionId(sessionId: ReturnType<typeof SessionId>): 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<ChatState> {
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<void> {
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<typeof SessionId>): Promise<AgentHandle> {
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<void> {
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<void> { private async deliver(chatId: number, markdown: string): Promise<void> {
const chunks = splitMessage(markdown, this.maxMessageLength) const chunks = splitMessage(markdown, this.maxMessageLength)
for (const chunk of chunks) { for (const chunk of chunks) {
+40 -3
View File
@@ -20,9 +20,26 @@ export interface TelegramMessage {
text?: string text?: string
} }
export interface TelegramCallbackQuery {
id: string
from: TelegramUser
message?: TelegramMessage
data?: string
}
export interface TelegramUpdate { export interface TelegramUpdate {
update_id: number update_id: number
message?: TelegramMessage message?: TelegramMessage
callback_query?: TelegramCallbackQuery
}
export interface InlineKeyboardButton {
text: string
callback_data: string
}
export interface InlineKeyboardMarkup {
inline_keyboard: InlineKeyboardButton[][]
} }
export interface TelegramClientOptions { export interface TelegramClientOptions {
@@ -34,8 +51,14 @@ export interface TelegramClientOptions {
export interface TelegramClientLike { export interface TelegramClientLike {
getMe(): Promise<TelegramUser> getMe(): Promise<TelegramUser>
getUpdates(offset?: number): Promise<TelegramUpdate[]> getUpdates(offset?: number): Promise<TelegramUpdate[]>
sendMessage(chatId: number, text: string, parseMode?: string): Promise<TelegramMessage> sendMessage(
chatId: number,
text: string,
parseMode?: string,
replyMarkup?: InlineKeyboardMarkup,
): Promise<TelegramMessage>
sendChatAction(chatId: number, action: string): Promise<boolean> sendChatAction(chatId: number, action: string): Promise<boolean>
answerCallbackQuery(callbackQueryId: string, text?: string): Promise<boolean>
} }
export class TelegramClient implements TelegramClientLike { export class TelegramClient implements TelegramClientLike {
@@ -87,7 +110,7 @@ export class TelegramClient implements TelegramClientLike {
async getUpdates(offset?: number): Promise<TelegramUpdate[]> { async getUpdates(offset?: number): Promise<TelegramUpdate[]> {
const body: Record<string, unknown> = { const body: Record<string, unknown> = {
timeout: this.pollingTimeoutSec, timeout: this.pollingTimeoutSec,
allowed_updates: ['message'], allowed_updates: ['message', 'callback_query'],
} }
if (offset !== undefined) { if (offset !== undefined) {
body.offset = offset body.offset = offset
@@ -95,15 +118,29 @@ export class TelegramClient implements TelegramClientLike {
return this.call<TelegramUpdate[]>('getUpdates', body) return this.call<TelegramUpdate[]>('getUpdates', body)
} }
async sendMessage(chatId: number, text: string, parseMode?: string): Promise<TelegramMessage> { async sendMessage(
chatId: number,
text: string,
parseMode?: string,
replyMarkup?: InlineKeyboardMarkup,
): Promise<TelegramMessage> {
const body: Record<string, unknown> = { chat_id: chatId, text } const body: Record<string, unknown> = { chat_id: chatId, text }
if (parseMode !== undefined) { if (parseMode !== undefined) {
body.parse_mode = parseMode body.parse_mode = parseMode
} }
if (replyMarkup !== undefined) {
body.reply_markup = replyMarkup
}
return this.call<TelegramMessage>('sendMessage', body) return this.call<TelegramMessage>('sendMessage', body)
} }
async sendChatAction(chatId: number, action: string): Promise<boolean> { async sendChatAction(chatId: number, action: string): Promise<boolean> {
return this.call<boolean>('sendChatAction', { chat_id: chatId, action }) return this.call<boolean>('sendChatAction', { chat_id: chatId, action })
} }
async answerCallbackQuery(callbackQueryId: string, text?: string): Promise<boolean> {
const body: Record<string, unknown> = { callback_query_id: callbackQueryId }
if (text !== undefined) body.text = text
return this.call<boolean>('answerCallbackQuery', body)
}
} }
+37 -11
View File
@@ -1,15 +1,30 @@
export const MSG = { export const MSG = {
DENIED: '无权限。', DENIED: '无权限。',
WELCOME: WELCOME: [
'你好,我是 DeepSeek Harness 渠道机器人。直接发消息开始对话,或发送 /help 查看命令。', '你好,这是 DeepSeek Harness 的手机遥控器。',
'本机会话是真相源:请先在 Web 打开对话,再用 /sessions 选择并附着。',
'发送 /help 查看命令。',
].join('\n'),
HELP: [ HELP: [
'/start — 开始 / 欢迎', '/sessions — 列出本机正在运行的会话并选择附着',
'/new 或 /clear — 新开会话', '/status — 查看当前绑定',
'/unbind — 断开绑定(不关闭本机会话)',
'/help — 显示帮助', '/help — 显示帮助',
'', '',
'仅白名单用户可对话;会话在内存中,重启 Harness 后需重新发消息建立。', '绑定后直接发文字,消息会进入该本机会话;Web 与手机看到同一条轨迹。',
'仅白名单用户可用。无 live 会话时请先在 dsh web 开对话。',
].join('\n'), ].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 { unknown(command: string): string {
return `未知命令 ${command}。发送 /help 查看可用命令。` return `未知命令 ${command}。发送 /help 查看可用命令。`
}, },
@@ -18,22 +33,33 @@ export const MSG = {
export type ParsedCommand = export type ParsedCommand =
| { type: 'start'; text: string } | { type: 'start'; text: string }
| { type: 'help'; 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: 'unknown'; command: string; text: string }
| { type: 'plain'; text: string } | { type: 'plain'; text: string }
export function parseCommand(text: string): ParsedCommand { export function parseCommand(text: string): ParsedCommand {
if (!text.startsWith('/')) return { type: 'plain', text } 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) { switch (command) {
case '/start': case '/start':
return { type: 'start', text } return { type: 'start', text }
case '/help': case '/help':
return { type: 'help', text } return { type: 'help', text }
case '/new': case '/sessions':
case '/clear': case '/list':
return { type: 'new', text } return { type: 'sessions', text }
case '/status':
return { type: 'status', text }
case '/unbind':
case '/disconnect':
return { type: 'unbind', text }
default: default:
return { type: 'unknown', command, text } return { type: 'unknown', command, text }
} }
} }
/** Callback data prefix for session bind buttons. */
export const BIND_CB_PREFIX = 'bind:'
-9
View File
@@ -9,22 +9,16 @@ export interface TelegramChannelConfig {
token?: string token?: string
allowedUserIds?: number[] allowedUserIds?: number[]
allowAllUsers?: boolean allowAllUsers?: boolean
provider?: string
model?: string
maxMessageLength?: number maxMessageLength?: number
pollingTimeoutSec?: number pollingTimeoutSec?: number
cwd?: string
} }
export const Config: Schema<TelegramChannelConfig> = Schema.object({ export const Config: Schema<TelegramChannelConfig> = Schema.object({
token: Schema.string().default(''), token: Schema.string().default(''),
allowedUserIds: Schema.array(Schema.number()).default([]), allowedUserIds: Schema.array(Schema.number()).default([]),
allowAllUsers: Schema.boolean().default(false), allowAllUsers: Schema.boolean().default(false),
provider: Schema.string().default('deepseek-official'),
model: Schema.string().default('deepseek-v4-flash'),
maxMessageLength: Schema.number().default(4096), maxMessageLength: Schema.number().default(4096),
pollingTimeoutSec: Schema.number().default(30), pollingTimeoutSec: Schema.number().default(30),
cwd: Schema.string(),
}) })
export function apply(ctx: Context, config: TelegramChannelConfig): void { export function apply(ctx: Context, config: TelegramChannelConfig): void {
@@ -41,11 +35,8 @@ export function apply(ctx: Context, config: TelegramChannelConfig): void {
token, token,
allowedUserIds: config.allowedUserIds ?? [], allowedUserIds: config.allowedUserIds ?? [],
allowAllUsers: config.allowAllUsers ?? false, allowAllUsers: config.allowAllUsers ?? false,
provider: config.provider ?? 'deepseek-official',
model: config.model ?? 'deepseek-v4-flash',
maxMessageLength: config.maxMessageLength ?? 4096, maxMessageLength: config.maxMessageLength ?? 4096,
pollingTimeoutSec: config.pollingTimeoutSec ?? 30, pollingTimeoutSec: config.pollingTimeoutSec ?? 30,
cwd: config.cwd ?? process.cwd(),
}) })
ctx.effect(() => { ctx.effect(() => {
bridge.start() bridge.start()
+150 -262
View File
@@ -1,13 +1,17 @@
import assert from 'node:assert/strict' import assert from 'node:assert/strict'
import test from 'node:test' import test from 'node:test'
import type { CreateAgentOptions } from '@deepseek-ai/dsh-agent'
import type { UserMessage } from '@deepseek-ai/dsh-llm/types' import type { UserMessage } from '@deepseek-ai/dsh-llm/types'
import { SessionId } from '@deepseek-ai/dsh-session/types' import { SessionId } from '@deepseek-ai/dsh-session/types'
import { TelegramBridge } from '../src/bridge.ts' import { TelegramBridge } from '../src/bridge.ts'
import type { TelegramClientLike, TelegramUpdate } from '../src/client.ts' import type { InlineKeyboardMarkup, TelegramClientLike, TelegramUpdate } from '../src/client.ts'
import { MSG } from '../src/commands.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( function fakeClient(
sent: SentMessage[], sent: SentMessage[],
@@ -16,11 +20,12 @@ function fakeClient(
return { return {
getMe: async () => ({ id: 1 }), getMe: async () => ({ id: 1 }),
getUpdates: async () => [], getUpdates: async () => [],
sendMessage: async (chatId, text, parseMode) => { sendMessage: async (chatId, text, parseMode, replyMarkup) => {
sent.push({ chatId, text, parseMode }) sent.push({ chatId, text, parseMode, replyMarkup })
return { message_id: 1, date: 0, chat: { id: chatId, type: 'private' }, text } return { message_id: 1, date: 0, chat: { id: chatId, type: 'private' }, text }
}, },
sendChatAction: async () => true, sendChatAction: async () => true,
answerCallbackQuery: async () => true,
...overrides, ...overrides,
} }
} }
@@ -38,67 +43,24 @@ function messageUpdate(chatId: number, userId: number, text: string, updateId =
} }
} }
function fakeAgents(track: { function makeAgent(id: string, followups: UserMessage[]) {
createCount: number
disposeCount: number
sessionIds: string[]
followups: UserMessage[][]
}) {
return { return {
create: async (options: CreateAgentOptions) => { id: SessionId(id),
track.createCount += 1 session: { meta: { cwd: `/proj/${id}` } },
track.sessionIds.push(String(options.sessionId)) followup(message: UserMessage) {
const followups: UserMessage[] = [] followups.push(message)
track.followups.push(followups)
return {
agent: {
followup(message: UserMessage) {
followups.push(message)
},
},
dispose: async () => {
track.disposeCount += 1
},
}
}, },
} }
} }
function bridgeCtx( test('unauthorized user gets denied', async () => {
agents: ReturnType<typeof fakeAgents>,
onSessionEvent?: (session: { id: ReturnType<typeof SessionId> }, event: unknown) => void,
) {
return {
logger: { warn() {}, error() {} },
agents,
on(event: string, listener: (session: { id: ReturnType<typeof SessionId> }, event: unknown) => void) {
if (event === 'session/event' && onSessionEvent) {
onSessionEvent = listener
}
return () => {}
},
_fireSessionEvent(session: { id: ReturnType<typeof SessionId> }, ev: unknown) {
onSessionEvent?.(session, ev)
},
}
}
test('unauthorized user gets denied and no agent', async () => {
const sent: SentMessage[] = [] const sent: SentMessage[] = []
let createCount = 0
const ctx = { const ctx = {
logger: { warn() {}, error() {} }, logger: { warn() {}, error() {} },
agents: { agents: { list: () => [], roots: () => [], get: () => undefined },
create: async () => { on() { return () => {} },
createCount += 1
throw new Error('should not create')
},
},
on() {
return () => {}
},
} }
const bridge = new TelegramBridge(ctx as never, { const bridge = new TelegramBridge(ctx as any, {
token: 't', token: 't',
allowedUserIds: [1], allowedUserIds: [1],
allowAllUsers: false, allowAllUsers: false,
@@ -106,241 +68,167 @@ test('unauthorized user gets denied and no agent', async () => {
sleep: async () => {}, sleep: async () => {},
}) })
await bridge.processUpdate(messageUpdate(99, 99, 'hi')) await bridge.processUpdate(messageUpdate(99, 99, 'hi'))
assert.equal(createCount, 0)
assert.equal(sent[0]?.text, MSG.DENIED) 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 sent: SentMessage[] = []
const track = { createCount: 0, disposeCount: 0, sessionIds: [] as string[], followups: [] as UserMessage[][] } let createCalled = false
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<typeof SessionId> }, event: unknown) => void) | undefined
const ctx = { const ctx = {
logger: { warn() {}, error() {} }, logger: { warn() {}, error() {} },
agents: fakeAgents(track), agents: {
on(_event: string, listener: (session: { id: ReturnType<typeof SessionId> }, event: unknown) => void) { list: () => [],
fire = listener 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<typeof SessionId>) => (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<typeof SessionId> }, event: unknown) => void) | undefined
const ctx = {
logger: { warn() {}, error() {} },
agents: {
list: () => [agent],
roots: () => [agent],
get: (id: ReturnType<typeof SessionId>) => (String(id) === 'live-bbb' ? agent : undefined),
},
on(event: string, listener: (session: { id: ReturnType<typeof SessionId> }, event: unknown) => void) {
if (event === 'session/event') sessionListener = listener
return () => {} return () => {}
}, },
} }
const bridge = new TelegramBridge(ctx as never, { const bridge = new TelegramBridge(ctx as any, {
token: 't', token: 't',
allowedUserIds: [1], allowedUserIds: [1],
allowAllUsers: false, allowAllUsers: false,
client: fakeClient(sent, { client: fakeClient(sent),
sendChatAction: async (chatId, action) => { sleep: async () => {},
actions.push({ chatId, action }) })
return true 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',
}, },
}), data: `${BIND_CB_PREFIX}live-bbb`,
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<typeof SessionId> }, event: unknown) => void) | undefined
const ctx = {
logger: { warn() {}, error() {} },
agents: fakeAgents(track),
on(_event: string, listener: (session: { id: ReturnType<typeof SessionId> }, event: unknown) => void) {
fire = listener
return () => {}
}, },
}
const bridge = new TelegramBridge(ctx as never, {
token: 't',
allowedUserIds: [1],
allowAllUsers: false,
client: fakeClient(sent),
sleep: async () => {},
}) })
bridge.start() assert.match(sent.at(-1)!.text, /已附着/)
await bridge.processUpdate(messageUpdate(42, 1, 'hello'))
fire!( await bridge.processUpdate(messageUpdate(10, 1, 'hello from phone', 3))
{ id: SessionId(track.sessionIds[0]!) }, assert.equal(followups.length, 1)
assert.equal(followups[0]!.content[0]!.type, 'text')
await sessionListener?.(
{ id: SessionId('live-bbb') },
{ {
type: 'assistant/message', type: 'assistant/message',
data: { data: { message: { content: [{ type: 'text', text: 'reply from agent' }] } },
turn: 1,
step: 1,
message: {
content: [{ type: 'text', text: '**bold**' }],
},
},
}, },
) )
await new Promise((resolve) => setImmediate(resolve)) assert.ok(sent.some((m) => m.text.includes('reply from agent') || m.text.includes('reply')))
const reply = sent.find((m) => m.parseMode === 'HTML')
assert.ok(reply)
assert.match(reply!.text, /<b>bold<\/b>/)
await bridge.stop() 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 sent: SentMessage[] = []
const track = { createCount: 0, disposeCount: 0, sessionIds: [] as string[], followups: [] as UserMessage[][] } const followups: UserMessage[] = []
let fire: ((session: { id: ReturnType<typeof SessionId> }, event: unknown) => void) | undefined const agent = makeAgent('live-ccc', followups)
const ctx = { const ctx = {
logger: { warn() {}, error() {} }, logger: { warn() {}, error() {} },
agents: fakeAgents(track), agents: {
on(_event: string, listener: (session: { id: ReturnType<typeof SessionId> }, event: unknown) => void) { list: () => [agent],
fire = listener roots: () => [agent],
return () => {} get: () => agent,
}, },
on() { return () => {} },
} }
const bridge = new TelegramBridge(ctx as never, { const bridge = new TelegramBridge(ctx as any, {
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, {
token: 't', token: 't',
allowedUserIds: [1], allowedUserIds: [1],
allowAllUsers: false, allowAllUsers: false,
client: fakeClient(sent), client: fakeClient(sent),
sleep: async () => {}, sleep: async () => {},
}) })
await bridge.processUpdate(messageUpdate(42, 1, 'one')) await bridge.processUpdate({
await bridge.processUpdate(messageUpdate(43, 1, 'two', 2)) update_id: 1,
await bridge.stop() callback_query: {
assert.equal(track.disposeCount, 2) id: 'cq',
}) from: { id: 1 },
message: { message_id: 1, date: 0, chat: { id: 10, type: 'private' } },
test('default client uses pollingTimeoutSec from bridge options', async () => { data: `${BIND_CB_PREFIX}live-ccc`,
const calls: Array<{ body: unknown }> = [] },
const originalFetch = globalThis.fetch })
globalThis.fetch = async (_input, init) => { await bridge.processUpdate(messageUpdate(10, 1, '/unbind', 2))
calls.push({ body: JSON.parse(String(init?.body)) }) assert.equal(sent.at(-1)?.text, MSG.UNBOUND)
return new Response(JSON.stringify({ ok: true, result: [] }), { status: 200 }) await bridge.processUpdate(messageUpdate(10, 1, 'again', 3))
} assert.equal(sent.at(-1)?.text, MSG.NEED_BIND)
try { assert.equal(followups.length, 0)
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
}
}) })
+20 -2
View File
@@ -2,7 +2,7 @@ import assert from 'node:assert/strict'
import test from 'node:test' import test from 'node:test'
import { TelegramClient } from '../src/client.ts' 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 calls: Array<{ url: string; body: unknown }> = []
const fetchImpl: typeof fetch = async (input, init) => { const fetchImpl: typeof fetch = async (input, init) => {
calls.push({ url: String(input), body: JSON.parse(String(init?.body)) }) 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.match(calls[0]!.url, /\/botSECRET-TOKEN\/getUpdates$/)
assert.deepEqual(calls[0]!.body, { assert.deepEqual(calls[0]!.body, {
timeout: 12, timeout: 12,
allowed_updates: ['message'], allowed_updates: ['message', 'callback_query'],
offset: 9, offset: 9,
}) })
}) })
@@ -33,3 +33,21 @@ test('errors redact token', async () => {
test('empty token throws in constructor', () => { test('empty token throws in constructor', () => {
assert.throws(() => new TelegramClient(''), /token/) assert.throws(() => new TelegramClient(''), /token/)
}) })
test('sendMessage can include reply_markup', async () => {
const calls: Array<{ body: Record<string, unknown> }> = []
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' }]],
})
})
+12 -7
View File
@@ -2,17 +2,22 @@ import assert from 'node:assert/strict'
import test from 'node:test' import test from 'node:test'
import { MSG, parseCommand } from '../src/commands.ts' 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('/start').type, 'start')
assert.equal(parseCommand('/help extra').type, 'help') assert.equal(parseCommand('/help').type, 'help')
assert.equal(parseCommand('/new').type, 'new') assert.equal(parseCommand('/sessions').type, 'sessions')
assert.equal(parseCommand('/clear').type, 'new') 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('/foo').type, 'unknown')
assert.equal(parseCommand('hello').type, 'plain') 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.DENIED.includes('权限') || MSG.DENIED.includes('授权'))
assert.ok(MSG.HELP.includes('/new')) assert.ok(MSG.HELP.includes('/sessions'))
assert.ok(MSG.WELCOME.length > 0) assert.ok(MSG.NEED_BIND.includes('/sessions'))
assert.ok(MSG.WELCOME.includes('Web') || MSG.WELCOME.includes('遥控器'))
}) })