diff --git a/CHANGELOG.md b/CHANGELOG.md index 41242a7..926380b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,21 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.3.1] - 2026-09-01 + +### Added +- 微信用户授权、一次性远程确认码、任务与待确认状态查询 +- 图片/文件批次的立即开始与取消操作,以及模型编号选择 +- 中文与英文单语言操作文档 + +### Changed +- npm 包迁移至 `@jiah-liu/pi-wechat-assistant`,GitHub 仓库迁移至 `jiah-liu/pi-wechat-assistant` +- 微信回复改为任务完成后统一发送,并提供脱敏工具进度 + +### Fixed +- 限制未授权微信用户、符号链接文件外发、超大媒体下载、锁竞争与轮询重复消息 +- 修复文件批处理竞态和过期登录提示 + ## [0.3.0] - 2026-06-11 ### Added diff --git a/README.en.md b/README.en.md new file mode 100644 index 0000000..fe5a335 --- /dev/null +++ b/README.en.md @@ -0,0 +1,172 @@ +
A secure WeChat remote entry point for a pi session.
+ + + +> GitHub READMEs cannot safely run in-page language-switching JavaScript. Each language button opens a single-language document, so only one language is shown per page. + +## Overview + +This pi extension connects **one authorized WeChat user** to **one pi session**. WeChat messages are sent to the active Agent and final replies are returned to WeChat; the same session remains usable from the TUI. + +It is not a public chatbot. A WeChat user can influence a local Agent, its model, and its tools, so authorization is required. + +## Install + +```bash +pi install npm:@jiah-liu/pi-wechat-assistant +# or +pi install git:github.com/jiah-liu/pi-wechat-assistant +``` + +Development requires Node.js >= 20.3: + +```bash +npm install +npm run typecheck +npm test +``` + +## First use + +1. In the pi TUI, run: + ```text + /wechat login + /wechat start + ``` +2. Scan the QR code in WeChat. +3. Have the intended WeChat user send one message. +4. The TUI shows a 30-second authorization prompt. Approve it to bind that user to this session. + +On a headless server, send a message first, inspect **Known users** via `/wechat status`, then authorize manually: + +```text +/wechat config user` | Execute a pending operation |
+
+Unknown slash commands are not passed to the Agent; they return a `/help` hint instead.
+
+### Confirmation codes
+
+Model changes, tool changes, compaction, and login requests return a one-time code:
+
+```text
+⚠️ About to switch model to openai/gpt-5. Reply /confirm A1B2C3 to execute (valid for 5 minutes)
+```
+
+A code works only for the authorized WeChat user, expires after five minutes, is consumed after use, and a newer request replaces an older one. Use `/pending` to inspect it. This supports headless Linux servers without requiring a TUI confirmation for routine remote control.
+
+## Login on a Linux server
+
+`/login openai` can be requested and confirmed from WeChat; it then returns the server-side login instruction. Completing OAuth on a phone depends on the provider:
+
+- **Device Code support:** complete the authorization from a mobile browser.
+- **localhost-only OAuth callbacks:** complete the callback from a browser that can reach the server callback, commonly through an SSH tunnel:
+ ```bash
+ ssh -L :localhost: user@server
+ ```
+
+Do not retain or forward OAuth URLs containing `state`, PKCE data, or tokens in chat history. This extension never relays cookies, callback parameters, or credentials through WeChat.
+
+## Media behaviour
+
+- Images wait up to eight seconds for batching; a text supplement starts processing immediately.
+- Media receives an acknowledgement, a processing update, and the final Agent response.
+- `/cancel` affects only work not yet injected into the Agent.
+- Received files are saved in `.pi-wechat-files/` under the project, with sanitized, randomized names.
+- Images and files use bounded streaming downloads to protect memory and disk.
+- `send_file_to_wechat` and `send_image_to_wechat` only allow ordinary files whose real paths remain inside the project; symlinks and directories are rejected.
+
+Typing ordinary text in the TUI notifies WeChat that the session has moved to the computer; replies then remain in the TUI until the next WeChat turn.
+
+## Security
+
+- One pi session permits one WeChat user.
+- Credentials, context tokens, and update cursor live in `~/.pi/agent/wechat-assistant/`, with directory mode `0700` and file mode `0600`.
+- An atomic lock prevents two pi instances from polling the same account.
+- The WeChat-specific Agent prompt requires explicit confirmation before destructive, irreversible, or external-output actions.
+- This is not a sandbox. Only authorize your own WeChat account and retain pi's normal tool-permission policy.
+
+## Configuration
+
+| Variable | Default | Description |
+| --- | ---: | --- |
+| `PI_WECHAT_DEBUG` | disabled | Set to `1` for debug logs |
+| `PI_WECHAT_DEBUG_FILE` | `~/.pi/agent/wechat-assistant/debug.log` | Debug log path |
+| `PI_WECHAT_IMAGE_BATCH_WAIT_MS` | `8000` | Image batch delay in milliseconds |
+| `PI_WECHAT_IMAGE_MAX_BYTES` | `52428800` | Per-media download limit |
+
+```text
+~/.pi/agent/wechat-assistant/
+├── credentials.json
+├── config.json
+├── context-tokens.json
+├── update-cursor.json
+└── session.lock
+```
+
+```json
+{
+ "autoStart": true,
+ "allowedUserId": "WECHAT_USER_ID",
+ "imageBatchWaitMs": 8000,
+ "imageMaxBytes": 52428800
+}
+```
+
+## Troubleshooting
+
+- **No reply:** run `/wechat status`; verify bridge state, authorized user, and session validity.
+- **Expired session:** run `/wechat login --force`.
+- **Account locked by another instance:** stop that instance with `/wechat stop`.
+- **Media failure:** check `image-max`, connectivity, and debug logs.
+- **OAuth cannot finish on a phone:** use a Device Code provider or finish the localhost callback through the TUI/SSH tunnel.
+
+## License
+
+[MIT](LICENSE)
diff --git a/README.md b/README.md
index 958d340..de197f0 100644
--- a/README.md
+++ b/README.md
@@ -1,651 +1,183 @@
pi-wechat-assistant
-
- A personal WeChat assistant for pi TUI
- Turn WeChat into your mobile remote for a single pi session.
-
+将微信作为 pi 会话的安全远程入口。
-
-
----
-
-
-
-## What is this?
+> GitHub README 不支持安全的页面内脚本切换语言;语言按钮会打开对应的单语言文档,因此每页只显示一种语言。
-**pi-wechat-assistant** is a [pi](https://github.com/earendil-works/pi-coding-agent) extension that connects **one WeChat user** to **one pi TUI session**. It is not a generic chatbot bridge or a multi-session orchestrator — it is a personal assistant that lets you continue your pi conversation from WeChat when you step away from your computer.
+## 是什么
-Messages you send on WeChat are injected into the active pi session; AI replies are sent back to WeChat. Messages you type in TUI are also previewed on WeChat, keeping both sides in sync.
+这是面向**一个授权微信用户和一个 pi 会话**的扩展。微信消息会被送入当前 pi Agent,最终回复回传微信;电脑端仍可继续使用同一会话。
-> **One WeChat user ↔ One pi TUI session.** That's the whole scope.
-
-## Features
-
-- 📱 **Personal assistant** — designed for a single user continuing a single pi session from WeChat
-- 🔐 **QR code login** — scan with WeChat to authorize
-- 🔄 **Two-way sync** — WeChat ↔ TUI messages are visible on both sides
-- 🖼️ **Image support** — download, decrypt, and send images to the model for analysis
-- 📁 **File support** — receive files from WeChat, save to project directory; send project files back
-- 🗣️ **Voice support** — uses WeChat's built-in speech-to-text
-- 💬 **Message queuing & batching** — consecutive messages are merged; multiple images wait for a text supplement
-- 🔒 **Exclusive lock** — only one TUI session can hold the WeChat connection at a time
-- 📊 **Status bar** — shows WeChat connection status and pending message count in TUI
-
-## Quick Start
-
-### Install
-
-```bash
-pi install npm:pi-wechat-assistant
-```
+它不是公共聊天机器人。微信用户可影响本机 Agent、模型和工具配置,因此必须先完成授权。
-Or from GitHub:
+## 安装
```bash
-pi install git:github.com/shenjiecode/pi-wechat-assistant
-```
-
-### Usage
-
-In the pi TUI:
-
-```
-/wechat login
-/wechat start
-```
-
-Then open WeChat, find the bot, and start messaging.
-
-## TUI Commands
-
-All commands use `/wechat` with a subcommand:
-
-| Command | Description |
-| --- | --- |
-| `/wechat login` | Scan QR code to log in |
-| `/wechat login --force` | Force re-scan |
-| `/wechat start` | Start the WeChat connection |
-| `/wechat stop` | Stop and release the lock |
-| `/wechat status` | Show connection status and config |
-| `/wechat config` | View image-related settings |
-| `/wechat config image-wait ` | Set batch wait time (default `8000`) |
-| `/wechat config image-max ` | Set per-image size limit (default `50`) |
-| `/wechat autostart` | Toggle auto-start on session begin |
-| `/wechat logout` | Clear credentials and stop |
-
-## WeChat Remote Commands
-
-Send text, voice, or images on WeChat to chat normally. Additional commands:
-
-| Command | Description |
-| --- | --- |
-| `/status` | Model, context usage, queue, config |
-| `/stop` | Abort current generation |
-| `/model` | List available models |
-| `/model ` | Switch model |
-| `/config` | View image config |
-| `/name ` | Set session name |
-| `/session` | Show session details |
-| `/help` | Show help |
-
-Advanced: `/thinking`, `/tools`, `/compact`.
-
-## Supported Message Types
-
-| Type | Status |
-| --- | --- |
-| Text | ✅ |
-| Voice | ✅ (WeChat speech-to-text) |
-| Image | ✅ (downloaded and sent to model) |
-| Quote reply | ✅ (quoted text prepended) |
-| File | ✅ (saved to project directory) |
-| Video | ❌ Not supported |
-
-## Configuration
-
-### Environment Variables
-
-| Variable | Description | Default |
-| --- | --- | --- |
-| `PI_WECHAT_DEBUG` | Set to `1` to enable debug logging | Off |
-| `PI_WECHAT_DEBUG_FILE` | Debug log file path | `~/.pi/agent/wechat-assistant/debug.log` |
-| `PI_WECHAT_IMAGE_BATCH_WAIT_MS` | Batch wait time for images | `8000` |
-| `PI_WECHAT_IMAGE_MAX_BYTES` | Per-image size limit | `52428800` (50 MB) |
-
-### Config Files
-
-```
-~/.pi/agent/wechat-assistant/
-├── credentials.json # Login credentials (mode 600)
-├── config.json # Auto-start, image limits
-└── session.lock # Exclusive lock file
-```
-
-`config.json` example:
-
-```json
-{
- "autoStart": true,
- "imageBatchWaitMs": 8000,
- "imageMaxBytes": 52428800
-}
-```
-
-## Architecture
-
+pi install npm:@jiah-liu/pi-wechat-assistant
+# 或
+pi install git:github.com/jiah-liu/pi-wechat-assistant
```
-WeChat ⇄ pi TUI session ⇄ AI model + tools
-```
-
-- WeChat messages are fetched via iLink Bot API long polling
-- Incoming messages are injected into the active pi session via `pi.sendUserMessage()`
-- When you type in TUI, a preview is sent to WeChat
-- AI replies are delivered incrementally (per `message_end`) and finalized on `agent_end`
-- Only the TUI session that runs `/wechat start` holds the connection
-
-## FAQ
-
-**WeChat not responding?** Run `/wechat status` to check. If the session expired, run `/wechat login --force`.
-**"Already occupied by another pi instance"?** Another TUI session holds the lock. Run `/wechat stop` in that session.
-
-**Why aren't images processed immediately?** Images wait up to 8 seconds for batching — so you can send multiple images and add a text description. The first image gets an acknowledgment immediately; if you send text during the wait, processing starts right away.
-
-## Development
+开发环境:Node.js >= 20.3。
```bash
-git clone https://github.com/shenjiecode/pi-wechat-assistant.git
-cd pi-wechat-assistant
npm install
npm run typecheck
-
-# Quick test
-pi -e ./src/index.ts
+npm test
```
-## Releasing
+## 首次使用
-```bash
-npm version patch # updates package.json, commits, and tags
-git push --follow-tags # triggers CI → npm publish + GitHub Release
-```
-
-## License
-
-[MIT](LICENSE)
-
----
----
-
-
-
-## 这是什么?
-
-**pi-wechat-assistant** 是一个 [pi](https://github.com/earendil-works/pi-coding-agent) 扩展,将**一个微信用户**连接到**一个 pi TUI 会话**。它不是一个通用聊天机器人桥接,也不是多会话编排器——它是一个个人助手,让你离开电脑后可以通过微信继续和同一个 pi 会话交互。
-
-你在微信里发的消息会被注入当前 pi 会话,AI 的回复会发回微信。你在 TUI 里输入的消息也会在微信端显示预览,保持两边同步。
-
-> **一个微信用户 ↔ 一个 pi TUI 会话。** 这就是全部的定位。
-
-## 特性
-
-- 📱 **个人助手** — 为单用户远程操控单个 pi 会话设计
-- 🔐 **扫码登录** — 微信扫码授权
-- 🔄 **双向同步** — 微信 ↔ TUI 消息在两边都可见
-- 🖼️ **图片支持** — 下载、解密、发送给模型分析
-- 📁 **文件支持** — 从微信接收文件保存到项目目录;发送项目文件回微信
-- 🗣️ **语音支持** — 使用微信内置语音转文字
-- 💬 **消息排队与合并** — 连续消息自动合并;多张图片等待文字补充后一起处理
-- 🔒 **排他锁** — 同一时间只有一个 TUI 会话持有微信连接
-- 📊 **状态栏** — TUI 底部显示微信连接状态和待处理消息数
-
-## 快速开始
+1. 在服务器或本机 pi TUI 中执行:
+ ```text
+ /wechat login
+ /wechat start
+ ```
+2. 用微信扫描二维码。
+3. 让准备使用该会话的微信账号发送一条消息。
+4. TUI 会弹出 30 秒授权确认;确认后,该账号成为唯一可控制此会话的微信用户。
-### 安装
-
-```bash
-pi install npm:pi-wechat-assistant
-```
-
-或从 GitHub 安装:
-
-```bash
-pi install git:github.com/shenjiecode/pi-wechat-assistant
-```
-
-### 使用
-
-在 pi TUI 中执行:
+没有 TUI 时,可在 `/wechat status` 中查看“已见用户”,再手动授权:
-```
-/wechat login
-/wechat start
+```text
+/wechat config user <微信用户ID>
```
-然后打开微信找到机器人,直接发消息即可。
+未授权的微信消息不会注入 Agent,也不会执行远程命令。
-## TUI 命令
+## pi TUI 命令
| 命令 | 说明 |
| --- | --- |
-| `/wechat login` | 扫码登录 |
-| `/wechat login --force` | 强制重新扫码 |
-| `/wechat start` | 启动微信连接 |
-| `/wechat stop` | 停止并释放锁 |
-| `/wechat status` | 查看连接状态和配置 |
-| `/wechat config` | 查看图片相关设置 |
-| `/wechat config image-wait ` | 设置图片批量等待时间,默认 `8000` |
-| `/wechat config image-max ` | 设置单张图片大小上限,默认 `50` |
-| `/wechat autostart` | 开关自动启动 |
-| `/wechat logout` | 清除凭证并停止 |
-
-## 微信远程命令
-
-微信里直接发文字/语音/图片就是正常对话。额外命令:
+| `/wechat login` | 扫码登录或加载本地凭证 |
+| `/wechat login --force` | 清除旧会话并重新扫码 |
+| `/wechat start` | 启动微信桥接 |
+| `/wechat stop` | 停止桥接并释放锁 |
+| `/wechat status` | 查看连接、账号、已见用户和队列状态 |
+| `/wechat logout` | 停止并清除凭证、上下文 token |
+| `/wechat autostart` | 切换 pi 会话启动时自动连接 |
+| `/wechat config` | 查看配置 |
+| `/wechat config user ` | 设置唯一授权微信用户 |
+| `/wechat config image-wait ` | 设置图片批量等待时间,默认 8000ms |
+| `/wechat config image-max ` | 设置单个图片或文件上限,默认 50MB |
+
+## 微信端操作
+
+直接发送文字、语音、图片或文件即可对话。内容会带上“微信消息”来源标记送给 Agent;当 Agent 正忙时,后续消息会标为“微信追加消息”。
| 命令 | 说明 |
| --- | --- |
-| `/status` | 查看模型、上下文、队列、配置 |
-| `/stop` | 停止当前生成 |
-| `/model` | 查看可用模型 |
-| `/model <名称>` | 切换模型 |
-| `/config` | 查看图片配置 |
-| `/name <名称>` | 设置会话名称 |
-| `/session` | 查看会话详情 |
-| `/help` | 显示帮助 |
+| `/help` | 显示命令帮助 |
+| `/status` | 查看模型、上下文、工具数、队列和图片限制 |
+| `/task` | 查看当前任务、运行时长和当前步骤 |
+| `/pending` | 查看待确认操作及剩余时间 |
+| `/start` 或 `开始` | 立即处理当前图片/文件批次 |
+| `/stop` | 中止当前 Agent 生成 |
+| `/cancel` 或 `取消` | 取消尚未开始的图片/文件批次 |
+| `/model` | 查看带编号的模型列表 |
+| `/model <名称或编号>` | 请求切换模型(需确认码) |
+| `/thinking ` | 设置 thinking level |
+| `/tools` | 查看活跃工具 |
+| `/tools <名称...>` | 请求修改活跃工具(需确认码) |
+| `/compact` | 请求压缩上下文(需确认码) |
+| `/name <名称>` | 设置 pi 会话名称 |
+| `/session` | 查看会话统计与文件位置 |
+| `/config` | 查看图片/文件限制 |
+| `/login ` | 请求登录指引(需确认码) |
+| `/confirm ` | 执行待确认操作 |
-高级命令:`/thinking`、`/tools`、`/compact`。
+未知的 `/命令` 不会再被发送给 Agent,而会提示使用 `/help`。
-## 支持的消息类型
+### 微信确认码
-| 类型 | 状态 |
-| --- | --- |
-| 文字 | ✅ |
-| 语音 | ✅(微信语音转文字) |
-| 图片 | ✅(下载后发送给模型分析) |
-| 引用回复 | ✅(拼接引用文本) |
-| 文件 | ✅(保存到项目目录) |
-| 视频 | ❌ 暂不支持 |
-
-## 配置
-
-### 环境变量
-
-| 变量 | 说明 | 默认值 |
-| --- | --- | --- |
-| `PI_WECHAT_DEBUG` | 设为 `1` 开启调试日志 | 关闭 |
-| `PI_WECHAT_DEBUG_FILE` | 调试日志路径 | `~/.pi/agent/wechat-assistant/debug.log` |
-| `PI_WECHAT_IMAGE_BATCH_WAIT_MS` | 图片批量等待时间 | `8000` |
-| `PI_WECHAT_IMAGE_MAX_BYTES` | 单张图片大小上限 | `52428800`(50 MB) |
-
-### 配置文件
-
-```
-~/.pi/agent/wechat-assistant/
-├── credentials.json # 登录凭证(权限 600)
-├── config.json # 自动启动、图片限制
-└── session.lock # 排他锁文件
-```
-
-## 架构
+模型切换、工具集变更、上下文压缩和登录请求均返回一次性确认码:
+```text
+⚠️ 即将切换模型到 openai/gpt-5。回复 /confirm A1B2C3 执行(5 分钟内有效)
```
-微信 ⇄ pi TUI 会话 ⇄ AI 模型 + 工具
-```
-
-- 微信消息通过 iLink Bot API 长轮询获取
-- 收到的消息通过 `pi.sendUserMessage()` 注入当前 pi 会话
-- TUI 输入时微信端会收到预览
-- AI 回复增量发送(每条 `message_end` 即发),`agent_end` 时补发遗漏
-- 只有执行 `/wechat start` 的 TUI 会话持有连接
-
-## 常见问题
-
-**微信没有回复?** 执行 `/wechat status` 检查状态。Session 过期则执行 `/wechat login --force`。
-
-**提示"已被其他 pi 实例占用"?** 另一个 TUI 会话持有锁,在那个会话执行 `/wechat stop`。
-**图片为什么不马上处理?** 图片默认等待 8 秒用于批量合并,方便你连发多张图再补描述。收到第一张图会立即回执,期间发文字则立即开始处理。
+确认码只对当前授权微信用户有效、执行一次即失效;新的待确认请求会替换旧请求。发送 `/pending` 可查看操作与剩余时间。这让无头 Linux 服务器无需为日常远程控制打开 TUI。
-## 开发
+## Linux 服务器上的账号登录
-```bash
-git clone https://github.com/shenjiecode/pi-wechat-assistant.git
-cd pi-wechat-assistant
-npm install
-npm run typecheck
-
-# 快速测试
-pi -e ./src/index.ts
-```
-
-## 发版
-
-```bash
-npm version patch # 更新 package.json、提交、打 tag
-git push --follow-tags # 触发 CI → npm 发布 + GitHub Release
-```
-
-## 许可证
-
-[MIT](LICENSE)
+微信可以请求 `/login openai` 并用确认码确认;确认后会返回服务器端登录指引。OAuth 是否能完全在手机完成取决于 provider:
----
----
+- **支持 Device Code 的 provider**:可以在手机浏览器输入设备码并完成授权。
+- **仅支持 localhost OAuth 回调的 provider**:仍需在有浏览器的一端完成回调。推荐 SSH 隧道:
+ ```bash
+ ssh -L <本地端口>:localhost:<服务器端口> user@server
+ ```
+ 然后在本机浏览器打开授权页。
-
+不要把包含 OAuth `state`、PKCE 数据或 token 的链接长期转发到聊天记录中。扩展不会通过微信传递 cookie、回调参数或凭证。
-## これは何?
+## 消息、图片与文件
-**pi-wechat-assistant** は [pi](https://github.com/earendil-works/pi-coding-agent) の拡張機能で、**1人のWeChatユーザー**を**1つのpi TUIセッション**に接続します。汎用チャットボットブリジでも、マルチセッションオーケストレーターでもありません。パソコンから離れている間、WeChatから同じpiセッションの会話を続けるためのパーソナルアシスタントです。
+- 连续图片默认等待 8 秒,用于合并图片和补充文字;收到文字会立即开始处理。
+- 图片和文件会先收到回执,再收到“处理中”与最终 Agent 回复。
+- `/cancel` 仅取消尚未注入 Agent 的批次,不能撤销已开始的 Agent 操作。
+- 文件保存于项目目录:`.pi-wechat-files/`。文件名会被净化并加随机前缀。
+- 单个图片和文件受 `image-max` 限制;下载采用限额流式读取,避免占满内存。
+- Agent 可调用 `send_file_to_wechat` 和 `send_image_to_wechat` 发送项目内产物;符号链接、目录及项目外实际路径会被拒绝。
-WeChatで送信したメッセージは現在のpiセッションに注入され、AIの返信がWeChatに送り返されます。TUIで入力したメッセージもWeChat側にプレビューが表示され、両側を同期します。
+当你在 TUI 输入普通文本时,微信会收到“已切换到电脑端”的提示;之后回复仅在 TUI 显示,直到新的微信输入开始下一轮远程对话。
-> **1人のWeChatユーザー ↔ 1つのpi TUIセッション。** それがこのプロジェクトのスコープです。
+## 安全模型
-## 特徴
+- 一个 pi 会话只允许一个微信用户。
+- 凭证、上下文 token 和轮询 cursor 保存在 `~/.pi/agent/wechat-assistant/`,目录权限为 `0700`,文件权限为 `0600`。
+- 进程间通过原子锁保证同一微信账号不会被多个 pi 实例同时轮询。
+- 微信上下文会要求 Agent 在删除、覆盖、提交、推送、外发文件等不可逆操作前先获取明确确认。
+- 这不是沙箱:授权微信用户仍可向具备工具权限的 Agent 下达请求。请只授权你自己的账号,并保留 pi 原有的工具权限策略。
-- 📱 **パーソナルアシスタント** — 単一ユーザーが単一のpiセッションをWeChatからリモート操作するために設計
-- 🔐 **QRコードログイン** — WeChatでスキャンして認証
-- 🔄 **双方向同期** — WeChat ↔ TUIのメッセージが両側で閲覧可能
-- 🖼️ **画像対応** — ダウンロード、復号、モデルに送信して分析
-- 📁 **ファイル対応** — WeChatからファイルを受信してプロジェクトディレクトリに保存、プロジェクトファイルをWeChatに送信
-- 🗣️ **音声対応** — WeChat内蔵の音声認識を利用
-- 💬 **メッセージキューイング&バッチ処理** — 連続メッセージの自動マージ、複数画像はテキスト補完を待ってから一括処理
-- 🔒 **排他ロック** — 同時に1つのTUIセッションのみWeChat接続を保持
-- 📊 **ステータスバー** — TUI下部にWeChat接続状態と保留メッセージ数を表示
-
-## クイックスタート
-
-### インストール
-
-```bash
-pi install npm:pi-wechat-assistant
-```
-
-またはGitHubから:
-
-```bash
-pi install git:github.com/shenjiecode/pi-wechat-assistant
-```
-
-### 使い方
-
-pi TUIで以下を実行:
-
-```
-/wechat login
-/wechat start
-```
-
-WeChatでボットを見つけて、メッセージを送るだけです。
-
-## TUIコマンド
-
-| コマンド | 説明 |
-| --- | --- |
-| `/wechat login` | QRコードをスキャンしてログイン |
-| `/wechat login --force` | 強制的に再スキャン |
-| `/wechat start` | WeChat接続を開始 |
-| `/wechat stop` | 停止してロックを解放 |
-| `/wechat status` | 接続状態と設定を表示 |
-| `/wechat config` | 画像関連設定を表示 |
-| `/wechat config image-wait ` | バッチ待機時間を設定(デフォルト `8000`) |
-| `/wechat config image-max ` | 画像サイズ上限を設定(デフォルト `50`) |
-| `/wechat autostart` | セッション開始時の自動起動を切替 |
-| `/wechat logout` | 認証情報をクリアして停止 |
-
-## WeChatリモートコマンド
-
-WeChatでテキスト、音声、画像を送るだけで通常の会話になります。追加コマンド:
-
-| コマンド | 説明 |
-| --- | --- |
-| `/status` | モデル、コンテキスト使用量、キュー、設定 |
-| `/stop` | 現在の生成を中止 |
-| `/model` | 利用可能なモデル一覧 |
-| `/model <名前>` | モデル切替 |
-| `/config` | 画像設定を表示 |
-| `/name <名前>` | セッション名を設定 |
-| `/session` | セッション詳細を表示 |
-| `/help` | ヘルプを表示 |
-
-上級者向け:`/thinking`、`/tools`、`/compact`。
-
-## 対応メッセージタイプ
-
-| タイプ | 状態 |
-| --- | --- |
-| テキスト | ✅ |
-| 音声 | ✅(WeChat音声認識) |
-| 画像 | ✅(ダウンロード後モデルに送信) |
-| 引用返信 | ✅(引用テキストを付加) |
-| ファイル | ✅(プロジェクトディレクトリに保存) |
-| 動画 | ❌ 未対応 |
-
-## 設定
-
-### 環境変数
-
-| 変数 | 説明 | デフォルト |
-| --- | --- | --- |
-| `PI_WECHAT_DEBUG` | `1` でデバッグログ有効 | オフ |
-| `PI_WECHAT_DEBUG_FILE` | デバッグログパス | `~/.pi/agent/wechat-assistant/debug.log` |
-| `PI_WECHAT_IMAGE_BATCH_WAIT_MS` | 画像バッチ待機時間 | `8000` |
-| `PI_WECHAT_IMAGE_MAX_BYTES` | 画像サイズ上限 | `52428800`(50 MB) |
-
-### 設定ファイル
-
-```
-~/.pi/agent/wechat-assistant/
-├── credentials.json # ログイン認証情報(権限 600)
-├── config.json # 自動起動、画像制限
-└── session.lock # 排他ロックファイル
-```
-
-## アーキテクチャ
-
-```
-WeChat ⇄ pi TUIセッション ⇄ AIモデル + ツール
-```
-
-- WeChatメッセージはiLink Bot APIのロングポーリングで取得
-- 受信メッセージは `pi.sendUserMessage()` で現在のpiセッションに注入
-- TUIで入力するとWeChat側にプレビューが送信
-- AI返信は増分的に送信(`message_end` ごと)、`agent_end` で残りを補完
-- `/wechat start` を実行したTUIセッションのみが接続を保持
-
-## FAQ
-
-**WeChatから返信がない?** `/wechat status` で状態を確認。セッション期限切れなら `/wechat login --force` を実行。
-
-**「他のpiインスタンスが使用中」と表示?** 別のTUIセッションがロックを保持中。そのセッションで `/wechat stop` を実行。
-
-**画像がすぐに処理されない?** バッチ処理のため最大8秒待機。複数画像を連続送信後、テキストで補足できます。最初の画像は即座に確認応答、待機中にテキストを送ると即時処理。
-
-## 開発
-
-```bash
-git clone https://github.com/shenjiecode/pi-wechat-assistant.git
-cd pi-wechat-assistant
-npm install
-npm run typecheck
-
-# クイックテスト
-pi -e ./src/index.ts
-```
-
-## リリース
-
-```bash
-npm version patch # package.jsonを更新、コミット、タグ作成
-git push --follow-tags # CI → npm公開 + GitHub Release をトリガー
-```
-
-## ライセンス
-
-[MIT](LICENSE)
-
----
----
-
-
-
-## 이것은 무엇인가요?
-
-**pi-wechat-assistant**는 [pi](https://github.com/earendil-works/pi-coding-agent) 확장 기능으로, **1명의 WeChat 사용자**를 **1개의 pi TUI 세션**에 연결합니다. 범용 챗봇 브릿지도, 멀티 세션 오케스트레이터도 아닙니다. 컴퓨터를 떠나 있는 동안 WeChat에서 동일한 pi 세션 대화를 이어갈 수 있게 해주는 개인 어시스턴트입니다.
-
-WeChat에서 보낸 메시지는 현재 pi 세션에 주입되고, AI 응답이 WeChat으로 돌아옵니다. TUI에서 입력한 메시지도 WeChat 측에 미리보기가 표시되어 양쪽을 동기화합니다.
-
-> **1명의 WeChat 사용자 ↔ 1개의 pi TUI 세션.** 그것이 이 프로젝트의 전체 범위입니다.
-
-## 특징
-
-- 📱 **개인 어시스턴트** — 단일 사용자가 WeChat에서 단일 pi 세션을 원격 조작하도록 설계
-- 🔐 **QR 코드 로그인** — WeChat으로 스캔하여 인증
-- 🔄 **양방향 동기화** — WeChat ↔ TUI 메시지가 양쪽에서 표시
-- 🖼️ **이미지 지원** — 다운로드, 복호화, 모델에 전송하여 분석
-- 📁 **파일 지원** — WeChat에서 파일을 수신하여 프로젝트 디렉토리에 저장, 프로젝트 파일을 WeChat으로 전송
-- 🗣️ **음성 지원** — WeChat 내장 음성 인식 활용
-- 💬 **메시지 큐잉 & 배치** — 연속 메시지 자동 병합, 여러 이미지는 텍스트 보충을 대기 후 일괄 처리
-- 🔒 **배타적 잠금** — 동시에 하나의 TUI 세션만 WeChat 연결 유지
-- 📊 **상태 표시줄** — TUI 하단에 WeChat 연결 상태와 대기 메시지 수 표시
-
-## 빠른 시작
-
-### 설치
-
-```bash
-pi install npm:pi-wechat-assistant
-```
-
-또는 GitHub에서:
-
-```bash
-pi install git:github.com/shenjiecode/pi-wechat-assistant
-```
-
-### 사용법
-
-pi TUI에서 실행:
-
-```
-/wechat login
-/wechat start
-```
-
-WeChat에서 봇을 찾아 메시지를 보내기만 하면 됩니다.
-
-## TUI 명령어
-
-| 명령어 | 설명 |
-| --- | --- |
-| `/wechat login` | QR 코드 스캔하여 로그인 |
-| `/wechat login --force` | 강제 재스캔 |
-| `/wechat start` | WeChat 연결 시작 |
-| `/wechat stop` | 중지 및 잠금 해제 |
-| `/wechat status` | 연결 상태 및 설정 표시 |
-| `/wechat config` | 이미지 관련 설정 표시 |
-| `/wechat config image-wait ` | 배치 대기 시간 설정 (기본값 `8000`) |
-| `/wechat config image-max ` | 이미지 크기 제한 설정 (기본값 `50`) |
-| `/wechat autostart` | 세션 시작 시 자동 시작 전환 |
-| `/wechat logout` | 자격 증명 지우기 및 중지 |
-
-## WeChat 원격 명령어
-
-WeChat에서 텍스트, 음성, 이미지를 보내면 일반 대화입니다. 추가 명령어:
-
-| 명령어 | 설명 |
-| --- | --- |
-| `/status` | 모델, 컨텍스트 사용량, 큐, 설정 |
-| `/stop` | 현재 생성 중단 |
-| `/model` | 사용 가능한 모델 목록 |
-| `/model <이름>` | 모델 전환 |
-| `/config` | 이미지 설정 표시 |
-| `/name <이름>` | 세션 이름 설정 |
-| `/session` | 세션 상세 정보 |
-| `/help` | 도움말 표시 |
-
-고급: `/thinking`, `/tools`, `/compact`.
-
-## 지원 메시지 유형
-
-| 유형 | 상태 |
-| --- | --- |
-| 텍스트 | ✅ |
-| 음성 | ✅ (WeChat 음성 인식) |
-| 이미지 | ✅ (다운로드 후 모델에 전송) |
-| 인용 답장 | ✅ (인용 텍스트 추가) |
-| 파일 | ✅ (프로젝트 디렉토리에 저장) |
-| 동영상 | ❌ 미지원 |
-
-## 설정
+## 配置
-### 환경 변수
+环境变量:
-| 변수 | 설명 | 기본값 |
-| --- | --- | --- |
-| `PI_WECHAT_DEBUG` | `1`로 설정 시 디버그 로그 활성화 | 꺼짐 |
-| `PI_WECHAT_DEBUG_FILE` | 디버그 로그 경로 | `~/.pi/agent/wechat-assistant/debug.log` |
-| `PI_WECHAT_IMAGE_BATCH_WAIT_MS` | 이미지 배치 대기 시간 | `8000` |
-| `PI_WECHAT_IMAGE_MAX_BYTES` | 이미지 크기 제한 | `52428800` (50 MB) |
+| 变量 | 默认值 | 说明 |
+| --- | ---: | --- |
+| `PI_WECHAT_DEBUG` | 未启用 | 设为 `1` 开启调试日志 |
+| `PI_WECHAT_DEBUG_FILE` | `~/.pi/agent/wechat-assistant/debug.log` | 调试日志路径 |
+| `PI_WECHAT_IMAGE_BATCH_WAIT_MS` | `8000` | 图片批处理等待毫秒数 |
+| `PI_WECHAT_IMAGE_MAX_BYTES` | `52428800` | 单个媒体下载上限 |
-### 설정 파일
+状态文件:
-```
+```text
~/.pi/agent/wechat-assistant/
-├── credentials.json # 로그인 자격 증명 (권한 600)
-├── config.json # 자동 시작, 이미지 제한
-└── session.lock # 배타적 잠금 파일
-```
-
-## 아키텍처
-
-```
-WeChat ⇄ pi TUI 세션 ⇄ AI 모델 + 도구
+├── credentials.json
+├── config.json
+├── context-tokens.json
+├── update-cursor.json
+└── session.lock
```
-- WeChat 메시지는 iLink Bot API 롱 폴링으로 가져옴
-- 수신 메시지는 `pi.sendUserMessage()` 로 현재 pi 세션에 주입
-- TUI에서 입력하면 WeChat 측에 미리보기 전송
-- AI 응답은 증분 전송 (`message_end` 마다), `agent_end` 에서 나머지 보완
-- `/wechat start` 를 실행한 TUI 세션만 연결 유지
-
-## FAQ
-
-**WeChat에서 응답이 없음?** `/wechat status` 로 상태 확인. 세션 만료 시 `/wechat login --force` 실행.
-
-**"다른 pi 인스턴스가 사용 중" 표시?** 다른 TUI 세션이 잠금을 유지 중. 해당 세션에서 `/wechat stop` 실행.
-
-**이미지가 바로 처리되지 않는 이유?** 배치 처리를 위해 최대 8초 대기. 여러 이미지 연속 전송 후 텍스트로 보충 가능. 첫 이미지는 즉시 확인 응답, 대기 중 텍스트 전송 시 즉시 처리.
-
-## 개발
+`config.json` 示例:
-```bash
-git clone https://github.com/shenjiecode/pi-wechat-assistant.git
-cd pi-wechat-assistant
-npm install
-npm run typecheck
-
-# 빠른 테스트
-pi -e ./src/index.ts
+```json
+{
+ "autoStart": true,
+ "allowedUserId": "微信用户ID",
+ "imageBatchWaitMs": 8000,
+ "imageMaxBytes": 52428800
+}
```
-## 릴리스
+## 故障排查
-```bash
-npm version patch # package.json 업데이트, 커밋, 태그 생성
-git push --follow-tags # CI → npm 게시 + GitHub Release 트리거
-```
+- **没有回复**:在 TUI 执行 `/wechat status`;确认桥接运行、已授权用户正确、会话未过期。
+- **会话过期**:执行 `/wechat login --force`。
+- **被其他实例占用**:在持锁实例执行 `/wechat stop`,或等待异常进程退出后重新启动。
+- **图片/文件失败**:检查 `image-max`、网络和调试日志;文件需在限制以内。
+- **OAuth 无法在手机完成**:provider 可能不支持 Device Code;使用服务器 TUI 或 SSH 隧道完成本机回调。
-## 라이선스
+## 许可证
[MIT](LICENSE)
diff --git a/package-lock.json b/package-lock.json
index d11ee5d..afa3b9b 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
- "name": "pi-wechat-assistant",
- "version": "0.3.0",
+ "name": "@jiah-liu/pi-wechat-assistant",
+ "version": "0.3.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
- "name": "pi-wechat-assistant",
- "version": "0.3.0",
+ "name": "@jiah-liu/pi-wechat-assistant",
+ "version": "0.3.1",
"license": "MIT",
"dependencies": {
"@sinclair/typebox": "^0.34.49",
diff --git a/package.json b/package.json
index a7381dd..56bb9ef 100644
--- a/package.json
+++ b/package.json
@@ -1,18 +1,18 @@
{
- "name": "pi-wechat-assistant",
- "version": "0.3.0",
+ "name": "@jiah-liu/pi-wechat-assistant",
+ "version": "0.3.1",
"type": "module",
"description": "微信作为 pi TUI 的移动端分身 — 通过微信远程与 pi 交互",
"main": "index.ts",
"license": "MIT",
- "author": "sj",
- "homepage": "https://github.com/shenjiecode/pi-wechat-assistant#readme",
+ "author": "jiah-liu",
+ "homepage": "https://github.com/jiah-liu/pi-wechat-assistant#readme",
"repository": {
"type": "git",
- "url": "git+https://github.com/shenjiecode/pi-wechat-assistant.git"
+ "url": "git+https://github.com/jiah-liu/pi-wechat-assistant.git"
},
"bugs": {
- "url": "https://github.com/shenjiecode/pi-wechat-assistant/issues"
+ "url": "https://github.com/jiah-liu/pi-wechat-assistant/issues"
},
"keywords": [
"pi",
@@ -35,6 +35,9 @@
"engines": {
"node": ">=20.3.0"
},
+ "publishConfig": {
+ "access": "public"
+ },
"files": [
"index.ts",
"src",
diff --git a/src/api.ts b/src/api.ts
index e864137..dff07da 100644
--- a/src/api.ts
+++ b/src/api.ts
@@ -3,6 +3,7 @@
// ============================================================================
import { randomBytes, randomUUID } from 'node:crypto'
+import { LONG_POLL_TIMEOUT_MS } from './constants.js'
import type {
BaseInfo,
GetConfigResp,
@@ -129,7 +130,7 @@ export async function getUpdates(
get_updates_buf: cursor,
base_info: buildBaseInfo(),
}
- return apiPost(baseUrl, '/ilink/bot/getupdates', body, token, 40_000, signal)
+ return apiPost(baseUrl, '/ilink/bot/getupdates', body, token, LONG_POLL_TIMEOUT_MS, signal)
}
export async function sendMessage(
diff --git a/src/auth.ts b/src/auth.ts
index 4ae8d91..c274f66 100644
--- a/src/auth.ts
+++ b/src/auth.ts
@@ -15,6 +15,7 @@ const CREDS_FILE = path.join(STATE_DIR, 'credentials.json')
const CONFIG_FILE = path.join(STATE_DIR, 'config.json')
const LOCK_FILE = path.join(STATE_DIR, 'session.lock')
const CONTEXT_TOKENS_FILE = path.join(STATE_DIR, 'context-tokens.json')
+const UPDATE_CURSOR_FILE = path.join(STATE_DIR, 'update-cursor.json')
export function getStateDir(): string {
return STATE_DIR
@@ -27,7 +28,8 @@ export function getCredentialsPath(): string {
// --- 通用文件辅助 ---
async function ensureStateDir(): Promise {
- await fs.mkdir(STATE_DIR, { recursive: true })
+ await fs.mkdir(STATE_DIR, { recursive: true, mode: 0o700 })
+ await fs.chmod(STATE_DIR, 0o700)
}
async function readJsonFile(filePath: string): Promise {
@@ -42,6 +44,7 @@ async function readJsonFile(filePath: string): Promise {
async function writeJsonFile(filePath: string, data: unknown): Promise {
await ensureStateDir()
await fs.writeFile(filePath, JSON.stringify(data, null, 2), { mode: 0o600 })
+ await fs.chmod(filePath, 0o600)
}
async function deleteFile(filePath: string): Promise {
@@ -68,12 +71,15 @@ export async function clearCredentials(): Promise {
export async function clearContextTokens(): Promise {
await deleteFile(CONTEXT_TOKENS_FILE)
+ await deleteFile(UPDATE_CURSOR_FILE)
}
// --- 配置 ---
export interface BridgeConfig {
autoStart?: boolean
+ /** 允许控制此 pi 会话的微信用户 ID;未配置时拒绝全部远程消息。 */
+ allowedUserId?: string
/** 图片批量合并等待时间;收到文字补充会立即处理 */
imageBatchWaitMs?: number
/** 单张图片最大下载大小,单位字节 */
@@ -121,28 +127,32 @@ async function _readLockFile(): Promise {
}
async function _writeLockFile(sessionId: string): Promise {
- const data: LockData = { pid: process.pid, sessionId, timestamp: Date.now() }
- await writeJsonFile(LOCK_FILE, data)
+ await writeJsonFile(LOCK_FILE, { pid: process.pid, sessionId, timestamp: Date.now() })
}
export async function acquireLock(sessionId: string): Promise<{ success: boolean; message: string }> {
- const existing = await _readLockFile()
- if (existing) {
- if (existing.sessionId === sessionId) {
- await _writeLockFile(sessionId)
- return { success: true, message: '锁已更新' }
- }
- if (isProcessRunning(existing.pid)) {
- return {
- success: false,
- message: `微信已被其他 pi 实例占用 (PID: ${existing.pid}),请先在那个实例中执行 /wechat-stop`,
+ await ensureStateDir()
+ const data = JSON.stringify({ pid: process.pid, sessionId, timestamp: Date.now() })
+ for (let attempt = 0; attempt < 2; attempt++) {
+ try {
+ const handle = await fs.open(LOCK_FILE, 'wx', 0o600)
+ await handle.writeFile(data)
+ await handle.close()
+ return { success: true, message: '成功获取锁' }
+ } catch (error: unknown) {
+ if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error
+ const existing = await _readLockFile()
+ if (existing?.sessionId === sessionId) {
+ await _writeLockFile(sessionId)
+ return { success: true, message: '锁已更新' }
}
+ if (existing && isProcessRunning(existing.pid)) {
+ return { success: false, message: `微信已被其他 pi 实例占用 (PID: ${existing.pid}),请先在那个实例中执行 /wechat stop` }
+ }
+ await deleteFile(LOCK_FILE)
}
- // 进程不存在,锁已失效,可抢占
}
-
- await _writeLockFile(sessionId)
- return { success: true, message: '成功获取锁' }
+ return { success: false, message: '获取微信锁失败,请重试' }
}
export async function releaseLock(sessionId: string): Promise {
@@ -190,6 +200,20 @@ export async function pollQrStatus(
// --- Context Tokens 持久化 ---
+export interface PersistedUpdateCursor {
+ accountId: string
+ cursor: string
+}
+
+export async function loadUpdateCursor(accountId: string): Promise {
+ const data = await readJsonFile(UPDATE_CURSOR_FILE)
+ return data?.accountId === accountId ? data.cursor : ''
+}
+
+export async function saveUpdateCursor(accountId: string, cursor: string): Promise {
+ await writeJsonFile(UPDATE_CURSOR_FILE, { accountId, cursor })
+}
+
export interface PersistedContextTokens {
/** 上次活跃的微信用户 ID */
lastUserId: string | null
diff --git a/src/client.ts b/src/client.ts
index db2e383..a59229c 100644
--- a/src/client.ts
+++ b/src/client.ts
@@ -20,7 +20,9 @@ import { debugLog } from './logger.js'
import type { Credentials, IncomingMessage, MessageItem, WeixinMessage } from './types.js'
import {
loadContextTokens,
+ loadUpdateCursor,
saveContextTokensThrottled,
+ saveUpdateCursor,
flushContextTokens,
} from './auth.js'
import {
@@ -147,7 +149,11 @@ export class WeixinClient {
}
private async _init(): Promise {
- const persisted = await loadContextTokens()
+ const [persisted, cursor] = await Promise.all([
+ loadContextTokens(),
+ loadUpdateCursor(this.credentials.accountId),
+ ])
+ this.cursor = cursor
this._lastActiveUserId = persisted.lastUserId
for (const [userId, token] of Object.entries(persisted.tokens)) {
this.contextTokens.set(userId, token)
@@ -186,7 +192,11 @@ export class WeixinClient {
throw error
}
- this.cursor = response.get_updates_buf || this.cursor
+ const nextCursor = response.get_updates_buf || this.cursor
+ if (nextCursor !== this.cursor) {
+ this.cursor = nextCursor
+ await saveUpdateCursor(this.credentials.accountId, this.cursor)
+ }
const incoming: IncomingMessage[] = []
for (const raw of response.msgs ?? []) {
diff --git a/src/commands.ts b/src/commands.ts
index 720b8f4..3b99ed4 100644
--- a/src/commands.ts
+++ b/src/commands.ts
@@ -150,6 +150,8 @@ async function cmdStatus(_args: string, ctx: Ctx, deps: CommandDeps): Promise', '/wechat config image-wait 8000', '/wechat config image-max 50',
].join('\n'), 'info')
return
}
+ if (key === 'user') {
+ if (!value) { deps.notify('请提供微信用户 ID;可先让用户发消息,再用 /wechat status 查看“已见用户”', 'error'); return }
+ config.allowedUserId = value
+ await saveConfig(config)
+ deps.notify('授权微信用户已更新 ✅', 'info')
+ return
+ }
const numeric = Number(value)
if (!Number.isFinite(numeric) || numeric <= 0) { deps.notify('配置值必须是正数', 'error'); return }
if (key === 'image-wait') {
@@ -188,7 +198,7 @@ async function cmdConfig(args: string, ctx: Ctx, deps: CommandDeps): Promise | null = null
ended = false
+ toolProgressSent = false
+ startedAt: number | null = null
+ activeTool: string | null = null
reset(): void {
this.wechatConversationActive = false
@@ -45,6 +48,9 @@ class TurnContext {
this.sentCount = 0
this.messages = null
this.ended = false
+ this.toolProgressSent = false
+ this.startedAt = null
+ this.activeTool = null
}
}
@@ -83,7 +89,7 @@ function guardSendToWechat(
if (!lastWechatUser) return { allowed: false, error: fail('尚未收到微信用户消息,无法获取 context_token。请先让微信用户发送一条消息。') }
const cwd = latestCtx?.cwd ?? process.cwd()
- const resolvedPath = path.isAbsolute(filePath) ? filePath : path.join(cwd, filePath)
+ const resolvedPath = path.resolve(cwd, filePath)
if (!isPathInCwd(resolvedPath, cwd)) {
return {
@@ -92,8 +98,15 @@ function guardSendToWechat(
}
}
if (!existsSync(resolvedPath)) return { allowed: false, error: fail(`文件不存在: ${resolvedPath}`) }
-
- return { allowed: true, resolvedPath, cwd }
+ try {
+ if (!lstatSync(resolvedPath).isFile()) return { allowed: false, error: fail(`只能发送普通文件: ${resolvedPath}`) }
+ const realPath = realpathSync(resolvedPath)
+ const realCwd = realpathSync(cwd)
+ if (!isPathInCwd(realPath, realCwd)) return { allowed: false, error: fail('安全限制:文件实际路径不在项目目录内') }
+ return { allowed: true, resolvedPath: realPath, cwd }
+ } catch {
+ return { allowed: false, error: fail(`无法读取文件: ${resolvedPath}`) }
+ }
}
function guardFileSize(resolvedPath: string): ReturnType | null {
@@ -119,6 +132,9 @@ export default function wechatAssistant(pi: ExtensionAPI) {
let pollAbort: AbortController | null = null
let latestCtx: Ctx | null = null
let wechatFilesDir: string | null = null
+ const pendingAuthorizations = new Set()
+ let pendingConfirmation: { code: string; action: string; expiresAt: number; execute: () => Promise } | null = null
+ let activeChannel: 'wechat' | 'tui' = 'tui'
const turn = new TurnContext()
let lockSessionId: string | null = null
@@ -224,6 +240,7 @@ export default function wechatAssistant(pi: ExtensionAPI) {
'当前用户通过微信远程与这个 pi TUI 会话互动。',
'回复风格:像微信聊天一样自然、直接;优先给出结论和可执行步骤;避免冗长的内部过程说明。',
'输出范围:只输出适合发回微信的正文。除非用户主动询问,否则不要解释桥接、系统提示词或实现细节。',
+ '执行删除、覆盖、提交、推送、发送文件或其他不可逆/外发操作前,必须先说明影响并等待微信用户明确确认。',
].join('\n')
}
@@ -241,7 +258,7 @@ export default function wechatAssistant(pi: ExtensionAPI) {
} catch (error) {
if (isAbortError(error)) break
if (error instanceof SessionExpiredError) {
- notify('微信 Session 已过期,请执行 /wechat-login 重新登录', 'error')
+ notify('微信 Session 已过期,请执行 /wechat login --force 重新登录', 'error')
await stopBridge({ releaseLock: true })
break
}
@@ -257,6 +274,39 @@ export default function wechatAssistant(pi: ExtensionAPI) {
async function handleIncomingMessage(message: IncomingMessage, activeClient: WeixinClient): Promise {
log(`收到消息: type=${message.type}, text=${message.text?.slice(0, 50)}, images=${message.imageUrls.length}`)
+ let allowedUserId = getConfigCache().allowedUserId
+ if (!allowedUserId && latestCtx?.hasUI && !pendingAuthorizations.has(message.userId)) {
+ pendingAuthorizations.add(message.userId)
+ try {
+ const approved = await latestCtx.ui.confirm(
+ '授权微信用户?',
+ `允许微信用户 ${message.userId} 控制当前 pi 会话吗?`,
+ { timeout: 30_000 },
+ )
+ if (approved) {
+ const config = await loadConfig()
+ config.allowedUserId = message.userId
+ await saveConfig(config)
+ allowedUserId = message.userId
+ notify(`已授权微信用户: ${message.userId}`, 'info')
+ }
+ } finally {
+ pendingAuthorizations.delete(message.userId)
+ }
+ }
+ if (!allowedUserId || message.userId !== allowedUserId) {
+ log(`拒绝未授权微信用户: ${message.userId}`)
+ return
+ }
+
+ if (activeChannel !== 'wechat') {
+ activeChannel = 'wechat'
+ void activeClient.sendText(message.userId, '📱 已切回微信端继续对话。').catch(() => {})
+ }
+
+ if (message.text.trim() === '开始') message.text = '/start'
+ if (message.text.trim() === '取消') message.text = '/cancel'
+
if (UNSUPPORTED_TYPES.has(message.type)) {
const reply = UNSUPPORTED_REPLY[message.type] ?? UNSUPPORTED_REPLY['unknown']
try {
@@ -284,6 +334,32 @@ export default function wechatAssistant(pi: ExtensionAPI) {
getCtx: () => latestCtx,
client: () => client,
queueLength: () => queue.pending,
+ cancelPending: () => queue.cancelPending(),
+ startPending: () => queue.startPending(),
+ taskStatus: () => {
+ if (agentIdle || !turn.startedAt) return '当前没有运行中的 Agent 任务'
+ const seconds = Math.floor((Date.now() - turn.startedAt) / 1000)
+ return `任务运行中:${seconds}s${turn.activeTool ? `\n当前步骤:${turn.activeTool}` : ''}\n排队消息:${queue.pending}`
+ },
+ pendingStatus: () => {
+ if (!pendingConfirmation) return '当前没有待确认操作'
+ const seconds = Math.max(0, Math.ceil((pendingConfirmation.expiresAt - Date.now()) / 1000))
+ return seconds > 0
+ ? `待确认:${pendingConfirmation.action}\n确认码:${pendingConfirmation.code}\n剩余时间:${seconds}s`
+ : '待确认操作已过期,请重新发起'
+ },
+ requestConfirmation: (action, execute) => {
+ const code = randomBytes(3).toString('hex').toUpperCase()
+ pendingConfirmation = { code, action, execute, expiresAt: Date.now() + 5 * 60_000 }
+ return `⚠️ 即将${action}。回复 /confirm ${code} 执行(5 分钟内有效)`
+ },
+ approveConfirmation: async (code) => {
+ const pending = pendingConfirmation
+ if (!pending || !code || code.toUpperCase() !== pending.code) return '❌ 没有匹配的待确认操作'
+ pendingConfirmation = null
+ if (Date.now() > pending.expiresAt) return `⌛ 操作“${pending.action}”已过期,请重新发起`
+ return pending.execute()
+ },
}
// --- TUI 命令注册 ---
@@ -413,6 +489,10 @@ export default function wechatAssistant(pi: ExtensionAPI) {
if (event.source === 'extension') return
const text = event.text?.trim()
if (!text || text.startsWith('/')) return
+ if (turn.wechatConversationActive && turn.targetUser && client) {
+ void client.sendText(turn.targetUser, '💻 当前会话已切换到电脑端继续,后续回复将显示在电脑端。').catch(() => {})
+ }
+ activeChannel = 'tui'
turn.wechatConversationActive = false
})
@@ -433,6 +513,9 @@ export default function wechatAssistant(pi: ExtensionAPI) {
latestCtx = ctx
agentIdle = false
turn.sentCount = 0
+ turn.toolProgressSent = false
+ turn.startedAt = Date.now()
+ turn.activeTool = null
turn.messages = null
turn.ended = false
@@ -448,36 +531,16 @@ export default function wechatAssistant(pi: ExtensionAPI) {
}
})
- // 增量发送(仅微信触发的 turn)
- pi.on('message_end', async (event, ctx) => {
- if (event.message.role !== 'assistant') return
- if (!running || !client || !turn.wechatConversationActive) return
-
- const targetUserId = turn.targetUser
- if (!targetUserId) {
- log(`[MSG-END-SKIP] no target user`)
- return
- }
-
- const text = extractTextFromMessageContent(event.message.content)
- if (!text) {
- log(`[MSG-END-SKIP] no text content (likely toolCall only)`)
- return
- }
-
- log(`[MSG-END] incremental send to ${targetUserId}, textLen=${text.length} preview=${text.slice(0, 60)} sentCount=${turn.sentCount}`)
-
- try {
- const chunks = splitAndFilterMarkdown(text)
- for (let i = 0; i < chunks.length; i++) {
- log(`[MSG-END-CHUNK] ${i + 1}/${chunks.length} len=${chunks[i].length}`)
- await client.sendText(targetUserId, chunks[i])
- }
- turn.sentCount++
- log(`[MSG-END-DONE] incrementally sent, totalSent=${turn.sentCount}`)
- } catch (err) {
- log(`[MSG-END-ERROR] ${formatError(err)}`)
+ // 首个工具调用时给微信简短进度,避免暴露命令、路径或工具参数。
+ pi.on('tool_execution_start', async (event) => {
+ if (!running || !client || !turn.wechatConversationActive || !turn.targetUser || turn.toolProgressSent) return
+ turn.toolProgressSent = true
+ turn.activeTool = event.toolName
+ const label: Record = {
+ read: '读取项目文件', grep: '检索项目内容', find: '查找项目文件', ls: '查看项目目录',
+ write: '生成文件', edit: '修改文件', bash: '执行项目操作', web_search: '查询资料',
}
+ await client.sendText(turn.targetUser, `⏳ 正在${label[event.toolName] ?? '处理请求'}...`).catch(() => {})
})
// agent 结束 → 补发遗漏 + 收尾
diff --git a/src/media.ts b/src/media.ts
index 7754053..306c3b0 100644
--- a/src/media.ts
+++ b/src/media.ts
@@ -27,6 +27,31 @@ function aesDecryptECB(encrypted: Buffer, keyHex: string): Buffer {
return decrypted.subarray(0, decrypted.length - padLen)
}
+async function readResponseLimited(response: Response, maxBytes: number): Promise {
+ const contentLength = Number(response.headers.get('content-length') ?? '0')
+ if (Number.isFinite(contentLength) && contentLength > maxBytes) return null
+ if (!response.body) return Buffer.alloc(0)
+
+ const reader = response.body.getReader()
+ const chunks: Uint8Array[] = []
+ let size = 0
+ try {
+ while (true) {
+ const { done, value } = await reader.read()
+ if (done) break
+ size += value.byteLength
+ if (size > maxBytes) {
+ await reader.cancel()
+ return null
+ }
+ chunks.push(value)
+ }
+ return Buffer.concat(chunks)
+ } finally {
+ reader.releaseLock()
+ }
+}
+
// --- 图片 ---
export interface ImageData {
@@ -53,14 +78,9 @@ export async function fetchImageAsBase64(
debugLog(`图片下载失败: 非图片 content-type=${rawContentType}`)
return null
}
- const contentLength = Number(response.headers.get('content-length') ?? '0')
- if (contentLength > maxBytes) {
- debugLog(`图片下载失败: content-length=${contentLength} 超过限制 ${maxBytes}`)
- return null
- }
- const buffer = Buffer.from(await response.arrayBuffer())
- if (buffer.byteLength > maxBytes) {
- debugLog(`图片下载失败: 实际大小=${buffer.byteLength} 超过限制 ${maxBytes}`)
+ const buffer = await readResponseLimited(response, maxBytes)
+ if (!buffer) {
+ debugLog(`图片下载失败: 超过限制 ${maxBytes}`)
return null
}
debugLog(`图片下载成功: ${buffer.byteLength} bytes, type=${contentType}`)
@@ -78,6 +98,7 @@ export async function fetchImageAsBase64(
export async function fetchFile(
encryptParam: string,
aesKey: string | undefined,
+ maxBytes: number,
signal?: AbortSignal,
): Promise {
try {
@@ -88,7 +109,11 @@ export async function fetchFile(
debugLog(`文件下载失败: HTTP ${response.status}`)
return null
}
- const buffer = Buffer.from(await response.arrayBuffer())
+ const buffer = await readResponseLimited(response, maxBytes)
+ if (!buffer) {
+ debugLog(`文件下载失败: 超过限制 ${maxBytes}`)
+ return null
+ }
debugLog(`文件下载成功: ${buffer.byteLength} bytes`)
if (aesKey) {
const hexKey = Buffer.from(aesKey, 'base64').toString('utf-8')
diff --git a/src/queue.ts b/src/queue.ts
index 0f700b5..f7a38d5 100644
--- a/src/queue.ts
+++ b/src/queue.ts
@@ -91,6 +91,8 @@ export class MessageQueue {
/** 待注入的已保存文件列表(文件不进入队列和 AI 注入,等文字/图片触发时拼接) */
private accumulatedFiles: Array<{ name: string; size: number; path: string }> = []
+ private readonly fileTasks = new Set>()
+ private fileGeneration = 0
/** 最后对话的微信用户(用于桥接双向同步) */
lastWechatUser: { userId: string; contextToken: string } | null = null
@@ -132,7 +134,9 @@ export class MessageQueue {
// 文件:立即下载保存 + 自动回复,不入队,不参与计时器
if (hasFile) {
- void this._handleFile(message)
+ const task = this._handleFile(message, this.fileGeneration)
+ this.fileTasks.add(task)
+ void task.finally(() => this.fileTasks.delete(task))
}
// 文字部分
@@ -191,12 +195,12 @@ export class MessageQueue {
}
/** 文件处理:下载 → 保存到项目目录 → 自动回复 → 累积 */
- private async _handleFile(message: IncomingMessage): Promise {
+ private async _handleFile(message: IncomingMessage, generation: number): Promise {
const log = debugLog
const client = this.getClient()
const fileName = message.fileName ?? '未知文件'
- const buffer = await fetchFile(message.fileEncryptParam!, message.fileAesKey, this.getPollSignal())
+ const buffer = await fetchFile(message.fileEncryptParam!, message.fileAesKey, getImageMaxBytes(), this.getPollSignal())
if (!buffer) {
log(`[FILE-DL-FAIL] ${fileName}`)
if (client) void client.sendText(message.userId, `⚠️ 文件下载失败: ${fileName}`).catch(() => {})
@@ -214,6 +218,7 @@ export class MessageQueue {
return
}
+ if (generation !== this.fileGeneration) return
this.accumulatedFiles.push({ name: fileName, size: buffer.length, path: savedPath })
log(`[FILE-SAVED] ${fileName} (${(buffer.length / 1024).toFixed(1)} KB) → ${savedPath}`)
@@ -268,16 +273,16 @@ export class MessageQueue {
const log = debugLog
const client = this.getClient()
- const pendingFiles = this.accumulatedFiles.splice(0)
-
- log(`[DRAIN-ENTER] running=${this.isRunning()} client=${!!client} queue=${this.queue.length} pendingFiles=${pendingFiles.length} pendingInjection=${!!this.pendingInjection} activeRequest=${!!this.activeRequest} agentIdle=${this.getAgentIdle()} batchTimer=${!!this.batchTimer}`)
+ log(`[DRAIN-ENTER] running=${this.isRunning()} client=${!!client} queue=${this.queue.length} pendingInjection=${!!this.pendingInjection} activeRequest=${!!this.activeRequest} agentIdle=${this.getAgentIdle()} batchTimer=${!!this.batchTimer}`)
if (!this.isRunning() || !client) { log(`[DRAIN-SKIP] not running or no client`); return }
if (this.pendingInjection) { log(`[DRAIN-SKIP] pendingInjection already set`); return }
- if (this.batchTimer) { log(`[DRAIN-SKIP] 批处理计时器运行中`); return }
- if (this.queue.length === 0 && pendingFiles.length === 0) { log(`[DRAIN-SKIP] queue empty and no pending files`); return }
+ if (this.batchTimer) { log(`[DRAIN-SKIP] 图片批处理计时器运行中`); return }
+ if (this.queue.length === 0) { log(`[DRAIN-SKIP] queue empty`); return }
+ await Promise.all([...this.fileTasks])
+ const pendingFiles = this.accumulatedFiles.splice(0)
const batch = this.queue.splice(0)
- if (batch.length === 0) { log(`[DRAIN-SKIP] queue empty after splice`); return }
+ if (batch.length === 0) { log(`[DRAIN-SKIP] queue empty after file download`); return }
log(`[DRAIN-BATCH] msgs=${batch.length} pendingFiles=${pendingFiles.length}`)
this.imageBatchAckSent = false
@@ -331,9 +336,15 @@ export class MessageQueue {
return
}
+ if (hasFiles || hadImageMessages) {
+ await client.sendText(first.userId, '⏳ 正在处理你发送的内容...').catch(() => {})
+ }
+
+ const source = isBusy ? '【微信追加消息:请结合当前任务处理】' : '【微信消息】'
+
if (hasFiles) {
const combinedText = texts.join('\n') + fileNote
- const content: Array<{ type: 'text'; text: string } | { type: 'image'; data: string; mimeType: string }> = [{ type: 'text', text: combinedText }]
+ const content: Array<{ type: 'text'; text: string } | { type: 'image'; data: string; mimeType: string }> = [{ type: 'text', text: `${source}\n${combinedText}` }]
for (const img of images) content.push({ type: 'image', data: img.data, mimeType: img.mediaType })
const deliverOpts = isBusy ? { deliverAs: 'followUp' as const } : undefined
log(`[DRAIN-SEND] file+text, files=${files.length}, images=${images.length}, mode=${deliverOpts?.deliverAs ?? 'direct'}`)
@@ -341,9 +352,9 @@ export class MessageQueue {
} else if (hasImages) {
const content: Array<{ type: 'text'; text: string } | { type: 'image'; data: string; mimeType: string }> = []
if (!hasText) {
- content.push({ type: 'text', text: (images.length === 1 ? '请帮我分析这张图片' : `请帮我分析这 ${images.length} 张图片`) })
+ content.push({ type: 'text', text: `${source}\n${images.length === 1 ? '请帮我分析这张图片' : `请帮我分析这 ${images.length} 张图片`}` })
} else {
- content.push({ type: 'text', text: texts.join('\n') })
+ content.push({ type: 'text', text: `${source}\n${texts.join('\n')}` })
}
for (const img of images) content.push({ type: 'image', data: img.data, mimeType: img.mediaType })
const deliverOpts = isBusy ? { deliverAs: 'followUp' as const } : undefined
@@ -352,7 +363,7 @@ export class MessageQueue {
} else if (hasText) {
const deliverOpts = isBusy ? { deliverAs: 'followUp' as const } : undefined
log(`[DRAIN-SEND] text, text=${texts.join(' ').slice(0, 80)}, mode=${deliverOpts?.deliverAs ?? 'direct'}`)
- this.sendUserMessage(texts.join('\n'), deliverOpts)
+ this.sendUserMessage(`${source}\n${texts.join('\n')}`, deliverOpts)
} else {
if (hadImageMessages) {
const limitMB = Math.round(getImageMaxBytes() / 1024 / 1024)
@@ -387,16 +398,30 @@ export class MessageQueue {
}
}
+ /** 立即开始当前图片批次。 */
+ startPending(): void {
+ if (this.batchTimer) { clearTimeout(this.batchTimer); this.batchTimer = null }
+ void this.drain()
+ }
+
+ /** 取消尚未注入 agent 的消息和图片/文件批处理。 */
+ cancelPending(): void {
+ this.fileGeneration++
+ this.queue.length = 0
+ this.accumulatedFiles = []
+ this.imageBatchAckSent = false
+ if (this.batchTimer) { clearTimeout(this.batchTimer); this.batchTimer = null }
+ this.updateStatusBar()
+ }
+
// --- 重置 ---
reset(): void {
- this.queue.length = 0
+ this.cancelPending()
this.pendingInjection = null
this.activeRequest = null
- this.imageBatchAckSent = false
- this.accumulatedFiles = []
+ this.fileTasks.clear()
this._draining = false
- if (this.batchTimer) { clearTimeout(this.batchTimer); this.batchTimer = null }
}
get pending(): number {
diff --git a/src/remote-commands.ts b/src/remote-commands.ts
index 75e39ea..8621f8d 100644
--- a/src/remote-commands.ts
+++ b/src/remote-commands.ts
@@ -15,11 +15,32 @@ export interface RemoteCommandDeps {
getCtx: () => Ctx | null
client: () => WeixinClient | null
queueLength: () => number
+ cancelPending: () => void
+ startPending: () => void
+ taskStatus: () => string
+ pendingStatus: () => string
+ requestConfirmation: (action: string, execute: () => Promise) => string
+ approveConfirmation: (code: string) => Promise
}
type RemoteCommandFn = (args: string, userId: string, client: WeixinClient, deps: RemoteCommandDeps) => Promise
+let lastModelChoices: Array<{ provider: string; id: string }> = []
+
const commands: Record = {
+ async login(args, _userId, _client, deps) {
+ const provider = args.trim()
+ if (!provider) return '用法: /login ,例如 /login openai'
+ if (!/^[\w.-]+$/.test(provider)) return '❌ 无效的 provider 名称'
+ return deps.requestConfirmation(`在服务器端发起 ${provider} 登录`, async () =>
+ '请在服务器 TUI 执行 /login ' + provider + ';若该 provider 支持设备码授权,可在手机浏览器完成。',
+ )
+ },
+
+ async confirm(args, _userId, _client, deps) {
+ return deps.approveConfirmation(args.trim())
+ },
+
async model(args, userId, client, deps) {
const ctx = deps.getCtx()
if (!ctx) return '❌ 会话上下文尚未就绪,请稍后再试'
@@ -27,18 +48,24 @@ const commands: Record = {
if (!args) {
const models = registry.getAvailable()
const current = ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : 'unknown'
- const lines = [`当前模型: ${current}`, '', '可用模型:']
+ const lines = [`当前模型: ${current}`, '', '可用模型(可使用 /model 编号 切换):']
const seen = new Set()
+ lastModelChoices = []
for (const m of models) {
const key = `${m.provider}/${m.id}`
if (seen.has(key)) continue
seen.add(key)
- lines.push(` ${key}${key === current ? ' ←' : ''}`)
+ lastModelChoices.push({ provider: m.provider, id: m.id })
+ lines.push(` ${lastModelChoices.length}. ${key}${key === current ? ' ←' : ''}`)
}
return lines.join('\n')
}
let model
- if (args.includes('/')) {
+ const choice = Number(args)
+ if (Number.isInteger(choice) && choice >= 1 && choice <= lastModelChoices.length) {
+ const selected = lastModelChoices[choice - 1]
+ model = registry.find(selected.provider, selected.id)
+ } else if (args.includes('/')) {
const [provider, ...idParts] = args.split('/')
model = registry.find(provider, idParts.join('/'))
} else {
@@ -47,10 +74,12 @@ const commands: Record = {
}
}
if (!model) return `❌ 未找到模型: ${args}\n输入 /model 查看可用列表`
- const success = await deps.pi.setModel(model)
- return success
- ? `✅ 已切换模型: ${model.provider}/${model.id}`
- : `❌ 切换失败: ${model.provider}/${model.id} 没有可用的 API key`
+ return deps.requestConfirmation(`切换模型到 ${model.provider}/${model.id}`, async () => {
+ const success = await deps.pi.setModel(model)
+ return success
+ ? `✅ 已切换模型: ${model.provider}/${model.id}`
+ : `❌ 切换失败: ${model.provider}/${model.id} 没有可用的 API key`
+ })
},
async thinking(args, _userId, _client, deps) {
@@ -76,18 +105,40 @@ const commands: Record = {
const allNames = deps.pi.getAllTools().map(t => t.name)
const invalid = toolNames.filter(t => !allNames.includes(t))
if (invalid.length > 0) return `❌ 未知工具: ${invalid.join(', ')}\n输入 /tools 查看全部`
- deps.pi.setActiveTools(toolNames.filter(t => allNames.includes(t)))
- return `✅ 活跃工具已设为: ${toolNames.filter(t => allNames.includes(t)).join(', ')}`
+ return deps.requestConfirmation(`修改活跃工具为 ${toolNames.join(', ')}`, async () => {
+ deps.pi.setActiveTools(toolNames.filter(t => allNames.includes(t)))
+ return `✅ 活跃工具已设为: ${toolNames.filter(t => allNames.includes(t)).join(', ')}`
+ })
},
async compact(_args, userId, client, deps) {
const ctx = deps.getCtx()
if (!ctx) return '❌ 会话上下文尚未就绪'
+ return deps.requestConfirmation('压缩当前会话上下文', async () => {
ctx.compact({
onComplete: () => { void client.sendText(userId, '✅ 上下文压缩完成') },
onError: (error) => { void client.sendText(userId, `❌ 压缩失败: ${error.message}`) },
})
return '⏳ 正在压缩上下文...'
+ })
+ },
+
+ async start(_args, _userId, _client, deps) {
+ deps.startPending()
+ return '⏳ 已开始处理当前图片/文件批次'
+ },
+
+ async pending(_args, _userId, _client, deps) {
+ return deps.pendingStatus()
+ },
+
+ async task(_args, _userId, _client, deps) {
+ return deps.taskStatus()
+ },
+
+ async cancel(_args, _userId, _client, deps) {
+ deps.cancelPending()
+ return '✅ 已取消尚未开始处理的消息、图片和文件批次'
},
async stop(_args, _userId, _client, deps) {
@@ -245,7 +296,13 @@ const commands: Record = {
'📋 微信远程命令:',
'',
'/status 查看当前状态',
+ '/task 查看当前任务',
+ '/pending 查看待确认操作',
+ '/start 立即处理图片/文件批次',
'/stop 停止当前生成',
+ '/cancel 取消尚未开始的图片/文件批次',
+ '/login 请求服务器端账号授权',
+ '/confirm 确认待执行操作',
'/model 查看 / 切换模型',
'/name <名称> 设置会话名称',
'/session 查看会话详情',
@@ -269,7 +326,10 @@ export async function handleRemoteCommand(
const [cmd, ...rest] = trimmed.slice(1).split(/\s+/)
const args = rest.join(' ')
const handler = commands[cmd]
- if (!handler) return false
+ if (!handler) {
+ await client.sendText(userId, `❌ 未知命令: /${cmd}\n发送 /help 查看可用命令`)
+ return true
+ }
try {
const reply = await handler(args, userId, client, deps)
if (reply !== null) await client.sendText(userId, reply)