全面升级:WeChat 3.9.5.81 + 全尺寸图片 OCR + 启动脚本

This commit is contained in:
hmo
2026-05-21 01:31:31 +08:00
parent 4296af615f
commit 1593105a4a
9 changed files with 634 additions and 54 deletions
+8
View File
@@ -13,4 +13,12 @@ Thumbs.db
.idea/ .idea/
# Logs # Logs
logs/
*.log *.log
# Temp
temp/
*.dat
# Large installer
WeChatSetup-*.exe
+44 -27
View File
@@ -11,7 +11,7 @@ Windows 微信机器人 ↔ Linux Hermes AI,全自动双向聊天。
│ Windows 192.168.0.111 │ │ Windows 192.168.0.111 │
│ │ │ │
│ ┌──────────────────┐ ┌───────────────────────────┐ │ │ ┌──────────────────┐ ┌───────────────────────────┐ │
│ │ 微信 3.9.10.19 x64 │ │ 日常微信 WeChatAppEx 4.x │ │ │ │ 微信 3.9.5.81 x64 │ │ 日常微信 WeChatAppEx 4.x │ │
│ │ 机器人号 modachen │ │ 老爸日常使用,互不干扰 │ │ │ │ 机器人号 modachen │ │ 老爸日常使用,互不干扰 │ │
│ │ wxhelper DLL 注入 │ └───────────────────────────┘ │ │ │ wxhelper DLL 注入 │ └───────────────────────────┘ │
│ └────────┬─────────┘ │ │ └────────┬─────────┘ │
@@ -23,6 +23,8 @@ Windows 微信机器人 ↔ Linux Hermes AI,全自动双向聊天。
│ │ → POST Hermes API :8642 │ ← sisyphus session │ │ │ → POST Hermes API :8642 │ ← sisyphus session │
│ │ ← 收回复 → wxhelper 发 │ │ │ │ ← 收回复 → wxhelper 发 │ │
│ │ HTTP :19088 收发消息 │ │ │ │ HTTP :19088 收发消息 │ │
│ │ 图片:downloadAttach │ ← 全尺寸原图 CDN 下载 │
│ │ 图片:decodeImage │ ← 解密 .dat → OCR 识别 │
│ │ 看门狗自愈 │ │ │ │ 看门狗自愈 │ │
│ └────────┬────────────────┘ │ │ └────────┬────────────────┘ │
└───────────┼─────────────────────────────────────────────────┘ └───────────┼─────────────────────────────────────────────────┘
@@ -92,13 +94,16 @@ Windows 微信机器人 ↔ Linux Hermes AI,全自动双向聊天。
→ 老爸手机收到 → 老爸手机收到
``` ```
### 图片消息 ### 图片消息(全尺寸 OCR
``` ```
老爸发图片 老爸发图片
→ WeChat 收到 → wxhelper 图片事件 → WeChat 收到 → wxhelper TCP 推送 (type=3, 含 msgId)
→ wechat_agent.py 保存图片 → 调豆包OCR (VolcEngine) → wechat_agent.py 提取 msgId
OCR 文字结果 + 通知 → POST Hermes API downloadAttach API → 从 CDN 下载 1.4MB+ 全尺寸原图
→ decodeImage API → 解密 .dat 加密文件 → JPEG
→ VolcEngine doubao-seed-code OCR → 完整文字提取
→ OCR 结果 → POST Hermes API
→ Hermes 知道图片内容 → 回复老爸 → Hermes 知道图片内容 → 回复老爸
``` ```
@@ -130,9 +135,11 @@ Hermes → POST http://192.168.0.111:5801/hermes-msg
## 组件 ## 组件
### Windows 端(wechat_agent.py v2 ### Windows 端(wechat_agent.py v2
- **wxhelper DLL 注入** — ttttupup/wxhelper 3.9.10.19 x64 - **wxhelper DLL 注入** — ttttupup/wxhelper 3.9.5.81 (官方 DLL, Injector_x64.exe 注入)
- **TCP 接收消息** — :19099 收微信事件 - **TCP 接收消息** — :19099 收微信事件
- **HTTP 发送消息** — :19088 wxhelper API - **HTTP 发送消息** — :19088 wxhelper API
- **全尺寸图片 OCR** — downloadAttach (CDN下载) + decodeImage (.dat解密) → VolcEngine OCR
- **空白响应过滤** — 空/白字符响应自动跳过,不发到微信群
- **Hermes API 调用** — 直接 POST :8642session 固定 `sisyphus` - **Hermes API 调用** — 直接 POST :8642session 固定 `sisyphus`
- **回复服务** — 5801 端口收 Hermes 消息 - **回复服务** — 5801 端口收 Hermes 消息
- **看门狗** — 120s 无消息刷新 webhookAPI 挂了才重注入 DLL - **看门狗** — 120s 无消息刷新 webhookAPI 挂了才重注入 DLL
@@ -148,20 +155,14 @@ Hermes → POST http://192.168.0.111:5801/hermes-msg
### Windows ### Windows
```batch 使用 Python 3.10Miniconda3 Python 3.13 的 encodings 模块损坏):
cd D:\F\NewI\opencode\daily-workspace\projects\wechat-hermes-gateway
scripts\start_bridge.bat
```
或直接:
```powershell ```powershell
$env:PYTHONHOME='' cd D:\F\NewI\opencode\daily-workspace\projects\wechat-hermes-gateway
Start-Process -WindowStyle Hidden python.exe scripts\wechat_agent.py $python = "C:\Users\hmo\AppData\Local\Programs\Python\Python310\python.exe"
Start-Process -WindowStyle Hidden -FilePath $python -ArgumentList "scripts\wechat_agent.py"
``` ```
启动后需用修复过低工具扫码登录微信。
### Linux(如重启后) ### Linux(如重启后)
```bash ```bash
@@ -192,11 +193,20 @@ wechat-hermes-gateway/
│ └── history_api.py # History REST API :19001 │ └── history_api.py # History REST API :19001
├── scripts/ ├── scripts/
│ ├── wechat_agent.py # 主力:微信机器人代理 │ ├── wechat_agent.py # 主力:微信机器人代理
── start_bridge.bat # 微信桥接一键启动 ── start_history_api.bat # History API 一键启动
│ ├── start_history_api.bat # History API 一键启动 ├── tools/
│ ├── moho_view.py # 莫荷聊天记录查看器 │ ├── Injector_x64.exe # DLL 注入器 (3.9.5.81)
── moho_chat.py # 莫荷聊天查看器(备选) ── wxhelper_official_39581.dll # 官方 wxhelper 3.9.5.81 DLL
└── temp/ # 废弃/临时脚本 │ ├── WeChatSetup-3.9.5.81.exe # 微信 3.9.5.81 安装包
│ ├── ConsoleApplication.exe # 旧注入器 (3.9.10.19 备份)
│ └── wxhelper_391019.dll # 旧 DLL 备份
├── docs/
│ ├── 通用架构-WeChat opencode 桥接.md
│ ├── 老莫消息路由设计.md
│ └── assets/
│ └── architecture.png
├── logs/ # 运行时日志
└── temp/ # 临时文件 (OCR 解码图等)
``` ```
## History REST API (:19001) ## History REST API (:19001)
@@ -345,6 +355,9 @@ curl -X POST http://localhost:19001/api/history -H "Content-Type: application/js
4. **session 自动重置** → 关闭 api_server 平台的重置策略 4. **session 自动重置** → 关闭 api_server 平台的重置策略
5. **群聊不认人** → session 固定 `sisyphus`,所有消息共享上下文 5. **群聊不认人** → session 固定 `sisyphus`,所有消息共享上下文
6. **Linux bridge 常挂** → 去掉 bridge.pyWindows 直接调 Hermes API 6. **Linux bridge 常挂** → 去掉 bridge.pyWindows 直接调 Hermes API
7. **3.9.10.19-v1 图片 API 不全** → 降级到 3.9.5.81,获得 downloadAttach + decodeImage 支持
8. **ConsoleApplication.exe 注入器不兼容** → 改用 Injector_x64.exe(参数 `-n WeChat.exe -i dll_path`
9. **缩略图 OCR 瞎编** → 全尺寸 downloadAttach → decodeImage → OCR1.4MB 原图识别 1376 字符
## 已实现的功能 ## 已实现的功能
@@ -352,14 +365,15 @@ curl -X POST http://localhost:19001/api/history -H "Content-Type: application/js
|------|------| |------|------|
| 文字消息收发(个人聊天) | ✅ 双向,session 上下文连贯 | | 文字消息收发(个人聊天) | ✅ 双向,session 上下文连贯 |
| 文字消息收发(群聊) | ✅ 同 session,认识老爸 | | 文字消息收发(群聊) | ✅ 同 session,认识老爸 |
| 图片接收 + OCR 分析 | ✅ 自动 OCR → 结果给莫荷 | | 图片接收 + 全尺寸 OCR | ✅ downloadAttach → decodeImage → 1.4MB 原图 → 豆包OCR |
| 发送网上图片 | ✅ [IMG]URL[/IMG] 标记,Bot.send_image 发出 | | 发送网上图片 | ✅ [IMG]URL[/IMG] 标记,Bot.send_image 发出 |
| 图像生成 (SenseNova商汤) | ✅ [IMG]generate:描述[/IMG] 支持多种比例 | | 图像生成 (SenseNova商汤) | ✅ [IMG]generate:描述[/IMG] 支持多种比例 |
| 图像理解/OCR | ✅ 豆包OCR + SenseNova 双引擎 | | 图像理解/OCR | ✅ 豆包 doubao-seed-code + 全尺寸原图,1376 字符实测通过 |
| 空白响应过滤 | ✅ 空/白字符响应自动跳过,不发微信群 |
| Hermes 身份认知 | ✅ 知道自己是莫荷/莫小荷,知道老爸 | | Hermes 身份认知 | ✅ 知道自己是莫荷/莫小荷,知道老爸 |
| 会话上下文持续 | ✅ session `sisyphus`,自动重置已关闭 | | 会话上下文持续 | ✅ session `sisyphus`,自动重置已关闭 |
| 小小莫 ↔ Hermes 双向通信 | ✅ API (:8642) + HTTP (:5801/hermes-msg) | | 小小莫 ↔ Hermes 双向通信 | ✅ API (:8642) + HTTP (:5801/hermes-msg) |
| 看门狗自愈 | ✅ 120s 无消息刷新 webhook | | 看门狗自愈 | ✅ 120s 无消息刷新 webhookAPI 挂了自动重注入 DLL |
| 昵称识别 | ✅ 从 getContactList 获取 | | 昵称识别 | ✅ 从 getContactList 获取 |
| 联系人列表查询 | ✅ wxhelper /api/getContactList | | 联系人列表查询 | ✅ wxhelper /api/getContactList |
| 历史聊天记录查询 | ✅ [HISTORY:wxid:count] 标签 → MSG0.db SQL | | 历史聊天记录查询 | ✅ [HISTORY:wxid:count] 标签 → MSG0.db SQL |
@@ -386,10 +400,10 @@ curl -X POST http://localhost:19001/api/history -H "Content-Type: application/js
→ 启动 opencode serve:4096,莫荷连接用) → 启动 opencode serve:4096,莫荷连接用)
→ 启动后台守护(自动清理僵尸连接) → 启动后台守护(自动清理僵尸连接)
2. 打开经典微信 3.9.10.19 2. 打开经典微信 3.9.5.81(用修复过低工具扫码登录)
→ 扫码登录机器人号 modachenchen → 扫码登录机器人号 modachenchen
3. wechat_agent.py 在登录后自动注入 DLL 3. wechat_agent.py 在登录后自动注入 wxhelper_official_39581.dll
→ 自动开始转发消息 → 自动开始转发消息
→ 日志在 projects/wechat-hermes-gateway/logs/ → 日志在 projects/wechat-hermes-gateway/logs/
``` ```
@@ -435,9 +449,12 @@ curl http://192.168.0.103:8642/v1/models -H "Authorization: Bearer hermes123"
## 注意事项 ## 注意事项
- wxhelper DLL 支持 3.9.10.19 x64 微信 - wxhelper DLL 支持 3.9.5.81 x64 微信(使用 `wxhelper_official_39581.dll`
- 注入器:使用 `Injector_x64.exe`(参数:`-n WeChat.exe -i dll_path`),不再是 ConsoleApplication.exe
- 每次 WeChat 重启需重新登录 - 每次 WeChat 重启需重新登录
- 启动顺序:先开微信 → agent 自动注入 DLL - 启动顺序:先开微信 → agent 自动注入 DLL
- Hermes API 首次调用可能较慢(大模型冷启动) - Hermes API 首次调用可能较慢(大模型冷启动)
- 看门狗每 120s 刷新 webhookAPI 挂了自动重注入 - 看门狗每 120s 刷新 webhookAPI 挂了自动重注入
- Python 请用 Python 3.10Miniconda3 3.13 的 encodings 模块损坏)
- 全尺寸图片 OCR:依赖 `downloadAttach` + `decodeImage` API,仅 3.9.5.81+ 支持
- 如果微信登录后没反应,等 1-2 分钟看门狗会自动处理 - 如果微信登录后没反应,等 1-2 分钟看门狗会自动处理
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 MiB

