fix: ship prebuilt lib and drop prepare for one-click git install
Avoids pnpm allowBuilds block on dsh plugin add; README install steps expanded for first-time users. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+1
-2
@@ -1,4 +1,3 @@
|
||||
node_modules/
|
||||
lib/
|
||||
node_modules/
|
||||
*.log
|
||||
.DS_Store
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](#english) · [中文](#中文)
|
||||
|
||||
**Keywords / 关键词:** Telegram · Bot · Mobile · Remote · Channel · Sessions · Bind · Unbind · Remove · Clear · DeepSeek Harness · DSH · Cordis · dsh-plugin · Agent · Live session · 手机 · 远程 · 遥控器 · 会话列表 · 附着 · 解绑 · 白名单
|
||||
**Keywords / 关键词:** Telegram · Bot · Mobile · Remote · Channel · Sessions · Bind · Unbind · Remove · Clear · DeepSeek Harness · DSH · Cordis · dsh-plugin · Agent · Live session · 手机 · 远程 · 遥控器 · 会话列表 · 附着 · 解绑 · 白名单 · 安装
|
||||
|
||||
DeepSeek Harness **手机遥控器**:用 Telegram Bot **附着本机正在运行的 Web 会话**(与 Web **同轨迹、双向可见**)。对标 Codex phone remote——本机会话是真相源,手机不另开平行对话。
|
||||
|
||||
@@ -10,90 +10,193 @@ DeepSeek Harness **手机遥控器**:用 Telegram Bot **附着本机正在运
|
||||
|
||||
## 中文
|
||||
|
||||
### 这是什么
|
||||
### 这是什么(30 秒看懂)
|
||||
|
||||
| | 旧「平行桥」 | **本插件(当前)** |
|
||||
1. 电脑上先用 `dsh web` 打开一个对话(保持开着)。
|
||||
2. 手机 Telegram 找你的 Bot,发 `/sessions`,点选那个对话。
|
||||
3. 之后手机发的话,会进**同一个**电脑会话;电脑上的回复,手机会同步看到。
|
||||
|
||||
| | 旧「平行桥」 | **本插件** |
|
||||
|---|---|---|
|
||||
| 会话 | Telegram 专用新建 agent | 附着 `dsh web` 里 **live** agent |
|
||||
| 会话 | Telegram 另开一个 agent | 附着电脑里 **正在跑的** 会话 |
|
||||
| Web | 不通 | 手机与 Web **同一条轨迹** |
|
||||
|
||||
流程:电脑上先开对话 → 手机 `/sessions` 选择 → 发消息进入该会话 → Web 也能看到。
|
||||
---
|
||||
|
||||
### 连接实操(约 10 分钟)
|
||||
### 安装前请准备(缺一不可)
|
||||
|
||||
1. 本机 `dsh web` 能跑,并 **先打开至少一个对话**(保持运行)。
|
||||
2. [@BotFather](https://t.me/BotFather) 建 Bot,拿 token;[@userinfobot](https://t.me/userinfobot) 拿自己的 user id。
|
||||
3. 安装:
|
||||
请用笔或备忘录记下三样东西:
|
||||
|
||||
| # | 准备什么 | 怎么拿 |
|
||||
|---|---|---|
|
||||
| 1 | 电脑已能打开 DeepSeek Harness 网页 | 终端运行 `dsh web`,浏览器能打开(常见 `http://127.0.0.1:3080`) |
|
||||
| 2 | Telegram **Bot Token** | 手机打开 Telegram → 搜 `@BotFather` → `/newbot` 按提示创建 → 复制一长串类似 `123456:AA...` 的 token |
|
||||
| 3 | 你的 Telegram **数字 User ID** | 搜 `@userinfobot` → 点 Start → 它会回复一串**纯数字**(不是 @用户名) |
|
||||
|
||||
> 安全提醒:Token 相当于钥匙,**不要发到公开群、不要提交到 GitHub**。若已泄露,去 BotFather 用 `/revoke` 作废并换新。
|
||||
|
||||
---
|
||||
|
||||
### 安装步骤(照着做即可)
|
||||
|
||||
下面假设你用 **Windows**,命令在 **PowerShell** 或 **CMD** 里执行。
|
||||
|
||||
#### 第 1 步:安装插件到 web 配置
|
||||
|
||||
打开终端,复制粘贴整行后回车:
|
||||
|
||||
```powershell
|
||||
dsh plugin --profile web add github:hi-wenw/dsh-telegram-channel
|
||||
```
|
||||
|
||||
或本地:
|
||||
**成功时**大致会看到安装完成、没有红色大段报错。
|
||||
|
||||
**如果报错出现 `allowBuilds` / `ERR_PNPM_GIT_DEP_PREPARE_NOT_ALLOWED`:**
|
||||
|
||||
1. 用记事本打开这个文件(没有就新建):
|
||||
`C:\Users\wenw\.dsh\profiles\web\pnpm-workspace.yaml`
|
||||
2. 在文件里加入(或合并)下面内容(路径以报错里打印的为准;若报错给了更长一行,用报错那一行):
|
||||
|
||||
```yaml
|
||||
packages:
|
||||
- .
|
||||
|
||||
nodeLinker: hoisted
|
||||
autoInstallPeers: false
|
||||
|
||||
allowBuilds:
|
||||
dsh-telegram-channel: true
|
||||
```
|
||||
|
||||
3. 保存文件,再重新执行第 1 步那条 `dsh plugin ... add ...` 命令。
|
||||
|
||||
> 说明:从 **v0.2.1** 起,仓库已自带编译好的 `lib/`,一般**不需要**再改 `allowBuilds`。若你装到的仍是旧版缓存,可先执行下面「第 1b 步」再装。
|
||||
|
||||
#### 第 1b 步(可选):清掉旧失败缓存后再装
|
||||
|
||||
若刚才失败过,可先:
|
||||
|
||||
```powershell
|
||||
dsh plugin --profile web remove dsh-telegram-channel
|
||||
```
|
||||
|
||||
然后再执行第 1 步。仍失败时,用本地目录安装(最稳):
|
||||
|
||||
```powershell
|
||||
git clone https://github.com/hi-wenw/dsh-telegram-channel.git D:\gitData\dsh-telegram-channel
|
||||
dsh plugin --profile web add D:\gitData\dsh-telegram-channel
|
||||
```
|
||||
|
||||
4. Token(推荐环境变量):
|
||||
#### 第 2 步:设置 Bot Token(推荐环境变量)
|
||||
|
||||
**当前 PowerShell 窗口临时生效**(关窗口会丢,适合先试通):
|
||||
|
||||
```powershell
|
||||
$env:DSH_TELEGRAM_TOKEN = '123456:ABC...'
|
||||
$env:DSH_TELEGRAM_TOKEN = "这里粘贴你的BotFather_Token"
|
||||
```
|
||||
|
||||
5. 白名单(换成你的 id):
|
||||
**想长期生效(用户级环境变量)**:
|
||||
|
||||
1. Win 键搜索「编辑系统环境变量」→「环境变量」。
|
||||
2. 在「用户变量」点「新建」:
|
||||
- 变量名:`DSH_TELEGRAM_TOKEN`
|
||||
- 变量值:粘贴 BotFather 给你的 token
|
||||
3. 确定保存后,**关掉所有旧终端**,新开一个终端。
|
||||
|
||||
#### 第 3 步:写入白名单(只允许你自己用)
|
||||
|
||||
打开(没有就新建)profile 补丁文件,常见路径:
|
||||
|
||||
`C:\Users\wenw\.dsh\profiles\web\cordis.patch.yml`
|
||||
|
||||
写入下面内容,把 `123456789` 换成你的 **数字 User ID**:
|
||||
|
||||
```yaml
|
||||
- insert:
|
||||
- id: dsh-telegram-channel
|
||||
name: dsh-telegram-channel
|
||||
config:
|
||||
token: ''
|
||||
token: ""
|
||||
allowedUserIds: [123456789]
|
||||
```
|
||||
|
||||
6. **重启 / 重载** profile。
|
||||
7. 手机 Telegram:
|
||||
- `/start`
|
||||
- `/sessions` → 点选本机会话
|
||||
- 发一句话 → Web 与手机构应出现同一轮问答
|
||||
- `/unbind` 仅断开手机绑定,不关本机会话
|
||||
注意:
|
||||
|
||||
### 命令
|
||||
- `allowedUserIds` 里必须是**数字**,两边是方括号。
|
||||
- `token: ""` 表示继续用环境变量里的 `DSH_TELEGRAM_TOKEN`(推荐)。
|
||||
- 若插件安装时已经自动插入过同名行,不要重复两份;在已有行上改 `allowedUserIds` 即可。
|
||||
|
||||
| 命令 | 行为 |
|
||||
也可用示例文件对照:仓库里的 `examples/telegram-agent/cordis.patch.example.yml`。
|
||||
|
||||
#### 第 4 步:启动(或重启)DSH Web
|
||||
|
||||
**重要:改完环境变量 / 补丁后,必须重新开一次 profile。**
|
||||
|
||||
```powershell
|
||||
dsh web
|
||||
```
|
||||
|
||||
等终端没有报错,浏览器能打开 Harness 页面。
|
||||
|
||||
#### 第 5 步:电脑上先开一个对话
|
||||
|
||||
在 Web 页面里 **新建或打开一个会话**,随便让它处于可用状态(这就是手机要遥控的「本机会话」)。
|
||||
|
||||
> 如果 Web 里一个会话都没有,手机发 `/sessions` 会提示「当前没有正在运行的本机会话」。
|
||||
|
||||
#### 第 6 步:手机连上 Bot
|
||||
|
||||
1. 打开 Telegram,搜索你创建的 Bot(BotFather 给你的名字)。
|
||||
2. 点 **Start** / 发送 `/start`。
|
||||
3. 发送 `/sessions`。
|
||||
4. 点按钮,选择电脑上那个会话(「已附着…」)。
|
||||
5. 发一句普通话,例如:`你好,请回复收到`。
|
||||
6. 看手机是否收到回复,并回到电脑 Web:**应是同一条对话轨迹**。
|
||||
|
||||
常用命令:
|
||||
|
||||
| 命令 | 作用 |
|
||||
|---|---|
|
||||
| `/sessions` `/list` | 列出 live 会话,内联键盘选择附着 |
|
||||
| `/status` | 当前绑定 |
|
||||
| `/unbind` `/disconnect` | 解绑(不 dispose 本机 agent) |
|
||||
| `/help` `/start` | 帮助 / 欢迎 |
|
||||
| 普通文字 | 进入已绑定会话的 `followup` |
|
||||
| `/sessions` 或 `/list` | 列出本机 live 会话并选择附着 |
|
||||
| `/status` | 看当前绑了谁 |
|
||||
| `/unbind` 或 `/disconnect` | 断开手机绑定(**不关闭**电脑会话) |
|
||||
| `/help` | 帮助 |
|
||||
|
||||
### 配置
|
||||
---
|
||||
|
||||
### 配置项一览
|
||||
|
||||
| 键 | 默认 | 含义 |
|
||||
|---|---|---|
|
||||
| `token` | 空 → `DSH_TELEGRAM_TOKEN` | Bot token |
|
||||
| `allowedUserIds` | `[]` | 白名单;空 = 全拒 |
|
||||
| `allowAllUsers` | `false` | 调试用,生产勿开 |
|
||||
| `maxMessageLength` | `4096` | 分片长度 |
|
||||
| `pollingTimeoutSec` | `30` | 长轮询超时 |
|
||||
| `token` | 空 → 读 `DSH_TELEGRAM_TOKEN` | Bot token |
|
||||
| `allowedUserIds` | `[]` | 白名单;空 = 谁都不能用 |
|
||||
| `allowAllUsers` | `false` | `true` 时谁都能聊(仅调试) |
|
||||
| `maxMessageLength` | `4096` | 单条消息分片长度 |
|
||||
| `pollingTimeoutSec` | `30` | 长轮询超时(秒) |
|
||||
|
||||
### 故障排查
|
||||
---
|
||||
|
||||
| 现象 | 处理 |
|
||||
### 故障排查(对照表)
|
||||
|
||||
| 你看到的现象 | 怎么处理 |
|
||||
|---|---|
|
||||
| `/sessions` 提示没有会话 | 先在 Web 打开对话并保持 profile 运行 |
|
||||
| `allowBuilds` / `ERR_PNPM_GIT_DEP_PREPARE_NOT_ALLOWED` | 见上文「第 1 步」补 `pnpm-workspace.yaml`,或改用本地目录安装;并确认装的是 **v0.2.1+** |
|
||||
| 日志 `missing bot token`;Bot 完全不说话 | 检查 `DSH_TELEGRAM_TOKEN` 是否设置;**新开终端**再 `dsh web` |
|
||||
| 回复「无权限。」 | `allowedUserIds` 是否写错;必须是 @userinfobot 给的数字 ID |
|
||||
| `/sessions` 说没有会话 | 先在 Web 打开对话并保持 `dsh web` 运行 |
|
||||
| 「尚未绑定」 | 先 `/sessions` 点选 |
|
||||
| 「会话已不在本机运行」 | Web 已关该会话;重新开再绑定 |
|
||||
| 「无权限」 | 检查 `allowedUserIds` |
|
||||
| missing bot token | 设 `DSH_TELEGRAM_TOKEN` 后重载 |
|
||||
| 「会话已不在本机运行」 | Web 已关掉该会话;重新开再 `/sessions` |
|
||||
| Telegram 401 | Token 错了或被 revoke;去 BotFather 换新 |
|
||||
|
||||
---
|
||||
|
||||
### 安全
|
||||
|
||||
Fail-closed 白名单;token 走环境变量;日志脱敏;解绑不关闭本机 agent。
|
||||
- 白名单默认 **谁都不允许**,必须填你的 ID。
|
||||
- Token 优先放环境变量,不要写进 Git。
|
||||
- `/unbind` 只断开手机,不会结束电脑上的 agent。
|
||||
|
||||
### 贡献者
|
||||
### 贡献者开发
|
||||
|
||||
```powershell
|
||||
git clone https://github.com/hi-wenw/dsh-telegram-channel.git
|
||||
@@ -113,31 +216,36 @@ npm run build
|
||||
|
||||
### What this is
|
||||
|
||||
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.
|
||||
Telegram **mobile remote** for **live** DeepSeek Harness Web agents in the same `dsh web` process. Desktop/Web is the source of truth (Codex-style). Telegram **attaches**; it does not create a parallel hidden agent.
|
||||
|
||||
### Connect walkthrough
|
||||
### Install (Windows)
|
||||
|
||||
1. Run `dsh web` and **open at least one conversation**.
|
||||
2. Create a Bot (BotFather) + note your user id.
|
||||
3. Install:
|
||||
1. Prerequisites: working `dsh web`, BotFather **token**, numeric Telegram **user id** (`@userinfobot`).
|
||||
2. Install:
|
||||
|
||||
```powershell
|
||||
dsh plugin --profile web add github:hi-wenw/dsh-telegram-channel
|
||||
```
|
||||
|
||||
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.
|
||||
If pnpm prints `allowBuilds` / `ERR_PNPM_GIT_DEP_PREPARE_NOT_ALLOWED`, add under `C:\Users\<you>\.dsh\profiles\web\pnpm-workspace.yaml`:
|
||||
|
||||
### Commands
|
||||
```yaml
|
||||
allowBuilds:
|
||||
dsh-telegram-channel: true
|
||||
```
|
||||
|
||||
| Command | Action |
|
||||
|---|---|
|
||||
| `/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 |
|
||||
Then re-run the install command. **v0.2.1+** ships prebuilt `lib/` so this is usually unnecessary.
|
||||
|
||||
Fallback (most reliable):
|
||||
|
||||
```powershell
|
||||
git clone https://github.com/hi-wenw/dsh-telegram-channel.git
|
||||
dsh plugin --profile web add <absolute-path-to-clone>
|
||||
```
|
||||
|
||||
3. Set token: `$env:DSH_TELEGRAM_TOKEN = "..."`.
|
||||
4. Patch `allowedUserIds` in `~\.dsh\profiles\web\cordis.patch.yml` (see example above).
|
||||
5. Restart `dsh web`, open a Web conversation, then on phone: `/sessions` → bind → chat.
|
||||
|
||||
### License
|
||||
|
||||
|
||||
Vendored
+5
@@ -0,0 +1,5 @@
|
||||
export declare function isAuthorized(opts: {
|
||||
allowAllUsers: boolean;
|
||||
allowedUserIds: number[];
|
||||
userId?: number;
|
||||
}): boolean;
|
||||
@@ -0,0 +1,7 @@
|
||||
export function isAuthorized(opts) {
|
||||
if (opts.allowAllUsers)
|
||||
return true;
|
||||
if (opts.userId === undefined)
|
||||
return false;
|
||||
return opts.allowedUserIds.includes(opts.userId);
|
||||
}
|
||||
Vendored
+42
@@ -0,0 +1,42 @@
|
||||
import type { Context } from '@deepseek-ai/cordis';
|
||||
import { type TelegramClientLike, type TelegramUpdate } from './client.js';
|
||||
export interface TelegramBridgeOptions {
|
||||
token: string;
|
||||
allowedUserIds: number[];
|
||||
allowAllUsers: boolean;
|
||||
client?: TelegramClientLike;
|
||||
sleep?: (ms: number) => Promise<void>;
|
||||
maxMessageLength?: number;
|
||||
pollingTimeoutSec?: number;
|
||||
}
|
||||
export declare class TelegramBridge {
|
||||
private readonly ctx;
|
||||
private readonly token;
|
||||
private readonly allowedUserIds;
|
||||
private readonly allowAllUsers;
|
||||
private readonly client;
|
||||
private readonly sleep;
|
||||
private readonly maxMessageLength;
|
||||
private readonly bindings;
|
||||
private polling;
|
||||
private offset;
|
||||
private pollPromise;
|
||||
private pollAbort;
|
||||
private disposeSessionListener;
|
||||
constructor(ctx: Context, options: TelegramBridgeOptions);
|
||||
start(): void;
|
||||
stop(): Promise<void>;
|
||||
processUpdate(update: TelegramUpdate): Promise<void>;
|
||||
private handleCallback;
|
||||
private liveAgents;
|
||||
private findLiveAgent;
|
||||
private sendSessionPicker;
|
||||
private sendStatus;
|
||||
private followupBound;
|
||||
private pollLoop;
|
||||
private interruptibleDelay;
|
||||
private interruptibleSleep;
|
||||
private onSessionEvent;
|
||||
private deliver;
|
||||
private redact;
|
||||
}
|
||||
+305
@@ -0,0 +1,305 @@
|
||||
import { createUserMessage } from '@deepseek-ai/dsh-llm/message';
|
||||
import { SessionId } from '@deepseek-ai/dsh-session/types';
|
||||
import { isAuthorized } from './auth.js';
|
||||
import { TelegramClient, } from './client.js';
|
||||
import { BIND_CB_PREFIX, MSG, parseCommand } from './commands.js';
|
||||
import { markdownToHtml, splitMessage } from './format.js';
|
||||
const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
function contentToText(content) {
|
||||
return content
|
||||
.filter((block) => block.type === 'text')
|
||||
.map((block) => block.text)
|
||||
.join('');
|
||||
}
|
||||
function shortLabel(agent, index) {
|
||||
const id = String(agent.id);
|
||||
const tail = id.length > 12 ? id.slice(-12) : id;
|
||||
const cwd = agent.session?.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 {
|
||||
ctx;
|
||||
token;
|
||||
allowedUserIds;
|
||||
allowAllUsers;
|
||||
client;
|
||||
sleep;
|
||||
maxMessageLength;
|
||||
bindings = new Map();
|
||||
polling = false;
|
||||
offset;
|
||||
pollPromise;
|
||||
pollAbort;
|
||||
disposeSessionListener;
|
||||
constructor(ctx, options) {
|
||||
this.ctx = ctx;
|
||||
this.token = options.token;
|
||||
this.allowedUserIds = options.allowedUserIds;
|
||||
this.allowAllUsers = options.allowAllUsers;
|
||||
this.client = options.client ?? new TelegramClient(options.token, {
|
||||
pollingTimeoutSec: options.pollingTimeoutSec ?? 30,
|
||||
});
|
||||
this.sleep = options.sleep ?? defaultSleep;
|
||||
this.maxMessageLength = options.maxMessageLength ?? 4096;
|
||||
}
|
||||
start() {
|
||||
this.disposeSessionListener?.();
|
||||
this.disposeSessionListener = this.ctx.on('session/event', (session, event) => {
|
||||
void this.onSessionEvent(session, event).catch((err) => {
|
||||
this.ctx.logger.error(this.redact(err));
|
||||
});
|
||||
});
|
||||
if (!this.polling) {
|
||||
this.polling = true;
|
||||
this.pollAbort = new AbortController();
|
||||
this.pollPromise = this.pollLoop();
|
||||
}
|
||||
}
|
||||
async stop() {
|
||||
this.polling = false;
|
||||
this.pollAbort?.abort();
|
||||
this.pollAbort = undefined;
|
||||
this.disposeSessionListener?.();
|
||||
this.disposeSessionListener = undefined;
|
||||
// Never dispose host agents — only clear remote bindings.
|
||||
this.bindings.clear();
|
||||
if (this.pollPromise) {
|
||||
await this.pollPromise.catch(() => { });
|
||||
this.pollPromise = undefined;
|
||||
}
|
||||
}
|
||||
async processUpdate(update) {
|
||||
if (update.callback_query) {
|
||||
await this.handleCallback(update);
|
||||
return;
|
||||
}
|
||||
const message = update.message;
|
||||
if (!message?.text)
|
||||
return;
|
||||
const chatId = message.chat.id;
|
||||
const userId = message.from?.id;
|
||||
if (!isAuthorized({ allowAllUsers: this.allowAllUsers, allowedUserIds: this.allowedUserIds, userId })) {
|
||||
await this.client.sendMessage(chatId, MSG.DENIED);
|
||||
return;
|
||||
}
|
||||
const parsed = parseCommand(message.text);
|
||||
switch (parsed.type) {
|
||||
case 'start':
|
||||
await this.client.sendMessage(chatId, MSG.WELCOME);
|
||||
return;
|
||||
case 'help':
|
||||
await this.client.sendMessage(chatId, MSG.HELP);
|
||||
return;
|
||||
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.followupBound(chatId, parsed.text);
|
||||
return;
|
||||
}
|
||||
}
|
||||
async handleCallback(update) {
|
||||
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));
|
||||
}
|
||||
liveAgents() {
|
||||
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 [];
|
||||
}
|
||||
findLiveAgent(sessionId) {
|
||||
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);
|
||||
}
|
||||
async sendSessionPicker(chatId) {
|
||||
const agents = this.liveAgents();
|
||||
if (agents.length === 0) {
|
||||
await this.client.sendMessage(chatId, MSG.NO_LIVE);
|
||||
return;
|
||||
}
|
||||
const keyboard = {
|
||||
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);
|
||||
}
|
||||
async sendStatus(chatId) {
|
||||
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));
|
||||
}
|
||||
async followupBound(chatId, text) {
|
||||
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);
|
||||
}
|
||||
async pollLoop() {
|
||||
const signal = this.pollAbort?.signal;
|
||||
let errorCount = 0;
|
||||
while (this.polling) {
|
||||
try {
|
||||
const updates = await this.client.getUpdates(this.offset);
|
||||
if (!this.polling)
|
||||
break;
|
||||
errorCount = 0;
|
||||
if (updates.length === 0) {
|
||||
await this.interruptibleDelay(50, signal);
|
||||
continue;
|
||||
}
|
||||
for (const update of updates) {
|
||||
if (!this.polling)
|
||||
break;
|
||||
await this.processUpdate(update);
|
||||
this.offset = update.update_id + 1;
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
if (!this.polling)
|
||||
break;
|
||||
errorCount += 1;
|
||||
this.ctx.logger.error(this.redact(err));
|
||||
await this.interruptibleSleep(Math.min(1000 * errorCount, 10_000), signal);
|
||||
}
|
||||
}
|
||||
}
|
||||
interruptibleDelay(ms, signal) {
|
||||
if (signal?.aborted || !this.polling)
|
||||
return Promise.resolve();
|
||||
return new Promise((resolve) => {
|
||||
const timer = setTimeout(resolve, ms);
|
||||
signal?.addEventListener('abort', () => {
|
||||
clearTimeout(timer);
|
||||
resolve();
|
||||
}, { once: true });
|
||||
});
|
||||
}
|
||||
async interruptibleSleep(ms, signal) {
|
||||
if (signal?.aborted || !this.polling)
|
||||
return;
|
||||
await Promise.race([
|
||||
this.sleep(ms),
|
||||
new Promise((resolve) => {
|
||||
if (signal?.aborted) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
signal?.addEventListener('abort', () => resolve(), { once: true });
|
||||
}),
|
||||
]);
|
||||
}
|
||||
async onSessionEvent(session, event) {
|
||||
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 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)
|
||||
return;
|
||||
await Promise.all(targets.map((b) => this.deliver(b.chatId, text)));
|
||||
}
|
||||
}
|
||||
async deliver(chatId, markdown) {
|
||||
const chunks = splitMessage(markdown, this.maxMessageLength);
|
||||
for (const chunk of chunks) {
|
||||
const html = markdownToHtml(chunk);
|
||||
try {
|
||||
await this.client.sendMessage(chatId, html, 'HTML');
|
||||
}
|
||||
catch {
|
||||
try {
|
||||
await this.client.sendMessage(chatId, chunk);
|
||||
}
|
||||
catch (err) {
|
||||
this.ctx.logger.error(this.redact(err));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
redact(value) {
|
||||
const message = value instanceof Error ? value.message : String(value);
|
||||
return message.split(this.token).join('***');
|
||||
}
|
||||
}
|
||||
Vendored
+63
@@ -0,0 +1,63 @@
|
||||
export interface TelegramUser {
|
||||
id: number;
|
||||
is_bot?: boolean;
|
||||
first_name?: string;
|
||||
username?: string;
|
||||
}
|
||||
export interface TelegramChat {
|
||||
id: number;
|
||||
type: string;
|
||||
title?: string;
|
||||
username?: string;
|
||||
}
|
||||
export interface TelegramMessage {
|
||||
message_id: number;
|
||||
date: number;
|
||||
chat: TelegramChat;
|
||||
from?: TelegramUser;
|
||||
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 {
|
||||
fetch?: typeof fetch;
|
||||
baseUrl?: string;
|
||||
pollingTimeoutSec?: number;
|
||||
}
|
||||
export interface TelegramClientLike {
|
||||
getMe(): Promise<TelegramUser>;
|
||||
getUpdates(offset?: number): Promise<TelegramUpdate[]>;
|
||||
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 declare class TelegramClient implements TelegramClientLike {
|
||||
private readonly token;
|
||||
private readonly fetchImpl;
|
||||
private readonly baseUrl;
|
||||
private readonly pollingTimeoutSec;
|
||||
constructor(token: string, options?: TelegramClientOptions);
|
||||
private redact;
|
||||
private call;
|
||||
getMe(): Promise<TelegramUser>;
|
||||
getUpdates(offset?: number): Promise<TelegramUpdate[]>;
|
||||
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>;
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
export class TelegramClient {
|
||||
token;
|
||||
fetchImpl;
|
||||
baseUrl;
|
||||
pollingTimeoutSec;
|
||||
constructor(token, options = {}) {
|
||||
if (!token) {
|
||||
throw new Error('bot token is required');
|
||||
}
|
||||
this.token = token;
|
||||
this.fetchImpl = options.fetch ?? fetch;
|
||||
this.baseUrl = options.baseUrl ?? 'https://api.telegram.org';
|
||||
this.pollingTimeoutSec = options.pollingTimeoutSec ?? 30;
|
||||
}
|
||||
redact(message) {
|
||||
return message.split(this.token).join('***');
|
||||
}
|
||||
async call(method, body) {
|
||||
const url = `${this.baseUrl}/bot${this.token}/${method}`;
|
||||
try {
|
||||
const init = {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
};
|
||||
if (body !== undefined) {
|
||||
init.body = JSON.stringify(body);
|
||||
}
|
||||
const response = await this.fetchImpl(url, init);
|
||||
const json = (await response.json());
|
||||
if (!json.ok) {
|
||||
throw new Error(json.description ?? 'Telegram API error');
|
||||
}
|
||||
return json.result;
|
||||
}
|
||||
catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
throw new Error(this.redact(message));
|
||||
}
|
||||
}
|
||||
async getMe() {
|
||||
return this.call('getMe');
|
||||
}
|
||||
async getUpdates(offset) {
|
||||
const body = {
|
||||
timeout: this.pollingTimeoutSec,
|
||||
allowed_updates: ['message', 'callback_query'],
|
||||
};
|
||||
if (offset !== undefined) {
|
||||
body.offset = offset;
|
||||
}
|
||||
return this.call('getUpdates', body);
|
||||
}
|
||||
async sendMessage(chatId, text, parseMode, replyMarkup) {
|
||||
const body = { chat_id: chatId, text };
|
||||
if (parseMode !== undefined) {
|
||||
body.parse_mode = parseMode;
|
||||
}
|
||||
if (replyMarkup !== undefined) {
|
||||
body.reply_markup = replyMarkup;
|
||||
}
|
||||
return this.call('sendMessage', body);
|
||||
}
|
||||
async sendChatAction(chatId, action) {
|
||||
return this.call('sendChatAction', { chat_id: chatId, action });
|
||||
}
|
||||
async answerCallbackQuery(callbackQueryId, text) {
|
||||
const body = { callback_query_id: callbackQueryId };
|
||||
if (text !== undefined)
|
||||
body.text = text;
|
||||
return this.call('answerCallbackQuery', body);
|
||||
}
|
||||
}
|
||||
Vendored
+39
@@ -0,0 +1,39 @@
|
||||
export declare const MSG: {
|
||||
readonly DENIED: "无权限。";
|
||||
readonly WELCOME: string;
|
||||
readonly HELP: string;
|
||||
readonly NEED_BIND: "尚未绑定本机会话。请先发送 /sessions 选择一个。";
|
||||
readonly NO_LIVE: "当前没有正在运行的本机会话。请先在 Web(dsh web)打开或继续一个对话,再发 /sessions。";
|
||||
readonly BOUND: (label: string) => string;
|
||||
readonly UNBOUND: "已断开绑定。本机会话仍在运行。";
|
||||
readonly STATUS_NONE: "当前未绑定任何本机会话。发送 /sessions 选择。";
|
||||
readonly STATUS_BOUND: (label: string) => string;
|
||||
readonly GONE: "绑定的会话已不在本机运行(可能已在 Web 关闭)。请重新 /sessions。";
|
||||
readonly unknown: (command: string) => string;
|
||||
};
|
||||
export type ParsedCommand = {
|
||||
type: 'start';
|
||||
text: string;
|
||||
} | {
|
||||
type: 'help';
|
||||
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 declare function parseCommand(text: string): ParsedCommand;
|
||||
/** Callback data prefix for session bind buttons. */
|
||||
export declare const BIND_CB_PREFIX = "bind:";
|
||||
@@ -0,0 +1,55 @@
|
||||
export const MSG = {
|
||||
DENIED: '无权限。',
|
||||
WELCOME: [
|
||||
'你好,这是 DeepSeek Harness 的手机遥控器。',
|
||||
'本机会话是真相源:请先在 Web 打开对话,再用 /sessions 选择并附着。',
|
||||
'发送 /help 查看命令。',
|
||||
].join('\n'),
|
||||
HELP: [
|
||||
'/sessions — 列出本机正在运行的会话并选择附着',
|
||||
'/status — 查看当前绑定',
|
||||
'/unbind — 断开绑定(不关闭本机会话)',
|
||||
'/help — 显示帮助',
|
||||
'',
|
||||
'绑定后直接发文字,消息会进入该本机会话;Web 与手机看到同一条轨迹。',
|
||||
'仅白名单用户可用。无 live 会话时请先在 dsh web 开对话。',
|
||||
].join('\n'),
|
||||
NEED_BIND: '尚未绑定本机会话。请先发送 /sessions 选择一个。',
|
||||
NO_LIVE: '当前没有正在运行的本机会话。请先在 Web(dsh web)打开或继续一个对话,再发 /sessions。',
|
||||
BOUND(label) {
|
||||
return `已附着本机会话:${label}\n此后消息将进入该会话(与 Web 同轨迹)。`;
|
||||
},
|
||||
UNBOUND: '已断开绑定。本机会话仍在运行。',
|
||||
STATUS_NONE: '当前未绑定任何本机会话。发送 /sessions 选择。',
|
||||
STATUS_BOUND(label) {
|
||||
return `当前绑定:${label}`;
|
||||
},
|
||||
GONE: '绑定的会话已不在本机运行(可能已在 Web 关闭)。请重新 /sessions。',
|
||||
unknown(command) {
|
||||
return `未知命令 ${command}。发送 /help 查看可用命令。`;
|
||||
},
|
||||
};
|
||||
export function parseCommand(text) {
|
||||
if (!text.startsWith('/'))
|
||||
return { type: 'plain', 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 '/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:';
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
export declare function escapeHtml(text: string): string;
|
||||
export declare function markdownToHtml(text: string): string;
|
||||
export declare function splitMessage(text: string, maxLength: number): string[];
|
||||
@@ -0,0 +1,50 @@
|
||||
export function escapeHtml(text) {
|
||||
return text
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
function inlineToHtml(text) {
|
||||
let html = escapeHtml(text);
|
||||
html = html.replace(/`([^`\n]+)`/g, '<code>$1</code>');
|
||||
html = html.replace(/\*\*([^*\n]+)\*\*/g, '<b>$1</b>');
|
||||
return html;
|
||||
}
|
||||
export function markdownToHtml(text) {
|
||||
const parts = text.split(/```[\w-]*/);
|
||||
if (parts.length % 2 === 0)
|
||||
return inlineToHtml(text);
|
||||
let html = '';
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
if (i % 2 === 1) {
|
||||
const code = parts[i].replace(/^\n/, '').replace(/\n$/, '');
|
||||
html += `<pre>${escapeHtml(code)}</pre>`;
|
||||
}
|
||||
else {
|
||||
html += inlineToHtml(parts[i]);
|
||||
}
|
||||
}
|
||||
return html;
|
||||
}
|
||||
export function splitMessage(text, maxLength) {
|
||||
if (maxLength < 1)
|
||||
throw new Error('maxLength must be >= 1');
|
||||
if (text.length <= maxLength)
|
||||
return [text];
|
||||
const chunks = [];
|
||||
let rest = text;
|
||||
while (rest.length > maxLength) {
|
||||
const window = rest.slice(0, maxLength);
|
||||
const newline = window.lastIndexOf('\n');
|
||||
const ideographic = window.lastIndexOf('。');
|
||||
const sentence = window.lastIndexOf('. ');
|
||||
const breakAt = Math.max(newline, ideographic, sentence);
|
||||
const cut = breakAt > 0 ? (breakAt === sentence ? breakAt + 2 : breakAt + 1) : maxLength;
|
||||
chunks.push(rest.slice(0, cut));
|
||||
rest = rest.slice(cut);
|
||||
}
|
||||
chunks.push(rest);
|
||||
return chunks;
|
||||
}
|
||||
Vendored
+18
@@ -0,0 +1,18 @@
|
||||
import type { Context } from '@deepseek-ai/cordis';
|
||||
import Schema from '@deepseek-ai/schemastery';
|
||||
export declare const name = "dsh-telegram-channel";
|
||||
export declare const inject: string[];
|
||||
export interface TelegramChannelConfig {
|
||||
token?: string;
|
||||
allowedUserIds?: number[];
|
||||
allowAllUsers?: boolean;
|
||||
maxMessageLength?: number;
|
||||
pollingTimeoutSec?: number;
|
||||
}
|
||||
export declare const Config: Schema<TelegramChannelConfig>;
|
||||
export declare function apply(ctx: Context, config: TelegramChannelConfig): void;
|
||||
export * from './format.js';
|
||||
export * from './client.js';
|
||||
export * from './auth.js';
|
||||
export * from './commands.js';
|
||||
export * from './bridge.js';
|
||||
@@ -0,0 +1,36 @@
|
||||
import Schema from '@deepseek-ai/schemastery';
|
||||
import { TelegramBridge } from './bridge.js';
|
||||
export const name = 'dsh-telegram-channel';
|
||||
export const inject = ['agents'];
|
||||
export const Config = Schema.object({
|
||||
token: Schema.string().default(''),
|
||||
allowedUserIds: Schema.array(Schema.number()).default([]),
|
||||
allowAllUsers: Schema.boolean().default(false),
|
||||
maxMessageLength: Schema.number().default(4096),
|
||||
pollingTimeoutSec: Schema.number().default(30),
|
||||
});
|
||||
export function apply(ctx, config) {
|
||||
const token = (config.token && config.token.length > 0)
|
||||
? config.token
|
||||
: (process.env.DSH_TELEGRAM_TOKEN ?? '');
|
||||
if (!token) {
|
||||
ctx.logger.error('dsh-telegram-channel: missing bot token (set config.token or DSH_TELEGRAM_TOKEN); polling not started');
|
||||
return;
|
||||
}
|
||||
const bridge = new TelegramBridge(ctx, {
|
||||
token,
|
||||
allowedUserIds: config.allowedUserIds ?? [],
|
||||
allowAllUsers: config.allowAllUsers ?? false,
|
||||
maxMessageLength: config.maxMessageLength ?? 4096,
|
||||
pollingTimeoutSec: config.pollingTimeoutSec ?? 30,
|
||||
});
|
||||
ctx.effect(() => {
|
||||
bridge.start();
|
||||
return () => { void bridge.stop(); };
|
||||
}, 'dsh-telegram-channel.serve');
|
||||
}
|
||||
export * from './format.js';
|
||||
export * from './client.js';
|
||||
export * from './auth.js';
|
||||
export * from './commands.js';
|
||||
export * from './bridge.js';
|
||||
+8
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "dsh-telegram-channel",
|
||||
"version": "0.2.0",
|
||||
"version": "0.2.1",
|
||||
"description": "DeepSeek Harness Telegram mobile remote: bind live Web sessions (Codex-style), same trajectory, /sessions picker, unbind/clear — dsh-plugin",
|
||||
"keywords": [
|
||||
"telegram",
|
||||
@@ -39,7 +39,12 @@
|
||||
},
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": ["lib", "cordis.patch.yml", "README.md", "LICENSE"],
|
||||
"files": [
|
||||
"lib",
|
||||
"cordis.patch.yml",
|
||||
"README.md",
|
||||
"LICENSE"
|
||||
],
|
||||
"license": "MIT",
|
||||
"dsh": {
|
||||
"bundle": {
|
||||
@@ -50,7 +55,7 @@
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"test": "node --import tsx --test tests/**/*.test.ts",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||
"prepare": "npm run build"
|
||||
"prepublishOnly": "npm run build"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/cordis": "^4.0.1",
|
||||
|
||||
Reference in New Issue
Block a user