chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
node_modules/
|
||||
dist/
|
||||
*.log
|
||||
.env
|
||||
.wechat-claude-code/
|
||||
.claude/
|
||||
CLAUDE.md
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
@@ -0,0 +1,7 @@
|
||||
# WeHub 来源说明
|
||||
|
||||
- 原始项目:`Wechat-ggGitHub/wechat-claude-code`
|
||||
- 原始仓库:https://github.com/Wechat-ggGitHub/wechat-claude-code
|
||||
- 导入方式:上游默认分支的最新快照
|
||||
- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准
|
||||
- 本文件仅用于记录来源,不代表 WeHub 是原项目作者
|
||||
+144
@@ -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)
|
||||
@@ -0,0 +1,139 @@
|
||||
---
|
||||
name: wechat-claude-code
|
||||
description: 微信消息桥接 - 在微信中与 Claude Code 聊天。支持文字对话、图片识别、实时进度推送、斜杠命令。
|
||||
---
|
||||
|
||||
# WeChat Claude Code Bridge
|
||||
|
||||
通过个人微信与本地 Claude Code 进行对话。
|
||||
|
||||
## 前置条件
|
||||
|
||||
- Node.js >= 18
|
||||
- macOS(daemon 使用 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&A(1485 字) | **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 loop(10+ 工具调用 + 密集 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 继续 | 按段落 flush(interstitial) |
|
||||
| `"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 两个 buffer(interstitial + 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%)
|
||||
Generated
+703
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
Executable
+422
@@ -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 "$@"
|
||||
@@ -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`).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}` });
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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}` : '🔧 [工具调用]';
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* TurnRouter 把 Claude CLI 的流式输出按"回合"分流:
|
||||
*
|
||||
* - tool_use 回合的文本 → 立即作为 interstitial emit(agent loop 进度)
|
||||
* - 其他 stop_reason(end_turn / max_tokens / stop_sequence / pause_turn / ...)
|
||||
* 的文本 → 攒到 pendingFinal,drain 时一次性作为 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 = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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] 列出已安装的 skill(full 显示描述)
|
||||
/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 查看可用列表`,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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
@@ -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 };
|
||||
}
|
||||
@@ -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
@@ -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}`);
|
||||
+94
File diff suppressed because one or more lines are too long
@@ -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']);
|
||||
});
|
||||
@@ -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, '🔧 [工具调用]');
|
||||
});
|
||||
@@ -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' },
|
||||
]);
|
||||
});
|
||||
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
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();
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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()]);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 上传失败: 多次重试后仍失败');
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
Reference in New Issue
Block a user