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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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,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
@@ -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
@@ -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
@@ -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('遥控器'))
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user