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) · [中文](#中文)
**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
+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).
## 适用场景
- 已能本地运行 `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.
@@ -4,5 +4,3 @@
config:
token: ''
allowedUserIds: [123456789]
provider: deepseek-official
model: deepseek-v4-flash
+9 -7
View File
@@ -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",
+151 -81
View File
@@ -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<void>
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<void>
private readonly provider: string
private readonly model: string
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 offset: number | undefined
private pollPromise: Promise<void> | 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<void> {
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<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> {
const signal = this.pollAbort?.signal
let errorCount = 0
@@ -189,74 +311,22 @@ export class TelegramBridge {
}
private async onSessionEvent(session: SessionLike, event: SessionEvent): Promise<void> {
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<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> {
const chunks = splitMessage(markdown, this.maxMessageLength)
for (const chunk of chunks) {
+40 -3
View File
@@ -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<TelegramUser>
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>
answerCallbackQuery(callbackQueryId: string, text?: string): Promise<boolean>
}
export class TelegramClient implements TelegramClientLike {
@@ -87,7 +110,7 @@ export class TelegramClient implements TelegramClientLike {
async getUpdates(offset?: number): Promise<TelegramUpdate[]> {
const body: Record<string, unknown> = {
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<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 }
if (parseMode !== undefined) {
body.parse_mode = parseMode
}
if (replyMarkup !== undefined) {
body.reply_markup = replyMarkup
}
return this.call<TelegramMessage>('sendMessage', body)
}
async sendChatAction(chatId: number, action: string): Promise<boolean> {
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 = {
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:'
-9
View File
@@ -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<TelegramChannelConfig> = 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()
+150 -262
View File
@@ -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<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 () => {
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<typeof SessionId> }, event: unknown) => void) | undefined
let createCalled = false
const ctx = {
logger: { warn() {}, error() {} },
agents: fakeAgents(track),
on(_event: string, listener: (session: { id: ReturnType<typeof SessionId> }, 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<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 () => {}
},
}
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<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 () => {}
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, /<b>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<typeof SessionId> }, 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<typeof SessionId> }, 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)
})
+20 -2
View File
@@ -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<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 { 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('遥控器'))
})