v2: project cleanup, Desktop paths fixed, README updated, serve daemon added, mohe-xxm protocol documented
This commit is contained in:
@@ -11,19 +11,19 @@ Windows 微信机器人 ↔ Linux Hermes AI,全自动双向聊天。
|
||||
│ Windows 192.168.0.111 │
|
||||
│ │
|
||||
│ ┌──────────────────┐ ┌───────────────────────────┐ │
|
||||
│ │ 微信 3.9.5.81 x64 │ │ 日常微信 WeChatAppEx 4.x │ │
|
||||
│ │ 机器人号modachen │ │ 老爸日常使用,互不干扰 │ │
|
||||
│ │ wxhook Bot类TCP │ └───────────────────────────┘ │
|
||||
│ │ 微信 3.9.10.19 x64 │ │ 日常微信 WeChatAppEx 4.x │ │
|
||||
│ │ 机器人号 modachen │ │ 老爸日常使用,互不干扰 │ │
|
||||
│ │ wxhelper DLL 注入 │ └───────────────────────────┘ │
|
||||
│ └────────┬─────────┘ │
|
||||
│ │ wxhook DLL 收消息 │
|
||||
│ │ wxhelper TCP (:19099) 收消息 │
|
||||
│ ┌────────▼────────────────┐ │
|
||||
│ │ wechat_agent.py │ ← 常驻进程 │
|
||||
│ │ wechat_agent.py v2 │ ← 常驻进程 │
|
||||
│ │ │ │
|
||||
│ │ Bot类接收微信事件(TCP) │ │
|
||||
│ │ → POST Hermes API :8642 │ ← 带session: sisyphus │
|
||||
│ │ ← 收回复 → wxhook发回 │ │
|
||||
│ │ :5801 ←Hermes找小小莫 │ ← 双向通道 │
|
||||
│ │ 看门狗防 wxhook 挂掉 │ │
|
||||
│ │ TCP 接收微信事件 │ │
|
||||
│ │ → POST Hermes API :8642 │ ← sisyphus session │
|
||||
│ │ ← 收回复 → wxhelper 发 │ │
|
||||
│ │ HTTP :19088 收发消息 │ │
|
||||
│ │ 看门狗自愈 │ │
|
||||
│ └────────┬────────────────┘ │
|
||||
└───────────┼─────────────────────────────────────────────────┘
|
||||
│ HTTP (局域网)
|
||||
@@ -42,18 +42,53 @@ Windows 微信机器人 ↔ Linux Hermes AI,全自动双向聊天。
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## 双向通道:小小莫 ↔ 莫荷
|
||||
|
||||
小小莫(Sisyphus)和莫荷(Hermes)通过 OpenCode session 实现双向沟通,不依赖微信。
|
||||
|
||||
### 通信方式
|
||||
|
||||
```
|
||||
莫荷 --run --attach--> session (serve :4096)
|
||||
│
|
||||
小小莫 --session_search--> 读取莫荷消息
|
||||
小小莫 --TUI 回复--> session(以 [xxm] 开头)
|
||||
老莫 --询问小小莫--> 得知对话内容
|
||||
```
|
||||
|
||||
### 协议
|
||||
|
||||
| 前缀 | 发送者 | 说明 |
|
||||
|------|--------|------|
|
||||
| `[mohe]` | 莫荷 | 通过 `run --attach` 写入 session |
|
||||
| `[xxm]` | 小小莫 | 在 TUI 中回复莫荷时使用 |
|
||||
|
||||
### 数据流
|
||||
|
||||
```
|
||||
莫荷想找小小莫
|
||||
→ Linux 执行 opencode run --attach http://192.168.0.111:4096
|
||||
→ 发消息带 [mohe] 前缀
|
||||
→ 写入 Windows 的 opencode serve session
|
||||
→ 小小莫通过 session_search 查到新消息
|
||||
→ 在 TUI 中以 [xxm] 前缀回复
|
||||
→ 莫荷可以通过 export/session 读到回复
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 数据流
|
||||
|
||||
### 文字消息
|
||||
|
||||
```
|
||||
老爸发微信
|
||||
→ WeChat 3.9.5.81 收到
|
||||
→ wxhook DLL TCP → Bot 类 → on_msg 处理器
|
||||
→ WeChat 3.9.10.19 收到
|
||||
→ wxhelper DLL TCP (:19099) 通知
|
||||
→ wechat_agent.py POST Hermes API (:8642)
|
||||
→ X-Hermes-Session-Id: sisyphus (固定)
|
||||
→ Hermes 处理 → 返回回复
|
||||
→ wechat_agent.py 收回复 → wxhook API 发回
|
||||
→ wechat_agent.py 收回复 → wxhelper API (:19088) 发回
|
||||
→ 老爸手机收到
|
||||
```
|
||||
|
||||
@@ -61,7 +96,7 @@ Windows 微信机器人 ↔ Linux Hermes AI,全自动双向聊天。
|
||||
|
||||
```
|
||||
老爸发图片
|
||||
→ WeChat 收到 → wxhook IMAGE_MESSAGE 事件
|
||||
→ WeChat 收到 → wxhelper 图片事件
|
||||
→ wechat_agent.py 保存图片 → 调豆包OCR (VolcEngine)
|
||||
→ OCR 文字结果 + 通知 → POST Hermes API
|
||||
→ Hermes 知道图片内容 → 回复老爸
|
||||
@@ -86,19 +121,22 @@ Hermes → POST http://192.168.0.111:5801/hermes-msg
|
||||
|
||||
| 端口 | 用途 | 所在 |
|
||||
|------|------|------|
|
||||
| 19001 | wxhook HTTP API | Windows |
|
||||
| 19088 | wxhelper HTTP API (收发消息) | Windows |
|
||||
| 19099 | wxhelper TCP 事件推送 | Windows |
|
||||
| 5801 | Hermes→小小莫 消息入口 | Windows |
|
||||
| 8642 | Hermes API Server (OpenAI兼容) | Linux |
|
||||
| 5800 | bridge.py (已废弃) | Linux |
|
||||
| 19001 | History REST API (独立启动) | Windows |
|
||||
|
||||
## 组件
|
||||
|
||||
### Windows 端(wechat_agent.py)
|
||||
- **wxhook Bot 类** — DLL 注入 + TCP 收消息
|
||||
### Windows 端(wechat_agent.py v2)
|
||||
- **wxhelper DLL 注入** — ttttupup/wxhelper 3.9.10.19 x64
|
||||
- **TCP 接收消息** — :19099 收微信事件
|
||||
- **HTTP 发送消息** — :19088 wxhelper API
|
||||
- **Hermes API 调用** — 直接 POST :8642,session 固定 `sisyphus`
|
||||
- **回复服务** — 5801 端口收 Hermes 消息
|
||||
- **看门狗** — 2 分钟无消息自动刷新 webhook;API 挂了才重注入 DLL
|
||||
- **昵称缓存** — 从 wxhook getContactList 获取联系人昵称
|
||||
- **看门狗** — 120s 无消息刷新 webhook;API 挂了才重注入 DLL
|
||||
- **双向通道** — 莫荷通过 `opencode run --attach` 与小小莫沟通
|
||||
|
||||
### Linux 端(Hermes Gateway)
|
||||
- **API Server** — 0.0.0.0:8642,Bearer auth
|
||||
@@ -142,7 +180,7 @@ curl http://127.0.0.1:8642/v1/models
|
||||
| 方向 | 方式 | 示例 |
|
||||
|------|------|------|
|
||||
| 小小莫 → Hermes | POST :8642/v1/chat/completions | 带 `X-Hermes-Session-Id: sisyphus` |
|
||||
| Hermes → 小小莫 | POST :5801/hermes-msg | 写入 `C:\Users\hmo\Desktop\hermes_inbox.txt` |
|
||||
| Hermes → 小小莫 | POST :5801/hermes-msg | 写入 `temp/hermes_inbox.txt` |
|
||||
| 老爸 ↔ Hermes | 微信聊天 | 自动通过 wechat_agent.py 桥接 |
|
||||
|
||||
## 项目文件
|
||||
@@ -150,12 +188,155 @@ curl http://127.0.0.1:8642/v1/models
|
||||
```
|
||||
wechat-hermes-gateway/
|
||||
├── README.md # 本文档
|
||||
├── api/
|
||||
│ └── history_api.py # History REST API :19001
|
||||
├── scripts/
|
||||
│ ├── wechat_agent.py # 主力:微信机器人代理
|
||||
│ └── start_bridge.bat # 一键启动脚本
|
||||
│ ├── start_bridge.bat # 微信桥接一键启动
|
||||
│ ├── start_history_api.bat # History API 一键启动
|
||||
│ ├── moho_view.py # 莫荷聊天记录查看器
|
||||
│ └── moho_chat.py # 莫荷聊天查看器(备选)
|
||||
└── temp/ # 废弃/临时脚本
|
||||
```
|
||||
|
||||
## History REST API (:19001)
|
||||
|
||||
独立的 HTTP REST API 服务器,可以直接查询微信聊天记录。
|
||||
|
||||
### 启动方式
|
||||
|
||||
```batch
|
||||
cd D:\F\NewI\opencode\daily-workspace\projects\wechat-hermes-gateway
|
||||
scripts\start_history_api.bat
|
||||
```
|
||||
|
||||
或:
|
||||
|
||||
```powershell
|
||||
$env:PYTHONHOME=''
|
||||
python api\history_api.py --port 19001
|
||||
```
|
||||
|
||||
**前提条件**: `wechat_agent.py` 已启动,wxhelper DLL 已注入。
|
||||
|
||||
### API 端点
|
||||
|
||||
| 方法 | 路径 | 说明 | 参数 |
|
||||
|------|------|------|------|
|
||||
| GET | `/` | API 信息 | - |
|
||||
| GET | `/health` | 健康检查(含 wxhelper 状态) | - |
|
||||
| GET | `/api/contacts` | 所有联系人列表 | - |
|
||||
| GET | `/api/recent` | 最近聊天列表 | `?limit=20` |
|
||||
| GET | `/api/history` | 查询聊天记录 | `?wxid=wxid_xxx&count=20` |
|
||||
| POST | `/api/history` | 同上(JSON body) | `{"wxid":"wxid_xxx","count":20}` |
|
||||
|
||||
### 响应格式
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"wxid": "wxid_c0a6izmwd78y22",
|
||||
"sender_name": "莫语不语",
|
||||
"count": 5,
|
||||
"messages": [
|
||||
{
|
||||
"time": "2026-05-19 10:30:00",
|
||||
"timestamp": 1716153000,
|
||||
"sender": "莫语不语",
|
||||
"is_self": false,
|
||||
"type": 1,
|
||||
"type_name": "text",
|
||||
"content": "消息内容..."
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 典型用法
|
||||
|
||||
```powershell
|
||||
# 获取老爸的最近聊天记录
|
||||
curl http://localhost:19001/api/history?wxid=wxid_c0a6izmwd78y22&count=20
|
||||
|
||||
# 获取联系人列表(人类可读)
|
||||
curl http://localhost:19001/api/contacts
|
||||
|
||||
# 获取最近活跃的聊天
|
||||
curl http://localhost:19001/api/recent?limit=10
|
||||
|
||||
# POST 方式
|
||||
curl -X POST http://localhost:19001/api/history -H "Content-Type: application/json" -d '{"wxid":"wxid_c0a6izmwd78y22","count":50}'
|
||||
```
|
||||
|
||||
## History REST API (:19001)
|
||||
|
||||
提供直接 HTTP REST 接口查询微信历史聊天记录,供程序化读取和记忆系统使用。
|
||||
|
||||
### 启动
|
||||
|
||||
```batch
|
||||
cd D:\F\NewI\opencode\daily-workspace\projects\wechat-hermes-gateway
|
||||
scripts\start_history_api.bat
|
||||
```
|
||||
|
||||
或直接:
|
||||
|
||||
```powershell
|
||||
$env:PYTHONHOME=''
|
||||
python api\history_api.py --port 19001
|
||||
```
|
||||
|
||||
> 依赖:需要 `wechat_agent.py` 先启动(微信已登录 + wxhelper DLL 已注入),因为 API 通过 wxhelper (:19088) 查询数据库。
|
||||
|
||||
### API 端点
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET | `/` | API 信息 |
|
||||
| GET | `/health` | 健康检查(含 wxhelper 连接状态) |
|
||||
| GET | `/api/contacts` | 获取所有微信联系人列表 |
|
||||
| GET | `/api/history?wxid=X&count=20` | 查询与某联系人的聊天记录 |
|
||||
| POST | `/api/history` | 同上,JSON body: `{"wxid":"X","count":20}` |
|
||||
| GET | `/api/recent` | 最近有消息的联系人列表 |
|
||||
|
||||
### 响应格式
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"wxid": "wxid_c0a6izmwd78y22",
|
||||
"sender_name": "莫语不语",
|
||||
"count": 20,
|
||||
"messages": [
|
||||
{
|
||||
"time": "2026-05-19 10:30:00",
|
||||
"timestamp": 1716153000,
|
||||
"sender": "莫语不语",
|
||||
"is_self": false,
|
||||
"type": 1,
|
||||
"type_name": "text",
|
||||
"content": "今天吃了吗"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### curl 示例
|
||||
|
||||
```bash
|
||||
# 健康检查
|
||||
curl http://localhost:19001/health
|
||||
|
||||
# 获取联系人列表
|
||||
curl http://localhost:19001/api/contacts
|
||||
|
||||
# 查询老爸聊天记录
|
||||
curl "http://localhost:19001/api/history?wxid=wxid_c0a6izmwd78y22&count=20"
|
||||
|
||||
# POST 方式
|
||||
curl -X POST http://localhost:19001/api/history -H "Content-Type: application/json" -d '{"wxid":"wxid_c0a6izmwd78y22","count":10}'
|
||||
```
|
||||
|
||||
## 历史决策
|
||||
|
||||
1. **wxhook HTTP webhook 不可靠** → 改用 Bot 类 TCP 收消息
|
||||
@@ -178,18 +359,19 @@ wechat-hermes-gateway/
|
||||
| Hermes 身份认知 | ✅ 知道自己是莫荷/莫小荷,知道老爸 |
|
||||
| 会话上下文持续 | ✅ session `sisyphus`,自动重置已关闭 |
|
||||
| 小小莫 ↔ Hermes 双向通信 | ✅ API (:8642) + HTTP (:5801/hermes-msg) |
|
||||
| wxhook 看门狗自愈 | ✅ 2分钟无消息刷新 webhook |
|
||||
| 看门狗自愈 | ✅ 120s 无消息刷新 webhook |
|
||||
| 昵称识别 | ✅ 从 getContactList 获取 |
|
||||
| 联系人列表查询 | ✅ wxhook /api/getContactList |
|
||||
| 联系人列表查询 | ✅ wxhelper /api/getContactList |
|
||||
| 历史聊天记录查询 | ✅ [HISTORY:wxid:count] 标签 → MSG0.db SQL |
|
||||
|
||||
## 未实现 / 不可行
|
||||
|
||||
| 功能 | 原因 |
|
||||
|------|------|
|
||||
| 语音消息(STT) | wxhook 不支持语音提取 |
|
||||
| 发送本地图片/文件 | bot.send_image API 已通,回复链路待完善 |
|
||||
| 换头像/改资料 | wxhook 无相关 API |
|
||||
| 群管理 | wxhook 群 API 有限 |
|
||||
| 语音消息(STT) | wxhelper 不支持语音提取 |
|
||||
| 发送本地图片/文件 | 功能已通,回复链路待完善 |
|
||||
| 换头像/改资料 | wxhelper 无相关 API |
|
||||
| 群管理 | wxhelper 群 API 有限 |
|
||||
| iLink 官方 bot 接口 | 限制太多,弃用 |
|
||||
| 多人独立会话 | 目前全部共享 `sisyphus` 单会话 |
|
||||
|
||||
@@ -197,24 +379,22 @@ wechat-hermes-gateway/
|
||||
|
||||
### 场景:Windows 重启
|
||||
|
||||
需要 3 步,**顺序不能错**:
|
||||
按顺序执行:
|
||||
|
||||
```
|
||||
第1步:双击 start_bridge.bat
|
||||
→ 自动杀旧微信 → Bot 类启动新微信 + 注入 DLL
|
||||
→ 等待微信窗口出现
|
||||
1. 双击 start-opencode-serve.bat(项目根目录)
|
||||
→ 启动 opencode serve(:4096,莫荷连接用)
|
||||
→ 启动后台守护(自动清理僵尸连接)
|
||||
|
||||
第2步:运行修复过低工具
|
||||
→ 选择修复过低6.0\低版通用杀器.sp.exe
|
||||
→ 自动扫描到已运行的微信 → 打补丁 → 弹出登录二维码
|
||||
2. 打开经典微信 3.9.10.19
|
||||
→ 扫码登录机器人号 modachenchen
|
||||
|
||||
第3步:手机扫码登录
|
||||
→ 登录后 wechat_agent.py 自动检测到登录状态
|
||||
→ 开始转发消息
|
||||
→ 给 filehelper 发 "[Bridge] online" 确认
|
||||
3. wechat_agent.py 在登录后自动注入 DLL
|
||||
→ 自动开始转发消息
|
||||
→ 日志在 projects/wechat-hermes-gateway/logs/
|
||||
```
|
||||
|
||||
验证:手机发条消息给 modachenchen,看能否收到自动回复。
|
||||
验证:微信上给 modachenchen 发条消息,看 Hermes 是否回复。
|
||||
|
||||
### 场景:Linux 重启
|
||||
|
||||
@@ -251,13 +431,13 @@ curl http://192.168.0.103:8642/v1/models -H "Authorization: Bearer hermes123"
|
||||
|------|------|
|
||||
| Gateway 偶尔 hang | 已修复 --replace 冲突,改用 systemd 管理 |
|
||||
| 生图 API 有时较慢 | 商汤 SenseNova,首次调用需加载模型 |
|
||||
| 语音转文字 | wxhook 不支持语音提取,暂不可行 |
|
||||
| 语音转文字 | wxhelper 不支持语音提取,暂不可行 |
|
||||
|
||||
## 注意事项
|
||||
|
||||
- wxhook DLL 仅支持 x64 微信 3.9.5.81
|
||||
- 每次 WeChat 重启需重新登录(修复过低工具)
|
||||
- **start_bridge.bat 必须第 1 步执行**,修复工具第 2 步
|
||||
- wxhelper DLL 支持 3.9.10.19 x64 微信
|
||||
- 每次 WeChat 重启需重新登录
|
||||
- 启动顺序:先开微信 → agent 自动注入 DLL
|
||||
- Hermes API 首次调用可能较慢(大模型冷启动)
|
||||
- 看门狗只刷新 webhook,不会误伤正常消息处理
|
||||
- 如果微信登录后没反应,等 1-2 分钟看门狗会自动刷新
|
||||
- 看门狗每 120s 刷新 webhook,API 挂了自动重注入
|
||||
- 如果微信登录后没反应,等 1-2 分钟看门狗会自动处理
|
||||
|
||||
@@ -0,0 +1,378 @@
|
||||
"""
|
||||
WeChat History REST API Server
|
||||
Starts on port 19001. Queries WeChat chat history via wxhelper DLL (http://127.0.0.1:19088).
|
||||
|
||||
Usage:
|
||||
python history_api.py # start on 0.0.0.0:19001
|
||||
python history_api.py --port 19001 # explicit port
|
||||
|
||||
Endpoints:
|
||||
GET / → API info
|
||||
GET /health → health check
|
||||
GET /api/contacts → list WeChat contacts (wxid + nickname)
|
||||
GET /api/history?wxid=wxid_xxx&count=20 → query chat history
|
||||
POST /api/history → same via JSON body {"wxid":"...","count":20}
|
||||
|
||||
Requires: wxhelper DLL injected and WeChat running (wechat_agent.py handles this).
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import time
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
from http.server import HTTPServer, BaseHTTPRequestHandler
|
||||
from urllib.parse import urlparse, parse_qs
|
||||
from datetime import datetime
|
||||
|
||||
os.environ["no_proxy"] = "*"
|
||||
os.environ["NO_PROXY"] = "*"
|
||||
|
||||
# ── Configuration ──
|
||||
WX_API = "http://127.0.0.1:19088"
|
||||
DEFAULT_PORT = 19001
|
||||
BOT_WXID = "wxid_7onnerpx2s2l22"
|
||||
HOST = "0.0.0.0"
|
||||
|
||||
# ── Cached state ──
|
||||
nickname_cache = {}
|
||||
db_handle_cache = None
|
||||
|
||||
# ── wxhelper API helpers ──
|
||||
|
||||
def wxpost(path, data=None, timeout=10):
|
||||
"""Call wxhelper HTTP API."""
|
||||
try:
|
||||
body = json.dumps(data or {}).encode()
|
||||
req = urllib.request.Request(
|
||||
WX_API + path,
|
||||
data=body,
|
||||
headers={"Content-Type": "application/json"}
|
||||
)
|
||||
r = urllib.request.urlopen(req, timeout=timeout)
|
||||
return json.loads(r.read().decode())
|
||||
except urllib.error.HTTPError as e:
|
||||
return json.loads(e.read().decode()) if e.code else {"code": -1}
|
||||
except Exception as e:
|
||||
return {"code": -1, "error": str(e)[:200]}
|
||||
|
||||
|
||||
def get_db_handle():
|
||||
"""Get handle for MSG*.db database containing MSG table. Cached after first call."""
|
||||
global db_handle_cache
|
||||
if db_handle_cache:
|
||||
return db_handle_cache
|
||||
r = wxpost("/api/getDBInfo", timeout=10)
|
||||
dbs = r.get("data") or []
|
||||
for db in dbs:
|
||||
dbname = db.get("databaseName", "")
|
||||
if "MSG" in dbname or "Msg" in dbname:
|
||||
db_handle_cache = db.get("handle")
|
||||
return db_handle_cache
|
||||
return None
|
||||
|
||||
|
||||
def get_nickname(wxid):
|
||||
"""Get contact nickname from wxid, with caching."""
|
||||
if wxid in nickname_cache:
|
||||
return nickname_cache[wxid]
|
||||
r = wxpost("/api/getContactList", timeout=10)
|
||||
for c in (r.get("data") or []):
|
||||
if c.get("wxid") == wxid:
|
||||
nick = c.get("nickname") or c.get("customAccount") or wxid
|
||||
nickname_cache[wxid] = nick
|
||||
return nick
|
||||
nickname_cache[wxid] = wxid
|
||||
return wxid
|
||||
|
||||
|
||||
def query_history(wxid, limit=10):
|
||||
"""Query historical text messages from MSG table for a contact.
|
||||
Returns list of dicts: [{CreateTime, IsSender, Type, content}, ...]"""
|
||||
h = get_db_handle()
|
||||
if not h:
|
||||
return None
|
||||
limit_val = min(int(limit), 200)
|
||||
sql = (
|
||||
f"SELECT CreateTime, IsSender, Type, SubType, StrContent, DisplayContent "
|
||||
f"FROM MSG WHERE StrTalker='{wxid}' AND Type IN (1,49) "
|
||||
f"ORDER BY CreateTime DESC LIMIT {limit_val}"
|
||||
)
|
||||
r = wxpost("/api/execSql", {"dbHandle": h, "sql": sql}, timeout=15)
|
||||
data = r.get("data") or []
|
||||
if not data or len(data) < 2:
|
||||
return None
|
||||
# Skip header row, reverse to chronological order
|
||||
rows = data[1:]
|
||||
rows.reverse()
|
||||
results = []
|
||||
for row in rows:
|
||||
content = (row[4] or "").strip() if len(row) > 4 else ""
|
||||
if not content and len(row) > 5:
|
||||
content = (row[5] or "").strip()
|
||||
if not content:
|
||||
continue
|
||||
results.append({
|
||||
"CreateTime": row[0],
|
||||
"IsSender": row[1],
|
||||
"Type": row[2],
|
||||
"content": content
|
||||
})
|
||||
return results
|
||||
|
||||
|
||||
def format_history_json(wxid, rows):
|
||||
"""Format raw MSG rows into JSON-serializable dict for API response."""
|
||||
sender_name = get_nickname(wxid)
|
||||
bot_name = get_nickname(BOT_WXID)
|
||||
if not rows:
|
||||
return {
|
||||
"ok": True, "wxid": wxid, "sender_name": sender_name,
|
||||
"count": 0, "messages": []
|
||||
}
|
||||
messages = []
|
||||
for row in rows:
|
||||
ts = int(row.get("CreateTime", 0))
|
||||
time_str = datetime.fromtimestamp(ts).strftime("%Y-%m-%d %H:%M:%S") if ts else ""
|
||||
is_sender = int(row.get("IsSender", 0))
|
||||
msg_type = int(row.get("Type", 1))
|
||||
content = row.get("content", "")
|
||||
messages.append({
|
||||
"time": time_str,
|
||||
"timestamp": ts,
|
||||
"sender": bot_name if is_sender else sender_name,
|
||||
"is_self": bool(is_sender),
|
||||
"type": msg_type,
|
||||
"type_name": {1: "text", 49: "link"}.get(msg_type, f"type_{msg_type}"),
|
||||
"content": content[:500]
|
||||
})
|
||||
return {
|
||||
"ok": True,
|
||||
"wxid": wxid,
|
||||
"sender_name": sender_name,
|
||||
"count": len(messages),
|
||||
"messages": messages
|
||||
}
|
||||
|
||||
|
||||
def get_contacts():
|
||||
"""Get all contacts from WeChat."""
|
||||
r = wxpost("/api/getContactList", timeout=10)
|
||||
contacts = r.get("data") or []
|
||||
# Update cache
|
||||
for c in contacts:
|
||||
wxid = c.get("wxid", "")
|
||||
nick = c.get("nickname") or c.get("customAccount") or wxid
|
||||
nickname_cache[wxid] = nick
|
||||
return [
|
||||
{
|
||||
"wxid": c.get("wxid", ""),
|
||||
"nickname": c.get("nickname", ""),
|
||||
"remark": c.get("remark", ""),
|
||||
"customAccount": c.get("customAccount", ""),
|
||||
}
|
||||
for c in contacts
|
||||
]
|
||||
|
||||
|
||||
def get_recent_chats(limit=20):
|
||||
"""Get list of contacts with recent messages."""
|
||||
h = get_db_handle()
|
||||
if not h:
|
||||
return []
|
||||
sql = (
|
||||
f"SELECT StrTalker, MAX(CreateTime) as last_time, COUNT(*) as msg_count "
|
||||
f"FROM MSG WHERE Type IN (1,49) "
|
||||
f"GROUP BY StrTalker ORDER BY last_time DESC LIMIT {min(limit, 50)}"
|
||||
)
|
||||
r = wxpost("/api/execSql", {"dbHandle": h, "sql": sql}, timeout=15)
|
||||
data = r.get("data") or []
|
||||
if not data or len(data) < 2:
|
||||
return []
|
||||
results = []
|
||||
for row in data[1:]:
|
||||
wxid = (row[0] or "").strip()
|
||||
if not wxid or wxid in ("fmessage", "weixin", "wechat", "filehelper"):
|
||||
continue
|
||||
if wxid.startswith("gh_"):
|
||||
continue
|
||||
ts = int(row[1]) if row[1] else 0
|
||||
count = int(row[2]) if len(row) > 2 and row[2] else 0
|
||||
results.append({
|
||||
"wxid": wxid,
|
||||
"nickname": get_nickname(wxid),
|
||||
"last_message_time": datetime.fromtimestamp(ts).strftime("%Y-%m-%d %H:%M:%S") if ts else None,
|
||||
"last_message_ts": ts,
|
||||
"message_count": count,
|
||||
})
|
||||
return results
|
||||
|
||||
|
||||
# ── HTTP Request Handler ──
|
||||
|
||||
class HistoryAPIHandler(BaseHTTPRequestHandler):
|
||||
|
||||
def _send_json(self, data, status=200):
|
||||
"""Send JSON response with proper headers."""
|
||||
body = json.dumps(data, ensure_ascii=False, indent=2).encode("utf-8")
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.send_header("Access-Control-Allow-Origin", "*")
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def _send_error_json(self, msg, status=400):
|
||||
self._send_json({"ok": False, "error": msg}, status=status)
|
||||
|
||||
def _read_json_body(self):
|
||||
"""Read and parse JSON request body."""
|
||||
length = int(self.headers.get("Content-Length", 0))
|
||||
if not length:
|
||||
return {}
|
||||
try:
|
||||
body = self.rfile.read(length)
|
||||
return json.loads(body)
|
||||
except json.JSONDecodeError:
|
||||
return {}
|
||||
|
||||
def do_GET(self):
|
||||
parsed = urlparse(self.path)
|
||||
path = parsed.path.rstrip("/") or "/"
|
||||
params = parse_qs(parsed.query)
|
||||
|
||||
# ── Root / Health ──
|
||||
if path in ("/", ""):
|
||||
self._send_json({
|
||||
"service": "WeChat History API",
|
||||
"version": "1.0",
|
||||
"port": DEFAULT_PORT,
|
||||
"wxhelper": WX_API,
|
||||
"endpoints": {
|
||||
"GET /api/contacts": "List all WeChat contacts",
|
||||
"GET /api/history": "Query chat history (params: wxid, count)",
|
||||
"POST /api/history": "Same via JSON body",
|
||||
"GET /api/recent": "Recent chats list",
|
||||
"GET /health": "Health check",
|
||||
}
|
||||
})
|
||||
return
|
||||
|
||||
if path == "/health":
|
||||
self._send_json({
|
||||
"status": "ok",
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"wxhelper": bool(get_db_handle())
|
||||
})
|
||||
return
|
||||
|
||||
# ── Contacts ──
|
||||
if path == "/api/contacts":
|
||||
contacts = get_contacts()
|
||||
self._send_json({"ok": True, "count": len(contacts), "contacts": contacts})
|
||||
return
|
||||
|
||||
# ── Recent Chats ──
|
||||
if path == "/api/recent":
|
||||
limit = int(params.get("limit", ["20"])[0])
|
||||
chats = get_recent_chats(limit)
|
||||
self._send_json({"ok": True, "count": len(chats), "chats": chats})
|
||||
return
|
||||
|
||||
# ── History ──
|
||||
if path == "/api/history":
|
||||
wxid = params.get("wxid", [""])[0]
|
||||
count = params.get("count", ["10"])[0]
|
||||
if not wxid:
|
||||
self._send_error_json("Missing required parameter: wxid")
|
||||
return
|
||||
try:
|
||||
rows = query_history(wxid, count)
|
||||
result = format_history_json(wxid, rows)
|
||||
self._send_json(result)
|
||||
except Exception as e:
|
||||
self._send_error_json(str(e)[:200], status=500)
|
||||
return
|
||||
|
||||
# ── 404 ──
|
||||
self._send_error_json(f"Not found: {path}", status=404)
|
||||
|
||||
def do_POST(self):
|
||||
parsed = urlparse(self.path)
|
||||
path = parsed.path.rstrip("/") or "/"
|
||||
body = self._read_json_body()
|
||||
|
||||
# ── History (POST) ──
|
||||
if path in ("/api/history", "/history"):
|
||||
wxid = (body.get("wxid", "") or "").strip()
|
||||
count = body.get("count", 10) or body.get("limit", 10)
|
||||
if not wxid:
|
||||
self._send_error_json("Missing required field: wxid")
|
||||
return
|
||||
try:
|
||||
rows = query_history(wxid, count)
|
||||
result = format_history_json(wxid, rows)
|
||||
self._send_json(result)
|
||||
except Exception as e:
|
||||
self._send_error_json(str(e)[:200], status=500)
|
||||
return
|
||||
|
||||
# ── 404 ──
|
||||
self._send_error_json(f"Not found: {path}", status=404)
|
||||
|
||||
def do_OPTIONS(self):
|
||||
"""Handle CORS preflight."""
|
||||
self.send_response(200)
|
||||
self.send_header("Access-Control-Allow-Origin", "*")
|
||||
self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
||||
self.send_header("Access-Control-Allow-Headers", "Content-Type")
|
||||
self.end_headers()
|
||||
|
||||
def log_message(self, *args):
|
||||
# Suppress default access logs to stdout
|
||||
pass
|
||||
|
||||
|
||||
# ── Main ──
|
||||
|
||||
def main():
|
||||
import sys
|
||||
port = DEFAULT_PORT
|
||||
if "--port" in sys.argv:
|
||||
idx = sys.argv.index("--port")
|
||||
if idx + 1 < len(sys.argv):
|
||||
port = int(sys.argv[idx + 1])
|
||||
|
||||
# Check wxhelper connectivity
|
||||
print(f"[History API] Checking wxhelper at {WX_API}...")
|
||||
try:
|
||||
r = wxpost("/api/checkLogin", timeout=5)
|
||||
if r.get("code") == 1:
|
||||
print("[History API] wxhelper ONLINE")
|
||||
db_handle = get_db_handle()
|
||||
print(f"[History API] DB handle: {db_handle or 'NOT FOUND'}")
|
||||
else:
|
||||
print(f"[History API] WARNING: wxhelper not logged in: {r}")
|
||||
except Exception as e:
|
||||
print(f"[History API] WARNING: Cannot reach wxhelper: {e}")
|
||||
|
||||
# Start server
|
||||
server = HTTPServer((HOST, port), HistoryAPIHandler)
|
||||
print(f"[History API] Listening on http://{HOST}:{port}")
|
||||
print(f"[History API] Endpoints:")
|
||||
print(f" GET http://localhost:{port}/")
|
||||
print(f" GET http://localhost:{port}/health")
|
||||
print(f" GET http://localhost:{port}/api/contacts")
|
||||
print(f" GET http://localhost:{port}/api/recent")
|
||||
print(f" GET http://localhost:{port}/api/history?wxid=wxid_xxx&count=20")
|
||||
print(f" POST http://localhost:{port}/api/history")
|
||||
|
||||
try:
|
||||
server.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
print("\n[History API] Shutting down...")
|
||||
server.shutdown()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,75 +0,0 @@
|
||||
"""
|
||||
[DEPRECATED] 早期架构组件,已由 wechat_agent.py 直调 Hermes API 替代。
|
||||
不再使用,仅作为架构参考保留。
|
||||
"""
|
||||
import pymem, pymem.process, requests, time, json, threading
|
||||
from http.server import HTTPServer, BaseHTTPRequestHandler
|
||||
|
||||
DLL = r"C:\Users\hmo\AppData\Local\Programs\Python\Python310\Lib\site-packages\wxhook\tools\wxhook.dll"
|
||||
WX = "http://127.0.0.1:19088"
|
||||
HERMES = "http://192.168.0.103:5800/callback"
|
||||
LOG = r"C:\Users\hmo\Desktop\bridge.log"
|
||||
BOT = "wxid_7onnerpx2s2l22"
|
||||
PORT = 5801
|
||||
|
||||
def log(m):
|
||||
with open(LOG, "a", encoding="utf-8") as f:
|
||||
f.write(f"{time.strftime('%H:%M:%S')} {m}\n")
|
||||
|
||||
# Inject DLL
|
||||
try:
|
||||
requests.post(f"{WX}/api/checkLogin", json={}, timeout=3)
|
||||
log("DLL OK")
|
||||
except:
|
||||
pm = pymem.Pymem("WeChat.exe")
|
||||
pymem.process.inject_dll(pm.process_handle, DLL.encode())
|
||||
time.sleep(2)
|
||||
log("DLL injected")
|
||||
|
||||
log(f"login: {requests.post(f'{WX}/api/checkLogin', json={}, timeout=5).json()}")
|
||||
|
||||
# Periodic webhook refresh (every 30 seconds)
|
||||
def webhook_keepalive():
|
||||
while True:
|
||||
try:
|
||||
requests.post(f"{WX}/api/hookSyncMsg", json={
|
||||
"port": PORT, "ip": "0.0.0.0", "enableHttp": 1,
|
||||
"url": f"http://127.0.0.1:{PORT}", "timeout": 300
|
||||
}, timeout=5)
|
||||
except:
|
||||
pass
|
||||
time.sleep(30)
|
||||
|
||||
threading.Thread(target=webhook_keepalive, daemon=True).start()
|
||||
log("keepalive started (30s)")
|
||||
|
||||
# Test message
|
||||
requests.post(f"{WX}/api/sendTextMsg",
|
||||
json={"wxid": "filehelper", "msg": "[Bridge] online"}, timeout=5)
|
||||
|
||||
class H(BaseHTTPRequestHandler):
|
||||
def do_POST(self):
|
||||
body = self.rfile.read(int(self.headers.get("Content-Length", 0)))
|
||||
try:
|
||||
d = json.loads(body)
|
||||
fu = d.get("fromUser", "")
|
||||
ct = d.get("content", "")
|
||||
if fu and ct and fu != BOT:
|
||||
log(f"MSG {fu}: {ct[:60]}")
|
||||
threading.Thread(target=lambda: requests.post(HERMES, json=[{"id": int(time.time()*1000), "type": 1, "content": ct, "sender": fu, "roomid": "", "ts": time.time()}], timeout=30), daemon=True).start()
|
||||
self.send_response(200); self.end_headers(); return
|
||||
to = d.get("to", "") or d.get("wxid", "")
|
||||
msg = d.get("message", "") or d.get("content", "")
|
||||
if to and msg:
|
||||
r = requests.post(f"{WX}/api/sendTextMsg", json={"wxid": to, "msg": msg}, timeout=5)
|
||||
log(f"SEND {to}: {r.get('msg','')}")
|
||||
self.send_response(200); self.end_headers(); return
|
||||
except Exception as e:
|
||||
log(f"ERR: {str(e)[:80]}")
|
||||
self.send_response(200); self.end_headers()
|
||||
def do_GET(self):
|
||||
self.send_response(200); self.end_headers(); self.wfile.write(b'{"ok":true}')
|
||||
def log_message(self, *a): pass
|
||||
|
||||
log(f"ready on :{PORT}")
|
||||
HTTPServer(("0.0.0.0", PORT), H).serve_forever()
|
||||
@@ -0,0 +1,118 @@
|
||||
# Mohe & XiaoXiaoMo Chat Viewer
|
||||
# Lists [mohe]/[xxm] messages from an OpenCode session.
|
||||
#
|
||||
# Usage:
|
||||
# .\moho_chat.ps1 <session_id> [minutes]
|
||||
#
|
||||
# Examples:
|
||||
# .\moho_chat.ps1 ses_1d95d15c4ffehQaZ6hrbIbak5k
|
||||
# .\moho_chat.ps1 ses_1d95d15c4ffehQaZ6hrbIbak5k 30
|
||||
|
||||
param(
|
||||
[Parameter(Mandatory=$true)][string]$SessionId,
|
||||
[int]$Minutes = 0
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
Write-Host "Exporting session..." -ForegroundColor DarkGray
|
||||
|
||||
$tmpFile = [System.IO.Path]::GetTempFileName() + ".json"
|
||||
$env:CI = 'true'
|
||||
opencode.cmd export $SessionId 2>$null | Set-Content -Path $tmpFile -NoNewline -Encoding UTF8
|
||||
|
||||
if (-not (Test-Path $tmpFile) -or (Get-Item $tmpFile).Length -eq 0) {
|
||||
Write-Error "Export failed"
|
||||
exit 1
|
||||
}
|
||||
|
||||
$size = (Get-Item $tmpFile).Length
|
||||
Write-Host "Read ($($size / 1MB -as [int]) MB)" -ForegroundColor DarkGray
|
||||
|
||||
$raw = Get-Content $tmpFile -Raw -Encoding UTF8
|
||||
Remove-Item $tmpFile -Force
|
||||
|
||||
# Check if it's actually UTF-16LE in disguise (opencode export uses UTF-16LE)
|
||||
if ($raw.Length -eq 0 -or $raw[0] -ne '{') {
|
||||
# Re-read as UTF-16
|
||||
$bytes = [System.IO.File]::ReadAllBytes($tmpFile)
|
||||
Remove-Item $tmpFile -Force
|
||||
$raw = [System.Text.Encoding]::Unicode.GetString($bytes)
|
||||
}
|
||||
|
||||
$brace = $raw.IndexOf('{')
|
||||
if ($brace -gt 0) { $raw = $raw.Substring($brace) }
|
||||
|
||||
# Use regex to find "text": "[mohe]..." patterns directly (no JSON parsing)
|
||||
$pattern = '"text":\s*"\[(mohe|xxm)\][^"]*"'
|
||||
|
||||
$cutoff = if ($Minutes -gt 0) { (Get-Date).ToUniversalTime().AddMinutes(-$Minutes) } else { $null }
|
||||
|
||||
$results = @()
|
||||
$matchPos = 0
|
||||
|
||||
while ($true) {
|
||||
$m = [regex]::Match($raw, $pattern, $matchPos)
|
||||
if (-not $m.Success) { break }
|
||||
$matchPos = $m.Index + 1
|
||||
|
||||
$tag = $m.Groups[1].Value
|
||||
$content = $m.Value
|
||||
|
||||
# Extract the full text content (everything between "text": " and the closing ")
|
||||
$start = $m.Index + $m.Value.IndexOf('"', $m.Value.IndexOf(':')+1) + 1
|
||||
$fullText = ''
|
||||
$escape = $false
|
||||
for ($i = $start; $i -lt $raw.Length; $i++) {
|
||||
$c = $raw[$i]
|
||||
if ($escape) { $fullText += $c; $escape = $false }
|
||||
elseif ($c -eq '\') { $fullText += $c; $escape = $true }
|
||||
elseif ($c -eq '"') { break }
|
||||
else { $fullText += $c }
|
||||
}
|
||||
|
||||
$fullText = $fullText.Trim()
|
||||
if (-not ($fullText.StartsWith('[mohe]') -or $fullText.StartsWith('[xxm]'))) { continue }
|
||||
|
||||
$sender = if ($fullText.StartsWith('[mohe]')) { '[mohe] Mohe' } else { '[xxm] XiaoXiaoMo' }
|
||||
$display = $fullText -replace '^\[\w+\]\s*', ''
|
||||
|
||||
# Get timestamp by looking backward
|
||||
$before = $raw.Substring([Math]::Max(0, $m.Index - 3000), [Math]::Min(3000, $m.Index))
|
||||
$tsMatch = [regex]::Match($before, '"timestamp":\s*"([^"]+)"')
|
||||
$tsStr = if ($tsMatch.Success) { $tsMatch.Groups[1].Value } else { '' }
|
||||
|
||||
if (-not $tsStr) {
|
||||
$crMatch = [regex]::Match($before, '"created":\s*(\d+)')
|
||||
if ($crMatch.Success) {
|
||||
$tsStr = ([DateTimeOffset]::FromUnixTimeMilliseconds([long]$crMatch.Groups[1].Value).UtcDateTime).ToString('o')
|
||||
}
|
||||
}
|
||||
|
||||
$ts = $null
|
||||
if ($tsStr) {
|
||||
try { $ts = [DateTime]::Parse($tsStr, $null, [System.Globalization.DateTimeStyles]::AssumeUniversal) } catch {}
|
||||
}
|
||||
|
||||
if ($cutoff -and $ts -and $ts -lt $cutoff) { continue }
|
||||
|
||||
$timeLocal = if ($ts) { $ts.ToLocalTime().ToString('HH:mm:ss') } else { '??' }
|
||||
|
||||
$results += [PSCustomObject]@{ Time=$timeLocal; Sort=if($ts){$ts.Ticks}else{0}; Sender=$sender; Message=$display }
|
||||
}
|
||||
|
||||
$results = $results | Sort-Object Sort
|
||||
|
||||
if ($results.Count -eq 0) {
|
||||
Write-Host "No [mohe]/[xxm] messages found" -ForegroundColor Yellow
|
||||
if ($Minutes -gt 0) { Write-Host "(last $Minutes minutes)" }
|
||||
exit
|
||||
}
|
||||
|
||||
Write-Host "`n$($results.Count) message(s)" -ForegroundColor Cyan
|
||||
if ($Minutes -gt 0) { Write-Host "(last $Minutes min)" }
|
||||
Write-Host ("=" * 60)
|
||||
|
||||
foreach ($r in $results) {
|
||||
Write-Host ("[{0}] {1}: {2}" -f $r.Time, $r.Sender, $r.Message)
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Mohe & XiaoXiaoMo Chat Viewer
|
||||
Fixes one corrupted byte in session export, then parses cleanly.
|
||||
|
||||
Usage:
|
||||
python moho_chat.py <session_id> [minutes]
|
||||
|
||||
Examples:
|
||||
python moho_chat.py ses_1d95d15c4ffehQaZ6hrbIbak5k
|
||||
python moho_chat.py ses_1d95d15c4ffehQaZ6hrbIbak5k 30
|
||||
"""
|
||||
|
||||
import subprocess, sys, os, tempfile, re, json
|
||||
from datetime import datetime, timezone, timedelta
|
||||
|
||||
def export(session_id):
|
||||
"""Export session via cmd.exe (preserves UTF-16LE), return path."""
|
||||
tmp = tempfile.NamedTemporaryFile(suffix='.json', delete=False)
|
||||
tmp.close()
|
||||
r = subprocess.run(
|
||||
f'opencode.cmd export {session_id} > "{tmp.name}" 2>nul',
|
||||
shell=True, timeout=180
|
||||
)
|
||||
if r.returncode != 0 or os.path.getsize(tmp.name) == 0:
|
||||
raise ValueError('Export failed')
|
||||
return tmp.name
|
||||
|
||||
def load(filepath):
|
||||
"""Read UTF-16LE, fix corruption, parse JSON, return messages."""
|
||||
with open(filepath, 'rb') as f:
|
||||
raw = f.read()
|
||||
|
||||
# Decode UTF-16LE
|
||||
if raw[:2] == b'\xff\xfe':
|
||||
text = raw.decode('utf-16-le', errors='replace')
|
||||
else:
|
||||
text = raw.decode('utf-8', errors='replace')
|
||||
|
||||
# Strip to first {
|
||||
brace = text.find('{')
|
||||
if brace > 0:
|
||||
text = text[brace:]
|
||||
|
||||
# FIX THE CORRUPTION:
|
||||
# The first message's text field is missing its closing quote.
|
||||
# Pattern: corrupt_char, comma, newline, then a field name
|
||||
# Insert missing closing quote before the comma
|
||||
|
||||
# Specific fix: after corrupted chars that precede a structural comma
|
||||
fixed = re.sub(
|
||||
r'(\uFFFD)\s*,\s*\n\s*"(id|role|parts|info|timestamp|type|text)"',
|
||||
r'?",\n "\2"',
|
||||
text
|
||||
)
|
||||
|
||||
# Also try without \uFFFD (in case it was decoded differently)
|
||||
fixed = re.sub(
|
||||
r'(\?)\s*,\s*\n\s*"(id|role|parts|info|timestamp|type|text)"',
|
||||
r'?",\n "\2"',
|
||||
fixed
|
||||
)
|
||||
|
||||
return json.loads(fixed).get('messages', [])
|
||||
|
||||
def msg_text(msg):
|
||||
parts = msg.get('parts', [])
|
||||
text = ''
|
||||
for p in parts:
|
||||
if isinstance(p, dict) and p.get('type') == 'text':
|
||||
text += p.get('text', '')
|
||||
return text.strip()
|
||||
|
||||
def msg_ts(msg):
|
||||
info = msg.get('info', {})
|
||||
ts = info.get('timestamp', '') or ''
|
||||
if not ts:
|
||||
t = info.get('time', {})
|
||||
if isinstance(t, dict) and t.get('created'):
|
||||
ts = datetime.fromtimestamp(t['created']/1000, tz=timezone.utc).isoformat()
|
||||
return ts
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 2:
|
||||
print(__doc__)
|
||||
sys.exit(1)
|
||||
|
||||
session_id = sys.argv[1]
|
||||
minutes = int(sys.argv[2]) if len(sys.argv) > 2 else None
|
||||
|
||||
print('Exporting...', file=sys.stderr)
|
||||
tmp = export(session_id)
|
||||
|
||||
try:
|
||||
messages = load(tmp)
|
||||
finally:
|
||||
os.unlink(tmp)
|
||||
|
||||
print(f'{len(messages)} messages', file=sys.stderr)
|
||||
|
||||
# Search for 测试新协议 (Moho's test message keyword)
|
||||
found_moho = 0
|
||||
for m in messages:
|
||||
t = msg_text(m)
|
||||
if '测试新协议' in t or '写入Session' in t or '23 × 17' in t:
|
||||
found_moho += 1
|
||||
if found_moho <= 5:
|
||||
print(f' [Moho #{found_moho}] {t[:200]!r}', file=sys.stderr)
|
||||
print(f' Moho explicit messages: {found_moho}', file=sys.stderr)
|
||||
|
||||
cutoff = None
|
||||
if minutes:
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(minutes=minutes)
|
||||
|
||||
results = []
|
||||
|
||||
for m in messages:
|
||||
text = msg_text(m)
|
||||
if not (text.startswith('[mohe]') or text.startswith('[xxm]')):
|
||||
continue
|
||||
|
||||
sender = '[mohe] Mohe' if text.startswith('[mohe]') else '[xxm] XiaoXiaoMo'
|
||||
display = re.sub(r'^\[\w+\]\s*', '', text, count=1).strip()
|
||||
ts_str = msg_ts(m)
|
||||
|
||||
ts_dt = None
|
||||
if ts_str:
|
||||
try: ts_dt = datetime.fromisoformat(ts_str.replace('Z', '+00:00'))
|
||||
except: pass
|
||||
if cutoff and ts_dt and ts_dt < cutoff:
|
||||
continue
|
||||
|
||||
results.append((ts_dt or datetime.min, ts_str, sender, display))
|
||||
|
||||
results.sort(key=lambda x: x[0])
|
||||
|
||||
if not results:
|
||||
print('No [mohe]/[xxm] messages found' + (f' in last {minutes} min' if minutes else ''))
|
||||
return
|
||||
|
||||
print(f'\n{len(results)} message(s)' + (f' (last {minutes} min)' if minutes else ''))
|
||||
print('=' * 60)
|
||||
for ts_dt, ts_str, sender, display in results:
|
||||
t = '??'
|
||||
if ts_str:
|
||||
try: t = datetime.fromisoformat(ts_str.replace('Z', '+00:00')).strftime('%H:%M:%S')
|
||||
except: t = str(ts_str)[:19]
|
||||
print(f'[{t}] {sender}: {display}')
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -1,34 +0,0 @@
|
||||
@echo off
|
||||
title WeChat Hermes Bridge
|
||||
cd /d "%~dp0.."
|
||||
|
||||
set PYTHON=C:\Users\hmo\AppData\Local\Programs\Python\Python310\python.exe
|
||||
set AGENT=scripts\wechat_agent.py
|
||||
|
||||
echo ========================================
|
||||
echo WeChat Hermes Bridge
|
||||
echo ========================================
|
||||
echo.
|
||||
echo [1/3] 启动微信 + 注入 DLL...
|
||||
echo.
|
||||
set PYTHONHOME=
|
||||
set WXHOOK_LOG_LEVEL=ERROR
|
||||
|
||||
%PYTHON% %AGENT%
|
||||
|
||||
echo.
|
||||
echo ========================================
|
||||
echo 微信已启动!
|
||||
echo.
|
||||
echo 第 2 步:运行修复过低工具 ^> 扫码登录
|
||||
echo 路径:D:\Program Files (x86)\低版本修复工具\
|
||||
echo 低版本修复工具\修复过低6.0\低版通用杀器.sp.exe
|
||||
echo.
|
||||
echo 第 3 步:手机扫码 → 自动开始转发
|
||||
echo.
|
||||
echo 按 Ctrl+C 停止桥接
|
||||
echo ========================================
|
||||
|
||||
if %errorlevel% neq 0 (
|
||||
pause
|
||||
)
|
||||
@@ -0,0 +1,28 @@
|
||||
@echo off
|
||||
title WeChat History API
|
||||
cd /d "%~dp0.."
|
||||
|
||||
set PYTHON=python
|
||||
|
||||
echo ========================================
|
||||
echo WeChat History REST API Server
|
||||
echo Port: 19001
|
||||
echo ========================================
|
||||
echo.
|
||||
echo Starting history API server...
|
||||
echo Endpoints:
|
||||
echo GET http://localhost:19001/
|
||||
echo GET http://localhost:19001/health
|
||||
echo GET http://localhost:19001/api/contacts
|
||||
echo GET http://localhost:19001/api/recent
|
||||
echo GET http://localhost:19001/api/history?wxid=wxid_xxx^&count=20
|
||||
echo POST http://localhost:19001/api/history
|
||||
echo ========================================
|
||||
echo.
|
||||
|
||||
set PYTHONHOME=
|
||||
%PYTHON% api\history_api.py --port 19001
|
||||
|
||||
if %errorlevel% neq 0 (
|
||||
pause
|
||||
)
|
||||
+189
-8
@@ -5,22 +5,29 @@ import os, json, time, threading, requests, re, socketserver, subprocess, urllib
|
||||
os.environ["no_proxy"] = "*"
|
||||
os.environ["NO_PROXY"] = "*"
|
||||
from http.server import HTTPServer, BaseHTTPRequestHandler
|
||||
from urllib.parse import urlparse, parse_qs
|
||||
|
||||
BOT_WXID = "wxid_7onnerpx2s2l22"
|
||||
BLOCK_WXIDS = {"fmessage", "weixin", "wechat"} # 系统账号/微信团队,不回复
|
||||
WX_API = "http://127.0.0.1:19088"
|
||||
LOG_FILE = r"C:\Users\hmo\Desktop\wechat_agent.log"
|
||||
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
LOG_DIR = os.path.join(PROJECT_ROOT, "logs")
|
||||
TEMP_DIR = os.path.join(PROJECT_ROOT, "temp")
|
||||
LOG_FILE = os.path.join(LOG_DIR, "wechat_agent.log")
|
||||
os.makedirs(LOG_DIR, exist_ok=True)
|
||||
os.makedirs(TEMP_DIR, exist_ok=True)
|
||||
TCP_PORT = 19099
|
||||
last_msg_time = time.time()
|
||||
nickname_cache = {}
|
||||
db_handle_cache = None # MicroMsg.db handle for history queries
|
||||
|
||||
HERMES_API = "http://192.168.0.103:8642/v1/chat/completions"
|
||||
HERMES_KEY = "hermes123"
|
||||
SENSENOVA_KEY = "sk-aRNj3UwKSLPsDfh15QNTPwbHxahblfaO"
|
||||
SENSENOVA_URL = "https://token.sensenova.cn/v1"
|
||||
|
||||
INJECTOR = r"C:\Users\hmo\Desktop\wxhelper_v11\wxhelper-3.9.5.81-v11\tool\injector\ConsoleApplication.exe"
|
||||
WXHELPER_DLL = r"C:\Users\hmo\Desktop\wxhelper_391019.dll"
|
||||
INJECTOR = r"D:\F\NewI\opencode\daily-workspace\projects\wechat-hermes-gateway\tools\ConsoleApplication.exe"
|
||||
WXHELPER_DLL = r"D:\F\NewI\opencode\daily-workspace\projects\wechat-hermes-gateway\tools\wxhelper_391019.dll"
|
||||
|
||||
def log(m):
|
||||
with open(LOG_FILE, "a", encoding="utf-8") as f:
|
||||
@@ -37,6 +44,121 @@ def wxpost(path, data=None, timeout=10):
|
||||
log(f"WX ERR: {e}")
|
||||
return {"code": -1}
|
||||
|
||||
# ── History Query (via MSG table in MSG*.db databases) ──
|
||||
def get_db_handle():
|
||||
"""Get handle for database containing MSG table. Cached after first call."""
|
||||
global db_handle_cache
|
||||
if db_handle_cache:
|
||||
return db_handle_cache
|
||||
r = wxpost("/api/getDBInfo", timeout=10)
|
||||
dbs = r.get("data") or []
|
||||
# WeChat 3.9.5.81+: messages stored in MSG0.db, MSG1.db, etc.
|
||||
# Older versions: messages in MicroMsg.db's MSG table
|
||||
for db in dbs:
|
||||
dbname = db.get("databaseName", "")
|
||||
if "MSG" in dbname or "Msg" in dbname:
|
||||
db_handle_cache = db.get("handle")
|
||||
log(f"History DB: {dbname} handle={db_handle_cache}")
|
||||
return db_handle_cache
|
||||
log("History DB handle: NOT FOUND")
|
||||
return None
|
||||
|
||||
# Message type labels
|
||||
MSG_TYPES = {1: "文字", 3: "图片", 34: "语音", 43: "视频", 47: "表情", 49: "链接", 10000: "系统", 10002: "红包"}
|
||||
|
||||
def query_history(wxid, limit=10):
|
||||
"""Query historical text messages with a contact from MSG table."""
|
||||
h = get_db_handle()
|
||||
if not h:
|
||||
return None
|
||||
# Text (type=1) and appmsg/link (type=49), use DisplayContent as fallback for StrContent
|
||||
limit_val = min(int(limit), 50)
|
||||
sql = f"SELECT CreateTime, IsSender, Type, SubType, StrContent, DisplayContent FROM MSG WHERE StrTalker='{wxid}' AND Type IN (1,49) ORDER BY CreateTime DESC LIMIT {limit_val}"
|
||||
r = wxpost("/api/execSql", {"dbHandle": h, "sql": sql}, timeout=15)
|
||||
data = r.get("data") or []
|
||||
if not data or len(data) < 2:
|
||||
return None
|
||||
# Skip header row, reverse to chronological order
|
||||
rows = data[1:]
|
||||
rows.reverse()
|
||||
# Normalize content: prefer StrContent, fallback to DisplayContent
|
||||
results = []
|
||||
for row in rows:
|
||||
content = (row[4] or "").strip() if len(row) > 4 else ""
|
||||
if not content and len(row) > 5:
|
||||
content = (row[5] or "").strip()
|
||||
if not content:
|
||||
continue
|
||||
results.append({"CreateTime": row[0], "IsSender": row[1], "Type": row[2], "content": content})
|
||||
return results
|
||||
|
||||
def format_history(wxid, rows):
|
||||
"""Format MSG rows into readable chat history text."""
|
||||
sender_name = get_nickname(wxid)
|
||||
bot_name = get_nickname(BOT_WXID)
|
||||
lines = [f"📜 最近与 {sender_name} 的聊天记录 ({len(rows)}条):"]
|
||||
for row in rows:
|
||||
ts = int(row.get("CreateTime", 0))
|
||||
time_str = time.strftime("%m/%d %H:%M", time.localtime(ts)) if ts else "?"
|
||||
is_sender = int(row.get("IsSender", 0))
|
||||
msg_type = int(row.get("Type", 1))
|
||||
content = row.get("content", "")
|
||||
# Determine who sent it
|
||||
who = bot_name if is_sender else sender_name
|
||||
# Format content
|
||||
if msg_type == 49:
|
||||
content = f"[链接] {content[:60]}"
|
||||
else:
|
||||
content = content[:200]
|
||||
lines.append(f"[{time_str}] {who}: {content}")
|
||||
return "\n".join(lines)
|
||||
|
||||
def handle_history(wxid, count):
|
||||
"""Query and format history for a given wxid. Returns text to send."""
|
||||
try:
|
||||
rows = query_history(wxid, count)
|
||||
if rows:
|
||||
return format_history(wxid, rows)
|
||||
return f"暂无与 {get_nickname(wxid)} 的聊天记录"
|
||||
except Exception as e:
|
||||
log(f"History ERR: {e}")
|
||||
return "查询历史记录失败"
|
||||
|
||||
def handle_history_json(wxid, count):
|
||||
"""Query history and return JSON-serializable dict for HTTP API."""
|
||||
try:
|
||||
rows = query_history(wxid, count)
|
||||
sender_name = get_nickname(wxid)
|
||||
if not rows:
|
||||
return {"ok": True, "wxid": wxid, "sender_name": sender_name, "count": 0, "messages": []}
|
||||
bot_name = get_nickname(BOT_WXID)
|
||||
messages = []
|
||||
for row in rows:
|
||||
ts = int(row.get("CreateTime", 0))
|
||||
time_str = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(ts)) if ts else ""
|
||||
is_sender = int(row.get("IsSender", 0))
|
||||
msg_type = int(row.get("Type", 1))
|
||||
content = row.get("content", "")
|
||||
messages.append({
|
||||
"time": time_str,
|
||||
"timestamp": ts,
|
||||
"sender": bot_name if is_sender else sender_name,
|
||||
"is_self": bool(is_sender),
|
||||
"type": msg_type,
|
||||
"content": content[:500]
|
||||
})
|
||||
return {
|
||||
"ok": True,
|
||||
"wxid": wxid,
|
||||
"sender_name": sender_name,
|
||||
"count": len(messages),
|
||||
"requested_count": min(int(count or 10), 50),
|
||||
"messages": messages
|
||||
}
|
||||
except Exception as e:
|
||||
log(f"History JSON ERR: {e}")
|
||||
return {"ok": False, "error": str(e)[:200]}
|
||||
|
||||
def send_wx(wxid, msg):
|
||||
# Strip weixin:// URLs that WeChat interprets as commands
|
||||
import re as _re2
|
||||
@@ -69,6 +191,25 @@ def call_hermes(wxid, content):
|
||||
log(f"API ERR: {e}")
|
||||
return None
|
||||
|
||||
def inject_to_hermes_session(text):
|
||||
"""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"}
|
||||
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": [
|
||||
{"role": "system", "content": sys_prompt},
|
||||
{"role": "user", "content": text}
|
||||
]}
|
||||
try:
|
||||
r = requests.post(HERMES_API, json=body, headers=headers, timeout=180, proxies={"http": None, "https": None})
|
||||
if r.status_code == 200:
|
||||
reply = r.json()["choices"][0]["message"]["content"]
|
||||
log(f"Inject history OK: {reply[:50]}")
|
||||
return True
|
||||
log(f"Inject history HTTP {r.status_code}")
|
||||
except Exception as e:
|
||||
log(f"Inject history ERR: {e}")
|
||||
return False
|
||||
|
||||
# ── Inject wxhelper DLL ──
|
||||
def inject_wxhelper():
|
||||
try:
|
||||
@@ -179,6 +320,12 @@ def process_tags(reply, fu):
|
||||
members = (r.get("data") or {}).get("members", "")
|
||||
mlist = [m for m in members.split("\u0007") if m]
|
||||
send_wx(fu, f"群成员 ({len(mlist)}): {','.join(mlist[:20])}")
|
||||
# [HISTORY:wxid:count] - query chat history from MSG table
|
||||
hm = re.search(r'\[HISTORY:(\S+?):(\d+)\]', clean)
|
||||
if hm:
|
||||
clean = re.sub(r'\s*\[HISTORY:\S+?:\d+\]\s*', '', clean).strip()
|
||||
target_wxid, count = hm.group(1), int(hm.group(2))
|
||||
threading.Thread(target=lambda: send_wx(fu, handle_history(target_wxid, count)), daemon=True).start()
|
||||
# [PAT:roomid:wxid]
|
||||
pm = re.search(r'\[PAT:(\S+):(\S+)\]', clean)
|
||||
if pm:
|
||||
@@ -191,7 +338,7 @@ def download_and_send_file(m, fu):
|
||||
url = m.group(1).strip()
|
||||
ir = requests.get(url, timeout=60, proxies={"http": None, "https": None})
|
||||
if ir.status_code == 200:
|
||||
tmp = os.path.join(r"C:\Users\hmo\Desktop", f"send_file_{int(time.time())}.dat")
|
||||
tmp = os.path.join(TEMP_DIR, f"send_file_{int(time.time())}.dat")
|
||||
with open(tmp, "wb") as f: f.write(ir.content)
|
||||
wxpost("/api/sendFileMsg", {"wxid": fu, "filePath": tmp})
|
||||
os.remove(tmp)
|
||||
@@ -216,7 +363,7 @@ def handle_img(m, fu):
|
||||
img_url = r.json()["data"][0]["url"]
|
||||
ir = requests.get(img_url, timeout=60)
|
||||
if ir.status_code == 200:
|
||||
tmp = os.path.join(r"C:\Users\hmo\Desktop", f"gen_img_{int(time.time())}.png")
|
||||
tmp = os.path.join(TEMP_DIR, f"gen_img_{int(time.time())}.png")
|
||||
with open(tmp, "wb") as f: f.write(ir.content)
|
||||
wxpost("/api/sendImagesMsg", {"wxid": fu, "imagePath": tmp})
|
||||
os.remove(tmp)
|
||||
@@ -225,7 +372,7 @@ def handle_img(m, fu):
|
||||
if ir.status_code == 200:
|
||||
ext = ".jpg"
|
||||
if "png" in ir.headers.get("content-type", ""): ext = ".png"
|
||||
tmp = os.path.join(r"C:\Users\hmo\Desktop", f"send_img_{int(time.time())}{ext}")
|
||||
tmp = os.path.join(TEMP_DIR, f"send_img_{int(time.time())}{ext}")
|
||||
with open(tmp, "wb") as f: f.write(ir.content)
|
||||
wxpost("/api/sendImagesMsg", {"wxid": fu, "imagePath": tmp})
|
||||
os.remove(tmp)
|
||||
@@ -234,7 +381,7 @@ def download_emoji(m, fu):
|
||||
url = m.group(1).strip()
|
||||
ir = requests.get(url, timeout=30, proxies={"http": None, "https": None})
|
||||
if ir.status_code == 200:
|
||||
tmp = os.path.join(r"C:\Users\hmo\Desktop", f"emoji_{int(time.time())}.png")
|
||||
tmp = os.path.join(TEMP_DIR, f"emoji_{int(time.time())}.png")
|
||||
with open(tmp, "wb") as f: f.write(ir.content)
|
||||
wxpost("/api/sendCustomEmotion", {"wxid": fu, "filePath": tmp})
|
||||
os.remove(tmp)
|
||||
@@ -293,11 +440,29 @@ class RH(BaseHTTPRequestHandler):
|
||||
body = self.rfile.read(int(self.headers.get("Content-Length", 0)))
|
||||
try:
|
||||
d = json.loads(body)
|
||||
if self.path in ("/history", "/api/chatHistory"):
|
||||
wxid = (d.get("wxid", "") or "").strip()
|
||||
count = d.get("count", 10) or d.get("limit", 10)
|
||||
if not wxid:
|
||||
self._send_json({"ok": False, "error": "Missing wxid"})
|
||||
return
|
||||
self._send_json(handle_history_json(wxid, count))
|
||||
return
|
||||
if self.path == "/hermes-msg":
|
||||
msg = d.get("message", "") or d.get("content", "") or str(d)[:200]
|
||||
log("<<< HERMES: " + msg[:100])
|
||||
with open(r"C:\Users\hmo\Desktop\hermes_inbox.txt", "a", encoding="utf-8") as f:
|
||||
with open(os.path.join(TEMP_DIR, "hermes_inbox.txt"), "a", encoding="utf-8") as f:
|
||||
f.write(f"{time.strftime('%H:%M:%S')} {msg}\n")
|
||||
# HISTORY_DATA tag: query history and inject back to Hermes session
|
||||
hm = re.search(r'\[HISTORY_DATA:(\S+?):(\d+)\]', msg)
|
||||
if hm:
|
||||
target_wxid, count = hm.group(1), int(hm.group(2))
|
||||
history_text = handle_history(target_wxid, count)
|
||||
if history_text:
|
||||
threading.Thread(target=lambda: inject_to_hermes_session(history_text), daemon=True).start()
|
||||
log(f"HISTORY_DATA: injected for {target_wxid} ({count} msgs)")
|
||||
else:
|
||||
log(f"HISTORY_DATA: no messages for {target_wxid}")
|
||||
self.send_response(200); self.end_headers(); return
|
||||
to = d.get("to", "") or d.get("wxid", "")
|
||||
msg = d.get("message", "") or d.get("content", "")
|
||||
@@ -308,7 +473,23 @@ class RH(BaseHTTPRequestHandler):
|
||||
log(f"RH ERR: {e}")
|
||||
self.send_response(200); self.end_headers()
|
||||
def do_GET(self):
|
||||
parsed = urlparse(self.path)
|
||||
if parsed.path in ("/history", "/api/chatHistory"):
|
||||
params = parse_qs(parsed.query)
|
||||
wxid = params.get("wxid", [""])[0]
|
||||
count = params.get("count", ["10"])[0]
|
||||
result = handle_history_json(wxid, count)
|
||||
log(f"HTTP GET {parsed.path} wxid={wxid} count={count}")
|
||||
self._send_json(result)
|
||||
return
|
||||
self.send_response(200); self.end_headers(); self.wfile.write(b'{"ok":true}')
|
||||
def _send_json(self, data):
|
||||
body = json.dumps(data, ensure_ascii=False).encode("utf-8")
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
def log_message(self, *a): pass
|
||||
|
||||
threading.Thread(target=lambda: HTTPServer(("0.0.0.0", 5801), RH).serve_forever(), daemon=True).start()
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user