diff --git a/README.md b/README.md index c2e98fa..f0b7a4b 100644 --- a/README.md +++ b/README.md @@ -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 分钟看门狗会自动处理 diff --git a/api/history_api.py b/api/history_api.py new file mode 100644 index 0000000..55ad551 --- /dev/null +++ b/api/history_api.py @@ -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() diff --git a/scripts/bridge.py b/scripts/bridge.py deleted file mode 100644 index d32e673..0000000 --- a/scripts/bridge.py +++ /dev/null @@ -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() diff --git a/scripts/moho_chat.ps1 b/scripts/moho_chat.ps1 new file mode 100644 index 0000000..ba15401 --- /dev/null +++ b/scripts/moho_chat.ps1 @@ -0,0 +1,118 @@ +# Mohe & XiaoXiaoMo Chat Viewer +# Lists [mohe]/[xxm] messages from an OpenCode session. +# +# Usage: +# .\moho_chat.ps1 [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) +} diff --git a/scripts/moho_chat.py b/scripts/moho_chat.py new file mode 100644 index 0000000..e3b24fa --- /dev/null +++ b/scripts/moho_chat.py @@ -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 [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() diff --git a/scripts/start_bridge.bat b/scripts/start_bridge.bat deleted file mode 100644 index 52114c9..0000000 --- a/scripts/start_bridge.bat +++ /dev/null @@ -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 -) diff --git a/scripts/start_history_api.bat b/scripts/start_history_api.bat new file mode 100644 index 0000000..580ffeb --- /dev/null +++ b/scripts/start_history_api.bat @@ -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 +) diff --git a/scripts/wechat_agent.py b/scripts/wechat_agent.py index 51945d6..5f855a3 100644 --- a/scripts/wechat_agent.py +++ b/scripts/wechat_agent.py @@ -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() diff --git a/tools/ConsoleApplication.exe b/tools/ConsoleApplication.exe new file mode 100644 index 0000000..617f596 Binary files /dev/null and b/tools/ConsoleApplication.exe differ diff --git a/tools/wxhelper_391019.dll b/tools/wxhelper_391019.dll new file mode 100644 index 0000000..a5df2db Binary files /dev/null and b/tools/wxhelper_391019.dll differ