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 @@ +

pi-wechat-assistant

+ +

A secure WeChat remote entry point for a pi session.

+ +

+ Switch to Chinese + English + npm + MIT +

+ +> 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 +``` + +Unauthorized messages are never injected into the Agent and cannot run remote commands. + +## pi TUI commands + +| Command | Description | +| --- | --- | +| `/wechat login` | Scan in or load saved credentials | +| `/wechat login --force` | Clear the old session and scan again | +| `/wechat start` / `/wechat stop` | Start or stop the bridge | +| `/wechat status` | Show connection, account, known users, and queue state | +| `/wechat logout` | Stop and clear credentials/context tokens | +| `/wechat autostart` | Toggle startup connection | +| `/wechat config user ` | Set the sole authorized WeChat user | +| `/wechat config image-wait ` | Image batch wait; default 8000ms | +| `/wechat config image-max ` | Per-image/per-file limit; default 50MB | + +## WeChat commands + +Send text, voice, images, or files for normal Agent interaction. Input is labelled as a WeChat message; while the Agent is busy, new input is labelled as a WeChat follow-up. + +| Command | Description | +| --- | --- | +| `/help` | Command help | +| `/status` | Model, context, tools, queue, and media limits | +| `/task` | Current task, elapsed time, and active step | +| `/pending` | Pending confirmation and time remaining | +| `/start` or `开始` | Start the current image/file batch immediately | +| `/stop` | Abort the active Agent run | +| `/cancel` or `取消` | Discard an image/file batch not yet started | +| `/model` / `/model ` | List numbered models / request a model change | +| `/thinking ` | Set thinking level | +| `/tools` / `/tools ` | List tools / request an active-tool change | +| `/compact` | Request context compaction | +| `/name ` | Set the session name | +| `/session` | Session details and statistics | +| `/config` | Media configuration | +| `/login ` | Request login instructions | +| `/confirm ` | 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 会话的安全远程入口。

- npm - License - Node + 中文 + Switch to English + npm + MIT

-

- English · 中文 · 日本語 · 한국어 -

- ---- - - - -## 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)