+153
View File
@@ -0,0 +1,153 @@
# 老莫微信消息 → serve session 路由设计方案
> 2026-05-20
> 目标:老莫给机器人号发微信,小小莫也能看到,不依赖莫荷转述
---
## 现状
```
你微信 → wxhelper TCP (:19099) → wechat_agent.py
Hermes API (:8642)
莫荷回复你
```
莫荷独占所有微信消息。小小莫只能被动等她转述。
---
## 目标
```
你微信 → wxhelper TCP → wechat_agent.py ─┬→ Hermes API → 莫荷 (不变)
└→ opencode serve (:4096) → 小小莫看到
```
新增一条岔路:老莫的消息同时写入 serve session,小小莫可主动查看。
---
## 方案对比
### 方案 Asubprocess 调用 `opencode run --attach --message`
**做法**wechat_agent.py 的 `process_msg()` 里加一段:
```python
def fork_to_session(fu, ct):
"""将消息写入 opencode serve session (非阻塞)"""
if fu != "wxid_c0a6izmwd78y22":
return # 只转发老莫
try:
import subprocess
subprocess.run(
["opencode", "run", "--attach", "http://localhost:4096",
"--password", "hermes123",
"--session", SESSION_ID,
"--message", f"[老莫] {ct}"],
capture_output=True, timeout=10,
env={**os.environ, "PYTHONHOME": ""}
)
except Exception as e:
log(f"FORK ERR (non-fatal): {e}")
```
| 方面 | 评估 |
|------|------|
| 复杂度 | ⭐ 低,~10 行代码 |
| 对现有链路影响 | ❌ **无**,fork 是独立线程,失败不影响 Hermes |
| session ID 稳定性 | ⚠️ serve 重启后 ses_xxx 会变 → 需想办法拿到当前 ID |
| 性能开销 | subprocess 每次约 1-2 秒,但独立线程不阻塞主流程 |
| serve 密码硬编码 | ⚠️ 已经在代码库(hermes123),无新增风险 |
| 可靠性 | subprocess 可能因 PATH/PYTHONHOME 问题失败 |
### 方案 B:直接 HTTP POST 调用 serve API
**做法**:抓包分析 `opencode run --attach` 的 HTTP 协议,直接用 `urllib.request` POST
```python
# 伪代码,serve API 协议未知,需逆向
urllib.request.urlopen("http://localhost:4096/api/session/inject",
data=json.dumps({"session": SID, "message": "[老莫] xxx"}))
```
| 方面 | 评估 |
|------|------|
| 复杂度 | ⭐⭐⭐ 未知,需逆向 serve API |
| 性能 | ✅ 纯 HTTP,无 subprocess 开销 |
| 稳定性 | ⚠️ 非官方 API,版本更新可能不兼容 |
### 方案 C:写 inbox 文件 + 小小莫轮询
**做法**wechat_agent 写文件,小小莫定期读取
| 方面 | 评估 |
|------|------|
| 复杂度 | ⭐ 最低 |
| 即时性 | ❌ 需要轮询,无法实时 |
---
## 关键风险
### 1. session ID 稳定性(最核心)
`opencode run --attach` 需要 session ID。每次 serve 重启后,当前 TUI session 的 ID 可能变化:
- 如果 serve 重启 → 老 session 消失 → 新 session 新 ID → 需要更新 wechat_agent 里的配置
- **解决思路**:用 session 名称而不是 ID,或每次启动时自动获取
### 2. 不对莫荷通信造成任何影响
**铁律**fork 到 session 的代码必须:
- 在独立线程中运行
- 捕获所有异常
- 设置超时(≤10 秒)
- 永远不阻塞 `call_hermes()``send_wx()`
### 3. 循环消息风暴
如果我不小心回了一条 `[老莫]` 到 sessionwechat_agent 不能把它再 fork 一次。
- 现有的 `is_self` 检查已经过滤自己发送的消息
- 但如果 serve session 的消息被 serve 再推给 wechat_agent... 需要确认不会发生
### 4. `opencode` CLI 在 wechat_agent 环境中是否可用
wechat_agent 以 `$env:PYTHONHOME=''` 启动,`opencode.cmd` 可能也依赖 Python。
- 需测试:从 Python subprocess 能否直接调用 `opencode run --attach --message`
---
## 推荐方案
**方案 A**subprocess)最稳妥:
1. 对现有链路零影响
2. 改动最小
3. 可以逐步优化(先 subprocess,后改 HTTP API
### 待确认事项
1. **serve session ID 如何维护?**
- 能否用固定名称?还是每次启动获取?
- 如果 serve 重启导致 ID 变了,wechat_agent 如何感知?
2. **`opencode run --attach --message` 是否支持在 Python subprocess 中调用?**
- 需要验证 CLI 安装路径和调用方式
3. **是否需要前缀路由?**
- 是全部消息都 fork
- 还是只有特定前缀(如 `[小小莫]` 开头的消息)才 fork
---
## 验证清单
实现后验证:
- [ ] 老莫发微信 → 莫荷正常回复(链路不变)
- [ ] 老莫发微信 → serve session 能看到消息(新增)
- [ ] `opencode run --attach` 超时/失败 → 莫荷通信不受影响
- [ ] 老莫连续发多条 → 都能看到
- [ ] wechat_agent 重启后依然工作
- [ ] serve 重启后 session ID 变化时能自动适配
+184
View File
@@ -0,0 +1,184 @@
# 通用架构:WeChat ↔ opencode 双向桥接
> 2026-05-20
> 剥离 Hermes AI 依赖,建立 opencode serve 与微信账号之间的通用双向通道
---
## 核心理念
微信机器人本质是一套通用的消息路由系统:
```
微信 ←→ wxhelper ←→ 桥接代理 ←→ 任何 AI / 程序
```
**不应该是"莫荷专属"**。当前架构把 Hermes API 硬编码在桥接逻辑中,限制了通用性。
---
## 架构对比
### 当前(莫荷专用,双机)
```
你微信 → wxhelper TCP → wechat_agent → Hermes API (:8642, Linux) → 莫荷回复
消息路由硬编码,改一处都要动 agent
```
### 目标(通用,纯 Windows
```
你微信 → wxhelper TCP → Bridge Agent ─┬→ opencode serve session (小小莫看到)
└→ HTTP API (:5801) 供任何程序消费
serve 里的 AI / 外部程序 POST 回复
```
**关键变化**:桥接代理不再"替 AI 做决定",而是变成**中立的消息通道**:
| 功能 | 现状 (wechat_agent.py) | 目标 (Bridge Agent) |
|------|----------------------|-------------------|
| 收到微信消息 | 直接 POST Hermes API | **写入 serve session + 提供 HTTP 消费接口** |
| 消息路由 | 硬编码 (call_hermes) | 无业务逻辑,只负责转发 |
| AI 是谁 | 只能是莫荷 (Hermes) | 可以是 serve 里的任何人(Sisyphus、莫荷... |
| 外部调用 | :5801 简陋收消息 | http API 收消息 |
---
## 通用架构图
```
┌─────────────────────────────────────────────────────────────────┐
│ Windows 192.168.0.111 │
│ │
│ ┌──────────────────────┐ │
│ │ 微信 3.9.10.19 机器人 │ │
│ │ (wxid_xxxxxxxxx) │ │
│ └────────┬─────────────┘ │
│ │ wxhelper TCP (:19099) → 收消息 │
│ │ wxhelper HTTP (:19088) → 发消息 │
│ ▼ │
│ ┌────────────────────────────────────────────┐ │
│ │ Bridge Agent (bridge.py v3) │ │
│ │ │ │
│ │ ┌────────────────────────────────────┐ │ │
│ │ │ 消息收 (TCP thread) │ │ │
│ │ │ 收到微信消息 → 写入 serve session │ │ opencode │
│ │ │ → 触发 webhook(可选) │──┼──→ serve :4096 │
│ │ └────────────────────────────────────┘ │ │
│ │ │ │
│ │ ┌────────────────────────────────────┐ │ │
│ │ │ HTTP API 服务 (:5801) │ │ │
│ │ │ POST /send → 发微信 │ │ │
│ │ │ POST /history → 查历史 │ │ │
│ │ │ POST /inject → 写 serve 会话 │ │ │
│ │ └────────────────────────────────────┘ │ │
│ └────────────────────────────────────────────┘ │
│ │ ▲ │
│ │ serve session 里的 AI │ HTTP POST /send │
│ ▼ │ │
│ ┌────────────────────────────────────┐ │ │
│ │ opencode serve TUI │ │ │
│ │ (Sisyphus / 任何 Agent) │──┘ │
│ │ │ │
│ │ 「老莫:今天吃了吗」← 从 session 看到 │ │
│ │ 「回复:[xxm] 吃了」→ POST :5801 │ │
│ └────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
```
---
## 数据流
### 微信消息 → AI(上行)
```
1. 老莫发微信给机器人号
2. wxhelper DLL 通过 TCP (:19099) 推送给 Bridge Agent
3. Bridge Agent 收到消息,写入 serve session:
subprocess.run(["opencode", "run", "--attach",
"--message", "[老莫] 消息内容"])
4. 同时,消息可通过 :5801 HTTP API 被任何订阅者消费
5. serve session 里的 AI 在 TUI 或通过 session_search 看到
```
### AI 回复 → 微信(下行)
```
1. AI 决定回复 → POST http://localhost:5801/send
{"to": "wxid_xxx", "message": "回复内容"}
2. Bridge Agent 收到 → wxpost /api/sendTextMsg
3. wxhelper DLL 发送 → 老莫手机收到
```
---
## Bridge Agent 接口规范
### HTTP API (:5801)
| 方法 | 路径 | 用途 | Body |
|------|------|------|------|
| POST | `/send` | 发微信消息 | `{"to":"wxid","message":"text"}` |
| POST | `/history` | 查聊天记录 | `{"wxid":"...","count":20}` |
| POST | `/recent` | 最近联系人 | 无 |
| POST | `/inject` | 写 serve session | `{"message":"[xxm] 内容"}` |
| GET | `/health` | 健康检查 | 无 |
### 输出到 serve session 的格式
```
[老莫] 消息内容 → 来自微信的普通消息
[老莫|昵称] 消息内容 → 带昵称
[系统] 新联系人 xxx → 系统事件
[session:<id>] 启动签到 → Agent 上线通知
```
---
## 与 Hermes AI 的关系
Hermes 不再是架构的核心组件,而是可选的消息消费者之一:
```
┌─→ opencode serve session → Sisyphus (小小莫)
老莫微信 → Bridge ──┤
└─→ 消费 HTTP API 的任意程序
┌─────┴─────┐
│ │
Hermes AI 其他 AI
(莫荷) (未来)
```
**迁就策略**
1. 第一阶段:Bridge Agent 同时写 serve session + POST Hermes API(现状保留)
2. 第二阶段:Bridge Agent 只写 serve sessionHermes 通过 serve session 接入
3. 第三阶段:完全通用化,Hermes 只是 serve session 里的一个 AI 角色
---
## 实施原则
1. **不破坏现有链路** — Bridge Agent 改造期间,Hermes 消息路由不变
2. **增量迁移** — 先加新功能,再逐步替换旧逻辑
3. **session 为主** — 所有消息以 serve session 为中心,HTTP API 为辅助
4. **最低依赖** — 纯 Windows 可运行,不需要 Linux 端 Hermes
---
## 开放问题
1. **session ID 管理**Bridge Agent 如何知道当前有效的 session ID?
2. **session write 方式**subprocess (`opencode run --attach --message`) 是否有更轻量的替代?
3. **消息去重**:写 session + POST Hermes 可能导致重复处理?
4. **serve 重启恢复**Bridge Agent 如何在 serve 重启后自动重连?
5. **历史消息**AI 上线后能否拉取 session 中已有的消息历史?
---
*参考:projects/wechat-hermes-gateway/docs/老莫消息路由设计.md*
+39
View File
@@ -0,0 +1,39 @@
@echo off
title WeChat Agent
set PROJECT_DIR=D:\F\NewI\opencode\daily-workspace\projects\wechat-hermes-gateway
set TOOLS_DIR=%PROJECT_DIR%\tools
set PYTHONW=C:\Users\hmo\AppData\Local\Programs\Python\Python310\pythonw.exe
set INJECTOR=%TOOLS_DIR%\Injector_x64.exe
set DLL=%TOOLS_DIR%\wxhelper_official_39581.dll
set LOG=%PROJECT_DIR%\logs\startup.log
echo [1/4] Waiting for WeChat...
:wait_wechat
tasklist /fi "imagename eq WeChat.exe" 2>nul | find /i "WeChat.exe" >nul
if errorlevel 1 (
timeout /t 2 /nobreak >nul
goto wait_wechat
)
echo [2/4] WeChat started, checking wxhelper...
curl -s -m 3 -X POST http://127.0.0.1:19088/api/checkLogin -H "Content-Type: application/json" -d "{}" 2>nul | find "code" >nul
if not errorlevel 1 (
echo [3/4] wxhelper OK, skipping inject
goto start_agent
)
echo [3/4] Injecting wxhelper...
%INJECTOR% -n WeChat.exe -i "%DLL%" >> "%LOG%" 2>&1
echo [3/4] Waiting for wxhelper HTTP...
:wait_wxhelper
timeout /t 2 /nobreak >nul
curl -s -m 3 -X POST http://127.0.0.1:19088/api/checkLogin -H "Content-Type: application/json" -d "{}" 2>nul | find "code" >nul
if errorlevel 1 goto wait_wxhelper
:start_agent
echo [4/4] Clearing cache and starting agent...
if exist "%PROJECT_DIR%\scripts\__pycache__" rmdir /s /q "%PROJECT_DIR%\scripts\__pycache__"
start "" "%PYTHONW%" "%PROJECT_DIR%\scripts\wechat_agent.py"
echo Done.
+206 -27
View File
@@ -1,14 +1,14 @@
""" """
WeChat Agent v2 - wxhelper DLL + Hermes API (:8642) WeChat Agent v2 - wxhelper DLL + Hermes API (:8642)
""" """
import os, json, time, threading, requests, re, socketserver, subprocess, urllib.request, urllib.error import os, json, time, threading, requests, re, socketserver, subprocess, urllib.request, urllib.error, base64
os.environ["no_proxy"] = "*" os.environ["no_proxy"] = "*"
os.environ["NO_PROXY"] = "*" os.environ["NO_PROXY"] = "*"
from http.server import HTTPServer, BaseHTTPRequestHandler from http.server import HTTPServer, BaseHTTPRequestHandler
from urllib.parse import urlparse, parse_qs from urllib.parse import urlparse, parse_qs
BOT_WXID = "wxid_7onnerpx2s2l22" BOT_WXID = "wxid_7onnerpx2s2l22"
BLOCK_WXIDS = {"fmessage", "weixin", "wechat"} # 系统账号/微信团队,不回复 BLOCK_WXIDS = {"fmessage", "weixin", "wechat"} # ϵͳ˺Ŷӣظ
WX_API = "http://127.0.0.1:19088" WX_API = "http://127.0.0.1:19088"
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
LOG_DIR = os.path.join(PROJECT_ROOT, "logs") LOG_DIR = os.path.join(PROJECT_ROOT, "logs")
@@ -26,8 +26,8 @@ HERMES_KEY = "hermes123"
SENSENOVA_KEY = "sk-aRNj3UwKSLPsDfh15QNTPwbHxahblfaO" SENSENOVA_KEY = "sk-aRNj3UwKSLPsDfh15QNTPwbHxahblfaO"
SENSENOVA_URL = "https://token.sensenova.cn/v1" SENSENOVA_URL = "https://token.sensenova.cn/v1"
INJECTOR = r"D:\F\NewI\opencode\daily-workspace\projects\wechat-hermes-gateway\tools\ConsoleApplication.exe" INJECTOR = r"D:\F\NewI\opencode\daily-workspace\projects\wechat-hermes-gateway\tools\Injector_x64.exe"
WXHELPER_DLL = r"D:\F\NewI\opencode\daily-workspace\projects\wechat-hermes-gateway\tools\wxhelper_391019.dll" WXHELPER_DLL = r"D:\F\NewI\opencode\daily-workspace\projects\wechat-hermes-gateway\tools\wxhelper_official_39581.dll"
def log(m): def log(m):
with open(LOG_FILE, "a", encoding="utf-8") as f: with open(LOG_FILE, "a", encoding="utf-8") as f:
@@ -44,7 +44,7 @@ def wxpost(path, data=None, timeout=10):
log(f"WX ERR: {e}") log(f"WX ERR: {e}")
return {"code": -1} return {"code": -1}
# ── History Query (via MSG table in MSG*.db databases) ── # History Query (via MSG table in MSG*.db databases)
def get_db_handle(): def get_db_handle():
"""Get handle for database containing MSG table. Cached after first call.""" """Get handle for database containing MSG table. Cached after first call."""
global db_handle_cache global db_handle_cache
@@ -78,7 +78,7 @@ def get_db_handle():
return None return None
# Message type labels # Message type labels
MSG_TYPES = {1: "文字", 3: "图片", 34: "语音", 43: "视频", 47: "表情", 49: "链接", 10000: "系统", 10002: "红包"} MSG_TYPES = {1: "", 3: "ͼƬ", 34: "", 43: "Ƶ", 47: "", 49: "", 10000: "ϵͳ", 10002: ""}
def query_history(wxid, limit=10): def query_history(wxid, limit=10):
"""Query historical text messages with a contact from MSG table.""" """Query historical text messages with a contact from MSG table."""
@@ -110,7 +110,7 @@ def format_history(wxid, rows):
"""Format MSG rows into readable chat history text.""" """Format MSG rows into readable chat history text."""
sender_name = get_nickname(wxid) sender_name = get_nickname(wxid)
bot_name = get_nickname(BOT_WXID) bot_name = get_nickname(BOT_WXID)
lines = [f"📜 最近与 {sender_name} 的聊天记录 ({len(rows)}):"] lines = [f"?? {sender_name} ¼ ({len(rows)}):"]
for row in rows: for row in rows:
ts = int(row.get("CreateTime", 0)) ts = int(row.get("CreateTime", 0))
time_str = time.strftime("%m/%d %H:%M", time.localtime(ts)) if ts else "?" time_str = time.strftime("%m/%d %H:%M", time.localtime(ts)) if ts else "?"
@@ -121,7 +121,7 @@ def format_history(wxid, rows):
who = bot_name if is_sender else sender_name who = bot_name if is_sender else sender_name
# Format content # Format content
if msg_type == 49: if msg_type == 49:
content = f"[链接] {content[:60]}" content = f"[] {content[:60]}"
else: else:
content = content[:200] content = content[:200]
lines.append(f"[{time_str}] {who}: {content}") lines.append(f"[{time_str}] {who}: {content}")
@@ -133,10 +133,10 @@ def handle_history(wxid, count):
rows = query_history(wxid, count) rows = query_history(wxid, count)
if rows: if rows:
return format_history(wxid, rows) return format_history(wxid, rows)
return f"暂无与 {get_nickname(wxid)} 的聊天记录" return f" {get_nickname(wxid)} ¼"
except Exception as e: except Exception as e:
log(f"History ERR: {e}") log(f"History ERR: {e}")
return "查询历史记录失败" return "ѯʷ¼ʧ"
def handle_history_json(wxid, count): def handle_history_json(wxid, count):
"""Query history and return JSON-serializable dict for HTTP API.""" """Query history and return JSON-serializable dict for HTTP API."""
@@ -176,7 +176,7 @@ def handle_history_json(wxid, count):
def send_wx(wxid, msg): def send_wx(wxid, msg):
# Strip weixin:// URLs that WeChat interprets as commands # Strip weixin:// URLs that WeChat interprets as commands
import re as _re2 import re as _re2
msg = _re2.sub(r'weixin://[^\s]+', '[链接已过滤]', msg) msg = _re2.sub(r'weixin://[^\s]+', '[ѹ]', msg)
r = wxpost("/api/sendTextMsg", {"wxid": wxid, "msg": msg}) r = wxpost("/api/sendTextMsg", {"wxid": wxid, "msg": msg})
log(f"SEND {wxid}: {r.get('msg','')}") log(f"SEND {wxid}: {r.get('msg','')}")
@@ -195,7 +195,7 @@ def get_nickname(wxid):
def call_hermes(wxid, content): def call_hermes(wxid, content):
nickname = get_nickname(wxid) nickname = get_nickname(wxid)
headers = {"Authorization": f"Bearer {HERMES_KEY}", "X-Hermes-Session-Id": "sisyphus", "Content-Type": "application/json"} headers = {"Authorization": f"Bearer {HERMES_KEY}", "X-Hermes-Session-Id": "sisyphus", "Content-Type": "application/json"}
sys_prompt = "回复简短。" sys_prompt = "ظ̡"
body = {"model": "hermes-agent", "messages": [{"role": "system", "content": sys_prompt}, {"role": "user", "content": content}]} body = {"model": "hermes-agent", "messages": [{"role": "system", "content": sys_prompt}, {"role": "user", "content": content}]}
try: try:
r = requests.post(HERMES_API, json=body, headers=headers, timeout=180, proxies={"http": None, "https": None}) r = requests.post(HERMES_API, json=body, headers=headers, timeout=180, proxies={"http": None, "https": None})
@@ -208,7 +208,7 @@ def call_hermes(wxid, content):
def inject_to_hermes_session(text): def inject_to_hermes_session(text):
"""Inject chat history / context directly into Hermes's sisyphus session for memory repair.""" """Inject chat history / context directly into Hermes's sisyphus session for memory repair."""
headers = {"Authorization": f"Bearer {HERMES_KEY}", "X-Hermes-Session-Id": "sisyphus", "Content-Type": "application/json"} headers = {"Authorization": f"Bearer {HERMES_KEY}", "X-Hermes-Session-Id": "sisyphus", "Content-Type": "application/json"}
sys_prompt = "📥 MEMORY INJECTION: Below is past chat history. Absorb this into your context for memory repair. Do NOT reply to this just acknowledge with 'Memory synced.'" sys_prompt = "?? MEMORY INJECTION: Below is past chat history. Absorb this into your context for memory repair. Do NOT reply to this just acknowledge with 'Memory synced.'"
body = {"model": "hermes-agent", "messages": [ body = {"model": "hermes-agent", "messages": [
{"role": "system", "content": sys_prompt}, {"role": "system", "content": sys_prompt},
{"role": "user", "content": text} {"role": "user", "content": text}
@@ -224,7 +224,7 @@ def inject_to_hermes_session(text):
log(f"Inject history ERR: {e}") log(f"Inject history ERR: {e}")
return False return False
# ── Inject wxhelper DLL ── # Inject wxhelper DLL
def inject_wxhelper(): def inject_wxhelper():
try: try:
r = wxpost("/api/checkLogin", timeout=5) r = wxpost("/api/checkLogin", timeout=5)
@@ -233,9 +233,37 @@ def inject_wxhelper():
return True return True
except: except:
pass pass
# Also check if port 19088 is just listening (wxhelper HTTP server alive)
try: try:
result = subprocess.run([INJECTOR, "-i", "WeChat.exe", "-p", WXHELPER_DLL], capture_output=True, text=True, timeout=30) import socket as _sock
log(f"Inject: {result.stdout.strip()[:50]}") s = _sock.create_connection(("127.0.0.1", 19088), timeout=2)
s.close()
r = wxpost("/api/checkLogin", timeout=5)
if r.get("code") == 1:
log("wxhelper HTTP server alive, login OK")
return True
except:
pass
# Wait a moment in case server is still starting
time.sleep(3)
try:
r = wxpost("/api/checkLogin", timeout=5)
if r.get("code") == 1:
log("wxhelper responding after wait")
return True
except:
pass
try:
# Injector_x64.exe: -n process_name -i dll_path
result = subprocess.run([INJECTOR, "-n", "WeChat.exe", "-i", WXHELPER_DLL], capture_output=True, text=True, timeout=30)
output = (result.stdout + result.stderr).strip()
log(f"Inject: {output[:100]}")
# Check if injection succeeded by looking for "success" in output
if "success" not in output.lower():
log(f"Inject MAY HAVE FAILED (no 'success' in output), retrying...")
time.sleep(2)
result2 = subprocess.run([INJECTOR, "-n", "WeChat.exe", "-i", WXHELPER_DLL], capture_output=True, text=True, timeout=30)
log(f"Inject retry: {(result2.stdout+result2.stderr).strip()[:100]}")
time.sleep(3) time.sleep(3)
r = wxpost("/api/checkLogin", timeout=5) r = wxpost("/api/checkLogin", timeout=5)
if r.get("code") == 1: if r.get("code") == 1:
@@ -247,7 +275,7 @@ def inject_wxhelper():
log(f"Inject FAIL: {e}") log(f"Inject FAIL: {e}")
return False return False
# ── TCP Message Receiver ── # TCP Message Receiver
class MsgHandler(socketserver.BaseRequestHandler): class MsgHandler(socketserver.BaseRequestHandler):
def handle(self): def handle(self):
try: try:
@@ -265,6 +293,128 @@ class MsgHandler(socketserver.BaseRequestHandler):
finally: finally:
self.request.close() self.request.close()
# Image OCR
WX_FILES_BASE = os.path.join(os.path.expanduser("~"), "Documents", "WeChat Files")
BOT_WX_DIR = os.path.join(WX_FILES_BASE, BOT_WXID, "wxhelper")
def ocr_image(base64_data):
"""OCR from in-memory base64 image data. Returns text or None."""
try:
headers = {"Authorization": "Bearer b0359bed-09f2-49e2-a53c-32ba057412e3", "Content-Type": "application/json"}
payload = {
"model": "doubao-seed-code",
"messages": [{
"role": "user",
"content": [
{"type": "text", "text": "请识别这张图片中的所有中文和英文字符,保持原文输出,包括数字、表格、百分比的完整结构。严格逐行逐列输出所有数据,不要省略、不要总结。"},
{"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{base64_data}"}}
]
}]
}
r = requests.post(
"https://ark.cn-beijing.volces.com/api/coding/v3/chat/completions",
json=payload, headers=headers, timeout=60,
proxies={"http": None, "https": None}
)
if r.status_code == 200:
text = r.json()["choices"][0]["message"]["content"].strip()
log(f"OCR OK ({len(text)} chars)")
return text
log(f"OCR HTTP {r.status_code}: {r.text[:200]}")
except Exception as e:
log(f"OCR ERR: {e}")
return None
def ocr_image_file(image_path):
"""OCR an image file on disk. Returns text or None."""
try:
with open(image_path, "rb") as f:
b64 = base64.b64encode(f.read()).decode()
return ocr_image(b64)
except Exception as e:
log(f"ocr_image_file ERR: {e}")
return None
# Full Image Download & Decode (wxhelper 3.9.5.81+)
def download_full_image(msg_id):
"""Download full image from CDN via downloadAttach. Returns encrypted .dat path or None.
Retries both the API call (wxhelper may return -2 transiently)
and file existence (async CDN download takes time).
"""
try:
dat_path = os.path.join(BOT_WX_DIR, "image", f"{msg_id}.dat")
# Phase 1: Retry API call (wxhelper may return -2 if msg not ready)
for api_attempt in range(10):
r = wxpost("/api/downloadAttach", {"msgId": int(msg_id)}, timeout=30)
code = r.get("code", -1)
if code >= 0:
break
log(f"downloadAttach attempt {api_attempt+1}: code={code} {r.get('msg','')}")
time.sleep(1)
else:
log(f"downloadAttach FAILED after 10 attempts, last code={code}")
return None
# Phase 2: Wait for async CDN download
log(f"downloadAttach queued, waiting for file...")
for wait_attempt in range(20):
if os.path.exists(dat_path):
log(f"Download OK: {dat_path} ({os.path.getsize(dat_path)} bytes)")
return dat_path
time.sleep(1)
log(f"downloadAttach: .dat not found after 20s for msgId={msg_id}")
except Exception as e:
log(f"downloadAttach ERR: {e}")
return None
def decode_image_file(dat_path):
"""Decrypt encrypted .dat to viewable image. Returns decoded path or None.
Some .dat files are already valid PNG/JPEG images (not encrypted).
Falls back to checking if .dat itself is a valid image.
"""
try:
before_files = set(os.listdir(TEMP_DIR))
r = wxpost("/api/decodeImage", {"filePath": dat_path, "storeDir": TEMP_DIR}, timeout=30)
if r.get("code", -1) > 0:
base = os.path.splitext(os.path.basename(dat_path))[0]
for ext in ['.jpg', '.jpeg', '.png', '.bmp']:
cand = os.path.join(TEMP_DIR, base + ext)
if os.path.exists(cand):
log(f"Decoded: {cand}")
return cand
for f in os.listdir(TEMP_DIR):
if f in before_files: continue
if f.lower().endswith(('.jpg', '.jpeg', '.png')):
cand = os.path.join(TEMP_DIR, f)
log(f"Decoded (new): {cand}")
return cand
log("decodeImage OK but no new image file found")
# Fallback: .dat file may already be a valid image (not encrypted)
with open(dat_path, "rb") as f:
header = f.read(4)
ext = None
if header[:2] == b'\xff\xd8': # JPEG
ext = '.jpg'
elif header[:4] == b'\x89PNG': # PNG
ext = '.png'
elif header[:4] == b'GIF8': # GIF
ext = '.gif'
elif header[:2] == b'BM': # BMP
ext = '.bmp'
if ext:
out_path = os.path.join(TEMP_DIR, os.path.splitext(os.path.basename(dat_path))[0] + ext)
import shutil
shutil.copy(dat_path, out_path)
log(f".dat is already {ext}, copied to {out_path}")
return out_path
log(f"decodeImage FAIL: code={r.get('code')} {r.get('msg','')}")
except Exception as e:
log(f"decodeImage ERR: {e}")
return None
def process_msg(raw_data): def process_msg(raw_data):
global last_msg_time global last_msg_time
last_msg_time = time.time() last_msg_time = time.time()
@@ -278,24 +428,52 @@ def process_msg(raw_data):
if not fu or not ct or fu == BOT_WXID or fu in BLOCK_WXIDS or fu.startswith("gh_") or is_self: if not fu or not ct or fu == BOT_WXID or fu in BLOCK_WXIDS or fu.startswith("gh_") or is_self:
log(f"SKIP: fu={fu} self={is_self}") log(f"SKIP: fu={fu} self={is_self}")
return return
# Route by message type # Route by message type
if msg_type == 34: # Voice if msg_type == 34: # Voice
log(f"<- {fu}: [voice]") log(f"<- {fu}: [voice]")
reply = call_hermes(fu, "[voice message]") reply = call_hermes(fu, "[voice message]")
if reply: send_wx(fu, reply) if reply and reply.strip():
send_wx(fu, reply.strip())
return return
if msg_type == 3: # Image - wxhelper sends image as separate event if msg_type == 3: # Image
msg_id = d.get("msgId", 0) or d.get("svrid", 0)
log(f"IMAGE: msgId={msg_id} b64_len={len(d.get('base64Img',''))}")
ocr_text = None
# Full-image OCR via wxhelper 3.9.5.81 APIs
if msg_id:
dat_path = download_full_image(msg_id)
if dat_path:
decoded = decode_image_file(dat_path)
if decoded:
log(f"Full image OCR on {decoded}")
ocr_text = ocr_image_file(decoded)
if ocr_text:
log(f"OCR result ({len(ocr_text)} chars): {ocr_text[:200]}")
reply = call_hermes(fu, f"[老莫发送了一张图片,OCR识别结果如下]\n{ocr_text}")
elif msg_id:
# Had msgId but full-image OCR failed - report error, don't use thumbnail
log("Full-image OCR failed, skipping thumbnail (useless at 84x210)")
reply = call_hermes(fu, "[老莫发送了一张图片,但全尺寸图片下载或OCR识别失败,无法读取内容]")
else:
# No msgId at all - rare, just report failure
log("No msgId available, cannot download full image")
reply = call_hermes(fu, "[老莫发送了一张图片,但无法获取图片ID,无法识别]")
if reply and reply.strip():
log(f"-> {fu}: {reply[:50]}")
process_tags(reply, fu)
else:
log(f"-> {fu}: skip (blank image response)")
return return
# Text - prepend sender wxid+name so Hermes knows who's talking # Text - prepend sender wxid+name so Hermes knows who's talking
sender_name = get_nickname(fu) sender_name = get_nickname(fu)
msg_with_sender = f"[{fu}|{sender_name}] {ct}" msg_with_sender = f"[{fu}|{sender_name}] {ct}"
log(f"<- {fu} ({sender_name}): {ct[:50]}") log(f"<- {fu} ({sender_name}): {ct[:50]}")
reply = call_hermes(fu, msg_with_sender) reply = call_hermes(fu, msg_with_sender)
if reply: if reply and reply.strip():
log(f"-> {fu}: {reply[:50]}") log(f"-> {fu}: {reply[:50]}")
process_tags(reply, fu) process_tags(reply, fu)
else: else:
log(f"-> {fu}: no reply") log(f"-> {fu}: no reply (blank/empty)")
except Exception as e: except Exception as e:
log(f"MSG ERR: {e}") log(f"MSG ERR: {e}")
import traceback import traceback
@@ -325,7 +503,7 @@ def process_tags(reply, fu):
clean = re.sub(r'\s*\[CONTACT:\w+\]\s*', '', clean).strip() clean = re.sub(r'\s*\[CONTACT:\w+\]\s*', '', clean).strip()
r = wxpost("/api/getContactProfile", {"wxid": cm.group(1)}) r = wxpost("/api/getContactProfile", {"wxid": cm.group(1)})
cd = r.get("data", {}) cd = r.get("data", {})
send_wx(fu, f"昵称: {cd.get('nickname','?')} 备注: {cd.get('remark','')}") send_wx(fu, f"dz: {cd.get('nickname','?')} ע: {cd.get('remark','')}")
# [ROOM_MEMBERS:roomid] # [ROOM_MEMBERS:roomid]
rm = re.search(r'\[ROOM_MEMBERS:(\S+)\]', clean) rm = re.search(r'\[ROOM_MEMBERS:(\S+)\]', clean)
if rm: if rm:
@@ -333,7 +511,7 @@ def process_tags(reply, fu):
r = wxpost("/api/getMemberFromChatRoom", {"chatRoomId": rm.group(1)}) r = wxpost("/api/getMemberFromChatRoom", {"chatRoomId": rm.group(1)})
members = (r.get("data") or {}).get("members", "") members = (r.get("data") or {}).get("members", "")
mlist = [m for m in members.split("\u0007") if m] mlist = [m for m in members.split("\u0007") if m]
send_wx(fu, f"群成员 ({len(mlist)}): {','.join(mlist[:20])}") send_wx(fu, f"ȺԱ ({len(mlist)}): {','.join(mlist[:20])}")
# [HISTORY:wxid:count] - query chat history from MSG table # [HISTORY:wxid:count] - query chat history from MSG table
hm = re.search(r'\[HISTORY:(\S+?):(\d+)\]', clean) hm = re.search(r'\[HISTORY:(\S+?):(\d+)\]', clean)
if hm: if hm:
@@ -400,7 +578,7 @@ def download_emoji(m, fu):
wxpost("/api/sendCustomEmotion", {"wxid": fu, "filePath": tmp}) wxpost("/api/sendCustomEmotion", {"wxid": fu, "filePath": tmp})
os.remove(tmp) os.remove(tmp)
# ── Watchdog ── # Watchdog
def watchdog(): def watchdog():
global last_msg_time global last_msg_time
while True: while True:
@@ -419,7 +597,7 @@ def watchdog():
last_msg_time = time.time() last_msg_time = time.time()
time.sleep(30) time.sleep(30)
# ── Start ── # Start
print("[Agent] starting...", flush=True) print("[Agent] starting...", flush=True)
log("=== Agent v2 (wxhelper) ===") log("=== Agent v2 (wxhelper) ===")
@@ -519,3 +697,4 @@ try:
time.sleep(1) time.sleep(1)
except KeyboardInterrupt: except KeyboardInterrupt:
log("Bye") log("Bye")
Binary file not shown.
Binary file not shown.