chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:33:50 +08:00
commit 30b27fe8fb
43 changed files with 7691 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
node_modules/
dist/
*.log
.env
.wechat-claude-code/
.claude/
CLAUDE.md
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Wechat-ggGitHub
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+133
View File
@@ -0,0 +1,133 @@
# WeChat Claude Code Bridge
<p align="center">
<strong>Chat with Claude Code in WeChat, just like texting a friend</strong>
</p>
<p align="center">
<a href="https://github.com/Wechat-ggGitHub/wechat-claude-code/blob/main/LICENSE"><img src="https://img.shields.io/badge/License-MIT-green?style=flat-square" alt="License: MIT"></a>
<a href="https://skills.sh/Wechat-ggGitHub/wechat-claude-code"><img src="https://img.shields.io/badge/skills.sh-view_page-blue?style=flat-square" alt="skills.sh"></a>
<a href="README_en.md"><img src="https://img.shields.io/badge/Lang-English-lightgrey?style=flat-square" alt="English"></a>
</p>
扫码绑定微信后,你的微信里会多出一个好友。给它发消息,消息会自动转发给你电脑上运行的 Claude Code,回复也会实时推送到微信。支持文字、图片、语音、文件的收发。
<img width="3018" height="1216" alt="ScreenShot_2026-06-10_211251_410" src="https://github.com/user-attachments/assets/2ba4c53b-9c63-4ffd-bd0a-71935a6eabec" />
## 核心亮点
| | |
|---|---|
| **扫码即用** | 不用注册账号,不用部署服务器。微信扫码绑定,一分钟搞定。数据全在本地,隐私有保障。 |
| **消息不刷屏** | 只推送核心信息——进度、结果、关键决策。工具调用、中间过程等噪音自动过滤,阅读体验清爽。 |
| **"对方正在输入中..."** | Claude 在处理任务时,微信顶部会显示输入状态,随时感知它在干活。 |
| **电脑手机体验一致** | 手机端和电脑端 Claude Code 行为完全相同——同样的编排逻辑、同样的输出效果。不是两个割裂的 AI。 |
| **文件双向收发** | 发图片、Word、PDF 给 Claude 分析;Claude 生成的文件也会直接推送到微信,不用回到电脑前查看。 |
| **超时安抚** | 任务超过 5 分钟没响应?它会自动发一条消息告诉你还在干,不会让你对着空白聊天框干等。 |
## 快速安装
**方式一:skills CLI(推荐)**
```bash
npx skills add Wechat-ggGitHub/wechat-claude-code
```
首次在对话中触发时,会自动克隆项目源码并安装依赖。
**方式二:手动克隆**
```bash
git clone https://github.com/Wechat-ggGitHub/wechat-claude-code.git ~/.claude/skills/wechat-claude-code
cd ~/.claude/skills/wechat-claude-code && npm install
```
## 快速开始
### 1. 扫码绑定
```bash
cd ~/.claude/skills/wechat-claude-code
npm run setup
```
弹出二维码,用微信扫码。
### 2. 启动服务
```bash
npm run daemon -- start
```
macOS 下自动注册 launchd,开机自启、崩溃自动重启。
### 3. 开始聊天
打开微信,给你新出现的那个"好友"发条消息试试。
### 管理服务
```bash
npm run daemon -- status # 查看运行状态
npm run daemon -- stop # 停止服务
npm run daemon -- restart # 重启服务(更新代码后使用)
npm run daemon -- logs # 查看日志
```
## 微信端命令
直接在微信聊天中发送即可:
| 命令 | 说明 |
|------|------|
| `/help` | 显示帮助 |
| `/clear` | 清除当前会话,开始新对话 |
| `/stop` | 停止当前任务 |
| `/model <名称>` | 切换 Claude 模型 |
| `/prompt <内容>` | 设置系统提示词(如"用中文回答" |
| `/cwd <路径>` | 切换工作目录 |
| `/skills` | 查看已安装的 Skill |
| `/status` | 查看当前会话状态 |
| `/history [数量]` | 查看最近对话记录 |
| `/compact` | 压缩上下文,开始新 CLI 会话 |
| `/reset` | 完全重置(包括工作目录等设置) |
| `/undo [数量]` | 撤销最近几条对话 |
| `/<skill> [参数]` | 触发任意已安装的 Skill |
## 工作原理
```
微信(手机) ←→ ilink Bot API ←→ Node.js 守护进程 ←→ Claude Code CLI(本地)
```
守护进程通过长轮询监听微信消息,转发给本地 `claude` CLI 处理,回复实时流式推送回微信。全程跑在你自己电脑上。
## 后续计划
- **消息队列优化** — 连续发多条指令时,回复容易串。正在研究更好的队列策略,也欢迎讨论。
- **电脑休眠不中断** — 利用 macOS 的 `caffeinate` 命令阻止系统睡眠,合上盖子也能响应微信消息。
- **接续电脑会话** — 在电脑上聊了很久,出门想接着聊。计划支持从当前电脑端的 Claude Code 会话直接续聊,工作空间和上下文保持一致。
## 前置条件
- Node.js >= 18
- macOS 或 Linux
- 个人微信账号
- 已安装 [Claude Code](https://docs.anthropic.com/en/docs/claude-code) CLI 并完成认证
> **提示:** Claude Code 支持第三方 API 提供商(OpenRouter、AWS Bedrock 等),设置 `ANTHROPIC_BASE_URL` 和 `ANTHROPIC_API_KEY` 即可。
## 数据目录
所有数据存储在 `~/.wechat-claude-code/`
```
~/.wechat-claude-code/
├── accounts/ # 微信账号凭证
├── config.json # 全局配置
├── sessions/ # 会话数据
└── logs/ # 运行日志(每日轮转,保留 30 天)
```
## License
[MIT](LICENSE)
+7
View File
@@ -0,0 +1,7 @@
# WeHub 来源说明
- 原始项目:`Wechat-ggGitHub/wechat-claude-code`
- 原始仓库:https://github.com/Wechat-ggGitHub/wechat-claude-code
- 导入方式:上游默认分支的最新快照
- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准
- 本文件仅用于记录来源,不代表 WeHub 是原项目作者
+144
View File
@@ -0,0 +1,144 @@
# WeChat Claude Code Bridge
<p align="center">
<strong>Chat with Claude Code in WeChat, just like texting a friend</strong>
</p>
<p align="center">
<a href="https://github.com/Wechat-ggGitHub/wechat-claude-code/blob/main/LICENSE"><img src="https://img.shields.io/badge/License-MIT-green?style=flat-square" alt="License: MIT"></a>
<a href="https://skills.sh/Wechat-ggGitHub/wechat-claude-code"><img src="https://img.shields.io/badge/skills.sh-view_page-blue?style=flat-square" alt="skills.sh"></a>
<a href="README.md"><img src="https://img.shields.io/badge/Lang-中文-lightgrey?style=flat-square" alt="中文"></a>
</p>
Scan a QR code to bind your WeChat, and a new "friend" appears in your contacts. Send it a message — it gets forwarded to Claude Code running on your computer, and the reply streams back to WeChat in real time. Supports text, images, voice, and files.
---
## Highlights
| | |
|---|---|
| **Scan and go** | No account signup, no server deployment. Scan a QR code and you're done in a minute. All data stays on your machine. |
| **Clean messages** | Only key info gets pushed — progress, results, key decisions. Tool calls and intermediate noise are filtered out automatically. |
| **"Typing..." indicator** | WeChat shows a typing indicator while Claude is working, so you always know it's on it. |
| **Consistent experience** | Mobile and desktop Claude Code behave identically — same orchestration, same output. Not two disconnected AIs. |
| **Two-way files** | Send images, Word docs, PDFs for Claude to analyze. Files Claude generates get pushed directly to WeChat — no need to go back to your computer. |
| **Timeout reassurance** | Task taking longer than 5 minutes? You'll get an automatic message letting you know it's still working. |
---
## Install
**Option 1: skills CLI (recommended)**
```bash
npx skills add Wechat-ggGitHub/wechat-claude-code
```
The first time you trigger the skill, it will automatically clone the source and install dependencies.
**Option 2: Manual clone**
```bash
git clone https://github.com/Wechat-ggGitHub/wechat-claude-code.git ~/.claude/skills/wechat-claude-code
cd ~/.claude/skills/wechat-claude-code && npm install
```
## Quick Start
### 1. Bind WeChat
```bash
cd ~/.claude/skills/wechat-claude-code
npm run setup
```
A QR code will pop up — scan it with WeChat.
### 2. Start the service
```bash
npm run daemon -- start
```
On macOS, this registers a launchd agent for auto-start on boot and auto-restart on crash.
### 3. Start chatting
Open WeChat and send a message to your new "friend".
### Manage the service
```bash
npm run daemon -- status # Check if running
npm run daemon -- stop # Stop the service
npm run daemon -- restart # Restart (after code updates)
npm run daemon -- logs # View recent logs
```
---
## WeChat Commands
Send these directly in the WeChat chat:
| Command | Description |
|---------|-------------|
| `/help` | Show available commands |
| `/clear` | Clear current session, start fresh |
| `/stop` | Stop current task |
| `/model <name>` | Switch Claude model |
| `/prompt <text>` | Set a system prompt (e.g. "reply in Chinese") |
| `/cwd <path>` | Switch working directory |
| `/skills` | List installed Skills |
| `/status` | View current session state |
| `/history [n]` | View recent chat history |
| `/compact` | Compact context, start a new CLI session |
| `/reset` | Full reset including working directory |
| `/undo [n]` | Remove last N messages from history |
| `/<skill> [args]` | Trigger any installed Skill |
---
## How It Works
```
WeChat (phone) ←→ ilink Bot API ←→ Node.js daemon ←→ Claude Code CLI (local)
```
The daemon long-polls WeChat for new messages, forwards them to the local `claude` CLI, and streams replies back to WeChat. Everything runs on your own machine.
---
## Roadmap
- **Message queue optimization** — Consecutive messages can produce mixed-up replies. Working on a better queuing strategy. Ideas welcome.
- **Prevent sleep** — Use macOS `caffeinate` to keep the system awake, so closing the lid doesn't interrupt the service.
- **Resume desktop session** — Chat on your computer for a while, then continue the same session from WeChat on the go. Same workspace, same context.
---
## Prerequisites
- Node.js >= 18
- macOS or Linux
- A personal WeChat account
- [Claude Code](https://docs.anthropic.com/en/docs/claude-code) CLI installed and authenticated
> **Note:** Claude Code supports third-party API providers (OpenRouter, AWS Bedrock, etc.) — set `ANTHROPIC_BASE_URL` and `ANTHROPIC_API_KEY` accordingly.
## Data Directory
All data is stored in `~/.wechat-claude-code/`:
```
~/.wechat-claude-code/
├── accounts/ # WeChat account credentials
├── config.json # Global config
├── sessions/ # Session data
└── logs/ # Rotating logs (daily, 30-day retention)
```
## License
[MIT](LICENSE)
+139
View File
@@ -0,0 +1,139 @@
---
name: wechat-claude-code
description: 微信消息桥接 - 在微信中与 Claude Code 聊天。支持文字对话、图片识别、实时进度推送、斜杠命令。
---
# WeChat Claude Code Bridge
通过个人微信与本地 Claude Code 进行对话。
## 前置条件
- Node.js >= 18
- macOSdaemon 使用 launchd 管理)
- 个人微信账号(需扫码绑定)
- 已安装 Claude Code`@anthropic-ai/claude-agent-sdk`
## 安装
**方式一:通过 skills CLI(推荐)**
```bash
npx skills add Wechat-ggGitHub/wechat-claude-code
```
首次触发时 skill 会自动克隆完整项目源码并安装依赖。
**方式二:手动克隆**
```bash
git clone https://github.com/Wechat-ggGitHub/wechat-claude-code.git ~/.claude/skills/wechat-claude-code
cd ~/.claude/skills/wechat-claude-code && npm install
```
## 触发场景
用户提到"微信桥接"、"微信聊天"、"wechat bridge"、"连接微信"、"微信状态"、"停止微信"等与微信桥接相关的话题时触发。
## 触发后的执行流程
**被触发时,不要直接执行任何操作,先探查当前状态再给出可用操作。**
按顺序检查以下状态:
### 第 1 步:检查项目是否完整安装
```bash
test -f ~/.claude/skills/wechat-claude-code/package.json && echo "source_ok" || echo "source_missing"
```
- 如果 `source_missing`:需要从 GitHub 克隆完整项目。执行:
```bash
git clone https://github.com/Wechat-ggGitHub/wechat-claude-code.git /tmp/wechat-claude-code-install && cp -r /tmp/wechat-claude-code-install/{src,scripts,*.ts,*.json,*.md,LICENSE} ~/.claude/skills/wechat-claude-code/ && rm -rf /tmp/wechat-claude-code-install
```
然后继续检查依赖。
- 如果 `source_ok`:继续检查依赖。
```bash
cd ~/.claude/skills/wechat-claude-code && test -d node_modules && echo "deps_ok" || echo "deps_missing"
```
- 如果 `deps_missing`:执行 `cd ~/.claude/skills/wechat-claude-code && npm install` 安装依赖,然后继续。
- 如果 `deps_ok`:继续下一步。
### 第 2 步:检查是否已绑定微信账号
```bash
ls ~/.wechat-claude-code/accounts/*.json 2>/dev/null | head -1
```
- 如果没有账号文件:提示用户需要先执行 setup 扫码绑定,询问是否现在执行。
- 如果有账号文件:继续下一步。
### 第 3 步:检查 daemon 运行状态
```bash
cd ~/.claude/skills/wechat-claude-code && npm run daemon -- status
```
### 第 4 步:根据状态展示信息
**如果 daemon 未运行:**
```
微信桥接已绑定但未运行。
可用操作:
setup 重新扫码绑定(换号或过期时使用)
start 启动服务
logs 查看上次运行的日志
```
**如果 daemon 正在运行:**
```
微信桥接正在运行(PID: xxx)。
可用操作:
stop 停止服务
restart 重启服务(代码更新后使用)
logs 查看运行日志
微信端命令(直接在微信中发送):
/help 显示帮助
/clear 清除当前会话,开始新对话
/status 查看当前会话状态
/model 切换 Claude 模型
/prompt 设置系统提示词
/cwd 切换工作目录
/skills 查看已安装的 skill
```
如果用户明确指定了操作(如"启动微信"、"停止微信服务"、"看看日志"等),跳过状态展示直接执行对应命令。
## 子命令参考
所有命令的工作目录为 `~/.claude/skills/wechat-claude-code`。
| 命令 | 执行 | 说明 |
|------|------|------|
| setup | `npm run setup` | 首次安装向导:生成 QR 码 → 微信扫码 → 配置工作目录 |
| start | `npm run daemon -- start` | 启动 launchd 守护进程(开机自启、自动重启) |
| stop | `npm run daemon -- stop` | 停止守护进程 |
| restart | `npm run daemon -- restart` | 重启守护进程 |
| status | `npm run daemon -- status` | 查看运行状态 |
| logs | `npm run daemon -- logs` | 查看最近日志(tail -100 |
## 数据目录
所有数据存储在 `~/.wechat-claude-code/`
```
~/.wechat-claude-code/
├── accounts/ # 绑定的微信账号数据(每个账号一个 JSON)
├── config.env # 全局配置(工作目录、模型、系统提示词)
├── sessions/ # 会话数据(每个账号一个 JSON)
├── get_updates_buf # 消息轮询同步缓冲
└── logs/ # 运行日志(每日轮转,保留 30 天)
```
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,186 @@
# 微信消息分块优化:按回合分流最终答案
**日期**2026-06-20
**作者**brainstorming session
**状态**:待评审
## 1. 问题陈述
当前流式推送逻辑把 Claude CLI 的所有 `text_delta` 一视同仁地按"段落边界"切割推送,导致**最终答案也被切成大量碎片**。
真实 trace 验证(`/tmp/claude-stream-test/traces/`):
| Trace | 场景 | 现状推送条数 |
|---|---|:---:|
| A | 纯文本 Q&A1485 字) | **8 条** |
| D | tool_use + 9121 字长答案 | **59 条** |
Trace D 的 59 条会反复撞服务端 ~10 条/分钟的突发限制(`ret:-2`),触发多次 60s 冷却,**累计卡顿 5-6 分钟**。这是用户报告的"体验特别差"的根因。
## 2. 目标与非目标
### 目标
- **最终答案**(Claude 给用户的交付内容)尽可能合并成最少条消息,仅在超过微信单条 4000 字硬上限时按段落边界切分。
- **Agent loop 期间的 interstitial**(工具调用之间的简短评注)保持现状的实时段落推送,不引入额外延迟。
### 非目标
- **不改限流逻辑**。`api.ts` 的 2.5s 间隔、60s 冷却、指数退避全部保持现状。理由:Problem 1 修复后发送量大幅下降(D 场景 59 → 4),足以让绝大多数任务在突发限制以内完成;用户已确认接受重度 agent loop10+ 工具调用 + 密集 interstitial)仍可能撞墙的边界情况。
- 不改 `splitMessage` 的 4000 字上限。
- 不改 typing 指示器、silence warning(5min 兜底)、文件自动推送等机制。
## 3. 根因分析
`src/main.ts:574-590``onText` 回调对每个 `text_delta` 执行同一段 flush 判断:
```ts
const shouldFlush =
(endsWithStructuralBoundary(textBuffer) && textBuffer.trim().length >= MIN_BATCH_FLUSH_LEN)
|| textBuffer.length > SOFT_FLUSH_LIMIT;
```
这段逻辑同时作用于两类语义不同的文本:
1. **Interstitial**:agent 在工具调用之间吐出的简短进度("让我看一下代码"、"找到问题了")。
2. **Final answer**:回合自然结束时交付给用户的完整回答。
两者都被段落边界切分。`src/main.ts:610-621` 虽然有"整段重发"分支,但条件是 `!anySent`——只要流式期间推过任何内容就不再触发,所以最终答案只能依靠流式 flush,被切成 N 段。
## 4. 关键洞察:`stop_reason` 是明确的回合类型信号
抓取真实 NDJSON trace`claude -p - --output-format stream-json --verbose --include-partial-messages`)后发现:**每个 assistant 回合结束时会发 `message_delta` 事件,带明确的 `stop_reason` 字段**。
| `stop_reason` | 含义 | 文本应如何处理 |
|---|---|---|
| `"tool_use"` | 回合因要调工具结束,agent loop 继续 | 按段落 flushinterstitial |
| `"end_turn"` | 自然结束,这就是最终答案 | **整回合 buffer,流结束才一次性发** |
| `"max_tokens"` / `"stop_sequence"` / `"pause_turn"` | 非自然结束但属终态 | 同 `end_turn` |
这比早期讨论的"是否有 tool_use"启发式更可靠——不需要猜测、不需要特例处理纯 Q&A 场景,API 直接告诉我们这回合是什么。
## 5. 设计
### 5.1 状态机
`sendToClaude` 内部维护三个局部状态(替换现在的单个 `textBuffer`):
```ts
let turnBuffer = ''; // 当前回合累积的 text_delta
let pendingFinal = ''; // 标记为最终答案的回合文本(可能跨多个 end_turn 回合)
let anySent = false; // 是否曾成功推送(保留现有安全网语义)
```
### 5.2 事件路由
**`onText(delta)`**:每个 `text_delta` 追加到 `turnBuffer`。**不再做段落边界 flush 判断**——段落 flush 推迟到回合结束时根据 `stop_reason` 决定。
**`onTurnEnd(stopReason)`**(新回调,由 `provider.ts``message_delta` 事件时触发):
```ts
const turnText = turnBuffer;
turnBuffer = '';
if (stopReason === 'tool_use') {
// interstitial:按段落边界 flush(保留 agent loop 进度的实时性)
await flushInterstitial(turnText);
} else {
// end_turn / max_tokens / 等:标记为最终答案,不立即发
pendingFinal += (pendingFinal && turnText) ? '\n\n' : '';
pendingFinal += turnText;
}
```
**流结束**`claudeQuery` 返回后):
```ts
// 1. 先发最终答案(splitMessage 按 4000 字硬切,自然按段落打包)
await flushFinal(pendingFinal);
// 2. drain 残留 interstitial(保险)
await flushInterstitial(turnBuffer);
// 3. 安全网:完全没流式过任何内容时,用 result.text 整段发
if (!anySent && result.text) {
for (const chunk of splitMessage(result.text)) await sender.sendText(...);
}
```
### 5.3 `flushInterstitial` 与 `flushFinal`
两者都复用现有 `flushChain`(串行 promise 链)保证发送顺序,都调用 `splitMessage`
- `flushInterstitial(text)`:把 text 通过 `splitMessage` 切(对长 interstitial 也能处理),逐块 `sender.sendText`
- `flushFinal(text)`:同上。差别只在**调用时机**——interstitial 在回合结束立即调用,final 只在流结束调用。
实现上可以参数化合并成一个 `flush(text, role)` 函数,role 仅用于日志区分。
### 5.4 边界情况
| 场景 | 行为 |
|---|---|
| 纯 Q&A(无 tool_use,单回合 end_turn | 所有文本进 `pendingFinal`,流结束一次性发。**这正是 Trace A 的 8→1**。 |
| Agent 多轮 tool_use 后给最终答案 | 每个 tool_use 回合的文本立即当 interstitial 推;最后的 end_turn 回合 buffer 到流结束一次性推。 |
| 最终答案超 4000 字 | `splitMessage` 按段落边界硬切成 N 块(N = ceil(字数/4000))。Trace D 的 9121 字 → 3 块。 |
| 多个 end_turn 回合(罕见,如 `pause_turn` 后续接 end_turn | 用 `\n\n` 连接累积到 `pendingFinal`,结束时一起发。 |
| Abort(被新消息打断) | `provider.ts` 现有 `onAbort` 捕获 `partialText` 返回。`sendToClaude``finally` 会 drain 两个 bufferinterstitial + final);被打断时部分最终答案也能推送给用户,不丢内容。 |
| 流式完全失败(`onText` 从未触发) | `anySent` 保持 false,安全网分支用 `result.text` 整段发(沿用现有逻辑)。 |
### 5.5 不变的部分
- `splitMessage` / `parseBlocks` / `findSafeSplitPoint` / `splitByNewline`:完全不动,仍是最终切分手段。
- `MAX_MESSAGE_LENGTH = 4000`:不动。
- silence warning 5min 兜底(`flushTimer`):保留。长最终答案生成期间 typing 指示器 + 5min 兜底仍在。
- `result.text` 写入 chat history:不变。
- `api.ts` 限流逻辑:不变。
### 5.6 删除的死代码
新方案不再需要按段落边界判断 flush 时机,以下符号变成死代码,一并删除(遵循项目"不留无用符号"规范):
- `MIN_BATCH_FLUSH_LEN`(常量)
- `SOFT_FLUSH_LIMIT`(常量)
- `endsWithStructuralBoundary`(函数)
`onBlockEnd` 回调也不再需要——回合边界由 `onTurnEnd` 接管。`QueryOptions.onBlockEnd` 字段从 `provider.ts` 删除,`main.ts` 不再传它。
## 6. 改动文件
| 文件 | 改动 |
|---|---|
| `src/claude/provider.ts` | `stream_event` 分支里,`message_delta` 事件触发新回调 `onTurnEnd(stopReason)``QueryOptions` 增加可选字段 `onTurnEnd?: (stopReason: string) => void`**删除** `onBlockEnd` 字段及对应的 `content_block_stop` 分支调用(不再需要)。 |
| `src/main.ts` | `sendToClaude` 内:把单个 `textBuffer` 拆成 `turnBuffer` + `pendingFinal``onText` 只累积不 flush;新增 `onTurnEnd` 路由;流结束顺序改为先 `flushFinal``flushInterstitial`**删除** `MIN_BATCH_FLUSH_LEN``SOFT_FLUSH_LIMIT``endsWithStructuralBoundary``onBlockEnd` 入参。`splitMessage` 等辅助函数不动。 |
**改动量预估**`provider.ts` 加约 5 行(一个 case 分支 + 类型定义)- 3 行(删 onBlockEnd);`main.ts` 改约 30-40 行(路由重写)+ 删约 15 行(死代码)。净增量小。
## 7. 验证计划
### 7.1 单元测试
- `splitMessage` 行为不变(现有测试继续通过)。
- 新增:构造模拟的 `turn` 序列,验证 `simulateProposed` 对各种 `stop_reason` 组合的分流正确。
### 7.2 集成验证(用真实 trace 回放)
复用本次 brainstorming 期间的模拟器 `/tmp/claude-stream-test/simulate.mjs`,把 `provider.ts` 改完后的真实输出再抓一次 trace,确认推送条数符合预期:
- 纯 Q&A → 1 条
- 多 tool_use + 最终答案 → interstitial 数 + 1(或按 4000 字切的 N 块)
### 7.3 手测(微信端)
1. 在微信里发"用三段话解释闭包"→ 应该收到 1 条完整答案(而非现在的 8 条)。
2. 发"分析 src/main.ts 的结构"→ 应该收到 1 条 interstitial + 3 条最终答案块(而非现在的 59 条)。
3. 发一个会触发 abort 的任务(`/stop`)→ 已推的 interstitial 不丢,未发的最终答案部分按已生成的部分推送。
## 8. 风险与缓解
| 风险 | 缓解 |
|---|---|
| Agent 单回合内既有长文本又有 tool_use(如先写 500 字解释再调工具) | 文本会延迟到回合结束(看到 `stop_reason=tool_use`)才作为 interstitial 推送。延迟量级 = 回合内文本生成时间,通常几秒。silence warning 5min 兜底覆盖极端情况。 |
| `message_delta` 事件因 CLI 异常未触发 | 流结束时的 `flushFinal(pendingFinal)` + `flushInterstitial(turnBuffer)` 兜底 drain,任何已累积的文本都不会丢。 |
| `stop_reason` 取值未来扩展(新枚举) | 默认走 `else` 分支当最终答案处理——对新值也是安全选择(宁可少切也不丢内容)。 |
| Interstitial 过长(agent 在工具间吐大段评注) | `flushInterstitial``splitMessage`,会按 4000 字硬切。行为与现状一致。 |
## 9. 未来可考虑的改进(不在本次 scope)
- **限流改造**:如果 Problem 1 修复后重度 agent loop 仍频繁撞墙,再考虑把 `api.ts` 的固定间隔改成 60s 滑动窗口(~8 条上限)。
- **长最终答案期间的进度反馈**:用户当前选择"完全等完再发"。若未来希望长答案期间有部分漏出,可在 `pendingFinal` 超过某阈值(如 5000 字)或等待超过 60s 时,主动 flush 一次部分内容。
- **更激进的 interstitial 合并**:把连续多个 tool_use 回合的 interstitial 攒到一起发,进一步减少条数。代价是 agent loop 实时性下降。
@@ -0,0 +1,92 @@
# 工具噪音过滤 — 设计文档
**日期**2026-06-27
**动机**:上游 LLM 供应商(Z.ai 等聚合代理)把"服务端内置工具"的输入输出(JSON
参数、URL、识别结果等)以文本形式下放到 `text_delta`,bridge 不加区分地透传到微信,
导致用户在后半程看到"全是代码"的消息,体验急剧恶化,并加速消耗 iLink 11 条配额。
## 目标
- 在 bridge 侧识别并剥离这类"工具噪音",只保留 Claude 的真正旁白
- **不依赖任何 provider 特定签名**(emoji、关键词都会变),用结构特征判定
- 不误伤 Claude 正常写给用户的内容(多行总结、代码示例、路径说明)
## 非目标
- 不修改 provider 侧行为(Z.ai 是第三方,改不动)
- 不解析"工具名"——保持中性占位 `🔧 [工具调用]`,避免再依赖任何命名约定
- 不做 `/verbose` 调试开关(v1 不需要,简化迭代)
## 规则
### 分类器(三个条件 AND
一段文字**同时**满足以下三个特征才被判定为工具噪音:
1. **长度 > 400 字符** — Claude 单段旁白极少超过此规模
2. **包含 ``` ```json ``` ``` 围栏代码块** — 工具输入序列化的强信号
3. **包含 URL 或绝对路径**`https?://...` / `/Users/...` / `/home/...` / `/tmp/...`
等)— 工具调用几乎都引用资源定位符
任一条件不满足即原样返回,零成本短路。
### 剥离器(行级结构判定)
`\n` 把文本切成行,逐行判定是否为"结构性行"。结构性行的定义:
- 空行(段落分隔)
- ``` ``` ``` 开头(代码围栏)
- `*` 开头(markdown 加粗 / 斜体,覆盖 `**Input:**` / `*Executing...*` 等)
- `[` / `]` / `{` / `}` 开头(JSON 括号)
-`\\n` 字面量(JSON 字符串里的换行转义)
- `"key":` 开头(JSON 键值对)
- `* ` 开头(markdown 斜体列表项)
扫描所有行,记录**最后一个结构性行**的位置 `lastStructuralIdx``lastStructuralIdx + 1`
之后的所有行就是 Claude 的旁白。整段替换为:
```
🔧 [工具调用] — <旁白>
```
若没有旁白(结尾仍全是结构性内容),就只输出 `🔧 [工具调用]`
## 接入点
`src/main.ts:587``TurnRouter` 的 emit 回调:
```ts
const router = new TurnRouter((msg) =>
emitText(filterToolNoise(msg.text), msg.role),
);
```
单一咽喉,所有送微信的文字都过这一道。不动 provider、router、sender。
## 为什么用结构检测而不是签名匹配
| 维度 | 签名匹配 | 结构检测(本设计) |
|---|---|---|
| Z.ai 改 emoji | ❌ 失效 | ✅ 仍命中 |
| 换 provider | ❌ 重写规则 | ✅ 仍命中 |
| 命中精度 | 高 | 高(三条件 AND 收紧) |
| 误伤风险 | 低 | 低(必须 ``` ```json ``` ``` + URL |
| 维护成本 | 每次格式变都改 | 零维护 |
## 测试
`src/tests/tool-noise-filter.test.ts`fixture 覆盖:
- **7 条命中样本**(来自今日真实日志,逐一断言"被压缩 + 旁白正确提取")
- **8 条正常样本**(含路径、代码块、表格的长总结——逐一断言"原样返回")
- **边界**:空串、纯短消息、有 JSON 但无 URL、有 URL 但无 JSON、长度恰好 400
Fixture 文件落在 `src/tests/fixtures/tool-noise/real-cases.json`,从今日 log
直接提取,未来回归测试可对照真实数据。
## 已验证效果(基于今日真实日志)
- 总发送:361 条
- 命中:7 条(全部为 Z.ai analyze_image dump
- 误伤:0
- 命中消息字符消耗:5472 → 504(压缩 90.8%
+703
View File
@@ -0,0 +1,703 @@
{
"name": "wechat-claude-code",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "wechat-claude-code",
"version": "1.0.0",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.1.0",
"qrcode": "^1.5.4",
"qrcode-terminal": "^0.12.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"@types/qrcode": "^1.5.6",
"@types/qrcode-terminal": "^0.12.0",
"typescript": "^5.7.0"
}
},
"node_modules/@anthropic-ai/claude-agent-sdk": {
"version": "0.1.77",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.1.77.tgz",
"integrity": "sha512-ZEjWQtkoB2MEY6K16DWMmF+8OhywAynH0m08V265cerbZ8xPD/2Ng2jPzbbO40mPeFSsMDJboShL+a3aObP0Jg==",
"license": "SEE LICENSE IN README.md",
"engines": {
"node": ">=18.0.0"
},
"optionalDependencies": {
"@img/sharp-darwin-arm64": "^0.33.5",
"@img/sharp-darwin-x64": "^0.33.5",
"@img/sharp-linux-arm": "^0.33.5",
"@img/sharp-linux-arm64": "^0.33.5",
"@img/sharp-linux-x64": "^0.33.5",
"@img/sharp-linuxmusl-arm64": "^0.33.5",
"@img/sharp-linuxmusl-x64": "^0.33.5",
"@img/sharp-win32-x64": "^0.33.5"
},
"peerDependencies": {
"zod": "^3.25.0 || ^4.0.0"
}
},
"node_modules/@img/sharp-darwin-arm64": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz",
"integrity": "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-arm64": "1.0.4"
}
},
"node_modules/@img/sharp-darwin-x64": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz",
"integrity": "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-x64": "1.0.4"
}
},
"node_modules/@img/sharp-libvips-darwin-arm64": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz",
"integrity": "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==",
"cpu": [
"arm64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"darwin"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-darwin-x64": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz",
"integrity": "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==",
"cpu": [
"x64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"darwin"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-arm": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz",
"integrity": "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==",
"cpu": [
"arm"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-arm64": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz",
"integrity": "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==",
"cpu": [
"arm64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-x64": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz",
"integrity": "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==",
"cpu": [
"x64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz",
"integrity": "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==",
"cpu": [
"arm64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz",
"integrity": "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==",
"cpu": [
"x64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-linux-arm": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz",
"integrity": "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==",
"cpu": [
"arm"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm": "1.0.5"
}
},
"node_modules/@img/sharp-linux-arm64": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz",
"integrity": "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm64": "1.0.4"
}
},
"node_modules/@img/sharp-linux-x64": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz",
"integrity": "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-x64": "1.0.4"
}
},
"node_modules/@img/sharp-linuxmusl-arm64": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz",
"integrity": "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-arm64": "1.0.4"
}
},
"node_modules/@img/sharp-linuxmusl-x64": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz",
"integrity": "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-x64": "1.0.4"
}
},
"node_modules/@img/sharp-win32-x64": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz",
"integrity": "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==",
"cpu": [
"x64"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@types/node": {
"version": "22.19.15",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz",
"integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/@types/qrcode": {
"version": "1.5.6",
"resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz",
"integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/qrcode-terminal": {
"version": "0.12.2",
"resolved": "https://registry.npmjs.org/@types/qrcode-terminal/-/qrcode-terminal-0.12.2.tgz",
"integrity": "sha512-v+RcIEJ+Uhd6ygSQ0u5YYY7ZM+la7GgPbs0V/7l/kFs2uO4S8BcIUEMoP7za4DNIqNnUD5npf0A/7kBhrCKG5Q==",
"dev": true,
"license": "MIT"
},
"node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/camelcase": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/cliui": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
"license": "ISC",
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.0",
"wrap-ansi": "^6.2.0"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"license": "MIT",
"dependencies": {
"color-name": "~1.1.4"
},
"engines": {
"node": ">=7.0.0"
}
},
"node_modules/color-name": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"license": "MIT"
},
"node_modules/decamelize": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/dijkstrajs": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
"license": "MIT"
},
"node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"license": "MIT"
},
"node_modules/find-up": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
"license": "MIT",
"dependencies": {
"locate-path": "^5.0.0",
"path-exists": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/get-caller-file": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"license": "ISC",
"engines": {
"node": "6.* || 8.* || >= 10.*"
}
},
"node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/locate-path": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
"license": "MIT",
"dependencies": {
"p-locate": "^4.1.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/p-limit": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
"license": "MIT",
"dependencies": {
"p-try": "^2.0.0"
},
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/p-locate": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
"license": "MIT",
"dependencies": {
"p-limit": "^2.2.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/p-try": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/path-exists": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/pngjs": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
"license": "MIT",
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/qrcode": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
"license": "MIT",
"dependencies": {
"dijkstrajs": "^1.0.1",
"pngjs": "^5.0.0",
"yargs": "^15.3.1"
},
"bin": {
"qrcode": "bin/qrcode"
},
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/qrcode-terminal": {
"version": "0.12.0",
"resolved": "https://registry.npmjs.org/qrcode-terminal/-/qrcode-terminal-0.12.0.tgz",
"integrity": "sha512-EXtzRZmC+YGmGlDFbXKxQiMZNwCLEO6BANKXG4iCtSIM0yqc/pappSx3RIKr4r0uh5JsBckOXeKrB3Iz7mdQpQ==",
"bin": {
"qrcode-terminal": "bin/qrcode-terminal.js"
}
},
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/require-main-filename": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
"license": "ISC"
},
"node_modules/set-blocking": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
"license": "ISC"
},
"node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
},
"node_modules/which-module": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
"license": "ISC"
},
"node_modules/wrap-ansi": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/y18n": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
"license": "ISC"
},
"node_modules/yargs": {
"version": "15.4.1",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
"license": "MIT",
"dependencies": {
"cliui": "^6.0.0",
"decamelize": "^1.2.0",
"find-up": "^4.1.0",
"get-caller-file": "^2.0.1",
"require-directory": "^2.1.1",
"require-main-filename": "^2.0.0",
"set-blocking": "^2.0.0",
"string-width": "^4.2.0",
"which-module": "^2.0.0",
"y18n": "^4.0.0",
"yargs-parser": "^18.1.2"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yargs-parser": {
"version": "18.1.3",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
"license": "ISC",
"dependencies": {
"camelcase": "^5.0.0",
"decamelize": "^1.2.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/zod": {
"version": "4.3.6",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
}
}
}
+34
View File
@@ -0,0 +1,34 @@
{
"name": "wechat-claude-code",
"version": "1.0.0",
"description": "Chat with Claude Code from WeChat - a Claude Code Skill that bridges personal WeChat to local Claude Code",
"type": "module",
"scripts": {
"build": "tsc",
"postinstall": "npm run build",
"start": "node dist/main.js",
"run": "node dist/main.js start",
"dev": "tsc --watch",
"test": "node --test dist/tests/*.test.js",
"setup": "node dist/main.js setup",
"daemon": "bash scripts/daemon.sh",
"visualize": "npx tsx src/tools/visualize-logs.ts"
},
"dependencies": {
"qrcode": "^1.5.4",
"qrcode-terminal": "^0.12.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"@types/qrcode": "^1.5.6",
"@types/qrcode-terminal": "^0.12.0",
"typescript": "^5.7.0"
},
"keywords": ["wechat", "claude-code", "claude", "bridge", "chat", "skill"],
"author": "Wechat-ggGitHub",
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/Wechat-ggGitHub/wechat-claude-code.git"
}
}
+422
View File
@@ -0,0 +1,422 @@
#!/bin/bash
set -euo pipefail
# =============================================================================
# wechat-claude-code cross-platform daemon manager
# Supports: macOS (launchd) / Linux (systemd + nohup fallback)
# =============================================================================
DATA_DIR="${HOME}/.wechat-claude-code"
PROJECT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
SERVICE_NAME="wechat-claude-code"
# Platform detection
OS_TYPE="$(uname -s)"
# =============================================================================
# macOS (launchd) functions
# =============================================================================
macos_plist_label() {
echo "com.wechat-claude-code.bridge"
}
macos_plist_path() {
echo "${HOME}/Library/LaunchAgents/$(macos_plist_label).plist"
}
macos_is_loaded() {
launchctl print "gui/$(id -u)/$(macos_plist_label)" &>/dev/null
}
macos_start() {
local plist_label="$(macos_plist_label)"
local plist_path="$(macos_plist_path)"
local node_bin="$(command -v node || echo '/usr/local/bin/node')"
if macos_is_loaded; then
echo "Already running (or plist loaded)"
exit 0
fi
mkdir -p "$DATA_DIR/logs"
# Collect Anthropic/Claude env vars for plist
local plist_extra_env=""
for var in ANTHROPIC_AUTH_TOKEN ANTHROPIC_API_KEY ANTHROPIC_BASE_URL CLAUDE_API_KEY; do
if [ -n "${!var:-}" ]; then
plist_extra_env="${plist_extra_env} <key>${var}</key>
<string>${!var}</string>
"
fi
done
cat > "$plist_path" <<PLIST
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>${plist_label}</string>
<key>ProgramArguments</key>
<array>
<string>${node_bin}</string>
<string>${PROJECT_DIR}/dist/main.js</string>
<string>start</string>
</array>
<key>WorkingDirectory</key>
<string>${PROJECT_DIR}</string>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>StandardOutPath</key>
<string>${DATA_DIR}/logs/stdout.log</string>
<key>StandardErrorPath</key>
<string>${DATA_DIR}/logs/stderr.log</string>
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>${HOME}/.local/bin:${node_bin%/*}:/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin</string>
${plist_extra_env} </dict>
</dict>
</plist>
PLIST
launchctl load "$plist_path"
echo "Started wechat-claude-code daemon (macOS launchd)"
}
macos_stop() {
local plist_label="$(macos_plist_label)"
local plist_path="$(macos_plist_path)"
launchctl bootout "gui/$(id -u)/${plist_label}" 2>/dev/null || true
rm -f "$plist_path"
echo "Stopped wechat-claude-code daemon (macOS launchd)"
}
macos_status() {
if macos_is_loaded; then
local pid=$(pgrep -f "dist/main.js start" 2>/dev/null | head -1)
if [ -n "$pid" ]; then
echo "Running (PID: $pid)"
else
echo "Loaded but not running"
fi
else
echo "Not running"
fi
}
macos_logs() {
local log_dir="${DATA_DIR}/logs"
if [ -d "$log_dir" ]; then
local latest=$(ls -t "${log_dir}"/bridge-*.log 2>/dev/null | head -1)
if [ -n "$latest" ]; then
tail -100 "$latest"
else
echo "No bridge logs found. Checking stdout/stderr:"
for f in "${log_dir}"/stdout.log "${log_dir}"/stderr.log; do
if [ -f "$f" ]; then
echo "=== $(basename "$f") ==="
tail -30 "$f"
fi
done
fi
else
echo "No logs found"
fi
}
# =============================================================================
# Linux (systemd) functions
# =============================================================================
linux_ensure_user_session() {
if [ -z "${XDG_RUNTIME_DIR:-}" ]; then
export XDG_RUNTIME_DIR="/run/user/$(id -u)"
mkdir -p "$XDG_RUNTIME_DIR" 2>/dev/null || true
fi
if [ -z "${DBUS_SESSION_BUS_ADDRESS:-}" ]; then
export DBUS_SESSION_BUS_ADDRESS="unix:path=${XDG_RUNTIME_DIR}/bus"
fi
}
linux_service_file() {
echo "${HOME}/.config/systemd/user/${SERVICE_NAME}.service"
}
linux_pid_file() {
echo "${DATA_DIR}/${SERVICE_NAME}.pid"
}
linux_node_bin() {
local node_bin="$(command -v node 2>/dev/null || echo '')"
if [ -z "$node_bin" ]; then
local nvm_default="${NVM_DIR:-${HOME}/.nvm}/versions/node"
if [ -d "$nvm_default" ]; then
node_bin="$(find "$nvm_default" -name "node" -type f 2>/dev/null | head -1)"
fi
fi
echo "${node_bin:-/usr/bin/node}"
}
linux_systemd_available() {
linux_ensure_user_session
systemctl --user list-units &>/dev/null
}
linux_create_service_file() {
local service_file="$(linux_service_file)"
local node_bin="$(linux_node_bin)"
mkdir -p "$(dirname "$service_file")"
# Collect Anthropic/Claude env vars to pass through to the service
local extra_env=""
for var in ANTHROPIC_AUTH_TOKEN ANTHROPIC_API_KEY ANTHROPIC_BASE_URL CLAUDE_API_KEY; do
if [ -n "${!var:-}" ]; then
extra_env="${extra_env}Environment=${var}=${!var}
"
fi
done
cat > "$service_file" <<SERVICE
[Unit]
Description=WeChat Claude Code Bridge
Documentation=https://github.com/Wechat-ggGitHub/wechat-claude-code
After=network.target
[Service]
Type=simple
ExecStart=${node_bin} ${PROJECT_DIR}/dist/main.js start
WorkingDirectory=${PROJECT_DIR}
Restart=always
RestartSec=10
Environment=PATH=${HOME}/.local/bin:${node_bin%/*}:/usr/local/bin:/usr/bin:/bin
${extra_env}StandardOutput=append:${DATA_DIR}/logs/stdout.log
StandardError=append:${DATA_DIR}/logs/stderr.log
NoNewPrivileges=true
PrivateTmp=true
[Install]
WantedBy=default.target
SERVICE
chmod 644 "$service_file"
}
linux_reload_daemon() {
linux_ensure_user_session
systemctl --user daemon-reload 2>/dev/null || true
}
linux_direct_start() {
local pid_file="$(linux_pid_file)"
local node_bin="$(linux_node_bin)"
if [ -f "$pid_file" ]; then
local old_pid=$(cat "$pid_file" 2>/dev/null)
if [ -n "$old_pid" ] && kill -0 "$old_pid" 2>/dev/null; then
echo "Already running (PID: $old_pid)"
exit 0
fi
rm -f "$pid_file"
fi
mkdir -p "$DATA_DIR/logs"
echo "Starting wechat-claude-code daemon (direct mode)..."
nohup "$node_bin" "${PROJECT_DIR}/dist/main.js" start \
>> "$DATA_DIR/logs/stdout.log" \
2>> "$DATA_DIR/logs/stderr.log" &
local pid=$!
echo "$pid" > "$pid_file"
echo "Started (PID: $pid)"
echo "Logs: $DATA_DIR/logs/stdout.log"
}
linux_direct_stop() {
local pid_file="$(linux_pid_file)"
if [ ! -f "$pid_file" ]; then
echo "Not running (no PID file)"
exit 0
fi
local pid=$(cat "$pid_file" 2>/dev/null)
if [ -z "$pid" ]; then
rm -f "$pid_file"
echo "Stopped"
exit 0
fi
if kill -0 "$pid" 2>/dev/null; then
kill "$pid" 2>/dev/null || true
local count=0
while kill -0 "$pid" 2>/dev/null && [ $count -lt 10 ]; do
sleep 1
count=$((count + 1))
done
kill -9 "$pid" 2>/dev/null || true
echo "Stopped (PID: $pid)"
else
echo "Process not running (cleaning up PID file)"
fi
rm -f "$pid_file"
}
linux_direct_status() {
local pid_file="$(linux_pid_file)"
if [ ! -f "$pid_file" ]; then
echo "Not running"
exit 0
fi
local pid=$(cat "$pid_file" 2>/dev/null)
if [ -z "$pid" ]; then
echo "Not running (invalid PID file)"
exit 0
fi
if kill -0 "$pid" 2>/dev/null; then
echo "Running (PID: $pid)"
else
echo "Not running (stale PID file)"
fi
}
linux_start() {
if linux_systemd_available; then
local service_file="$(linux_service_file)"
if systemctl --user is-active --quiet "${SERVICE_NAME}" 2>/dev/null; then
echo "Already running"
exit 0
fi
mkdir -p "$DATA_DIR/logs"
linux_create_service_file
linux_reload_daemon
systemctl --user start "${SERVICE_NAME}"
systemctl --user enable "${SERVICE_NAME}" 2>/dev/null || true
echo "Started wechat-claude-code daemon (Linux systemd)"
else
echo "Note: systemd user session not available, using direct mode"
echo "To enable systemd mode, run: 'loginctl enable-linger $(whoami)'"
echo ""
linux_direct_start
fi
}
linux_stop() {
if linux_systemd_available && systemctl --user cat "${SERVICE_NAME}" &>/dev/null; then
systemctl --user stop "${SERVICE_NAME}" 2>/dev/null || true
systemctl --user disable "${SERVICE_NAME}" 2>/dev/null || true
echo "Stopped wechat-claude-code daemon (Linux systemd)"
else
linux_direct_stop
fi
}
linux_restart() {
linux_stop
sleep 1
linux_start
}
linux_status() {
if linux_systemd_available && systemctl --user cat "${SERVICE_NAME}" &>/dev/null; then
if systemctl --user is-active --quiet "${SERVICE_NAME}" 2>/dev/null; then
local pid=$(systemctl --user show-property --value=MainPID "${SERVICE_NAME}" 2>/dev/null)
if [ -n "$pid" ] && [ "$pid" != "0" ]; then
echo "Running (PID: $pid)"
else
echo "Active"
fi
else
echo "Not running"
fi
if systemctl --user cat "${SERVICE_NAME}" &>/dev/null; then
echo ""
systemctl --user status "${SERVICE_NAME}" --no-pager 2>/dev/null || true
fi
else
linux_direct_status
fi
}
linux_logs() {
if command -v journalctl >/dev/null 2>&1; then
if journalctl --user --unit="${SERVICE_NAME}" --quiet &>/dev/null; then
echo "=== systemd journal logs (last 100 lines) ==="
journalctl --user --unit="${SERVICE_NAME}" --no-pager -n 100 2>/dev/null || true
echo ""
echo "=== File logs ==="
fi
fi
local log_dir="${DATA_DIR}/logs"
if [ -d "$log_dir" ]; then
for f in "${log_dir}"/stdout.log "${log_dir}"/stderr.log; do
if [ -f "$f" ]; then
echo "=== $(basename "$f") ==="
tail -50 "$f"
echo ""
fi
done
else
echo "No logs found"
fi
}
# =============================================================================
# Main dispatcher
# =============================================================================
main() {
local command="${1:-}"
case "$OS_TYPE" in
Darwin)
case "$command" in
start) macos_start ;;
stop) macos_stop ;;
restart) macos_stop; sleep 1; macos_start ;;
status) macos_status ;;
logs) macos_logs ;;
*)
echo "Usage: daemon.sh {start|stop|restart|status|logs}"
echo "Platform: macOS (launchd)"
exit 1
;;
esac
;;
Linux)
case "$command" in
start) linux_start ;;
stop) linux_stop ;;
restart) linux_restart ;;
status) linux_status ;;
logs) linux_logs ;;
*)
echo "Usage: daemon.sh {start|stop|restart|status|logs}"
echo "Platform: Linux (systemd)"
exit 1
;;
esac
;;
*)
echo "Error: Unsupported platform '$OS_TYPE'"
echo "Supported platforms: macOS (Darwin), Linux"
exit 1
;;
esac
}
main "$@"
+314
View File
@@ -0,0 +1,314 @@
import { spawn, type ChildProcess } from 'node:child_process';
import { writeFileSync, unlinkSync, mkdirSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { createInterface } from 'node:readline';
import { logger } from '../logger.js';
// ---------------------------------------------------------------------------
// Public types
// ---------------------------------------------------------------------------
export interface QueryOptions {
prompt: string;
cwd: string;
resume?: string;
model?: string;
systemPrompt?: string;
images?: Array<{
type: "image";
source: { type: "base64"; media_type: string; data: string };
}>;
/** Called each time an assistant text chunk is produced (e.g. before/after tool calls). */
onText?: (text: string) => Promise<void> | void;
/** Called when an assistant turn ends, with its stop_reason
* ('tool_use' | 'end_turn' | 'max_tokens' | 'stop_sequence' | 'pause_turn' | ...).
* Use to decide whether the turn's text is interstitial or final answer. */
onTurnEnd?: (stopReason: string) => Promise<void> | void;
/** Optional abort controller to cancel the query (e.g. when user sends a new message). */
abortController?: AbortController;
}
export interface QueryResult {
text: string;
sessionId: string;
error?: string;
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const TEMP_DIR = join(tmpdir(), 'wechat-claude-code');
function saveImageTemp(images: NonNullable<QueryOptions['images']>): string[] {
mkdirSync(TEMP_DIR, { recursive: true });
const paths: string[] = [];
for (const img of images) {
const ext = img.source.media_type.split('/')[1] || 'png';
const fileName = `img-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.${ext}`;
const filePath = join(TEMP_DIR, fileName);
writeFileSync(filePath, Buffer.from(img.source.data, 'base64'));
paths.push(filePath);
}
return paths;
}
function cleanupTempFiles(paths: string[]): void {
for (const p of paths) {
try { unlinkSync(p); } catch { /* ignore */ }
}
}
// ---------------------------------------------------------------------------
// Stream parser (extracted for testability)
// ---------------------------------------------------------------------------
export interface StreamParserState {
sessionId: string;
textParts: string[];
errorMessage?: string;
trackingSkill: boolean;
skillInputAccum: string;
}
export interface StreamParserCallbacks {
onText?: (text: string) => void;
onTurnEnd?: (stopReason: string) => void;
}
export function handleStreamLine(
line: string,
state: StreamParserState,
callbacks: StreamParserCallbacks,
): void {
if (!line.trim()) return;
let obj: any;
try {
obj = JSON.parse(line);
} catch {
return;
}
switch (obj.type) {
case 'system': {
if (obj.subtype === 'init' && obj.session_id) {
state.sessionId = obj.session_id;
}
break;
}
case 'assistant': {
const content = obj.message?.content;
if (Array.isArray(content)) {
const text = content
.filter((b: any) => b.type === 'text')
.map((b: any) => b.text ?? '')
.join('');
if (text) state.textParts.push(text);
}
break;
}
case 'stream_event': {
const evt = obj.event;
if (evt?.type === 'content_block_start' && evt.content_block?.type === 'tool_use') {
if (evt.content_block.name === 'Skill') {
state.trackingSkill = true;
state.skillInputAccum = '';
}
} else if (evt?.type === 'content_block_delta' && evt.delta?.type === 'text_delta') {
const delta: string = evt.delta.text;
if (delta && callbacks.onText) {
Promise.resolve(callbacks.onText(delta)).catch(() => {});
}
} else if (evt?.type === 'content_block_delta' && evt.delta?.type === 'input_json_delta' && state.trackingSkill) {
state.skillInputAccum += evt.delta.partial_json ?? '';
try {
const parsed = JSON.parse(state.skillInputAccum);
if (parsed.skill) {
const msg = `\n正在调用 ${parsed.skill} 技能\n\n`;
if (callbacks.onText) Promise.resolve(callbacks.onText(msg)).catch(() => {});
state.trackingSkill = false;
}
} catch {
// JSON not complete yet
}
} else if (evt?.type === 'content_block_stop') {
state.trackingSkill = false;
} else if (evt?.type === 'message_delta' && evt.delta?.stop_reason) {
if (callbacks.onTurnEnd) Promise.resolve(callbacks.onTurnEnd(evt.delta.stop_reason)).catch(() => {});
}
break;
}
case 'result': {
if (obj.result && typeof obj.result === 'string') {
const combined = state.textParts.join('');
if (!combined.includes(obj.result)) {
state.textParts.push(obj.result);
}
}
if (obj.subtype === 'error' || (obj.errors && obj.errors.length > 0)) {
const errors = obj.errors ?? [obj.error_message ?? 'Unknown error'];
state.errorMessage = Array.isArray(errors) ? errors.join('; ') : String(errors);
logger.error('CLI returned error result', { errors });
}
break;
}
default:
break;
}
}
// ---------------------------------------------------------------------------
// Core function
// ---------------------------------------------------------------------------
export async function claudeQuery(options: QueryOptions): Promise<QueryResult> {
const {
prompt,
cwd,
resume,
model,
systemPrompt,
images,
onText,
onTurnEnd,
abortController,
} = options;
logger.info("Starting Claude CLI query", {
cwd,
model,
resume: !!resume,
hasImages: !!images?.length,
});
// Build CLI arguments
const args: string[] = [
'-p', '-',
'--output-format', 'stream-json',
'--verbose',
'--include-partial-messages',
'--dangerously-skip-permissions',
];
if (resume) args.push('--resume', resume);
if (model) args.push('--model', model);
if (systemPrompt) args.push('--append-system-prompt', systemPrompt);
// Handle images: save to temp files and append paths to prompt
const tempImagePaths = images?.length ? saveImageTemp(images) : [];
let fullPrompt = prompt;
if (tempImagePaths.length > 0) {
const imageLines = tempImagePaths.map(p => `\n![image](file://${p})`).join('');
fullPrompt += imageLines;
}
// Accumulators
let child: ChildProcess | undefined;
let settled = false;
const QUERY_TIMEOUT_MS = 60 * 60 * 1000;
return new Promise<QueryResult>((resolve) => {
const finish = (result: QueryResult) => {
if (settled) return;
settled = true;
cleanupTempFiles(tempImagePaths);
resolve(result);
};
try {
child = spawn('claude', args, {
cwd,
stdio: ['pipe', 'pipe', 'pipe'],
env: { ...process.env },
});
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
finish({ text: '', sessionId: '', error: `Failed to spawn claude: ${msg}` });
return;
}
// Write prompt to stdin and close
child.stdin!.write(fullPrompt);
child.stdin!.end();
// Timeout
const timeoutId = setTimeout(() => {
logger.warn('Claude CLI query timed out, killing process');
child!.kill('SIGTERM');
const partialText = parserState.textParts.join('\n').trim();
finish({
text: partialText,
sessionId: parserState.sessionId,
error: partialText ? undefined : 'Claude query timed out after 60 minutes',
});
}, QUERY_TIMEOUT_MS);
// Abort handling
const onAbort = () => {
logger.info('Claude CLI query aborted');
child!.kill('SIGTERM');
const partialText = parserState.textParts.join('\n').trim();
finish({ text: partialText, sessionId: parserState.sessionId });
};
abortController?.signal.addEventListener('abort', onAbort, { once: true });
// Collect stderr
const stderrParts: string[] = [];
child.stderr!.setEncoding('utf8');
child.stderr!.on('data', (chunk: string) => {
stderrParts.push(chunk);
});
// Parse NDJSON from stdout (logic in handleStreamLine for testability)
const parserState: StreamParserState = {
sessionId: '',
textParts: [],
trackingSkill: false,
skillInputAccum: '',
};
const parserCallbacks: StreamParserCallbacks = { onText, onTurnEnd };
const rl = createInterface({ input: child.stdout! });
rl.on('line', (line: string) => {
handleStreamLine(line, parserState, parserCallbacks);
});
// Handle process exit
child.on('close', (code: number | null) => {
clearTimeout(timeoutId);
abortController?.signal.removeEventListener('abort', onAbort);
if (code !== 0 && code !== null && !parserState.textParts.length && !parserState.errorMessage) {
const stderr = stderrParts.join('').trim();
parserState.errorMessage = stderr || `claude exited with code ${code}`;
logger.error('Claude CLI exited with error', { code, stderr: stderr.slice(0, 500) });
}
const fullText = parserState.textParts.join('\n').trim();
if (!fullText && !parserState.errorMessage) {
parserState.errorMessage = 'Claude returned an empty response.';
}
logger.info("Claude CLI query completed", {
sessionId: parserState.sessionId,
textLength: fullText.length,
hasError: !!parserState.errorMessage,
});
finish({
text: fullText,
sessionId: parserState.sessionId,
error: parserState.errorMessage,
});
});
child.on('error', (err: Error) => {
clearTimeout(timeoutId);
abortController?.signal.removeEventListener('abort', onAbort);
finish({ text: '', sessionId: parserState.sessionId, error: `Failed to spawn claude: ${err.message}` });
});
});
}
+159
View File
@@ -0,0 +1,159 @@
import { readdirSync, readFileSync, existsSync, type Dirent } from 'node:fs';
import { join } from 'node:path';
import { homedir } from 'node:os';
import { logger } from '../logger.js';
export interface SkillInfo {
name: string;
description: string;
path: string;
}
/**
* Parse YAML-like frontmatter from a SKILL.md file.
* Only extracts `name` and `description` fields.
*/
function parseSkillMd(filePath: string): { name: string; description: string } | null {
try {
const content = readFileSync(filePath, 'utf-8');
const match = content.match(/^---\n([\s\S]*?)\n---/);
if (!match) return null;
const frontmatter = match[1];
const nameMatch = frontmatter.match(/^name:\s*(.+)$/m);
const descMatch = frontmatter.match(/^description:\s*(.+)$/m);
if (!nameMatch) return null;
return {
name: nameMatch[1].trim().replace(/^["']|["']$/g, ''),
description: descMatch ? descMatch[1].trim().replace(/^["']|["']$/g, '') : '',
};
} catch {
logger.warn(`Failed to read SKILL.md: ${filePath}`);
return null;
}
}
/**
* Scan a directory for SKILL.md files, reading skill info from each.
*/
function scanDirectory(baseDir: string, depth: number = 2): SkillInfo[] {
const skills: SkillInfo[] = [];
if (!existsSync(baseDir)) return skills;
let entries: Dirent[];
try {
entries = readdirSync(baseDir, { withFileTypes: true });
} catch {
return skills;
}
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const fullPath = join(baseDir, entry.name);
if (depth > 1) {
// Recurse one level deeper
skills.push(...scanDirectory(fullPath, depth - 1));
}
const skillFile = join(fullPath, 'SKILL.md');
if (existsSync(skillFile)) {
const info = parseSkillMd(skillFile);
if (info) {
skills.push({ ...info, path: fullPath });
}
}
}
return skills;
}
/**
* Scan all known skill directories for installed Claude Code skills.
*
* Locations scanned:
* 1. ~/.claude/skills/ (each subdirectory)
* 2. ~/.claude/plugins/cache/{plugin}/skills/ (each subdirectory)
* 3. ~/.claude/plugins/cache/{plugin}/superpowers/skills/ (each subdirectory)
*/
export function scanAllSkills(): SkillInfo[] {
const home = homedir();
const claudeDir = join(home, '.claude');
const skills: SkillInfo[] = [];
const seen = new Set<string>();
// 1. ~/.claude/skills/*/
const userSkillsDir = join(claudeDir, 'skills');
for (const skill of scanDirectory(userSkillsDir, 1)) {
if (!seen.has(skill.name)) {
seen.add(skill.name);
skills.push(skill);
}
}
// 2. ~/.claude/plugins/cache/*/skills/*/
const pluginsCacheDir = join(claudeDir, 'plugins', 'cache');
if (existsSync(pluginsCacheDir)) {
let cacheEntries: Dirent[];
try {
cacheEntries = readdirSync(pluginsCacheDir, { withFileTypes: true });
} catch {
cacheEntries = [];
}
for (const cacheEntry of cacheEntries) {
if (!cacheEntry.isDirectory()) continue;
const cacheDir = join(pluginsCacheDir, cacheEntry.name);
// Regular skills
const pluginSkillsDir = join(cacheDir, 'skills');
for (const skill of scanDirectory(pluginSkillsDir, 1)) {
if (!seen.has(skill.name)) {
seen.add(skill.name);
skills.push(skill);
}
}
// Superpowers skills
const superpowersSkillsDir = join(cacheDir, 'superpowers', 'skills');
for (const skill of scanDirectory(superpowersSkillsDir, 1)) {
if (!seen.has(skill.name)) {
seen.add(skill.name);
skills.push(skill);
}
}
}
}
logger.info(`Scanned ${skills.length} skills`);
return skills;
}
/**
* Format a list of skills into a readable string for display.
*/
export function formatSkillList(skills: SkillInfo[]): string {
if (skills.length === 0) {
return 'No skills found.';
}
const lines = skills.map((s, i) => {
const desc = s.description ? ` - ${s.description}` : '';
return ` ${i + 1}. ${s.name}${desc}`;
});
return `Available skills (${skills.length}):\n${lines.join('\n')}`;
}
/**
* Find a skill by name (case-insensitive match).
*/
export function findSkill(skills: SkillInfo[], name: string): SkillInfo | undefined {
const lower = name.toLowerCase();
return skills.find(
(s) => s.name.toLowerCase() === lower || s.name.toLowerCase().replace(/\s+/g, '-') === lower,
);
}
+52
View File
@@ -0,0 +1,52 @@
/**
* 工具噪音过滤器
*
* 某些 LLM 供应商(如 Z.ai 等聚合代理)会把服务端内置工具的输入/输出(JSON 参数、
* URL、识别结果等)以文本形式塞进 `text_delta` 流。bridge 无法在协议层区分这些
* 内容与 Claude 的正常旁白,于是会被原样推到微信,造成"全是代码"的灾难体验。
*
* 本模块用结构特征判定(不依赖任何 provider 签名),把命中段剥到只剩 Claude 的
* 旁白,前面加上中性占位 `🔧 [工具调用]`。
*
* 设计文档:docs/superpowers/specs/2026-06-27-tool-noise-filter-design.md
*/
const FENCED_JSON = /```json\b[\s\S]*?```/i;
const URL_OR_PATH = /(https?:\/\/\S+)|(\/(?:Users|home|tmp|var|opt|etc)\/\S+)|(~\/\S+)/;
const LENGTH_THRESHOLD = 400;
function isStructuralLine(line: string): boolean {
if (!line.trim()) return true; // 段落分隔
if (/^\s*```/.test(line)) return true; // 代码围栏
if (/^\s*\*+/.test(line)) return true; // **粗体** / *斜体* 头
if (/^\s*[\[\]\{\}]/.test(line)) return true; // JSON 括号
if (/\\n/.test(line)) return true; // JSON 字符串里的换行转义
if (/^\s*"\w+"\s*:/.test(line)) return true; // "key": value 行
if (/^\s*\*\s/.test(line)) return true; // markdown 斜体列表项
return false;
}
/** 找出文本结尾处那段"Claude 的旁白",没有则返回空串。 */
function extractTail(text: string): string {
const lines = text.split('\n');
let lastStructuralIdx = -1;
for (let i = 0; i < lines.length; i++) {
if (isStructuralLine(lines[i])) lastStructuralIdx = i;
}
if (lastStructuralIdx < 0 || lastStructuralIdx === lines.length - 1) return '';
return lines.slice(lastStructuralIdx + 1).join('\n').trim();
}
/**
* 三条件 AND 判定:长度 > 阈值 + 含 ```json``` 围栏 + 含 URL 或绝对路径。
* 任一不满足即原样返回。
*/
export function filterToolNoise(text: string): string {
if (text.length <= LENGTH_THRESHOLD) return text;
if (!FENCED_JSON.test(text)) return text;
if (!URL_OR_PATH.test(text)) return text;
const tail = extractTail(text);
return tail ? `🔧 [工具调用] — ${tail}` : '🔧 [工具调用]';
}
+55
View File
@@ -0,0 +1,55 @@
/**
* TurnRouter 把 Claude CLI 的流式输出按"回合"分流:
*
* - tool_use 回合的文本 → 立即作为 interstitial emitagent loop 进度)
* - 其他 stop_reasonend_turn / max_tokens / stop_sequence / pause_turn / ...
* 的文本 → 攒到 pendingFinaldrain 时一次性作为 final emit
*
* 设计参考 docs/superpowers/specs/2026-06-20-message-batching-design.md。
*
* 本类不做任何 I/O,只决定"何时把哪段文本以什么 role emit"。
* 调用方(main.ts)负责把 RoutedMessage 切分(splitMessage)并发到微信。
*/
export type MessageRole = 'interstitial' | 'final';
export interface RoutedMessage {
text: string;
role: MessageRole;
}
export class TurnRouter {
private turnBuffer = '';
private pendingFinal = '';
constructor(private readonly emit: (msg: RoutedMessage) => void) {}
onText(delta: string): void {
this.turnBuffer += delta;
}
onTurnEnd(stopReason: string): void {
const text = this.turnBuffer;
this.turnBuffer = '';
if (!text.trim()) return;
if (stopReason === 'tool_use') {
this.emit({ text, role: 'interstitial' });
} else {
// end_turn / max_tokens / stop_sequence / pause_turn / 未知值
// 一律当最终答案处理(宁可合并也不丢)
this.pendingFinal += this.pendingFinal ? '\n\n' + text : text;
}
}
/** 流结束时调用。先发 final,再 drain 残留 interstitial。 */
drain(): void {
if (this.pendingFinal.trim()) {
this.emit({ text: this.pendingFinal, role: 'final' });
this.pendingFinal = '';
}
if (this.turnBuffer.trim()) {
this.emit({ text: this.turnBuffer, role: 'interstitial' });
this.turnBuffer = '';
}
}
}
+233
View File
@@ -0,0 +1,233 @@
import type { CommandContext, CommandResult } from './router.js';
import { scanAllSkills, formatSkillList, findSkill, type SkillInfo } from '../claude/skill-scanner.js';
import { loadConfig, saveConfig } from '../config.js';
import { DEFAULT_WORKING_DIR } from '../constants.js';
import { readFileSync, existsSync, statSync } from 'node:fs';
import { resolve, basename, join } from 'node:path';
import { homedir } from 'node:os';
import { fileURLToPath } from 'node:url';
const HELP_TEXT = `可用命令:
会话管理:
/help 显示帮助
/stop 停止当前对话并清空排队消息
/clear 清除当前会话
/reset 完全重置(包括工作目录等设置)
/status 查看当前会话状态
/compact 压缩上下文(开始新 SDK 会话,保留历史)
/history [数量] 查看对话记录(默认最近20条)
/undo [数量] 撤销最近对话(默认1条)
文件:
/send <路径> 发送本地文件(图片直接显示,其他文件作为附件)
配置:
/cwd [路径] 查看或切换工作目录
/model [名称] 查看或切换 Claude 模型
/prompt [内容] 查看或设置系统提示词(全局生效)
其他:
/skills [full] 列出已安装的 skillfull 显示描述)
/version 查看版本信息
/<skill> [参数] 触发已安装的 skill
直接输入文字即可与 Claude Code 对话`;
// 缓存 skill 列表,避免每次命令都扫描文件系统
let cachedSkills: SkillInfo[] | null = null;
let lastScanTime = 0;
const CACHE_TTL = 60_000; // 60秒
function getSkills(): SkillInfo[] {
const now = Date.now();
if (!cachedSkills || now - lastScanTime > CACHE_TTL) {
cachedSkills = scanAllSkills();
lastScanTime = now;
}
return cachedSkills;
}
/** 清除缓存,用于 /skills 命令强制刷新 */
export function invalidateSkillCache(): void {
cachedSkills = null;
}
export function handleHelp(_args: string): CommandResult {
return { reply: HELP_TEXT, handled: true };
}
export function handleClear(ctx: CommandContext): CommandResult {
const newSession = ctx.clearSession();
Object.assign(ctx.session, newSession);
return { reply: '✅ 会话已清除,下次消息将开始新会话。', handled: true };
}
export function handleCwd(ctx: CommandContext, args: string): CommandResult {
if (!args) {
return { reply: `当前工作目录: ${ctx.session.workingDirectory}\n用法: /cwd <路径>`, handled: true };
}
ctx.updateSession({ workingDirectory: args });
return { reply: `✅ 工作目录已切换为: ${args}`, handled: true };
}
export function handleModel(ctx: CommandContext, args: string): CommandResult {
if (!args) {
return { reply: '用法: /model <模型名称>\n例: /model claude-sonnet-4-6', handled: true };
}
ctx.updateSession({ model: args });
return { reply: `✅ 模型已切换为: ${args}`, handled: true };
}
export function handleStatus(ctx: CommandContext): CommandResult {
const s = ctx.session;
const lines = [
'📊 会话状态',
'',
`工作目录: ${s.workingDirectory}`,
`模型: ${s.model ?? '默认'}`,
`会话ID: ${s.sdkSessionId ?? '无'}`,
`状态: ${s.state}`,
];
return { reply: lines.join('\n'), handled: true };
}
export function handleSkills(args: string): CommandResult {
invalidateSkillCache();
const skills = getSkills();
if (skills.length === 0) {
return { reply: '未找到已安装的 skill。', handled: true };
}
const showFull = args.trim().toLowerCase() === 'full';
if (showFull) {
const lines = skills.map(s => `/${s.name}\n ${s.description}`);
return { reply: `📋 已安装的 Skill (${skills.length}):\n\n${lines.join('\n\n')}`, handled: true };
}
const lines = skills.map(s => `/${s.name}`);
return { reply: `📋 已安装的 Skill (${skills.length}):\n\n${lines.join('\n')}\n\n使用 /skills full 查看完整描述`, handled: true };
}
const MAX_HISTORY_LIMIT = 100;
export function handleHistory(ctx: CommandContext, args: string): CommandResult {
const limit = args ? parseInt(args, 10) : 20;
if (isNaN(limit) || limit <= 0) {
return { reply: '用法: /history [数量]\n例: /history 50(显示最近50条对话)', handled: true };
}
const effectiveLimit = Math.min(limit, MAX_HISTORY_LIMIT);
const historyText = ctx.getChatHistoryText?.(effectiveLimit) || '暂无对话记录';
return { reply: `📝 对话记录(最近${effectiveLimit}条):\n\n${historyText}`, handled: true };
}
/** 完全重置会话(包括工作目录等设置) */
export function handleReset(ctx: CommandContext): CommandResult {
const newSession = ctx.clearSession();
newSession.workingDirectory = DEFAULT_WORKING_DIR;
Object.assign(ctx.session, newSession);
return { reply: '✅ 会话已完全重置,所有设置恢复默认。', handled: true };
}
/** 压缩上下文 — 清除 SDK 会话 ID,开始新上下文但保留聊天历史 */
export function handleCompact(ctx: CommandContext): CommandResult {
const currentSessionId = ctx.session.sdkSessionId;
if (!currentSessionId) {
return { reply: 'ℹ️ 当前没有活动的 SDK 会话,无需压缩。', handled: true };
}
ctx.updateSession({
previousSdkSessionId: currentSessionId,
sdkSessionId: undefined,
});
return {
reply: '✅ 上下文已压缩\n\n下次消息将开始新的 SDK 会话(token 清零)\n聊天历史已保留,可用 /history 查看',
handled: true,
};
}
/** 撤销最近 N 条对话 */
export function handleUndo(ctx: CommandContext, args: string): CommandResult {
const count = args ? parseInt(args, 10) : 1;
if (isNaN(count) || count <= 0) {
return { reply: '用法: /undo [数量]\n例: /undo 2(撤销最近2条对话)', handled: true };
}
const history = ctx.session.chatHistory || [];
if (history.length === 0) {
return { reply: '⚠️ 没有对话记录可撤销', handled: true };
}
const actualCount = Math.min(count, history.length);
ctx.session.chatHistory = history.slice(0, -actualCount);
ctx.updateSession({ chatHistory: ctx.session.chatHistory });
return { reply: `✅ 已撤销最近 ${actualCount} 条对话`, handled: true };
}
/** 查看版本信息 */
export function handleVersion(): CommandResult {
try {
const __dirname = fileURLToPath(new URL('.', import.meta.url));
const pkg = JSON.parse(readFileSync(join(__dirname, '..', '..', 'package.json'), 'utf-8'));
const version = pkg.version || 'unknown';
return { reply: `wechat-claude-code v${version}`, handled: true };
} catch {
return { reply: 'wechat-claude-code (version unknown)', handled: true };
}
}
export function handlePrompt(_ctx: CommandContext, args: string): CommandResult {
const config = loadConfig();
if (!args) {
const current = config.systemPrompt;
if (current) {
return { reply: `📝 当前系统提示词:\n${current}\n\n用法:\n/prompt <提示词> — 设置\n/prompt clear — 清除`, handled: true };
}
return { reply: '📝 暂无系统提示词\n\n用法: /prompt <提示词>\n例: /prompt 用中文回答我', handled: true };
}
if (args.trim().toLowerCase() === 'clear') {
config.systemPrompt = undefined;
saveConfig(config);
return { reply: '✅ 系统提示词已清除', handled: true };
}
config.systemPrompt = args.trim();
saveConfig(config);
return { reply: `✅ 系统提示词已设置:\n${config.systemPrompt}`, handled: true };
}
export function handleSend(ctx: CommandContext, args: string): CommandResult {
if (!args) {
return { reply: '用法: /send <文件路径>\n例: /send ~/Documents/report.pdf\n /send ./chart.png', handled: true };
}
const resolved = args.startsWith('/')
? args
: resolve(ctx.session.workingDirectory, args.replace(/^~/, homedir()));
if (!existsSync(resolved)) {
return { reply: `文件不存在: ${resolved}`, handled: true };
}
const stat = statSync(resolved);
if (stat.isDirectory()) {
return { reply: `这是一个目录,请指定文件: ${resolved}`, handled: true };
}
if (stat.size > 25 * 1024 * 1024) {
return { reply: `文件过大 (${(stat.size / 1024 / 1024).toFixed(1)}MB),最大支持 25MB`, handled: true };
}
return { handled: true, sendFile: resolved };
}
export function handleUnknown(cmd: string, args: string): CommandResult {
const skills = getSkills();
const skill = findSkill(skills, cmd);
if (skill) {
const prompt = args ? `Use the ${skill.name} skill: ${args}` : `Use the ${skill.name} skill`;
return { handled: true, claudePrompt: prompt };
}
return {
handled: true,
reply: `未找到 skill: ${cmd}\n输入 /skills 查看可用列表`,
};
}
+77
View File
@@ -0,0 +1,77 @@
import type { Session } from '../session.js';
import { findSkill } from '../claude/skill-scanner.js';
import { logger } from '../logger.js';
import { handleHelp, handleClear, handleCwd, handleModel, handleStatus, handleSkills, handleHistory, handleReset, handleCompact, handleUndo, handleVersion, handlePrompt, handleSend, handleUnknown } from './handlers.js';
export interface CommandContext {
accountId: string;
session: Session;
updateSession: (partial: Partial<Session>) => void;
clearSession: () => Session;
getChatHistoryText?: (limit?: number) => string;
text: string;
}
export interface CommandResult {
reply?: string;
handled: boolean;
claudePrompt?: string;
sendFile?: string; // Absolute path to a file to send to the user
}
/**
* Parse and dispatch a slash command.
*
* Supported commands:
* /help - Show help text with all available commands
* /clear - Clear the current session
* /model <name> - Update the session model
* /status - Show current session info
* /skills - List all installed skills
* /<skill> - Invoke a skill by name (args are forwarded to Claude)
*/
export function routeCommand(ctx: CommandContext): CommandResult {
const text = ctx.text.trim();
if (!text.startsWith('/')) {
return { handled: false };
}
const spaceIdx = text.indexOf(' ');
const cmd = (spaceIdx === -1 ? text.slice(1) : text.slice(1, spaceIdx)).toLowerCase();
const args = spaceIdx === -1 ? '' : text.slice(spaceIdx + 1).trim();
logger.info(`Slash command: /${cmd} ${args}`.trimEnd());
switch (cmd) {
case 'help':
return handleHelp(args);
case 'clear':
return handleClear(ctx);
case 'reset':
return handleReset(ctx);
case 'cwd':
return handleCwd(ctx, args);
case 'model':
return handleModel(ctx, args);
case 'prompt':
return handlePrompt(ctx, args);
case 'status':
return handleStatus(ctx);
case 'skills':
return handleSkills(args);
case 'history':
return handleHistory(ctx, args);
case 'undo':
return handleUndo(ctx, args);
case 'compact':
return handleCompact(ctx);
case 'send':
return handleSend(ctx, args);
case 'version':
case 'v':
return handleVersion();
default:
return handleUnknown(cmd, args);
}
}
+48
View File
@@ -0,0 +1,48 @@
import { readFileSync, writeFileSync, mkdirSync, chmodSync } from "node:fs";
import { join } from "node:path";
import { homedir } from "node:os";
import { DEFAULT_WORKING_DIR } from "./constants.js";
export interface Config {
workingDirectory: string;
model?: string;
systemPrompt?: string;
}
const CONFIG_DIR = join(homedir(), ".wechat-claude-code");
const CONFIG_PATH = join(CONFIG_DIR, "config.json");
const DEFAULT_CONFIG: Config = {
workingDirectory: DEFAULT_WORKING_DIR,
};
export function loadConfig(): Config {
try {
const content = readFileSync(CONFIG_PATH, "utf-8");
const parsed = JSON.parse(content);
const config: Config = {
workingDirectory: parsed.workingDirectory || DEFAULT_CONFIG.workingDirectory,
model: parsed.model,
systemPrompt: parsed.systemPrompt,
};
mkdirSync(config.workingDirectory, { recursive: true });
return config;
} catch {
const config = { ...DEFAULT_CONFIG };
mkdirSync(config.workingDirectory, { recursive: true });
return config;
}
}
export function saveConfig(config: Config): void {
mkdirSync(CONFIG_DIR, { recursive: true });
const data: Record<string, string> = {
workingDirectory: config.workingDirectory,
};
if (config.model) data.model = config.model;
if (config.systemPrompt) data.systemPrompt = config.systemPrompt;
writeFileSync(CONFIG_PATH, JSON.stringify(data, null, 2) + "\n", "utf-8");
if (process.platform !== "win32") {
chmodSync(CONFIG_PATH, 0o600);
}
}
+8
View File
@@ -0,0 +1,8 @@
import { homedir } from 'node:os';
import { join } from 'node:path';
export const DATA_DIR = process.env.WCC_DATA_DIR || join(homedir(), '.wechat-claude-code');
export const DEFAULT_WORKING_DIR = join(homedir(), 'Documents', 'ClaudeCode');
export const CDN_BASE_URL = 'https://novac2c.cdn.weixin.qq.com/c2c';
+83
View File
@@ -0,0 +1,83 @@
import { mkdirSync, appendFileSync, readdirSync, unlinkSync } from "node:fs";
import { join } from "node:path";
import { homedir } from "node:os";
const LOG_DIR = join(homedir(), ".wechat-claude-code", "logs");
const MAX_LOG_FILES = 30; // Keep at most 30 days of logs
/** Clean up old log files beyond MAX_LOG_FILES retention. */
function cleanupOldLogs(): void {
try {
const files = readdirSync(LOG_DIR)
.filter((f) => f.startsWith("bridge-") && f.endsWith(".log"))
.sort();
while (files.length > MAX_LOG_FILES) {
unlinkSync(join(LOG_DIR, files.shift()!));
}
} catch {
// Ignore errors during cleanup
}
}
/**
* Redact sensitive values from a string:
* - Bearer tokens (Authorization headers)
* - aes_key values
* - generic token/secret values in JSON payloads
*/
export function redact(obj: unknown): string {
const raw = typeof obj === "string" ? obj : JSON.stringify(obj);
if (!raw) return raw;
let safe = raw;
// Mask Bearer tokens: "Bearer <anything>"
safe = safe.replace(/Bearer\s+[^\s"\\]+/gi, "Bearer ***");
// Mask generic token/secret/password/api_key values in JSON
// Matches both snake_case (bot_token) and camelCase (botToken)
safe = safe.replace(
/"(?:(?:[\w]+_)?[Tt]oken|(?:[\w]+_)?[Ss]ecret|(?:[\w]+_)?[Pp]assword|(?:[\w]+_)?api_key|[Aa]es_[Kk]ey)"\s*:\s*"[^"]*"/gi,
(match) => {
const key = match.match(/"[^"]*"/)?.[0] ?? '""';
return `${key}: "***"`;
},
);
return safe;
}
function ensureLogDir(): void {
mkdirSync(LOG_DIR, { recursive: true });
cleanupOldLogs();
}
function getLogFilePath(): string {
const now = new Date(Date.now() + 8 * 60 * 60 * 1000);
const date = now.toISOString().slice(0, 10); // YYYY-MM-DD
return join(LOG_DIR, `bridge-${date}.log`);
}
function writeLogLine(level: string, message: string, data?: unknown): void {
ensureLogDir();
const ts = new Date(Date.now() + 8 * 60 * 60 * 1000).toISOString();
const timestamp = ts.replace('Z', '+08:00');
const parts = [timestamp, level, message];
if (data !== undefined) {
parts.push(redact(data));
}
const line = parts.join(" ") + "\n";
appendFileSync(getLogFilePath(), line, "utf-8");
}
export const logger = {
info(message: string, data?: unknown): void {
writeLogLine("INFO", message, data);
},
warn(message: string, data?: unknown): void {
writeLogLine("WARN", message, data);
},
error(message: string, data?: unknown): void {
writeLogLine("ERROR", message, data);
},
debug(message: string, data?: unknown): void {
writeLogLine("DEBUG", message, data);
},
} as const;
+816
View File
@@ -0,0 +1,816 @@
import { createInterface } from 'node:readline';
import process from 'node:process';
import { spawnSync } from 'node:child_process';
import { join, basename } from 'node:path';
import { unlinkSync, writeFileSync, mkdirSync } from 'node:fs';
import { homedir } from 'node:os';
import { WeChatApi } from './wechat/api.js';
import { saveAccount, loadLatestAccount, type AccountData } from './wechat/accounts.js';
import { startQrLogin, waitForQrScan } from './wechat/login.js';
import { createMonitor, type MonitorCallbacks } from './wechat/monitor.js';
import { createSender } from './wechat/send.js';
import { downloadImage, extractText, extractFirstImageUrl, extractFirstFileItem, downloadFile } from './wechat/media.js';
import { createSessionStore, type Session } from './session.js';
import { routeCommand, type CommandContext, type CommandResult } from './commands/router.js';
import { claudeQuery, type QueryOptions } from './claude/provider.js';
import { TurnRouter } from './claude/turn-router.js';
import { filterToolNoise } from './claude/tool-noise-filter.js';
import { loadConfig, saveConfig } from './config.js';
import { logger } from './logger.js';
import { DATA_DIR } from './constants.js';
import { MessageType, type WeixinMessage } from './wechat/types.js';
import { loadPendingQueue, savePendingQueue, type PendingItem } from './pending-queue.js';
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const MAX_MESSAGE_LENGTH = 4000;
// Extensions eligible for auto-push when detected in Claude's response
const AUTO_PUSH_EXTENSIONS = new Set([
'.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.svg', '.ico',
'.pdf', '.doc', '.docx', '.ppt', '.pptx', '.rtf',
'.txt', '.md',
'.csv', '.xlsx', '.xls',
'.mp3', '.wav', '.m4a', '.mp4', '.mov',
]);
/** Extract local file paths from Claude's response text. */
function extractFilePathsFromText(text: string, cwd: string): string[] {
const paths: string[] = [];
// Match absolute paths (macOS/Linux), tilde paths, and Windows paths with a file extension
const regex = /(?:\/(?:Users|home|tmp|var|etc)\/[^\s`'"()\[\]{}|<>]+\.\w+|~\/[^\s`'"()\[\]{}|<>]+\.\w+|[A-Za-z]:[\\\/][^\s`'"()\[\]{}|<>]+\.\w+)/g;
let match: RegExpExecArray | null;
while ((match = regex.exec(text)) !== null) {
const raw = match[0];
const resolved = raw.startsWith('~')
? raw.replace(/^~/, homedir())
: raw;
paths.push(resolved);
}
return paths;
}
/** Split text into blocks at paragraph boundaries (double newlines). */
function parseBlocks(text: string): string[] {
return text.split(/\n\n+/).filter(block => block.length > 0);
}
/** Find a safe split point that won't break markdown formatting. */
function findSafeSplitPoint(text: string, maxLen: number): number {
// Try newline first (preserves list items, paragraphs)
let idx = text.lastIndexOf('\n', maxLen);
if (idx >= maxLen * 0.3) return idx;
// Try sentence-ending punctuation
const sentenceEnd = /[。!?.!?]$/;
for (let i = maxLen; i >= maxLen * 0.5; i--) {
if (sentenceEnd.test(text.slice(i - 1, i))) return i;
}
// Try space (won't split mid-word or mid-markdown)
idx = text.lastIndexOf(' ', maxLen);
if (idx >= maxLen * 0.3) return idx;
// Last resort: hard cut
return maxLen;
}
/** Fallback: split a single oversized block at safe boundaries. */
function splitByNewline(text: string, maxLen: number): string[] {
const chunks: string[] = [];
let remaining = text;
while (remaining.length > 0) {
if (remaining.length <= maxLen) {
chunks.push(remaining);
break;
}
const splitIdx = findSafeSplitPoint(remaining, maxLen);
chunks.push(remaining.slice(0, splitIdx));
remaining = remaining.slice(splitIdx).replace(/^\n+/, '');
}
return chunks;
}
/**
* Card-aware message splitter.
* Splits at paragraph boundaries (double newlines) to keep cards intact,
* falls back to newline-based splitting for oversized single blocks.
*/
function splitMessage(text: string, maxLen: number = MAX_MESSAGE_LENGTH): string[] {
if (text.length <= maxLen) return [text];
const blocks = parseBlocks(text);
const chunks: string[] = [];
let current = '';
for (const block of blocks) {
// Can this block fit into the current chunk?
if (current.length === 0) {
if (block.length <= maxLen) {
current = block;
} else {
chunks.push(...splitByNewline(block, maxLen));
}
} else if (current.length + 2 + block.length <= maxLen) {
current += '\n\n' + block;
} else {
// Current chunk is complete, start a new one
chunks.push(current);
if (block.length <= maxLen) {
current = block;
} else {
chunks.push(...splitByNewline(block, maxLen));
current = '';
}
}
}
if (current) chunks.push(current);
return chunks;
}
function promptUser(question: string, defaultValue?: string): Promise<string> {
return new Promise((resolve) => {
const rl = createInterface({ input: process.stdin, output: process.stdout });
const display = defaultValue ? `${question} [${defaultValue}]: ` : `${question}: `;
rl.question(display, (answer) => {
rl.close();
resolve(answer.trim() || defaultValue || '');
});
});
}
/** Open a file using the platform's default application (secure: uses spawnSync) */
function openFile(filePath: string): void {
const platform = process.platform;
let cmd: string;
let args: string[];
if (platform === 'darwin') {
cmd = 'open';
args = [filePath];
} else if (platform === 'win32') {
cmd = 'cmd';
args = ['/c', 'start', '', filePath];
} else {
// Linux: try xdg-open
cmd = 'xdg-open';
args = [filePath];
}
const result = spawnSync(cmd, args, { stdio: 'ignore' });
if (result.error) {
logger.warn('Failed to open file', { cmd, filePath, error: result.error.message });
}
}
// ---------------------------------------------------------------------------
// Setup
// ---------------------------------------------------------------------------
async function runSetup(): Promise<void> {
mkdirSync(DATA_DIR, { recursive: true });
const QR_PATH = join(DATA_DIR, 'qrcode.png');
console.log('正在设置...\n');
// Loop: generate QR → display → poll for scan → handle expiry → repeat
while (true) {
const { qrcodeUrl, qrcodeId } = await startQrLogin();
const isHeadlessLinux = process.platform === 'linux' &&
!process.env.DISPLAY && !process.env.WAYLAND_DISPLAY;
if (isHeadlessLinux) {
// Headless Linux: display QR in terminal using qrcode-terminal
try {
const qrcodeTerminal = await import('qrcode-terminal');
console.log('请用微信扫描下方二维码:\n');
qrcodeTerminal.default.generate(qrcodeUrl, { small: true });
console.log();
console.log('二维码链接:', qrcodeUrl);
console.log();
} catch {
logger.warn('qrcode-terminal not available, falling back to URL');
console.log('无法在终端显示二维码,请访问链接:');
console.log(qrcodeUrl);
console.log();
}
} else {
// macOS / Windows / GUI Linux: generate QR PNG and open with system viewer
const QRCode = await import('qrcode');
const pngData = await QRCode.toBuffer(qrcodeUrl, { type: 'png', width: 400, margin: 2 });
writeFileSync(QR_PATH, pngData);
openFile(QR_PATH);
console.log('已打开二维码图片,请用微信扫描:');
console.log(`图片路径: ${QR_PATH}\n`);
}
console.log('等待扫码绑定...');
try {
await waitForQrScan(qrcodeId);
console.log('✅ 绑定成功!');
break;
} catch (err: any) {
if (err.message?.includes('expired')) {
console.log('⚠️ 二维码已过期,正在刷新...\n');
continue;
}
throw err;
}
}
// Clean up QR image
try { unlinkSync(QR_PATH); } catch {
logger.warn('Failed to clean up QR image', { path: QR_PATH });
}
const workingDir = await promptUser('请输入工作目录', join(homedir(), 'Documents', 'ClaudeCode'));
const config = loadConfig();
config.workingDirectory = workingDir;
saveConfig(config);
console.log('运行 npm run daemon -- start 启动服务');
}
// ---------------------------------------------------------------------------
// Daemon
// ---------------------------------------------------------------------------
async function runDaemon(): Promise<void> {
const config = loadConfig();
const account = loadLatestAccount();
if (!account) {
console.error('未找到账号,请先运行 node dist/main.js setup');
process.exit(1);
}
const api = new WeChatApi(account.botToken, account.baseUrl);
const sessionStore = createSessionStore();
const session: Session = sessionStore.load(account.accountId);
// Fix: backfill session workingDirectory from config if it's still the default process.cwd()
if (config.workingDirectory && session.workingDirectory === process.cwd()) {
session.workingDirectory = config.workingDirectory;
sessionStore.save(account.accountId, session);
}
// Fix: reset stale non-idle state on startup (e.g. after crash)
if (session.state !== 'idle') {
logger.warn('Resetting stale session state on startup', { state: session.state });
session.state = 'idle';
sessionStore.save(account.accountId, session);
}
const sender = createSender(api, account.accountId);
const sharedCtx = { lastContextToken: '' };
const activeControllers = new Map<string, AbortController>();
// -- Message queue for serial processing --
const messageQueue: WeixinMessage[] = [];
let processingQueue = false;
async function drainQueue(): Promise<void> {
if (processingQueue) return;
processingQueue = true;
while (messageQueue.length > 0) {
const msg = messageQueue.shift()!;
await handleMessage(msg, account!, session, sessionStore, sender, config, sharedCtx, activeControllers, messageQueue);
}
processingQueue = false;
}
// -- Wire the monitor callbacks --
/** Handle priority commands (/stop, /clear) immediately, bypassing the serial queue. */
function handlePriorityCommand(msg: WeixinMessage): boolean {
if (msg.message_type !== MessageType.USER || !msg.item_list) return false;
const text = extractTextFromItems(msg.item_list);
if (!text.startsWith('/stop') && !text.startsWith('/clear')) return false;
if (session.state !== 'processing') return false;
const ctrl = activeControllers.get(account!.accountId);
if (ctrl) { ctrl.abort(); activeControllers.delete(account!.accountId); }
session.state = 'idle';
sessionStore.save(account!.accountId, session);
if (text.startsWith('/stop')) {
messageQueue.length = 0;
sender.sendText(msg.from_user_id!, msg.context_token ?? '', '⏹ 已停止当前对话,排队中的消息已清空。').catch(() => {});
}
return true;
}
const callbacks: MonitorCallbacks = {
onMessage: async (msg: WeixinMessage) => {
if (handlePriorityCommand(msg)) return;
messageQueue.push(msg);
drainQueue();
},
onSessionExpired: () => {
logger.warn('Session expired, will keep retrying...');
console.error('⚠️ 微信会话已过期,请重新运行 setup 扫码绑定');
},
};
const monitor = createMonitor(api, callbacks);
// -- Graceful shutdown --
function shutdown(): void {
logger.info('Shutting down...');
monitor.stop();
process.exit(0);
}
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);
logger.info('Daemon started', { accountId: account.accountId });
console.log(`已启动 (账号: ${account.accountId})`);
await monitor.run();
}
// ---------------------------------------------------------------------------
// Message handling
// ---------------------------------------------------------------------------
async function handleMessage(
msg: WeixinMessage,
account: AccountData,
session: Session,
sessionStore: ReturnType<typeof createSessionStore>,
sender: ReturnType<typeof createSender>,
config: ReturnType<typeof loadConfig>,
sharedCtx: { lastContextToken: string },
activeControllers: Map<string, AbortController>,
messageQueue: WeixinMessage[],
): Promise<void> {
// Filter: only user messages with required fields
if (msg.message_type !== MessageType.USER) return;
if (!msg.from_user_id || !msg.item_list) return;
if (account.userId && msg.from_user_id !== account.userId) return;
const contextToken = msg.context_token ?? '';
const fromUserId = msg.from_user_id;
sharedCtx.lastContextToken = contextToken;
// Flush any pending messages from prior rate-limit windows. User's new
// message brings a fresh context_token, which resets the iLink 11-msg quota.
await flushPending(account.accountId, fromUserId, contextToken, sender);
// Extract text from items
const userText = extractTextFromItems(msg.item_list);
const imageItem = extractFirstImageUrl(msg.item_list);
const fileItem = extractFirstFileItem(msg.item_list);
// Drop non-command messages while processing (priority commands already handled upstream)
if (session.state === 'processing' && !userText.startsWith('/')) {
return;
}
// -- Command routing --
if (userText.startsWith('/')) {
const updateSession = (partial: Partial<Session>) => {
Object.assign(session, partial);
sessionStore.save(account.accountId, session);
};
const ctx: CommandContext = {
accountId: account.accountId,
session,
updateSession,
clearSession: () => sessionStore.clear(account.accountId),
getChatHistoryText: (limit?: number) => sessionStore.getChatHistoryText(session, limit),
text: userText,
};
const result: CommandResult = routeCommand(ctx);
if (result.handled && result.reply) {
await sender.sendText(fromUserId, contextToken, result.reply);
return;
}
if (result.handled && result.claudePrompt) {
await sendToClaude(
result.claudePrompt, imageItem, fileItem, fromUserId, contextToken,
account, session, sessionStore, sender, config, activeControllers,
);
return;
}
if (result.handled && result.sendFile) {
await sender.sendFile(fromUserId, contextToken, result.sendFile);
return;
}
if (result.handled) return;
// Not handled, treat as normal message (fall through)
}
// -- Normal message -> Claude --
if (!userText && !imageItem && !fileItem) {
await sender.sendText(fromUserId, contextToken, '暂不支持此类型消息,请发送文字、语音、图片或文件');
return;
}
await sendToClaude(
userText, imageItem, fileItem, fromUserId, contextToken,
account, session, sessionStore, sender, config, activeControllers,
);
}
function extractTextFromItems(items: NonNullable<WeixinMessage['item_list']>): string {
return items.map((item) => extractText(item)).filter(Boolean).join('\n');
}
/**
* Drain the pending message queue (messages that couldn't be delivered in a
* prior rate-limit window). Called whenever a fresh user message arrives with
* a new context_token. Each flush attempt stops at the first failure —
* remaining items stay queued for the next user message.
*/
async function flushPending(
accountId: string,
toUserId: string,
contextToken: string,
sender: ReturnType<typeof createSender>,
): Promise<void> {
const queue = loadPendingQueue(accountId);
if (queue.length === 0) return;
logger.info('Flushing pending queue', { accountId, pending: queue.length });
const stillPending: PendingItem[] = [];
for (const item of queue) {
try {
const chunks = splitMessage(item.text);
for (const chunk of chunks) {
await sender.sendText(toUserId, contextToken, chunk);
}
} catch (err) {
logger.warn('Flush stopped at rate-limit, keeping remaining items queued', {
accountId,
flushed: queue.length - stillPending.length - 1,
remaining: stillPending.length + 1,
error: err instanceof Error ? err.message : String(err),
});
stillPending.push(item);
}
}
savePendingQueue(accountId, stillPending);
if (stillPending.length > 0 && stillPending.length === queue.length) {
// Nothing got flushed this round — nudge the user.
await sender
.sendText(toUserId, contextToken, `⏳ 还有 ${stillPending.length} 条暂存消息未能推送,再发任意消息我会继续补发。`)
.catch(() => {});
}
}
async function sendToClaude(
userText: string,
imageItem: ReturnType<typeof extractFirstImageUrl>,
fileItem: ReturnType<typeof extractFirstFileItem>,
fromUserId: string,
contextToken: string,
account: AccountData,
session: Session,
sessionStore: ReturnType<typeof createSessionStore>,
sender: ReturnType<typeof createSender>,
config: ReturnType<typeof loadConfig>,
activeControllers: Map<string, AbortController>,
): Promise<void> {
// Set state to processing
session.state = 'processing';
sessionStore.save(account.accountId, session);
// Create abort controller for this query so it can be cancelled by new messages
const abortController = new AbortController();
activeControllers.set(account.accountId, abortController);
// Flush timer for streaming text to WeChat during query (declared here for finally cleanup)
let flushTimer: ReturnType<typeof setInterval> | undefined;
// Record user message in chat history
sessionStore.addChatMessage(session, 'user', userText || '(图片)');
// Start typing indicator (keepalive until stopTyping is called)
const stopTyping = sender.startTyping(fromUserId, contextToken);
try {
// Download image if present
let images: QueryOptions['images'];
if (imageItem) {
const base64DataUri = await downloadImage(imageItem);
if (base64DataUri) {
const matches = base64DataUri.match(/^data:([^;]+);base64,(.+)$/);
if (matches) {
images = [
{
type: 'image',
source: {
type: 'base64',
media_type: matches[1],
data: matches[2],
},
},
];
}
}
}
// Download file if present
let prompt = userText || '请分析这张图片';
if (fileItem) {
const filePath = await downloadFile(fileItem);
if (filePath) {
const fileName = fileItem.file_item?.file_name || basename(filePath);
prompt = userText
? `${userText}\n\n用户发送了文件: ${fileName}\n文件已保存到: ${filePath}\n请先读取这个文件再回答。`
: `用户发送了文件: ${fileName}\n文件已保存到: ${filePath}\n请读取这个文件并总结其内容。`;
}
}
let anySent = false;
let lastSentTime = Date.now();
let pendingRetry: { text: string; role: 'interstitial' | 'final' } | null = null;
// Serial promise chain — each emit appends to the chain, no flags needed
let flushChain: Promise<void> = Promise.resolve();
function emitText(text: string, role: 'interstitial' | 'final'): void {
if (!text.trim()) return;
// 若上一次发送失败留下了 pendingRetry,先用它原本的 role 单独补发,
// 不要和当前 role 的文本合并(避免 interstitial 内容混进 final 答案)。
if (pendingRetry) {
const stuck = pendingRetry;
pendingRetry = null;
scheduleSend(stuck.text, stuck.role);
}
scheduleSend(text, role);
}
function scheduleSend(text: string, role: 'interstitial' | 'final'): void {
if (!text.trim()) return;
flushChain = flushChain.then(async () => {
const chunks = splitMessage(text);
for (let i = 0; i < chunks.length; i++) {
try {
await sender.sendText(fromUserId, contextToken, chunks[i]);
} catch (err) {
pendingRetry = { text: chunks.slice(i).join('\n\n'), role };
logger.warn('emitText send failed, content retained for retry', {
role,
error: err instanceof Error ? err.message : String(err),
retainedChunks: chunks.length - i,
});
return;
}
}
anySent = true;
lastSentTime = Date.now();
});
}
const router = new TurnRouter((msg) => emitText(filterToolNoise(msg.text), msg.role));
// Safety net: send keepalive if nothing was sent for 5 minutes
const SILENCE_WARNING_MS = 5 * 60 * 1000;
const SILENCE_MESSAGES = [
'我还在处理中,这个问题有点复杂,请再稍等一下',
'正在努力干活中,马上就有结果了,请稍等片刻',
'有点复杂正在处理,再给我一点时间,很快就好',
'快好了别着急,正在收尾阶段,马上给你回复',
'还在跑呢,任务量比较大,不过马上就能出结果了',
'任务比想象的复杂一些,再等等我,正在全力处理',
'正在处理中,进展顺利,再等一会儿就好',
'还没完不过已经快了,再给我一分钟就能搞定',
'我在认真思考这个问题,请再稍等一会儿',
'稍微有点棘手,不过已经快解决了,再等我一下',
];
flushTimer = setInterval(() => {
if (Date.now() - lastSentTime > SILENCE_WARNING_MS) {
const msg = SILENCE_MESSAGES[Math.floor(Math.random() * SILENCE_MESSAGES.length)];
sender.sendText(fromUserId, contextToken, msg).catch(() => {});
lastSentTime = Date.now();
}
}, 2000);
const queryOptions: QueryOptions = {
prompt,
cwd: (session.workingDirectory || config.workingDirectory).replace(/^~/, homedir()),
resume: session.sdkSessionId,
model: session.model,
systemPrompt: [
'你正在通过微信与用户对话,不是在终端里。不要让用户去终端操作。如果用户需要文件,直接输出文件地址就行,会自动识别解析推送文件到用户的微信中。',
config.systemPrompt,
].filter(Boolean).join('\n'),
abortController,
images,
onText: (delta: string) => {
router.onText(delta);
},
onTurnEnd: (stopReason: string) => {
router.onTurnEnd(stopReason);
},
};
let result = await claudeQuery(queryOptions);
// If resume failed (e.g. corrupted session), retry without resume
if (result.error && queryOptions.resume) {
logger.warn('Resume failed, retrying without resume', { error: result.error, sessionId: queryOptions.resume });
queryOptions.resume = undefined;
session.sdkSessionId = undefined;
sessionStore.save(account.accountId, session);
const retryResult = await claudeQuery(queryOptions);
Object.assign(result, retryResult);
}
// Stop periodic flush, drain router (final 先于 interstitial), wait for queued sends
clearInterval(flushTimer);
router.drain();
await flushChain;
// 兜底重试:drain() 的最后一次发送若失败,pendingRetry 会卡住没有下一个 emit 接力。
// 这里做有上限的终态重试,避免静默丢内容(commit d6d7d62 的 "never silently drop" 保证)。
const MAX_TERMINAL_ATTEMPTS = 3;
let terminalAttempt = 0;
while (pendingRetry && terminalAttempt < MAX_TERMINAL_ATTEMPTS) {
const stuck: { text: string; role: 'interstitial' | 'final' } = pendingRetry;
pendingRetry = null;
terminalAttempt++;
const delayMs = terminalAttempt * 5_000; // 5s, 10s, 15s
logger.warn(`terminal retry ${terminalAttempt}/${MAX_TERMINAL_ATTEMPTS} for stranded content`, {
role: stuck.role,
delayMs,
textLength: stuck.text.length,
});
await new Promise(r => setTimeout(r, delayMs));
const chunks = splitMessage(stuck.text);
let failed = false;
for (let i = 0; i < chunks.length; i++) {
try {
await sender.sendText(fromUserId, contextToken, chunks[i]);
anySent = true;
lastSentTime = Date.now();
} catch (err) {
pendingRetry = { text: chunks.slice(i).join('\n\n'), role: stuck.role };
logger.warn('terminal retry failed', {
attempt: terminalAttempt,
error: err instanceof Error ? err.message : String(err),
});
failed = true;
break;
}
}
if (!failed) break;
}
if (pendingRetry) {
// Park the stranded content to the pending queue. It will be flushed
// automatically when the user's next message brings a fresh context_token
// (which resets the iLink 11-msg quota).
const queue = loadPendingQueue(account.accountId);
queue.push({
text: pendingRetry.text,
role: pendingRetry.role,
queuedAt: Date.now(),
});
savePendingQueue(account.accountId, queue);
logger.warn('content parked to pending queue', {
role: pendingRetry.role,
textLength: pendingRetry.text.length,
queueSize: queue.length,
});
await sender
.sendText(fromUserId, contextToken, '⏳ 部分内容因微信单次推送上限暂存,下次你回复任意消息时自动补发。')
.catch(() => {});
pendingRetry = null;
}
// Send result back to WeChat
if (result.text) {
if (result.error) {
logger.warn('Claude query had error but returned text, using text', { error: result.error });
}
sessionStore.addChatMessage(session, 'assistant', result.text);
// If nothing was streamed at all (e.g. streaming not supported), send full text now
if (!anySent) {
const chunks = splitMessage(result.text);
for (const chunk of chunks) {
await sender.sendText(fromUserId, contextToken, chunk);
}
}
} else if (result.error) {
logger.error('Claude query error', { error: result.error });
await sender.sendText(fromUserId, contextToken, 'Claude 处理请求时出错,请稍后重试。');
} else if (!anySent) {
await sender.sendText(fromUserId, contextToken, 'Claude 无返回内容(可能因权限被拒而终止)');
}
// Update session with new SDK session ID
session.sdkSessionId = result.sessionId || undefined;
session.state = 'idle';
sessionStore.save(account.accountId, session);
// Auto-push deliverable files mentioned in Claude's response
if (result.text) {
const cwd = (session.workingDirectory || config.workingDirectory).replace(/^~/, homedir());
const detectedPaths = extractFilePathsFromText(result.text, cwd);
const { existsSync } = await import('node:fs');
const { extname } = await import('node:path');
const pushable = detectedPaths.filter(f => {
const ext = extname(f).toLowerCase();
return AUTO_PUSH_EXTENSIONS.has(ext) && existsSync(f);
});
if (pushable.length > 0) {
const failedFiles: string[] = [];
for (const filePath of pushable) {
try {
await sender.sendFile(fromUserId, contextToken, filePath);
} catch {
failedFiles.push(filePath);
}
}
if (failedFiles.length > 0) {
// Server-side rate limit requires longer cooldown (observed ret:-2 even after 9s backoff)
for (let attempt = 0; attempt < 3; attempt++) {
const delay = (attempt + 1) * 15_000;
logger.warn(`Rate-limited, retrying ${failedFiles.length} file(s) in ${delay / 1000}s (attempt ${attempt + 1}/3)`);
await new Promise(r => setTimeout(r, delay));
const stillFailed: string[] = [];
for (const filePath of failedFiles) {
try {
await sender.sendFile(fromUserId, contextToken, filePath);
} catch {
stillFailed.push(filePath);
}
}
if (stillFailed.length === 0) break;
failedFiles.length = 0;
failedFiles.push(...stillFailed);
}
if (failedFiles.length > 0) {
logger.error('File delivery failed after all retries', { files: failedFiles });
await sender.sendText(fromUserId, contextToken, `文件推送失败(服务端限频),请稍后重试。`).catch(() => {});
}
}
}
}
} catch (err) {
const isAbort = err instanceof Error && (err.name === 'AbortError' || err.message.includes('abort'));
if (isAbort) {
// Query was cancelled by a new incoming message — exit silently
logger.info('Claude query aborted by new message');
} else {
const errorMsg = err instanceof Error ? err.message : String(err);
logger.error('Error in sendToClaude', { error: errorMsg });
await sender.sendText(fromUserId, contextToken, '处理消息时出错,请稍后重试。');
}
session.state = 'idle';
sessionStore.save(account.accountId, session);
} finally {
clearInterval(flushTimer);
stopTyping();
// Clean up the abort controller if it's still ours
if (activeControllers.get(account.accountId) === abortController) {
activeControllers.delete(account.accountId);
}
}
}
// ---------------------------------------------------------------------------
// CLI
// ---------------------------------------------------------------------------
const command = process.argv[2];
if (command === 'setup') {
runSetup().catch((err) => {
logger.error('Setup failed', { error: err instanceof Error ? err.message : String(err) });
console.error('设置失败:', err);
process.exit(1);
});
} else {
// 'start' or no argument
runDaemon().catch((err) => {
logger.error('Daemon start failed', { error: err instanceof Error ? err.message : String(err) });
console.error('启动失败:', err);
process.exit(1);
});
}
+65
View File
@@ -0,0 +1,65 @@
import { mkdirSync, readFileSync, writeFileSync, existsSync } from 'node:fs';
import { join } from 'node:path';
import { DATA_DIR } from './constants.js';
import { logger } from './logger.js';
export interface PendingItem {
text: string;
role: 'interstitial' | 'final';
queuedAt: number;
}
const QUEUE_DIR = join(DATA_DIR, 'pending-queue');
function queuePath(accountId: string): string {
return join(QUEUE_DIR, `${accountId}.json`);
}
function ensureDir(): void {
if (!existsSync(QUEUE_DIR)) {
mkdirSync(QUEUE_DIR, { recursive: true });
}
}
export function loadPendingQueue(accountId: string): PendingItem[] {
try {
const path = queuePath(accountId);
if (!existsSync(path)) return [];
const raw = readFileSync(path, 'utf-8');
const data = JSON.parse(raw);
return Array.isArray(data) ? data : [];
} catch (err) {
logger.warn('Failed to load pending queue', {
accountId,
error: err instanceof Error ? err.message : String(err),
});
return [];
}
}
export function savePendingQueue(accountId: string, items: PendingItem[]): void {
try {
ensureDir();
writeFileSync(queuePath(accountId), JSON.stringify(items, null, 2), 'utf-8');
} catch (err) {
logger.warn('Failed to save pending queue', {
accountId,
error: err instanceof Error ? err.message : String(err),
});
}
}
export function appendPending(accountId: string, item: PendingItem): PendingItem[] {
const items = loadPendingQueue(accountId);
items.push(item);
savePendingQueue(accountId, items);
return items;
}
export function clearPending(accountId: string): void {
savePendingQueue(accountId, []);
}
export function hasPending(accountId: string): boolean {
return loadPendingQueue(accountId).length > 0;
}
+119
View File
@@ -0,0 +1,119 @@
import { loadJson, saveJson, validateAccountId } from './store.js';
import { mkdirSync } from 'node:fs';
import { DATA_DIR, DEFAULT_WORKING_DIR } from './constants.js';
import { join } from 'node:path';
import { logger } from './logger.js';
const SESSIONS_DIR = join(DATA_DIR, 'sessions');
export type SessionState = 'idle' | 'processing';
export interface ChatMessage {
role: 'user' | 'assistant';
content: string;
timestamp: number;
}
export interface Session {
sdkSessionId?: string;
previousSdkSessionId?: string;
workingDirectory: string;
model?: string;
state: SessionState;
chatHistory: ChatMessage[];
maxHistoryLength?: number;
}
const DEFAULT_MAX_HISTORY = 100;
export function createSessionStore() {
function getSessionPath(accountId: string): string {
validateAccountId(accountId);
return join(SESSIONS_DIR, `${accountId}.json`);
}
function load(accountId: string): Session {
validateAccountId(accountId);
const session = loadJson<Session>(getSessionPath(accountId), {
workingDirectory: DEFAULT_WORKING_DIR,
state: 'idle',
chatHistory: [],
maxHistoryLength: DEFAULT_MAX_HISTORY,
});
// Backward compatibility: ensure chatHistory exists
if (!session.chatHistory) {
session.chatHistory = [];
}
if (!session.maxHistoryLength) {
session.maxHistoryLength = DEFAULT_MAX_HISTORY;
}
return session;
}
function save(accountId: string, session: Session): void {
mkdirSync(SESSIONS_DIR, { recursive: true });
// Trim chat history if it exceeds max length before saving
const maxLen = session.maxHistoryLength || DEFAULT_MAX_HISTORY;
if (session.chatHistory.length > maxLen) {
session.chatHistory = session.chatHistory.slice(-maxLen);
}
saveJson(getSessionPath(accountId), session);
}
function clear(accountId: string, currentSession?: Session): Session {
const session: Session = {
sdkSessionId: undefined, // explicitly clear so Object.assign removes it
previousSdkSessionId: undefined,
workingDirectory: currentSession?.workingDirectory ?? DEFAULT_WORKING_DIR,
model: currentSession?.model,
state: 'idle',
chatHistory: [],
maxHistoryLength: currentSession?.maxHistoryLength || DEFAULT_MAX_HISTORY,
};
save(accountId, session);
return session;
}
function addChatMessage(session: Session, role: 'user' | 'assistant', content: string): void {
if (!session.chatHistory) {
session.chatHistory = [];
}
session.chatHistory.push({
role,
content,
timestamp: Date.now(),
});
// Trim if exceeds max length
const maxLen = session.maxHistoryLength || DEFAULT_MAX_HISTORY;
if (session.chatHistory.length > maxLen) {
session.chatHistory = session.chatHistory.slice(-maxLen);
}
}
function getChatHistoryText(session: Session, limit?: number): string {
const history = session.chatHistory || [];
const messages = limit ? history.slice(-limit) : history;
if (messages.length === 0) {
return '暂无对话记录';
}
const lines: string[] = [];
for (const msg of messages) {
const time = new Date(msg.timestamp).toLocaleString('zh-CN');
const role = msg.role === 'user' ? '用户' : 'Claude';
lines.push(`[${time}] ${role}:`);
lines.push(msg.content);
lines.push('');
}
return lines.join('\n');
}
return { load, save, clear, addChatMessage, getChatHistoryText };
}
+39
View File
@@ -0,0 +1,39 @@
import { readFileSync, writeFileSync, chmodSync, mkdirSync } from "node:fs";
import { dirname } from "node:path";
import { logger } from "./logger.js";
export function validateAccountId(accountId: string): void {
if (!/^[a-zA-Z0-9_.@=-]+$/.test(accountId)) {
throw new Error(`Invalid accountId: "${accountId}"`);
}
}
/**
* Load a JSON file, returning a typed object or the fallback if the file
* does not exist or cannot be parsed.
*/
export function loadJson<T>(filePath: string, fallback: T): T {
try {
const raw = readFileSync(filePath, "utf-8");
return JSON.parse(raw) as T;
} catch (err) {
const code = (err as NodeJS.ErrnoException).code;
if (code !== 'ENOENT') {
logger.warn('loadJson failed, using fallback', { filePath, error: err instanceof Error ? err.message : String(err) });
}
return fallback;
}
}
/**
* Persist an object as pretty-printed JSON.
* File is written with mode 0o600 (owner read/write only).
*/
export function saveJson(filePath: string, data: unknown): void {
mkdirSync(dirname(filePath), { recursive: true });
const raw = JSON.stringify(data, null, 2) + "\n";
writeFileSync(filePath, raw, "utf-8");
if (process.platform !== 'win32') {
chmodSync(filePath, 0o600);
}
}
+100
View File
@@ -0,0 +1,100 @@
#!/usr/bin/env node
// One-shot script: pull the 7 tool-noise hits + 8 normal samples from today's
// log into a JSON fixture the test suite can consume. Re-run after future
// incidents to refresh fixtures.
import { createReadStream, writeFileSync, mkdirSync } from 'node:fs';
import { createInterface } from 'node:readline';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const LOG_PATH = process.env.HOME + '/.wechat-claude-code/logs/bridge-2026-06-27.log';
const OUT = join(__dirname, 'real-cases.json');
const FENCED_JSON = /```json\b[\s\S]*?```/i;
const URL_OR_PATH = /(https?:\/\/\S+)|(\/(?:Users|home|tmp|var|opt|etc)\/\S+)|(~\/\S+)/;
function looksLikeToolDump(text) {
if (text.length <= 400) return false;
if (!FENCED_JSON.test(text)) return false;
if (!URL_OR_PATH.test(text)) return false;
return true;
}
function isStructuralLine(line) {
if (!line.trim()) return true;
if (/^\s*```/.test(line)) return true;
if (/^\s*\*+/.test(line)) return true;
if (/^\s*[\[\]\{\}]/.test(line)) return true;
if (/\\n/.test(line)) return true;
if (/^\s*"\w+"\s*:/.test(line)) return true;
if (/^\s*\*\s/.test(line)) return true;
return false;
}
function splitDumpAndTail(text) {
const lines = text.split('\n');
let lastIdx = -1;
for (let i = 0; i < lines.length; i++) {
if (isStructuralLine(lines[i])) lastIdx = i;
}
if (lastIdx < 0 || lastIdx === lines.length - 1) return '';
return lines.slice(lastIdx + 1).join('\n').trim();
}
function filterToolNoise(text) {
if (!looksLikeToolDump(text)) return text;
const tail = splitDumpAndTail(text);
return tail ? `🔧 [工具调用] — ${tail}` : '🔧 [工具调用]';
}
const rl = createInterface({ input: createReadStream(LOG_PATH) });
const all = [];
rl.on('line', (line) => {
if (!line.includes('/ilink/bot/sendmessage')) return;
if (!line.includes('"type":1,')) return;
const jsonStart = line.indexOf('{');
if (jsonStart < 0) return;
let payload;
try { payload = JSON.parse(line.slice(jsonStart)); } catch { return; }
const text = payload?.body?.msg?.item_list?.[0]?.text_item?.text;
if (typeof text !== 'string') return;
all.push({ ts: line.slice(0, 23), text });
});
await new Promise((r) => rl.on('close', r));
const hits = all.filter((p) => looksLikeToolDump(p.text));
// Normal sample: 8 diverse untouched messages
const normalPool = all.filter((p) => !looksLikeToolDump(p.text));
const normal = [];
for (const target of [
/^✅/, // status lines
/\n\n/, // multi-paragraph
/```/, // has code block (but not json-fenced)
/\/Users\//, // has paths
/\|.*\|.*\|/, // has tables
/已|完成|搞定|找到/, // conclusion phrases
]) {
const found = normalPool.find((p) => target.test(p.text) && !normal.includes(p));
if (found) normal.push(found);
}
while (normal.length < 8 && normal.length < normalPool.length) {
normal.push(normalPool[normal.length]);
}
const cases = hits.map((p, i) => ({
name: `hit-${i + 1}`,
input: p.text,
expectedFilter: true,
expectedTail: splitDumpAndTail(p.text),
})).concat(normal.slice(0, 8).map((p, i) => ({
name: `normal-${i + 1}`,
input: p.text,
expectedFilter: false,
expectedTail: null,
})));
mkdirSync(__dirname, { recursive: true });
writeFileSync(OUT, JSON.stringify({ cases }, null, 2));
console.log(`Extracted ${hits.length} hits + ${Math.min(8, normal.length)} normals → ${OUT}`);
File diff suppressed because one or more lines are too long
+105
View File
@@ -0,0 +1,105 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { handleStreamLine, type StreamParserState } from '../claude/provider.js';
function freshState(): StreamParserState {
return { sessionId: '', textParts: [], trackingSkill: false, skillInputAccum: '' };
}
test('handleStreamLine: system init 设置 sessionId', () => {
const state = freshState();
handleStreamLine(
JSON.stringify({ type: 'system', subtype: 'init', session_id: 'sess-123' }),
state,
{},
);
assert.equal(state.sessionId, 'sess-123');
});
test('handleStreamLine: text_delta 触发 onText', () => {
const calls: string[] = [];
handleStreamLine(
JSON.stringify({
type: 'stream_event',
event: { type: 'content_block_delta', delta: { type: 'text_delta', text: 'hello' } },
}),
freshState(),
{ onText: (t) => calls.push(t) },
);
assert.deepEqual(calls, ['hello']);
});
test('handleStreamLine: content_block_stop 重置 trackingSkill,无回调', () => {
const state = freshState();
state.trackingSkill = true;
let textCalls = 0;
let turnEndCalls = 0;
handleStreamLine(
JSON.stringify({ type: 'stream_event', event: { type: 'content_block_stop', index: 0 } }),
state,
{ onText: () => textCalls++, onTurnEnd: () => turnEndCalls++ },
);
assert.equal(state.trackingSkill, false);
assert.equal(textCalls, 0);
assert.equal(turnEndCalls, 0);
});
test('handleStreamLine: assistant 消息文本累积到 textParts', () => {
const state = freshState();
handleStreamLine(
JSON.stringify({
type: 'assistant',
message: { content: [{ type: 'text', text: '回复内容' }] },
}),
state,
{},
);
assert.deepEqual(state.textParts, ['回复内容']);
});
test('handleStreamLine: 空行和非法 JSON 静默跳过', () => {
const state = freshState();
handleStreamLine('', state, {});
handleStreamLine('not json', state, {});
handleStreamLine(' ', state, {});
assert.deepEqual(state.textParts, []);
});
test('handleStreamLine: message_delta 带 stop_reason 触发 onTurnEnd', () => {
const calls: string[] = [];
handleStreamLine(
JSON.stringify({
type: 'stream_event',
event: { type: 'message_delta', delta: { stop_reason: 'end_turn' } },
}),
freshState(),
{ onTurnEnd: (r) => calls.push(r) },
);
assert.deepEqual(calls, ['end_turn']);
});
test('handleStreamLine: message_delta 无 stop_reason 不触发 onTurnEnd', () => {
const calls: string[] = [];
handleStreamLine(
JSON.stringify({
type: 'stream_event',
event: { type: 'message_delta', delta: {} },
}),
freshState(),
{ onTurnEnd: (r) => calls.push(r) },
);
assert.deepEqual(calls, []);
});
test('handleStreamLine: tool_use stop_reason 也正常透传', () => {
const calls: string[] = [];
handleStreamLine(
JSON.stringify({
type: 'stream_event',
event: { type: 'message_delta', delta: { stop_reason: 'tool_use' } },
}),
freshState(),
{ onTurnEnd: (r) => calls.push(r) },
);
assert.deepEqual(calls, ['tool_use']);
});
+94
View File
@@ -0,0 +1,94 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { filterToolNoise } from '../claude/tool-noise-filter.js';
// 测试跑在 dist/tests/,但 fixture JSON 不会被 tsc 复制——直接从 src/ 读。
const fixturePath = join(process.cwd(), 'src', 'tests', 'fixtures', 'tool-noise', 'real-cases.json');
const fixture = JSON.parse(readFileSync(fixturePath, 'utf8')) as {
cases: Array<{
name: string;
input: string;
expectedFilter: boolean;
expectedTail: string | null;
}>;
};
test('real-cases.json 7 条命中样本应被压缩到 🔧 前缀', () => {
const hits = fixture.cases.filter((c) => c.expectedFilter);
assert.equal(hits.length, 7, '今日 log 应该恰好命中 7 条');
for (const c of hits) {
const got = filterToolNoise(c.input);
assert.ok(got.startsWith('🔧 [工具调用]'), `${c.name}: 缺少 🔧 前缀 → ${got.slice(0, 80)}`);
assert.ok(got.length < c.input.length / 2, `${c.name}: 压缩率不够 (input=${c.input.length} got=${got.length})`);
if (c.expectedTail) {
assert.ok(got.includes(c.expectedTail), `${c.name}: 没有保留期望的尾巴 → ${c.expectedTail.slice(0, 60)}`);
}
}
});
test('real-cases.json 正常样本不应被改写', () => {
const normals = fixture.cases.filter((c) => !c.expectedFilter);
assert.ok(normals.length >= 5, '至少 5 条正常样本');
for (const c of normals) {
const got = filterToolNoise(c.input);
assert.equal(got, c.input, `${c.name}: 正常消息被误改`);
}
});
test('短消息直接放行', () => {
assert.equal(filterToolNoise('OK, 看一下'), 'OK, 看一下');
assert.equal(filterToolNoise(''), '');
});
test('有 URL 但无 ```json``` 围栏:不命中', () => {
const text = [
'我去翻了翻文档:',
'https://example.com/docs/api',
'',
'```',
'const x = fetch(url)',
'const y = await x.json()',
'const z = JSON.stringify(y)',
'const w = JSON.parse(z)',
'const v = JSON.stringify(w)',
'const u = JSON.parse(v)',
'```',
'就是这么个写法。',
].join('\n');
assert.equal(filterToolNoise(text), text);
});
test('有 ```json``` 围栏但无 URL:不命中', () => {
const text = [
'配置长这样:',
'',
'```json',
'{"name": "demo", "version": "1.0.0", "main": "index.js", "license": "MIT"}',
'```',
'',
'直接抄就行。',
].join('\n');
assert.equal(filterToolNoise(text), text);
});
test('长度恰好 400:放行(边界 < 400', () => {
const text = 'a'.repeat(400);
assert.equal(filterToolNoise(text), text);
});
test('命中但没有可提取尾巴:返回中性占位', () => {
// 全是结构性行,没有 prose tail
const text = [
'```json',
'{"imageSource":"https://example.com/a.png","prompt":"describe"}',
'```',
'**Output:**',
'**result_summary:** [{"text": "hello world"}]',
].join('\n') + '\n';
// bump length so it crosses the 400-char threshold
const padded = text + ' '.repeat(420 - text.length);
const got = filterToolNoise(padded);
assert.equal(got, '🔧 [工具调用]');
});
+84
View File
@@ -0,0 +1,84 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { TurnRouter, type RoutedMessage } from '../claude/turn-router.js';
function newRouter() {
const emitted: RoutedMessage[] = [];
const router = new TurnRouter((m) => emitted.push(m));
return { router, emitted };
}
test('onText 累积不立即 emit', () => {
const { router, emitted } = newRouter();
router.onText('hello ');
router.onText('world');
assert.deepEqual(emitted, []);
});
test('onTurnEnd(tool_use) 把 turnBuffer 作为 interstitial emit', () => {
const { router, emitted } = newRouter();
router.onText('让我看一下');
router.onTurnEnd('tool_use');
assert.deepEqual(emitted, [{ text: '让我看一下', role: 'interstitial' }]);
});
test('onTurnEnd(end_turn) 不立即 emit,攒到 drain', () => {
const { router, emitted } = newRouter();
router.onText('最终答案第一段');
router.onTurnEnd('end_turn');
assert.deepEqual(emitted, []);
router.drain();
assert.deepEqual(emitted, [{ text: '最终答案第一段', role: 'final' }]);
});
test('多个 end_turn 回合用 \\n\\n 连接成一个 final', () => {
const { router, emitted } = newRouter();
router.onText('段一');
router.onTurnEnd('pause_turn');
router.onText('段二');
router.onTurnEnd('end_turn');
router.drain();
assert.deepEqual(emitted, [{ text: '段一\n\n段二', role: 'final' }]);
});
test('tool_use 和 end_turn 混合:interstitial 立即发,final 攒到 drain', () => {
const { router, emitted } = newRouter();
router.onText('让我查一下');
router.onTurnEnd('tool_use'); // → interstitial 立即
router.onText('找到了。');
router.onText('详细说明...');
router.onTurnEnd('end_turn'); // → final 攒着
router.drain(); // → final 发出
assert.deepEqual(emitted, [
{ text: '让我查一下', role: 'interstitial' },
{ text: '找到了。详细说明...', role: 'final' },
]);
});
test('空文本回合不产生空消息', () => {
const { router, emitted } = newRouter();
router.onTurnEnd('tool_use'); // turnBuffer 空
router.onTurnEnd('end_turn'); // turnBuffer 空
router.drain();
assert.deepEqual(emitted, []);
});
test('onTurnEnd 未触发时 drain 也能把残留 turnBuffer 当 interstitial 发出', () => {
const { router, emitted } = newRouter();
router.onText('未结束的残留');
router.drain();
assert.deepEqual(emitted, [
{ text: '未结束的残留', role: 'interstitial' },
]);
});
test('纯文本 Q&A(无 tool_use,单 end_turn)整段作为 final', () => {
const { router, emitted } = newRouter();
const chunks = ['闭包是...', '举个例子...', '总结...'];
for (const c of chunks) router.onText(c);
router.onTurnEnd('end_turn');
router.drain();
assert.deepEqual(emitted, [
{ text: chunks.join(''), role: 'final' },
]);
});
+731
View File
@@ -0,0 +1,731 @@
#!/usr/bin/env node
/**
* Log Visualizer for wechat-claude-code
*
* Generates a self-contained HTML page that compares Claude CLI's original
* output with what the user actually saw in WeChat.
*
* Usage:
* npx tsx src/tools/visualize-logs.ts [--date YYYY-MM-DD] [--output path] [--open]
*/
import { readFileSync, readdirSync, existsSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { homedir } from 'node:os';
import { execSync } from 'node:child_process';
const DATA_DIR = process.env.WCC_DATA_DIR || join(homedir(), '.wechat-claude-code');
// ─── Keepalive messages (must match main.ts SILENCE_MESSAGES) ───
const SILENCE_MESSAGES = new Set([
'我还在处理中,这个问题有点复杂,请再稍等一下',
'正在努力干活中,马上就有结果了,请稍等片刻',
'有点复杂正在处理,再给我一点时间,很快就好',
'快好了别着急,正在收尾阶段,马上给你回复',
'还在跑呢,任务量比较大,不过马上就能出结果了',
'任务比想象的复杂一些,再等等我,正在全力处理',
'正在处理中,进展顺利,再等一会儿就好',
'还没完不过已经快了,再给我一分钟就能搞定',
'我在认真思考这个问题,请再稍等一会儿',
'稍微有点棘手,不过已经快解决了,再等我一下',
'我还在处理,请稍等一下',
'请稍等一下',
]);
// ─── Types ───
interface ParsedLine {
timestamp: string;
level: string;
message: string;
raw?: string;
}
interface SentMessage {
text: string;
timestamp: string;
clientId: string;
isKeepalive: boolean;
}
interface Session {
index: number;
startTime: string;
endTime: string | null;
durationSec: number | null;
sessionId: string;
resume: boolean;
cwd: string;
hasError: boolean;
textLength: number | null;
userMessages: Array<{ text: string; timestamp: string }>;
sentMessages: SentMessage[];
slashCommands: string[];
// Enriched from chatHistory
claudeFullOutput: string | null;
}
// ─── CLI Args ───
function parseArgs(): { date: string; output?: string; open: boolean } {
const args = process.argv.slice(2);
let date = '';
let output: string | undefined;
let open = false;
// Default date: today in UTC+8
const now = new Date(Date.now() + 8 * 60 * 60 * 1000);
date = now.toISOString().slice(0, 10);
for (let i = 0; i < args.length; i++) {
if (args[i] === '--date' && args[i + 1]) {
date = args[++i];
} else if (args[i] === '--output' && args[i + 1]) {
output = args[++i];
} else if (args[i] === '--open') {
open = true;
}
}
return { date, output, open };
}
// ─── Log Parsing ───
function tryParseJson(raw: string): any {
try {
return JSON.parse(raw);
} catch {
// Try to fix corrupted JSON from redact() — context_token gets mangled
let fixed = raw;
// Pattern: "context_token": "***"***".replace(/"[^"]*"$/, "")
fixed = fixed.replace(/"(?:context_token)"\s*:\s*"\*\*\*"\*\*\*"[^}]*?\.replace\([^)]+\)/g, '"context_token":"***"');
try {
return JSON.parse(fixed);
} catch {
return null;
}
}
}
function parseLogFile(logPath: string): ParsedLine[] {
if (!existsSync(logPath)) {
console.error(`Log file not found: ${logPath}`);
process.exit(1);
}
const raw = readFileSync(logPath, 'utf-8');
const lines: ParsedLine[] = [];
for (const line of raw.split('\n')) {
if (!line.trim()) continue;
// Format: <timestamp> <LEVEL> <message> [JSON]
const match = line.match(/^(\d{4}-\d{2}-\d{2}T[\d:.]+(?:Z|[+-]\d{2}:\d{2}))\s+(INFO|WARN|ERROR|DEBUG)\s+(\S+)(?:\s+(.*))?$/);
if (!match) continue;
lines.push({
timestamp: match[1],
level: match[2],
message: match[3],
raw: match[4],
});
}
return lines;
}
// ─── Data Extraction ───
function extractSentMessage(line: ParsedLine): { text: string; clientId: string } | null {
if (line.message !== 'API' || !line.raw) return null;
// "API request {"url":"...sendmessage..."
const idx = line.raw.indexOf('sendmessage');
if (idx === -1) return null;
// Find the JSON object start
const jsonStart = line.raw.indexOf('{');
if (jsonStart === -1) return null;
const data = tryParseJson(line.raw.slice(jsonStart));
if (!data?.body?.msg?.item_list) return null;
for (const item of data.body.msg.item_list) {
if (item.text_item?.text) {
return { text: item.text_item.text, clientId: data.body.msg.client_id || '' };
}
}
return null;
}
function extractUserMessage(line: ParsedLine): { text: string; timestamp: string } | null {
if (line.message !== 'API' || !line.raw) return null;
const jsonStart = line.raw.indexOf('{');
if (jsonStart === -1) return null;
const data = tryParseJson(line.raw.slice(jsonStart));
if (!data?.msgs || !Array.isArray(data.msgs)) return null;
for (const msg of data.msgs) {
if (msg.from_user_id?.includes('@im.wechat') && msg.item_list) {
for (const item of msg.item_list) {
if (item.text_item?.text) {
return { text: item.text_item.text, timestamp: line.timestamp };
}
if (item.voice_item?.text) {
return { text: `[语音] ${item.voice_item.text}`, timestamp: line.timestamp };
}
}
}
}
return null;
}
// ─── Session Reconstruction ───
function reconstructSessions(lines: ParsedLine[]): Session[] {
const sessions: Session[] = [];
let current: Session | null = null;
let pendingUserMsgs: Array<{ text: string; timestamp: string }> = [];
for (const line of lines) {
// Collect user messages that arrive before a query starts
const userMsg = extractUserMessage(line);
if (userMsg) {
pendingUserMsgs.push(userMsg);
}
// Slash commands — handled outside sessions
if (line.message === 'Slash' && line.raw?.startsWith('command:')) {
if (current) {
current.slashCommands.push(line.raw.replace('command: ', ''));
}
pendingUserMsgs = [];
continue;
}
// Query start
if (line.message === 'Starting' && line.raw?.includes('Claude CLI query')) {
const data = tryParseJson(line.raw.replace(/^.*?\{/, '{'));
current = {
index: sessions.length + 1,
startTime: line.timestamp,
endTime: null,
durationSec: null,
sessionId: '',
resume: data?.resume ?? false,
cwd: data?.cwd ?? '',
hasError: false,
textLength: null,
userMessages: [...pendingUserMsgs],
sentMessages: [],
slashCommands: [],
claudeFullOutput: null,
};
pendingUserMsgs = [];
sessions.push(current);
continue;
}
// Query completed
if (line.message === 'Claude' && line.raw?.includes('CLI query completed')) {
if (current) {
current.endTime = line.timestamp;
const start = new Date(current.startTime).getTime();
const end = new Date(line.timestamp).getTime();
current.durationSec = Math.round((end - start) / 1000);
const data = tryParseJson(line.raw.replace(/^.*?\{/, '{'));
if (data) {
current.sessionId = data.sessionId || '';
current.textLength = data.textLength ?? null;
current.hasError = data.hasError ?? false;
}
}
current = null;
continue;
}
// Query aborted
if (line.message === 'Claude' && line.raw?.includes('CLI query aborted')) {
if (current) {
current.endTime = line.timestamp;
const start = new Date(current.startTime).getTime();
const end = new Date(line.timestamp).getTime();
current.durationSec = Math.round((end - start) / 1000);
current.hasError = true;
}
current = null;
continue;
}
// Query timed out
if (line.message === 'Claude' && line.raw?.includes('query timed out')) {
if (current) {
current.hasError = true;
}
continue;
}
// Sent messages during a session
if (current) {
const sent = extractSentMessage(line);
if (sent) {
current.sentMessages.push({
text: sent.text,
timestamp: line.timestamp,
clientId: sent.clientId,
isKeepalive: SILENCE_MESSAGES.has(sent.text),
});
}
}
}
return sessions;
}
// ─── Chat History Enrichment ───
interface ChatEntry {
role: string;
content: string;
timestamp: number;
}
function loadChatHistories(): ChatEntry[] {
const sessionDir = join(DATA_DIR, 'sessions');
if (!existsSync(sessionDir)) return [];
const entries: ChatEntry[] = [];
for (const f of readdirSync(sessionDir)) {
if (!f.endsWith('.json')) continue;
try {
const data = JSON.parse(readFileSync(join(sessionDir, f), 'utf-8'));
if (data.chatHistory && Array.isArray(data.chatHistory)) {
entries.push(...data.chatHistory);
}
} catch { /* skip */ }
}
return entries;
}
function enrichSessions(sessions: Session[], chatEntries: ChatEntry[]): void {
// Build lookup of assistant entries by timestamp
const assistantEntries = chatEntries
.filter(e => e.role === 'assistant')
.sort((a, b) => a.timestamp - b.timestamp);
for (const session of sessions) {
if (!session.endTime) continue;
const endMs = new Date(session.endTime).getTime();
// Find the closest assistant entry within 60s of session end
let best: ChatEntry | null = null;
let bestDist = Infinity;
for (const entry of assistantEntries) {
const dist = Math.abs(entry.timestamp - endMs);
if (dist < bestDist && dist < 60_000) {
bestDist = dist;
best = entry;
}
}
if (best) {
session.claudeFullOutput = best.content;
}
}
}
// ─── Diff ───
interface DiffSegment {
type: 'same' | 'lost' | 'extra';
text: string;
}
function computeDiff(fullOutput: string, sentMessages: SentMessage[]): DiffSegment[] {
const sent = sentMessages
.filter(m => !m.isKeepalive)
.map(m => m.text)
.join('\n');
if (!fullOutput && !sent) return [];
if (!fullOutput) return [{ type: 'extra', text: sent }];
if (!sent) return [{ type: 'lost', text: fullOutput }];
// Simple character-level diff: walk both strings
const segments: DiffSegment[] = [];
let i = 0, j = 0;
while (i < fullOutput.length || j < sent.length) {
// Find common prefix from current positions
let commonLen = 0;
while (i + commonLen < fullOutput.length && j + commonLen < sent.length && fullOutput[i + commonLen] === sent[j + commonLen]) {
commonLen++;
}
if (commonLen > 0) {
// Check if there's lost text before the common section
if (i > 0 && j > 0) {
// Already handled
}
segments.push({ type: 'same', text: fullOutput.slice(i, i + commonLen) });
i += commonLen;
j += commonLen;
} else {
// Find where they realign
let lostEnd = i;
let extraEnd = j;
// Look ahead for realignment
let found = false;
const lookAhead = Math.min(fullOutput.length - i, sent.length - j, 200);
for (let k = 1; k <= lookAhead; k++) {
// Check if skipping k chars from fullOutput matches
if (i + k < fullOutput.length && fullOutput[i + k] === sent[j]) {
segments.push({ type: 'lost', text: fullOutput.slice(i, i + k) });
i += k;
found = true;
break;
}
// Check if skipping k chars from sent matches
if (j + k < sent.length && fullOutput[i] === sent[j + k]) {
segments.push({ type: 'extra', text: sent.slice(j, j + k) });
j += k;
found = true;
break;
}
}
if (!found) {
// No realignment found nearby, mark rest as different
if (i < fullOutput.length) {
segments.push({ type: 'lost', text: fullOutput.slice(i) });
i = fullOutput.length;
}
if (j < sent.length) {
segments.push({ type: 'extra', text: sent.slice(j) });
j = sent.length;
}
}
}
}
return segments;
}
// ─── HTML Generation ───
function esc(str: string): string {
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
function fmtTime(ts: string): string {
const d = new Date(ts);
// Convert to UTC+8
const utc8 = new Date(d.getTime() + 8 * 60 * 60 * 1000);
return utc8.toISOString().slice(11, 19);
}
function fmtDuration(sec: number | null): string {
if (sec === null) return '?';
if (sec < 60) return `${sec}s`;
const m = Math.floor(sec / 60);
const s = sec % 60;
return `${m}m${s}s`;
}
function renderDiff(segments: DiffSegment[]): string {
if (segments.length === 0) return '<span class="diff-identical">无法比较</span>';
const isAllSame = segments.every(s => s.type === 'same');
if (isAllSame) return '<span class="diff-identical">完全一致,无文本丢失</span>';
let html = '';
for (const seg of segments) {
const text = esc(seg.text);
if (seg.type === 'same') {
html += text;
} else if (seg.type === 'lost') {
html += `<span class="diff-lost" title="Claude 输出了但未发送到微信">${text}</span>`;
} else {
html += `<span class="diff-extra" title="微信收到了但 Claude 未输出">${text}</span>`;
}
}
return html;
}
function generateHtml(sessions: Session[], date: string): string {
const totalSent = sessions.reduce((sum, s) => sum + s.sentMessages.filter(m => !m.isKeepalive).length, 0);
const totalKeepalive = sessions.reduce((sum, s) => sum + s.sentMessages.filter(m => m.isKeepalive).length, 0);
const totalErrors = sessions.filter(s => s.hasError).length;
const withFullOutput = sessions.filter(s => s.claudeFullOutput !== null).length;
return `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>WeChat-Claude-Code Log Viewer — ${esc(date)}</title>
<style>
:root {
--bg: #f0f2f5;
--card: #fff;
--border: #e0e0e0;
--text: #1a1a1a;
--text2: #666;
--accent: #4f46e5;
--green: #16a34a;
--red: #dc2626;
--orange: #ea580c;
--blue-bg: #eff6ff;
--green-bg: #f0fdf4;
--red-bg: #fef2f2;
--orange-bg: #fff7ed;
--gray-bg: #f9fafb;
--lost-bg: #fecaca;
--extra-bg: #bbf7d0;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: var(--bg);
color: var(--text);
line-height: 1.6;
padding: 16px;
max-width: 900px;
margin: 0 auto;
}
h1 { font-size: 20px; font-weight: 600; margin-bottom: 4px; }
.subtitle { color: var(--text2); font-size: 14px; margin-bottom: 16px; }
.stats {
display: flex; gap: 16px; flex-wrap: wrap;
margin-bottom: 16px; font-size: 13px;
}
.stat { background: var(--card); border: 1px solid var(--border); border-radius: 8px; padding: 8px 14px; }
.stat b { color: var(--accent); }
.filters {
display: flex; gap: 8px; flex-wrap: wrap;
margin-bottom: 20px;
}
.filters button {
background: var(--card); border: 1px solid var(--border); border-radius: 6px;
padding: 6px 12px; font-size: 13px; cursor: pointer;
}
.filters button.active { background: var(--accent); color: #fff; border-color: var(--accent); }
.filters button:hover { opacity: 0.8; }
.session {
background: var(--card); border: 1px solid var(--border); border-radius: 12px;
margin-bottom: 16px; overflow: hidden;
}
.session.hidden { display: none; }
.session-header {
display: flex; align-items: center; justify-content: space-between;
padding: 12px 16px; border-bottom: 1px solid var(--border);
cursor: pointer; user-select: none;
}
.session-header:hover { background: var(--gray-bg); }
.session-meta { font-size: 13px; color: var(--text2); }
.session-meta .time { font-weight: 600; color: var(--text); }
.session-badge {
display: inline-block; font-size: 11px; font-weight: 600;
padding: 2px 8px; border-radius: 10px; margin-left: 6px;
}
.badge-resume { background: var(--blue-bg); color: var(--accent); }
.badge-new { background: var(--green-bg); color: var(--green); }
.badge-error { background: var(--red-bg); color: var(--red); }
.badge-timeout { background: var(--orange-bg); color: var(--orange); }
.session-body { padding: 0 16px 16px; }
.section {
margin-top: 12px; border-radius: 8px; overflow: hidden;
border: 1px solid var(--border);
}
.section-title {
font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px;
padding: 6px 12px; color: var(--text2);
}
.section-title.user { background: var(--blue-bg); }
.section-title.claude { background: var(--green-bg); }
.section-title.sent { background: var(--orange-bg); }
.section-title.diff { background: var(--gray-bg); }
.section-content {
padding: 10px 12px; font-size: 14px; white-space: pre-wrap; word-break: break-word;
max-height: 400px; overflow-y: auto;
}
.sent-item {
padding: 8px 0;
border-bottom: 1px solid var(--border);
}
.sent-item:last-child { border-bottom: none; }
.sent-item .timestamp { font-size: 11px; color: var(--text2); margin-bottom: 2px; }
.sent-item .text { white-space: pre-wrap; word-break: break-word; }
.sent-item.keepalive { opacity: 0.4; font-style: italic; }
.diff-lost {
background: var(--lost-bg); border-radius: 2px; padding: 0 2px;
text-decoration: line-through; cursor: help;
}
.diff-extra {
background: var(--extra-bg); border-radius: 2px; padding: 0 2px;
cursor: help;
}
.diff-identical { color: var(--green); font-weight: 500; font-style: normal; white-space: normal; }
.no-data { color: var(--text2); font-style: italic; font-size: 13px; padding: 10px 12px; }
.char-count { font-size: 11px; color: var(--text2); margin-left: 8px; }
@media (max-width: 600px) {
body { padding: 8px; }
.session-header { flex-direction: column; align-items: flex-start; gap: 4px; }
}
</style>
</head>
<body>
<h1>WeChat-Claude-Code Log Viewer</h1>
<p class="subtitle">${esc(date)}${sessions.length} 个会话</p>
<div class="stats">
<div class="stat">会话 <b>${sessions.length}</b></div>
<div class="stat">发送消息 <b>${totalSent}</b></div>
<div class="stat">心跳 <b>${totalKeepalive}</b></div>
<div class="stat">错误 <b>${totalErrors}</b></div>
<div class="stat">有完整输出 <b>${withFullOutput}/${sessions.length}</b></div>
</div>
<div class="filters">
<button class="active" onclick="toggleFilter(this, 'all')">全部</button>
<button onclick="toggleFilter(this, 'error')">仅错误</button>
<button onclick="toggleFilter(this, 'diff')">有差异</button>
<button onclick="toggleAll(true)">展开全部</button>
<button onclick="toggleAll(false)">折叠全部</button>
</div>
${sessions.map(s => renderSessionCard(s)).join('\n')}
<script>
function toggleFilter(btn, type) {
document.querySelectorAll('.filters button').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
document.querySelectorAll('.session').forEach(el => {
const isErr = el.dataset.error === '1';
const hasDiff = el.dataset.diff === '1';
if (type === 'all') el.classList.remove('hidden');
else if (type === 'error') el.classList.toggle('hidden', !isErr);
else if (type === 'diff') el.classList.toggle('hidden', !hasDiff);
});
}
function toggleAll(expand) {
document.querySelectorAll('.session-body').forEach(el => {
el.style.display = expand ? '' : 'none';
});
}
document.querySelectorAll('.session-header').forEach(header => {
header.addEventListener('click', () => {
const body = header.nextElementSibling;
body.style.display = body.style.display === 'none' ? '' : 'none';
});
});
// Collapse all by default
document.querySelectorAll('.session-body').forEach(el => el.style.display = 'none');
</script>
</body>
</html>`;
}
function renderSessionCard(s: Session): string {
const startT = fmtTime(s.startTime);
const endT = s.endTime ? fmtTime(s.endTime) : '?';
const dur = fmtDuration(s.durationSec);
const resumeBadge = s.resume
? '<span class="session-badge badge-resume">resume</span>'
: '<span class="session-badge badge-new">new</span>';
const errorBadge = s.hasError ? '<span class="session-badge badge-error">error</span>' : '';
// Sent messages HTML
let sentHtml = '';
const realMessages = s.sentMessages.filter(m => !m.isKeepalive);
const keepaliveMessages = s.sentMessages.filter(m => m.isKeepalive);
if (s.sentMessages.length === 0) {
sentHtml = '<div class="no-data">无发送记录</div>';
} else {
sentHtml = '<div class="section-content">';
for (const m of realMessages) {
sentHtml += `<div class="sent-item"><div class="timestamp">${fmtTime(m.timestamp)}</div><div class="text">${esc(m.text)}</div></div>`;
}
for (const m of keepaliveMessages) {
sentHtml += `<div class="sent-item keepalive"><div class="timestamp">${fmtTime(m.timestamp)} [心跳]</div><div class="text">${esc(m.text)}</div></div>`;
}
sentHtml += '</div>';
}
// Claude full output
const claudeHtml = s.claudeFullOutput
? `<div class="section-content">${esc(s.claudeFullOutput)}</div>`
: '<div class="no-data">完整输出不可用(会话历史已修剪或未记录)</div>';
// Diff
let diffHtml: string;
let hasDiff = false;
if (s.claudeFullOutput) {
const segments = computeDiff(s.claudeFullOutput, s.sentMessages);
hasDiff = !segments.every(seg => seg.type === 'same');
diffHtml = `<div class="section-content" style="white-space:pre-wrap;word-break:break-word">${renderDiff(segments)}</div>`;
} else {
diffHtml = '<div class="no-data">无可比数据</div>';
hasDiff = false;
}
const totalSentChars = realMessages.reduce((sum, m) => sum + m.text.length, 0);
const diffIndicator = hasDiff ? ' style="color:var(--red)"' : ' style="color:var(--green)"';
return `<div class="session" data-error="${s.hasError ? 1 : 0}" data-diff="${hasDiff ? 1 : 0}">
<div class="session-header">
<div>
<span class="time">#${s.index} ${startT}${endT}</span> (${dur})
${resumeBadge}${errorBadge}
</div>
<div class="session-meta">
<span${diffIndicator}>${s.claudeFullOutput ? `${s.claudeFullOutput.length}${totalSentChars} chars` : 'no history'}</span>
${s.sessionId ? `<span class="char-count">sid: ${s.sessionId.slice(0, 8)}...</span>` : ''}
</div>
</div>
<div class="session-body">
<div class="section">
<div class="section-title user">用户输入</div>
<div class="section-content">${s.userMessages.map(m => esc(m.text)).join('\n') || '(无)'}</div>
</div>
<div class="section">
<div class="section-title claude">Claude 完整输出${s.claudeFullOutput ? ` <span class="char-count">${s.claudeFullOutput.length} chars</span>` : ''}</div>
${claudeHtml}
</div>
<div class="section">
<div class="section-title sent">发送到微信 <span class="char-count">${realMessages.length} 条消息${keepaliveMessages.length > 0 ? ` + ${keepaliveMessages.length} 心跳` : ''}</span></div>
${sentHtml}
</div>
<div class="section">
<div class="section-title diff">差异对比</div>
${diffHtml}
</div>
</div>
</div>`;
}
// ─── Main ───
function main() {
const { date, output, open } = parseArgs();
const logPath = join(DATA_DIR, 'logs', `bridge-${date}.log`);
console.log(`Parsing log: ${logPath}`);
const lines = parseLogFile(logPath);
console.log(`Parsed ${lines.length} log lines`);
const sessions = reconstructSessions(lines);
console.log(`Reconstructed ${sessions.length} sessions`);
const chatEntries = loadChatHistories();
console.log(`Loaded ${chatEntries.length} chat history entries`);
enrichSessions(sessions, chatEntries);
const enriched = sessions.filter(s => s.claudeFullOutput !== null).length;
console.log(`Enriched ${enriched}/${sessions.length} sessions with full output`);
const html = generateHtml(sessions, date);
if (output) {
writeFileSync(output, html, 'utf-8');
console.log(`Written to: ${output}`);
if (open) {
execSync(`open "${output}"`);
}
} else {
process.stdout.write(html);
}
}
main();
+64
View File
@@ -0,0 +1,64 @@
import { join } from 'node:path';
import { homedir } from 'node:os';
import { readdirSync, statSync } from 'node:fs';
import { loadJson, saveJson, validateAccountId } from '../store.js';
import { logger } from '../logger.js';
export const DEFAULT_BASE_URL = 'https://ilinkai.weixin.qq.com';
export interface AccountData {
botToken: string;
accountId: string;
baseUrl: string;
userId: string;
createdAt: string;
}
const ACCOUNTS_DIR = join(homedir(), '.wechat-claude-code', 'accounts');
function accountPath(accountId: string): string {
validateAccountId(accountId);
return join(ACCOUNTS_DIR, `${accountId}.json`);
}
/** Persist account credentials to disk. */
export function saveAccount(data: AccountData): void {
const filePath = accountPath(data.accountId);
saveJson(filePath, data);
logger.info('Account saved', { accountId: data.accountId });
}
/** Load account credentials by ID. Returns null if not found. */
export function loadAccount(accountId: string): AccountData | null {
const filePath = accountPath(accountId);
const data = loadJson<AccountData | null>(filePath, null);
if (data) {
logger.info('Account loaded', { accountId });
}
return data;
}
/** Load the most recently modified account. Returns null if none exist. */
export function loadLatestAccount(): AccountData | null {
try {
const files = readdirSync(ACCOUNTS_DIR).filter((f) => f.endsWith('.json'));
if (files.length === 0) return null;
let latestFile = files[0];
let latestMtime = 0;
for (const file of files) {
const stat = statSync(join(ACCOUNTS_DIR, file));
if (stat.mtimeMs > latestMtime) {
latestMtime = stat.mtimeMs;
latestFile = file;
}
}
const accountId = latestFile.replace(/\.json$/, '');
return loadAccount(accountId);
} catch {
// Directory does not exist or is unreadable
return null;
}
}
+235
View File
@@ -0,0 +1,235 @@
import type {
GetUpdatesResp,
SendMessageReq,
GetUploadUrlResp,
SendTypingReq,
GetConfigResp,
} from './types.js';
import { logger } from '../logger.js';
/** Generate a random base64 identifier. */
function generateUin(): string {
const buf = new Uint8Array(4);
crypto.getRandomValues(buf);
return Buffer.from(buf).toString('base64');
}
export class WeChatApi {
private readonly token: string;
private readonly baseUrl: string;
private readonly uin: string;
private readonly nextSendTime = new Map<string, number>();
private static readonly MIN_SEND_INTERVAL = 2500;
// Cooldown applied after a rate-limit (ret:-2). Aligned with the circuit
// breaker window so they don't fight each other.
private static readonly RATE_LIMIT_COOLDOWN_MS = 30_000;
// ── Circuit breaker ────────────────────────────────────────────────────
// Borrowed from Hermes WeChat adapter: trip after the first genuine
// rate-limit in a 30s window, stay open 30s. While open, all sends fail
// fast without hitting the API — breaking the 14-minute "head-banging"
// loop we observed in production logs.
private static readonly CIRCUIT_THRESHOLD = 1;
private static readonly CIRCUIT_WINDOW_MS = 30_000;
private static readonly CIRCUIT_OPEN_MS = 30_000;
private readonly _rateLimitEvents: number[] = [];
private _circuitUntil = 0;
// ret:-2 + errmsg="unknown error" is a stale-session signal (same family
// as errcode:-14), not a real rate-limit. Pause that user 10 minutes
// instead of cycling through the rate-limit path.
private static readonly STALE_SESSION_PAUSE_MS = 10 * 60 * 1000;
constructor(token: string, baseUrl: string = 'https://ilinkai.weixin.qq.com') {
if (baseUrl) {
try {
const url = new URL(baseUrl);
const allowedHosts = ['weixin.qq.com', 'wechat.com'];
const isAllowed = allowedHosts.some(h => url.hostname === h || url.hostname.endsWith('.' + h));
if (url.protocol !== 'https:' || !isAllowed) {
logger.warn('Untrusted baseUrl, using default', { baseUrl });
baseUrl = 'https://ilinkai.weixin.qq.com';
}
} catch {
logger.warn('Invalid baseUrl, using default', { baseUrl });
baseUrl = 'https://ilinkai.weixin.qq.com';
}
}
this.token = token;
this.baseUrl = baseUrl.replace(/\/+$/, '');
this.uin = generateUin();
}
private headers(): Record<string, string> {
return {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.token}`,
'AuthorizationType': 'ilink_bot_token',
'X-WECHAT-UIN': this.uin,
};
}
private async request<T = Record<string, unknown>>(
path: string,
body: unknown,
timeoutMs: number = 15_000,
): Promise<T> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
const url = `${this.baseUrl}/${path}`;
logger.debug('API request', { url, body });
try {
const res = await fetch(url, {
method: 'POST',
headers: this.headers(),
body: JSON.stringify(body),
signal: controller.signal,
});
if (!res.ok) {
const text = await res.text();
throw new Error(`HTTP ${res.status}: ${text}`);
}
const json = (await res.json()) as T;
logger.debug('API response', json);
return json;
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') {
throw new Error(`Request to ${url} timed out after ${timeoutMs}ms`);
}
throw err;
} finally {
clearTimeout(timer);
}
}
/** Long-poll for new messages. Timeout 35s for long-polling. */
async getUpdates(buf?: string): Promise<GetUpdatesResp> {
return this.request<GetUpdatesResp>(
'ilink/bot/getupdates',
buf ? { get_updates_buf: buf } : {},
35_000,
);
}
/** Send a message to a user. Per-user rate limited, retries on rate-limit (ret: -2). */
async sendMessage(req: SendMessageReq): Promise<void> {
// Circuit breaker: fail fast without calling the API while open.
// This is what breaks the 14-minute head-banging loop.
if (this._isCircuitOpen()) {
const remainingSec = Math.ceil((this._circuitUntil - Date.now()) / 1000);
logger.warn('sendMessage rejected by circuit breaker', { remainingSec });
throw new Error(`circuit breaker open, ${remainingSec}s remaining`);
}
const userId = req.msg?.to_user_id;
if (userId) {
const now = Date.now();
const nextAvailable = (this.nextSendTime.get(userId) ?? 0) + WeChatApi.MIN_SEND_INTERVAL;
const sendAt = Math.max(now, nextAvailable);
this.nextSendTime.set(userId, sendAt);
const waitMs = sendAt - now;
if (waitMs > 0) {
logger.debug('Rate limiter waiting', { userId, waitMs });
await new Promise(r => setTimeout(r, waitMs));
}
}
const MAX_RETRIES = 2;
let delay = 3_000;
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
// Re-check the circuit on each retry — a prior attempt may have just tripped it.
if (this._isCircuitOpen()) {
const remainingSec = Math.ceil((this._circuitUntil - Date.now()) / 1000);
logger.warn('sendMessage aborted mid-retry by circuit breaker', { attempt, remainingSec });
throw new Error(`circuit breaker open during retry, ${remainingSec}s remaining`);
}
const res = await this.request<{ ret?: number; errmsg?: string }>('ilink/bot/sendmessage', req);
if (res.ret === -2) {
// Distinguish stale-session (ret:-2 + errmsg "unknown error") from a real rate-limit.
// Hermes WeChat adapter established this pattern: the stale-session case behaves
// like errcode:-14 and is fixed by re-login, not by retry.
const errmsg = (res.errmsg ?? '').toLowerCase();
if (errmsg === 'unknown error') {
logger.warn('sendMessage stale session detected (ret:-2 + unknown error)', { userId });
if (userId) {
this.nextSendTime.set(userId, Date.now() + WeChatApi.STALE_SESSION_PAUSE_MS);
}
throw new Error('stale session — user must send a message to refresh context_token');
}
// Real rate-limit: trip the circuit breaker so subsequent sends fail fast.
this._tripCircuit();
if (userId) {
this.nextSendTime.set(userId, Date.now() + WeChatApi.RATE_LIMIT_COOLDOWN_MS);
}
if (attempt === MAX_RETRIES) {
logger.warn('sendMessage rate-limited after max retries', { attempts: MAX_RETRIES });
throw new Error(`sendMessage rate-limited after ${MAX_RETRIES} retries`);
}
logger.warn('sendMessage rate-limited (ret:-2), retrying', { attempt, delayMs: delay });
await new Promise(r => setTimeout(r, delay));
delay = Math.min(delay * 2, 15_000);
continue;
}
return;
}
}
// ── Circuit breaker helpers ────────────────────────────────────────────
/** True while the breaker is open (sends should fail fast). */
private _isCircuitOpen(): boolean {
if (this._circuitUntil === 0) return false;
if (Date.now() >= this._circuitUntil) {
this._circuitUntil = 0;
this._rateLimitEvents.length = 0;
return false;
}
return true;
}
/** Record a rate-limit event and open the breaker if threshold is met. */
private _tripCircuit(): void {
const now = Date.now();
const windowStart = now - WeChatApi.CIRCUIT_WINDOW_MS;
while (this._rateLimitEvents.length > 0 && this._rateLimitEvents[0] < windowStart) {
this._rateLimitEvents.shift();
}
this._rateLimitEvents.push(now);
if (this._rateLimitEvents.length >= WeChatApi.CIRCUIT_THRESHOLD) {
const openUntil = Math.max(this._circuitUntil, now + WeChatApi.CIRCUIT_OPEN_MS);
if (openUntil > this._circuitUntil) {
logger.warn('Circuit breaker tripped', {
events: this._rateLimitEvents.length,
openMs: WeChatApi.CIRCUIT_OPEN_MS,
});
}
this._circuitUntil = openUntil;
}
}
/** Fetch bot config (includes typing_ticket). */
async getConfig(ilinkUserId: string, contextToken?: string): Promise<GetConfigResp> {
return this.request<GetConfigResp>(
'ilink/bot/getconfig',
{ ilink_user_id: ilinkUserId, context_token: contextToken },
10_000,
);
}
/** Send a typing indicator to a user. */
async sendTyping(req: SendTypingReq): Promise<void> {
await this.request('ilink/bot/sendtyping', req, 10_000);
}
/** Get a presigned upload URL for media files. */
async getUploadUrl(req: import('./types.js').GetUploadUrlReq): Promise<GetUploadUrlResp> {
return this.request<GetUploadUrlResp>('ilink/bot/getuploadurl', req);
}
}
+53
View File
@@ -0,0 +1,53 @@
import { decryptAesEcb } from "./crypto.js";
import { logger } from "../logger.js";
import { CDN_BASE_URL } from "../constants.js";
export function buildCdnDownloadUrl(encryptQueryParam: string): string {
if (!/^[A-Za-z0-9%=&+._~\-/]+$/.test(encryptQueryParam)) {
throw new Error('Invalid CDN query parameter');
}
return `${CDN_BASE_URL}/download?encrypted_query_param=${encodeURIComponent(encryptQueryParam)}`;
}
export async function downloadAndDecrypt(
encryptQueryParam: string,
aesKeyBase64: string,
): Promise<Buffer> {
const url = buildCdnDownloadUrl(encryptQueryParam);
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 30_000);
let response: Response;
try {
response = await fetch(url, { signal: controller.signal });
} catch (err) {
clearTimeout(timer);
throw new Error(`CDN download failed: ${err instanceof Error ? err.message : String(err)}`);
}
clearTimeout(timer);
if (!response.ok) {
throw new Error(`CDN download failed: ${response.status} ${response.statusText}`);
}
const encrypted = Buffer.from(await response.arrayBuffer());
// Handle both formats:
// 1. base64-of-raw-16-bytes (16 raw bytes encoded as base64)
// 2. base64-of-hex-string (32 hex chars encoded as base64)
let aesKey: Buffer;
const raw = Buffer.from(aesKeyBase64, "base64");
if (raw.length === 16) {
// base64-of-raw-16-bytes
aesKey = raw;
} else {
// base64-of-hex-string: decode the string as hex to get the 16-byte key
const hexStr = raw.toString("utf-8");
aesKey = Buffer.from(hexStr, "hex");
}
const decrypted = decryptAesEcb(aesKey, encrypted);
logger.info("CDN download and decrypt succeeded", { size: decrypted.length });
return decrypted;
}
+20
View File
@@ -0,0 +1,20 @@
import { createCipheriv, createDecipheriv, randomBytes } from "crypto";
export function generateAesKey(): string {
return randomBytes(16).toString("base64");
}
export function aesEcbPaddedSize(size: number): number {
const block = 16;
return Math.floor((size + block - 1) / block) * block;
}
export function encryptAesEcb(key: Buffer, plaintext: Buffer): Buffer {
const cipher = createCipheriv("aes-128-ecb", key, null);
return Buffer.concat([cipher.update(plaintext), cipher.final()]);
}
export function decryptAesEcb(key: Buffer, ciphertext: Buffer): Buffer {
const decipher = createDecipheriv("aes-128-ecb", key, null);
return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
}
+138
View File
@@ -0,0 +1,138 @@
import type { AccountData } from './accounts.js';
import { DEFAULT_BASE_URL, saveAccount } from './accounts.js';
import { logger } from '../logger.js';
const QR_CODE_URL = `${DEFAULT_BASE_URL}/ilink/bot/get_bot_qrcode?bot_type=3`;
const QR_STATUS_URL = `${DEFAULT_BASE_URL}/ilink/bot/get_qrcode_status`;
const POLL_INTERVAL_MS = 3_000;
interface QrCodeResponse {
ret: number;
qrcode?: string;
qrcode_img_content?: string;
}
interface QrStatusResponse {
ret: number;
status: string;
retmsg?: string;
bot_token?: string;
ilink_bot_id?: string;
baseurl?: string;
ilink_user_id?: string;
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/** Phase 1: Request a QR code for login. Returns the URL and ID. */
export async function startQrLogin(): Promise<{ qrcodeUrl: string; qrcodeId: string }> {
logger.info('Requesting QR code');
const res = await fetch(QR_CODE_URL);
if (!res.ok) {
throw new Error(`Failed to get QR code: HTTP ${res.status}`);
}
const data = (await res.json()) as QrCodeResponse;
if (data.ret !== 0 || !data.qrcode_img_content || !data.qrcode) {
throw new Error(`Failed to get QR code (ret=${data.ret})`);
}
logger.info('QR code obtained', { qrcodeId: data.qrcode });
return {
qrcodeUrl: data.qrcode_img_content,
qrcodeId: data.qrcode,
};
}
/**
* Phase 2: Wait for the user to scan and confirm the QR code.
* Throws on expiry so the caller can regenerate the QR image.
* Returns the full AccountData on success.
*/
export async function waitForQrScan(qrcodeId: string): Promise<AccountData> {
let currentQrcodeId = qrcodeId;
while (true) {
const url = `${QR_STATUS_URL}?qrcode=${encodeURIComponent(currentQrcodeId)}`;
logger.debug('Polling QR status', { qrcodeId: currentQrcodeId });
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 60_000);
let res: Response;
try {
res = await fetch(url, { signal: controller.signal });
} catch (e: any) {
clearTimeout(timer);
if (e.name === 'AbortError' || e.code === 'ETIMEDOUT') {
logger.info('QR poll timed out, retrying');
continue;
}
throw e;
}
clearTimeout(timer);
if (!res.ok) {
throw new Error(`Failed to check QR status: HTTP ${res.status}`);
}
const data = (await res.json()) as QrStatusResponse;
logger.debug('QR status response', { status: data.status });
switch (data.status) {
case 'wait':
case 'scaned':
// Not yet confirmed, continue polling
break;
case 'confirmed': {
if (!data.bot_token || !data.ilink_bot_id || !data.ilink_user_id) {
throw new Error('QR confirmed but missing required fields in response');
}
const accountData: AccountData = {
botToken: data.bot_token,
accountId: data.ilink_bot_id,
baseUrl: data.baseurl || DEFAULT_BASE_URL,
userId: data.ilink_user_id,
createdAt: new Date().toISOString(),
};
saveAccount(accountData);
logger.info('QR login successful', { accountId: accountData.accountId });
return accountData;
}
case 'expired': {
logger.info('QR code expired');
throw new Error('QR code expired');
}
default:
logger.warn('Unknown QR status', { status: data.status, retmsg: data.retmsg });
// Surface error to user for known failure statuses
const status = data.status ?? '';
if (status && (
status.includes('not_support') ||
status.includes('version') ||
status.includes('forbid') ||
status.includes('reject') ||
status.includes('cancel')
)) {
throw new Error(`二维码扫描失败: ${data.retmsg || status}`);
}
if (data.retmsg) {
throw new Error(`二维码扫描失败: ${data.retmsg}`);
}
break;
}
await sleep(POLL_INTERVAL_MS);
}
}
+137
View File
@@ -0,0 +1,137 @@
import path from 'node:path';
import os from 'node:os';
import fs from 'node:fs';
import type { MessageItem, ImageItem } from './types.js';
import { MessageItemType } from './types.js';
import { downloadAndDecrypt } from './cdn.js';
import { logger } from '../logger.js';
function detectMimeType(data: Buffer): string {
if (data[0] === 0x89 && data[1] === 0x50) return 'image/png';
if (data[0] === 0xFF && data[1] === 0xD8) return 'image/jpeg';
if (data[0] === 0x47 && data[1] === 0x49) return 'image/gif';
if (data[0] === 0x52 && data[1] === 0x49) return 'image/webp';
if (data[0] === 0x42 && data[1] === 0x4D) return 'image/bmp';
return 'image/jpeg'; // fallback
}
/**
* Extract AES key and encrypt_query_param from an ImageItem,
* supporting both the old cdn_media format and the newer flat format.
*/
function getImageCdnData(imageItem: ImageItem): { aesKey: string; encryptQueryParam: string } | null {
// Old format: cdn_media.aes_key + cdn_media.encrypt_query_param
if (imageItem.cdn_media?.aes_key && imageItem.cdn_media?.encrypt_query_param) {
return {
aesKey: imageItem.cdn_media.aes_key,
encryptQueryParam: imageItem.cdn_media.encrypt_query_param,
};
}
// New format: aeskey + media.encrypt_query_param
// Use media.aes_key (base64-of-hex) over aeskey (raw hex) since downloadAndDecrypt expects base64
if (imageItem.media?.encrypt_query_param && (imageItem.media.aes_key || imageItem.aeskey)) {
return {
aesKey: imageItem.media.aes_key ?? imageItem.aeskey!,
encryptQueryParam: imageItem.media.encrypt_query_param,
};
}
logger.warn('Image item has no usable CDN data', {
hasCdnMedia: !!imageItem.cdn_media,
hasAeskey: !!imageItem.aeskey,
hasMedia: !!imageItem.media,
});
return null;
}
/**
* Download a CDN image, decrypt it, and return a base64 data URI.
* Returns null on failure.
*/
export async function downloadImage(item: MessageItem): Promise<string | null> {
const imageItem = item.image_item;
if (!imageItem) {
return null;
}
const cdnData = getImageCdnData(imageItem);
if (!cdnData) {
return null;
}
try {
const decrypted = await downloadAndDecrypt(cdnData.encryptQueryParam, cdnData.aesKey);
const mimeType = detectMimeType(decrypted);
const base64 = decrypted.toString('base64');
const dataUri = `data:${mimeType};base64,${base64}`;
logger.info('Image downloaded and decrypted', { size: decrypted.length });
return dataUri;
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
logger.warn('Failed to download image', { error: msg });
return null;
}
}
export function extractText(item: MessageItem): string {
if (item.text_item?.text) return item.text_item.text;
if (item.voice_item?.text) return item.voice_item.text;
if (item.file_item?.file_name) return `[用户发送了文件: ${item.file_item.file_name}]`;
if (item.type === MessageItemType.VIDEO) return '[用户发送了视频]';
return '';
}
/**
* Find the first IMAGE type item in a list.
*/
export function extractFirstImageUrl(items?: MessageItem[]): MessageItem | undefined {
return items?.find((item) => item.type === MessageItemType.IMAGE);
}
/**
* Find the first FILE type item in a list.
*/
export function extractFirstFileItem(items?: MessageItem[]): MessageItem | undefined {
return items?.find((item) => item.type === MessageItemType.FILE);
}
/**
* Download a CDN file, decrypt it, and save to a temp directory.
* Returns the local file path, or null on failure.
*/
export async function downloadFile(item: MessageItem): Promise<string | null> {
const fileItem = item.file_item;
if (!fileItem) return null;
let aesKey: string | undefined;
let encryptQueryParam: string | undefined;
if (fileItem.media?.encrypt_query_param) {
encryptQueryParam = fileItem.media.encrypt_query_param;
aesKey = fileItem.media.aes_key;
} else if (fileItem.cdn_media?.encrypt_query_param) {
encryptQueryParam = fileItem.cdn_media.encrypt_query_param;
aesKey = fileItem.cdn_media.aes_key;
}
if (!encryptQueryParam || !aesKey) {
logger.warn('File item has no usable CDN data');
return null;
}
try {
const decrypted = await downloadAndDecrypt(encryptQueryParam, aesKey);
const tmpDir = path.join(os.tmpdir(), 'wechat-claude-code');
fs.mkdirSync(tmpDir, { recursive: true });
const fileName = fileItem.file_name || `file-${Date.now()}.bin`;
const filePath = path.join(tmpDir, fileName);
fs.writeFileSync(filePath, decrypted);
logger.info('File downloaded and saved', { path: filePath, size: decrypted.length, name: fileName });
return filePath;
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
logger.warn('Failed to download file', { error: msg });
return null;
}
}
+124
View File
@@ -0,0 +1,124 @@
import { WeChatApi } from './api.js';
import { loadSyncBuf, saveSyncBuf } from './sync-buf.js';
import { logger } from '../logger.js';
import type { WeixinMessage } from './types.js';
const SESSION_EXPIRED_ERRCODE = -14;
const SESSION_EXPIRED_PAUSE_MS = 60 * 60 * 1000; // 1 hour
const BACKOFF_THRESHOLD = 3;
const BACKOFF_LONG_MS = 30_000;
const BACKOFF_SHORT_MS = 3_000;
export interface MonitorCallbacks {
onMessage: (msg: WeixinMessage) => Promise<void>;
onSessionExpired: () => void;
}
export function createMonitor(api: WeChatApi, callbacks: MonitorCallbacks) {
const controller = new AbortController();
let stopped = false;
const recentMsgIds = new Set<number>();
const MAX_MSG_IDS = 1000;
async function run(): Promise<void> {
let consecutiveFailures = 0;
while (!controller.signal.aborted) {
try {
const buf = loadSyncBuf();
logger.debug('Polling for messages', { hasBuf: buf.length > 0 });
const resp = await api.getUpdates(buf || undefined);
if (resp.ret === SESSION_EXPIRED_ERRCODE) {
logger.warn('Session expired, pausing for 1 hour');
callbacks.onSessionExpired();
await sleep(SESSION_EXPIRED_PAUSE_MS, controller.signal);
consecutiveFailures = 0;
continue;
}
if (resp.ret !== undefined && resp.ret !== 0) {
logger.warn('getUpdates returned error', { ret: resp.ret, retmsg: resp.retmsg });
}
// Save the new sync buffer regardless of ret
if (resp.get_updates_buf) {
saveSyncBuf(resp.get_updates_buf);
}
// Process messages (with deduplication)
const messages = resp.msgs ?? [];
if (messages.length > 0) {
logger.info('Received messages', { count: messages.length });
for (const msg of messages) {
// Skip already-processed messages
if (msg.message_id && recentMsgIds.has(msg.message_id)) {
continue;
}
if (msg.message_id) {
recentMsgIds.add(msg.message_id);
if (recentMsgIds.size > MAX_MSG_IDS) {
// Evict oldest half (Set iterates in insertion order)
const iter = recentMsgIds.values();
const toDelete: number[] = [];
for (let i = 0; i < MAX_MSG_IDS / 2; i++) {
const { value } = iter.next();
if (value !== undefined) toDelete.push(value);
}
for (const id of toDelete) recentMsgIds.delete(id);
}
}
// Fire-and-forget: don't block the polling loop on message processing
// This allows permission responses (y/n) to be received while a query is running
callbacks.onMessage(msg).catch((err) => {
const msg2 = err instanceof Error ? err.message : String(err);
logger.error('Error processing message', { error: msg2, messageId: msg.message_id });
});
}
}
consecutiveFailures = 0;
} catch (err) {
if (controller.signal.aborted) {
break;
}
consecutiveFailures++;
const errorMsg = err instanceof Error ? err.message : String(err);
logger.error('Monitor error', { error: errorMsg, consecutiveFailures });
const backoff = consecutiveFailures >= BACKOFF_THRESHOLD ? BACKOFF_LONG_MS : BACKOFF_SHORT_MS;
logger.info(`Backing off ${backoff}ms`, { consecutiveFailures });
await sleep(backoff, controller.signal);
}
}
stopped = true;
logger.info('Monitor stopped');
}
function stop(): void {
if (!controller.signal.aborted) {
logger.info('Stopping monitor...');
controller.abort();
}
}
return { run, stop };
}
function sleep(ms: number, signal?: AbortSignal): Promise<void> {
return new Promise<void>((resolve) => {
if (signal?.aborted) {
resolve();
return;
}
const timer = setTimeout(resolve, ms);
signal?.addEventListener('abort', () => {
clearTimeout(timer);
resolve();
}, { once: true });
});
}
+184
View File
@@ -0,0 +1,184 @@
import { existsSync } from 'node:fs';
import { resolve } from 'node:path';
import { WeChatApi } from './api.js';
import { MessageItemType, MessageType, MessageState, TypingStatus, type MessageItem, type OutboundMessage } from './types.js';
import { uploadFile } from './upload.js';
import { logger } from '../logger.js';
const TYPING_KEEPALIVE_MS = 5_000;
export function createSender(api: WeChatApi, botAccountId: string) {
let clientCounter = 0;
const typingTicketCache = new Map<string, { ticket: string; fetchedAt: number }>();
const TICKET_TTL = 24 * 60 * 60 * 1000;
function generateClientId(): string {
return `wcc-${Date.now()}-${++clientCounter}`;
}
async function getTypingTicket(userId: string, contextToken?: string): Promise<string> {
const cached = typingTicketCache.get(userId);
if (cached && Date.now() - cached.fetchedAt < TICKET_TTL) {
return cached.ticket;
}
try {
const resp = await api.getConfig(userId, contextToken);
if (resp.ret === 0 && resp.typing_ticket) {
typingTicketCache.set(userId, { ticket: resp.typing_ticket, fetchedAt: Date.now() });
return resp.typing_ticket;
}
logger.warn('getConfig returned no typing_ticket', { ret: resp.ret });
} catch (err) {
logger.warn('getConfig failed', { err: err instanceof Error ? err.message : String(err) });
}
return '';
}
/**
* Start typing indicator with keepalive. Returns a stop function.
* Fire-and-forget: errors are logged but not thrown.
*/
function startTyping(toUserId: string, contextToken: string): () => void {
let cancelled = false;
(async () => {
const ticket = await getTypingTicket(toUserId, contextToken);
if (!ticket || cancelled) return;
try {
await api.sendTyping({
ilink_user_id: toUserId,
typing_ticket: ticket,
status: TypingStatus.TYPING,
});
} catch (err) {
logger.debug('sendTyping start failed', { err: err instanceof Error ? err.message : String(err) });
return;
}
// Keepalive loop
while (!cancelled) {
await new Promise(r => setTimeout(r, TYPING_KEEPALIVE_MS));
if (cancelled) break;
try {
await api.sendTyping({
ilink_user_id: toUserId,
typing_ticket: ticket,
status: TypingStatus.TYPING,
});
} catch {
break;
}
}
// Send CANCEL to tell WeChat we're done typing
if (!ticket) return;
try {
await api.sendTyping({
ilink_user_id: toUserId,
typing_ticket: ticket,
status: TypingStatus.CANCEL,
});
} catch {
// ignore
}
})();
return () => {
cancelled = true;
};
}
async function sendText(toUserId: string, contextToken: string, text: string): Promise<void> {
const clientId = generateClientId();
const items: MessageItem[] = [
{
type: MessageItemType.TEXT,
text_item: { text },
},
];
const msg: OutboundMessage = {
from_user_id: botAccountId,
to_user_id: toUserId,
client_id: clientId,
message_type: MessageType.BOT,
message_state: MessageState.FINISH,
context_token: contextToken,
item_list: items,
};
logger.info('Sending text message', { toUserId, clientId, textLength: text.length });
await api.sendMessage({ msg });
logger.info('Text message sent', { toUserId, clientId });
}
async function sendFile(toUserId: string, contextToken: string, filePath: string): Promise<void> {
const resolved = resolve(filePath.replace(/^~/, process.env.HOME || ''));
if (!existsSync(resolved)) {
await sendText(toUserId, contextToken, `文件不存在: ${resolved}`);
return;
}
try {
const media = await uploadFile(api, toUserId, resolved);
const clientId = generateClientId();
// Convert aesKeyHex to base64: treat hex string as UTF-8, then base64 encode
// (matches OpenClaw's format: Buffer.from(hexString).toString("base64"))
const aesKeyBase64 = Buffer.from(media.aesKeyHex).toString('base64');
let item: MessageItem;
if (media.mediaType === 'image') {
item = {
type: MessageItemType.IMAGE,
image_item: {
media: {
encrypt_query_param: media.encryptQueryParam,
aes_key: aesKeyBase64,
encrypt_type: 1,
},
mid_size: media.fileSize,
},
};
} else {
item = {
type: MessageItemType.FILE,
file_item: {
media: {
encrypt_query_param: media.encryptQueryParam,
aes_key: aesKeyBase64,
encrypt_type: 1,
},
file_name: media.fileName,
len: String(media.rawSize),
},
};
}
const msg: OutboundMessage = {
from_user_id: botAccountId,
to_user_id: toUserId,
client_id: clientId,
message_type: MessageType.BOT,
message_state: MessageState.FINISH,
context_token: contextToken,
item_list: [item],
};
logger.info('Sending file message', { toUserId, clientId, fileName: media.fileName, mediaType: media.mediaType });
await api.sendMessage({ msg });
logger.info('File message sent', { toUserId, clientId, fileName: media.fileName });
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
logger.error('Failed to send file', { filePath: resolved, error: msg });
if (!msg.includes('rate-limited')) {
await sendText(toUserId, contextToken, `发送文件失败: ${msg}`);
}
throw err;
}
}
return { sendText, startTyping, sendFile };
}
+13
View File
@@ -0,0 +1,13 @@
import { loadJson, saveJson } from '../store.js';
import { DATA_DIR } from '../constants.js';
import { join } from 'node:path';
const SYNC_BUF_PATH = join(DATA_DIR, 'get_updates_buf');
export function loadSyncBuf(): string {
return loadJson<string>(SYNC_BUF_PATH, '');
}
export function saveSyncBuf(buf: string): void {
saveJson(SYNC_BUF_PATH, buf);
}
+166
View File
@@ -0,0 +1,166 @@
// WeChat Work (企业微信) protocol type definitions
// Extracted from the ClawBot WeChat plugin API
// ── Enums ──────────────────────────────────────────────────────────────────
export enum MessageType {
USER = 1,
BOT = 2,
}
export enum MessageItemType {
TEXT = 1,
IMAGE = 2,
VOICE = 3,
FILE = 4,
VIDEO = 5,
}
export enum MessageState {
NEW = 0,
GENERATING = 1,
FINISH = 2,
}
// ── Media ──────────────────────────────────────────────────────────────────
export interface CDNMedia {
aes_key: string;
encrypt_query_param: string;
cdn_url?: string;
}
// ── Message Items ───────────────────────────────────────────────────────────
export interface TextItem {
text: string;
}
export interface ImageItem {
cdn_media?: CDNMedia;
/** Alternative field name used by some API versions */
aeskey?: string;
media?: { encrypt_query_param: string; aes_key?: string; encrypt_type?: number };
url?: string;
mid_size?: number;
hd_size?: number;
}
export interface VoiceItem {
media?: CDNMedia;
/** 语音转文字内容 */
text?: string;
}
export interface FileItem {
cdn_media?: CDNMedia;
media?: { encrypt_query_param: string; aes_key?: string; encrypt_type?: number };
file_name?: string;
len?: string;
}
export interface VideoItem {
cdn_media: CDNMedia;
}
export interface MessageItem {
type: MessageItemType;
text_item?: TextItem;
image_item?: ImageItem;
voice_item?: VoiceItem;
file_item?: FileItem;
video_item?: VideoItem;
}
// ── Weixin Message ──────────────────────────────────────────────────────────
export interface WeixinMessage {
seq?: number;
message_id?: number;
from_user_id?: string;
to_user_id?: string;
create_time_ms?: number;
message_type?: MessageType;
message_state?: MessageState;
item_list?: MessageItem[];
context_token?: string;
}
// ── GetUpdates API ──────────────────────────────────────────────────────────
export interface GetUpdatesReq {
get_updates_buf?: string;
}
export interface GetUpdatesResp {
ret?: number;
retmsg?: string;
sync_buf: string;
get_updates_buf: string;
msgs?: WeixinMessage[];
}
// ── SendMessage API ─────────────────────────────────────────────────────────
export interface OutboundMessage {
from_user_id: string;
to_user_id: string;
client_id: string;
message_type: MessageType;
message_state: MessageState;
context_token: string;
item_list: MessageItem[];
}
export interface SendMessageReq {
msg: OutboundMessage;
}
// ── Typing API ──────────────────────────────────────────────────────────────
export const TypingStatus = {
TYPING: 1,
CANCEL: 2,
} as const;
export interface SendTypingReq {
ilink_user_id: string;
typing_ticket: string;
status: number;
}
export interface GetConfigResp {
ret?: number;
errmsg?: string;
typing_ticket?: string;
}
// ── GetUploadUrl API ────────────────────────────────────────────────────────
export const UploadMediaType = {
IMAGE: 1,
VIDEO: 2,
FILE: 3,
VOICE: 4,
} as const;
export interface GetUploadUrlReq {
filekey: string;
media_type: number;
to_user_id: string;
rawsize: number;
rawfilemd5: string;
filesize: number;
no_need_thumb: boolean;
aeskey: string;
base_info: {
channel_version: string;
bot_agent: string;
};
}
export interface GetUploadUrlResp {
ret?: number;
upload_param?: string;
upload_full_url?: string;
}
+144
View File
@@ -0,0 +1,144 @@
import { createHash, randomBytes } from 'node:crypto';
import { readFileSync, statSync } from 'node:fs';
import { basename, extname } from 'node:path';
import { encryptAesEcb, aesEcbPaddedSize } from './crypto.js';
import { WeChatApi } from './api.js';
import { UploadMediaType } from './types.js';
import { CDN_BASE_URL } from '../constants.js';
import { logger } from '../logger.js';
const MAX_FILE_SIZE = 25 * 1024 * 1024; // 25MB
const IMAGE_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.svg', '.ico']);
export interface UploadedMedia {
mediaType: 'image' | 'file';
encryptQueryParam: string;
aesKeyHex: string;
fileName: string;
fileSize: number;
rawSize: number;
}
export function isImageFile(filePath: string): boolean {
return IMAGE_EXTENSIONS.has(extname(filePath).toLowerCase());
}
export async function uploadFile(
api: WeChatApi,
toUserId: string,
filePath: string,
): Promise<UploadedMedia> {
const stat = statSync(filePath);
if (stat.size > MAX_FILE_SIZE) {
throw new Error(`文件过大 (${(stat.size / 1024 / 1024).toFixed(1)}MB),最大支持 25MB`);
}
const fileName = basename(filePath);
const isImage = isImageFile(filePath);
const mediaType = isImage ? UploadMediaType.IMAGE : UploadMediaType.FILE;
// Prepare file
const plaintext = readFileSync(filePath);
const rawSize = plaintext.length;
const rawFileMd5 = createHash('md5').update(plaintext).digest('hex');
const fileSize = aesEcbPaddedSize(rawSize);
const fileKey = randomBytes(16).toString('hex'); // 32-hex-char string
const aesKey = randomBytes(16); // 16 raw bytes
const aesKeyHex = aesKey.toString('hex');
// Get upload URL
logger.info('Requesting upload URL', { fileName, rawSize, mediaType, toUserId });
const uploadResp = await api.getUploadUrl({
filekey: fileKey,
media_type: mediaType,
to_user_id: toUserId,
rawsize: rawSize,
rawfilemd5: rawFileMd5,
filesize: fileSize,
no_need_thumb: true,
aeskey: aesKeyHex,
base_info: {
channel_version: '2.0.0',
bot_agent: 'wechat-claude-code',
},
});
logger.info('Upload URL response', { uploadResp });
if (!uploadResp.upload_full_url && !uploadResp.upload_param) {
throw new Error(`获取上传地址失败: ${JSON.stringify(uploadResp)}`);
}
// Encrypt
const encrypted = encryptAesEcb(aesKey, plaintext);
// Build CDN upload URL
let uploadUrl: string;
if (uploadResp.upload_full_url) {
uploadUrl = uploadResp.upload_full_url;
} else {
uploadUrl = `${CDN_BASE_URL}/upload?encrypted_query_param=${encodeURIComponent(uploadResp.upload_param!)}&filekey=${fileKey}`;
}
logger.info('Uploading to CDN', { uploadUrl, encryptedSize: encrypted.length });
// Upload to CDN (POST, get download param from response header)
const encryptQueryParam = await uploadToCdn(uploadUrl, encrypted);
logger.info('CDN upload succeeded', { fileName });
return {
mediaType: isImage ? 'image' : 'file',
encryptQueryParam,
aesKeyHex,
fileName,
fileSize,
rawSize,
};
}
async function uploadToCdn(url: string, encrypted: Buffer): Promise<string> {
const MAX_RETRIES = 3;
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 60_000);
try {
const res = await fetch(url, {
method: 'POST',
body: new Uint8Array(encrypted),
signal: controller.signal,
headers: { 'Content-Type': 'application/octet-stream' },
});
if (res.status >= 400 && res.status < 500) {
const text = await res.text();
throw new Error(`CDN 上传失败 (4xx): ${res.status} ${text.slice(0, 200)}`);
}
if (res.status >= 500) {
logger.warn('CDN upload 5xx, retrying', { status: res.status, attempt });
continue;
}
// Get download param from response header
const param = res.headers.get('x-encrypted-param');
if (!param) {
throw new Error('CDN 上传成功但未返回 x-encrypted-param');
}
return param;
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') {
throw new Error('CDN 上传超时');
}
throw err;
} finally {
clearTimeout(timer);
}
}
throw new Error('CDN 上传失败: 多次重试后仍失败');
}
+16
View File
@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"declaration": true
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
}