From 8ef16228222058e955662981301482ad9711f6bd Mon Sep 17 00:00:00 2001 From: owo <17620121969@163.com> Date: Fri, 14 Aug 2026 17:16:25 +0800 Subject: [PATCH] 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 --- .gitignore | 3 +- README.md | 222 ++++++++++++++++++++++++--------- lib/auth.d.ts | 5 + lib/auth.js | 7 ++ lib/bridge.d.ts | 42 +++++++ lib/bridge.js | 305 ++++++++++++++++++++++++++++++++++++++++++++++ lib/client.d.ts | 63 ++++++++++ lib/client.js | 72 +++++++++++ lib/commands.d.ts | 39 ++++++ lib/commands.js | 55 +++++++++ lib/format.d.ts | 3 + lib/format.js | 50 ++++++++ lib/index.d.ts | 18 +++ lib/index.js | 36 ++++++ package.json | 11 +- 15 files changed, 869 insertions(+), 62 deletions(-) create mode 100644 lib/auth.d.ts create mode 100644 lib/auth.js create mode 100644 lib/bridge.d.ts create mode 100644 lib/bridge.js create mode 100644 lib/client.d.ts create mode 100644 lib/client.js create mode 100644 lib/commands.d.ts create mode 100644 lib/commands.js create mode 100644 lib/format.d.ts create mode 100644 lib/format.js create mode 100644 lib/index.d.ts create mode 100644 lib/index.js diff --git a/.gitignore b/.gitignore index d6bb485..5f0d123 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,3 @@ -node_modules/ -lib/ +node_modules/ *.log .DS_Store diff --git a/README.md b/README.md index 324799b..fdf7c2f 100644 --- a/README.md +++ b/README.md @@ -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\\.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 +``` + +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 diff --git a/lib/auth.d.ts b/lib/auth.d.ts new file mode 100644 index 0000000..f040ccc --- /dev/null +++ b/lib/auth.d.ts @@ -0,0 +1,5 @@ +export declare function isAuthorized(opts: { + allowAllUsers: boolean; + allowedUserIds: number[]; + userId?: number; +}): boolean; diff --git a/lib/auth.js b/lib/auth.js new file mode 100644 index 0000000..368953a --- /dev/null +++ b/lib/auth.js @@ -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); +} diff --git a/lib/bridge.d.ts b/lib/bridge.d.ts new file mode 100644 index 0000000..4e8aa51 --- /dev/null +++ b/lib/bridge.d.ts @@ -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; + 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; + processUpdate(update: TelegramUpdate): Promise; + private handleCallback; + private liveAgents; + private findLiveAgent; + private sendSessionPicker; + private sendStatus; + private followupBound; + private pollLoop; + private interruptibleDelay; + private interruptibleSleep; + private onSessionEvent; + private deliver; + private redact; +} diff --git a/lib/bridge.js b/lib/bridge.js new file mode 100644 index 0000000..707ded7 --- /dev/null +++ b/lib/bridge.js @@ -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('***'); + } +} diff --git a/lib/client.d.ts b/lib/client.d.ts new file mode 100644 index 0000000..72e13a9 --- /dev/null +++ b/lib/client.d.ts @@ -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; + getUpdates(offset?: number): Promise; + sendMessage(chatId: number, text: string, parseMode?: string, replyMarkup?: InlineKeyboardMarkup): Promise; + sendChatAction(chatId: number, action: string): Promise; + answerCallbackQuery(callbackQueryId: string, text?: string): Promise; +} +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; + getUpdates(offset?: number): Promise; + sendMessage(chatId: number, text: string, parseMode?: string, replyMarkup?: InlineKeyboardMarkup): Promise; + sendChatAction(chatId: number, action: string): Promise; + answerCallbackQuery(callbackQueryId: string, text?: string): Promise; +} diff --git a/lib/client.js b/lib/client.js new file mode 100644 index 0000000..4b3af29 --- /dev/null +++ b/lib/client.js @@ -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); + } +} diff --git a/lib/commands.d.ts b/lib/commands.d.ts new file mode 100644 index 0000000..3d93faf --- /dev/null +++ b/lib/commands.d.ts @@ -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:"; diff --git a/lib/commands.js b/lib/commands.js new file mode 100644 index 0000000..ac2ab3b --- /dev/null +++ b/lib/commands.js @@ -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:'; diff --git a/lib/format.d.ts b/lib/format.d.ts new file mode 100644 index 0000000..f02c869 --- /dev/null +++ b/lib/format.d.ts @@ -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[]; diff --git a/lib/format.js b/lib/format.js new file mode 100644 index 0000000..72bec6a --- /dev/null +++ b/lib/format.js @@ -0,0 +1,50 @@ +export function escapeHtml(text) { + return text + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} +function inlineToHtml(text) { + let html = escapeHtml(text); + html = html.replace(/`([^`\n]+)`/g, '$1'); + html = html.replace(/\*\*([^*\n]+)\*\*/g, '$1'); + 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 += `
${escapeHtml(code)}
`; + } + 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; +} diff --git a/lib/index.d.ts b/lib/index.d.ts new file mode 100644 index 0000000..7013bfa --- /dev/null +++ b/lib/index.d.ts @@ -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; +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'; diff --git a/lib/index.js b/lib/index.js new file mode 100644 index 0000000..b3aab0a --- /dev/null +++ b/lib/index.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'; diff --git a/package.json b/package.json index 22dc0fa..c19732c 100644 --- a/package.json +++ b/package.json @@ -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",