From 93f4273b3d27673796efb5f7ba619fc683745c24 Mon Sep 17 00:00:00 2001 From: hmo Date: Sun, 17 May 2026 14:13:14 +0800 Subject: [PATCH] init: WeChat Hermes Gateway - wxhook + Hermes AI auto-reply bot --- .gitignore | 16 +++ README.md | 253 +++++++++++++++++++++++++++++++++++++++ scripts/bridge.py | 74 ++++++++++++ scripts/start_bridge.bat | 34 ++++++ scripts/wechat_agent.py | 187 +++++++++++++++++++++++++++++ 5 files changed, 564 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 scripts/bridge.py create mode 100644 scripts/start_bridge.bat create mode 100644 scripts/wechat_agent.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..60c7edd --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +# Python +__pycache__/ +*.pyc +.venv/ +*.egg-info/ + +# OS +.DS_Store +Thumbs.db + +# IDE +.vscode/ +.idea/ + +# Logs +*.log diff --git a/README.md b/README.md new file mode 100644 index 0000000..e9a68c7 --- /dev/null +++ b/README.md @@ -0,0 +1,253 @@ +# 🤖 WeChat Hermes Gateway + +Windows 微信机器人 ↔ Linux Hermes AI,全自动双向聊天。 + +--- + +## 最终架构 + +``` +┌──────────────────────────────────────────────────────────────┐ +│ Windows 192.168.0.111 │ +│ │ +│ ┌──────────────────┐ ┌───────────────────────────┐ │ +│ │ 微信 3.9.5.81 x64 │ │ 日常微信 WeChatAppEx 4.x │ │ +│ │ 机器人号modachen │ │ 老爸日常使用,互不干扰 │ │ +│ │ wxhook Bot类TCP │ └───────────────────────────┘ │ +│ └────────┬─────────┘ │ +│ │ wxhook DLL 收消息 │ +│ ┌────────▼────────────────┐ │ +│ │ wechat_agent.py │ ← 常驻进程 │ +│ │ │ │ +│ │ Bot类接收微信事件(TCP) │ │ +│ │ → POST Hermes API :8642 │ ← 带session: sisyphus │ +│ │ ← 收回复 → wxhook发回 │ │ +│ │ :5801 ←Hermes找小小莫 │ ← 双向通道 │ +│ │ 看门狗防 wxhook 挂掉 │ │ +│ └────────┬────────────────┘ │ +└───────────┼─────────────────────────────────────────────────┘ + │ HTTP (局域网) +┌───────────▼─────────────────────────────────────────────────┐ +│ Linux 192.168.0.103 │ +│ │ +│ ┌──────────────────────────────┐ │ +│ │ Hermes Gateway │ │ +│ │ - API Server :8642 │ ← OpenAI兼容API │ +│ │ - session自动重置: 已关闭 │ ← sisyphus永不清 │ +│ │ - 健康检查 /health │ │ +│ └──────────────────────────────┘ │ +│ │ +│ hermes CLI — AI 处理引擎 │ +│ 莫荷/莫小荷 — 老爸专属称呼 │ +└──────────────────────────────────────────────────────────────┘ +``` + +## 数据流 + +### 文字消息 + +``` +老爸发微信 + → WeChat 3.9.5.81 收到 + → wxhook DLL TCP → Bot 类 → on_msg 处理器 + → wechat_agent.py POST Hermes API (:8642) + → X-Hermes-Session-Id: sisyphus (固定) + → Hermes 处理 → 返回回复 + → wechat_agent.py 收回复 → wxhook API 发回 + → 老爸手机收到 +``` + +### 图片消息 + +``` +老爸发图片 + → WeChat 收到 → wxhook IMAGE_MESSAGE 事件 + → wechat_agent.py 保存图片 → 调豆包OCR (VolcEngine) + → OCR 文字结果 + 通知 → POST Hermes API + → Hermes 知道图片内容 → 回复老爸 +``` + +### Hermes 找小小莫(双向) + +``` +Hermes → POST http://192.168.0.111:5801/hermes-msg + → wechat_agent.py 写入日志和 inbox 文件 +``` + +## 人物 / ID + +| 角色 | 微信名 | wxid | 说明 | +|------|--------|------|------| +| 老爸 | 莫语不语 | `wxid_c0a6izmwd78y22` | 用户,主人 | +| 莫荷/莫小荷 | modachenchen | `wxid_7onnerpx2s2l22` | Hermes AI,老爸专属称呼"莫小荷" | +| 小小莫 | — | — | Sisyphus,Windows 运维,通过 API 与 Hermes 通信 | + +## 关键端口 + +| 端口 | 用途 | 所在 | +|------|------|------| +| 19001 | wxhook HTTP API | Windows | +| 5801 | Hermes→小小莫 消息入口 | Windows | +| 8642 | Hermes API Server (OpenAI兼容) | Linux | +| 5800 | bridge.py (旧,不再使用) | Linux | + +## 组件 + +### Windows 端(wechat_agent.py) +- **wxhook Bot 类** — DLL 注入 + TCP 收消息 +- **Hermes API 调用** — 直接 POST :8642,session 固定 `sisyphus` +- **回复服务** — 5801 端口收 Hermes 消息 +- **看门狗** — 2 分钟无消息自动刷新 webhook;API 挂了才重注入 DLL +- **昵称缓存** — 从 wxhook getContactList 获取联系人昵称 + +### Linux 端(Hermes Gateway) +- **API Server** — 0.0.0.0:8642,Bearer auth +- **session 管理** — `api_server` 平台关闭自动重置,`sisyphus` 永不清上下文 +- **配置位置** — `/home/hmo/.hermes/config.yaml` +- **Provider** — `ocg-new` → `https://opencode.ai/zen/go/v1` + +## 启动步骤 + +### Windows + +```batch +cd D:\F\NewI\opencode\daily-workspace\projects\wechat-hermes-gateway +scripts\start_bridge.bat +``` + +或直接: + +```powershell +$env:PYTHONHOME='' +Start-Process -WindowStyle Hidden python.exe scripts\wechat_agent.py +``` + +启动后需用修复过低工具扫码登录微信。 + +### Linux(如重启后) + +```bash +source /home/hmo/hermes-agent/.venv/bin/activate +hermes gateway restart +``` + +验证: +```bash +ss -tlnp | grep 8642 +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 | 微信聊天 | 自动通过 wechat_agent.py 桥接 | + +## 项目文件 + +``` +wechat-hermes-gateway/ +├── README.md # 本文档 +├── scripts/ +│ ├── wechat_agent.py # 主力:微信机器人代理 +│ └── start_bridge.bat # 一键启动脚本 +└── temp/ # 废弃/临时脚本 +``` + +## 历史决策 + +1. **wxhook HTTP webhook 不可靠** → 改用 Bot 类 TCP 收消息 +2. **Bot 类偶尔停发事件** → 加看门狗自动刷新 +3. `hermes -z` **无上下文** → 改用 Hermes API Server (:8642) + session +4. **session 自动重置** → 关闭 api_server 平台的重置策略 +5. **群聊不认人** → session 固定 `sisyphus`,所有消息共享上下文 +6. **Linux bridge 常挂** → 去掉 bridge.py,Windows 直接调 Hermes API + +## 已实现的功能 + +| 功能 | 状态 | +|------|------| +| 文字消息收发(个人聊天) | ✅ 双向,session 上下文连贯 | +| 文字消息收发(群聊) | ✅ 同 session,认识老爸 | +| 图片消息接收 + OCR 分析 | ✅ 自动保存 → 豆包 OCR → 结果给 Hermes | +| Hermes 身份认知 | ✅ 知道自己是莫荷/莫小荷,知道老爸 | +| 会话上下文持续 | ✅ session `sisyphus`,自动重置已关闭 | +| 小小莫 ↔ Hermes 双向通信 | ✅ API (:8642) + HTTP (:5801/hermes-msg) | +| wxhook 看门狗自愈 | ✅ 2分钟无消息刷新 webhook | +| 昵称识别 | ✅ 从 getContactList 获取 | +| 联系人列表查询 | ✅ wxhook /api/getContactList | + +## 未实现 / 不可行 + +| 功能 | 原因 | +|------|------| +| 语音消息 | ⏳ 已能检测并下载,转文字(STT)待接入 | +| 发送图片 | wxhook 有 send_image API 但未接入回复链路 | +| 文件收发 | 同上,未接入 | +| 换头像/改资料 | wxhook 无相关 API | +| 群管理(拉人踢人) | wxhook 无群管理 API | +| iLink 官方 bot 接口 | 限制太多,弃用 | +| 多人独立会话 | 目前全部共享 `sisyphus` 单会话 | + +## 灾难恢复流程 + +### 场景:Windows 重启 + +需要 3 步,**顺序不能错**: + +``` +第1步:双击 start_bridge.bat + → 自动杀旧微信 → Bot 类启动新微信 + 注入 DLL + → 等待微信窗口出现 + +第2步:运行修复过低工具 + → 选择修复过低6.0\低版通用杀器.sp.exe + → 自动扫描到已运行的微信 → 打补丁 → 弹出登录二维码 + +第3步:手机扫码登录 + → 登录后 wechat_agent.py 自动检测到登录状态 + → 开始转发消息 + → 给 filehelper 发 "[Bridge] online" 确认 +``` + +验证:手机发条消息给 modachenchen,看能否收到自动回复。 + +### 场景:Linux 重启 + +```bash +# 1. 启动 Hermes gateway(自动恢复 session) +source /home/hmo/hermes-agent/.venv/bin/activate +hermes gateway restart + +# 2. 验证 +ss -tlnp | grep 8642 # 确认 API 端口 +curl http://127.0.0.1:8642/v1/models # 确认 API 响应 + +# 3. 确认 Windows 能连上 +# 从 Windows 运行: +curl http://192.168.0.103:8642/v1/models -H "Authorization: Bearer hermes123" +``` + +### 场景:两边都重启了 + +1. Linux 先:`hermes gateway restart` + 验证 8642 监听 +2. Windows 后:`start_bridge.bat` → 修复工具登录 → 完成 + +### 场景:Hermes 不认人了(session 丢了) + +不用慌,system prompt 里已经写死了她的身份和你的身份。 +发条消息她就会看到: +> "你是莫荷,女生。你的主人是老爸(微信名:莫语不语)" + +如果连这都不奏效 → 告诉 Hermes "去找小小莫" → 它会 POST 到 :5801/hermes-msg → 我来处理。 + +## 注意事项 + +- wxhook DLL 仅支持 x64 微信 3.9.5.81 +- 每次 WeChat 重启需重新登录(修复过低工具) +- **start_bridge.bat 必须第 1 步执行**,修复工具第 2 步 +- Hermes API 首次调用可能较慢(大模型冷启动) +- 看门狗只刷新 webhook,不会误伤正常消息处理 +- 如果微信登录后没反应,等 1-2 分钟看门狗会自动刷新 diff --git a/scripts/bridge.py b/scripts/bridge.py new file mode 100644 index 0000000..0285fd7 --- /dev/null +++ b/scripts/bridge.py @@ -0,0 +1,74 @@ +""" +WeChat Hermes Bridge — with webhook keepalive +""" +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/start_bridge.bat b/scripts/start_bridge.bat new file mode 100644 index 0000000..52114c9 --- /dev/null +++ b/scripts/start_bridge.bat @@ -0,0 +1,34 @@ +@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/wechat_agent.py b/scripts/wechat_agent.py new file mode 100644 index 0000000..aa50c0b --- /dev/null +++ b/scripts/wechat_agent.py @@ -0,0 +1,187 @@ +""" +WeChat Agent - wxhook + Hermes API (:8642) +""" +import sys, os, json, time, threading, requests, re +from http.server import HTTPServer, BaseHTTPRequestHandler + +sys.path.insert(0, r"C:\Users\hmo\AppData\Local\Programs\Python\Python310\Lib\site-packages") +os.environ["WXHOOK_LOG_LEVEL"] = "ERROR" + +from wxhook import Bot +from wxhook.events import TEXT_MESSAGE, IMAGE_MESSAGE, VOICE_MESSAGE +import pymem, pymem.process + +BOT_WXID = "wxid_7onnerpx2s2l22" +WX_API = "" +LOG_FILE = r"C:\Users\hmo\Desktop\wechat_agent.log" +DLL = r"C:\Users\hmo\AppData\Local\Programs\Python\Python310\Lib\site-packages\wxhook\tools\wxhook.dll" +last_msg_time = time.time() +nickname_cache = {} + +HERMES_API = "http://192.168.0.103:8642/v1/chat/completions" +HERMES_KEY = "hermes123" + +def log(m): + with open(LOG_FILE, "a", encoding="utf-8") as f: + f.write(f"{time.strftime('%H:%M:%S')} {m}\n") + +def send_wx(wxid, msg): + try: + requests.post(WX_API + "/api/sendTextMsg", json={"wxid": wxid, "msg": msg}, timeout=5) + log(f"SEND {wxid}: ok") + except Exception as e: + log(f"SEND ERR: {e}") + +def get_nickname(wxid): + if wxid in nickname_cache: + return nickname_cache[wxid] + try: + r = requests.post(WX_API + "/api/getContactList", json={}, timeout=5) + for c in r.json().get("data", []): + if c.get("wxid") == wxid: + nick = c.get("nickname") or c.get("customAccount") or wxid + nickname_cache[wxid] = nick + return nick + except: + pass + nickname_cache[wxid] = wxid + return wxid + +def call_hermes(wxid, content): + nickname = get_nickname(wxid) + headers = {"Authorization": f"Bearer {HERMES_KEY}", "X-Hermes-Session-Id": "sisyphus", "Content-Type": "application/json"} + sys_prompt = f"你是莫荷,女生。你主人是老爸({nickname})。回复简短像聊天。发图用 [IMG]URL[/IMG]。" + body = {"model": "hermes-agent", "messages": [{"role": "system", "content": sys_prompt}, {"role": "user", "content": content}]} + try: + r = requests.post(HERMES_API, json=body, headers=headers, timeout=120, proxies={"http": None, "https": None}) + if r.status_code == 200: + return r.json()["choices"][0]["message"]["content"] + except Exception as e: + log(f"API ERR: {e}") + return None + +def watchdog(): + global last_msg_time + while True: + idle = time.time() - last_msg_time + if idle > 120 and WX_API: + try: + r = requests.post(WX_API + "/api/checkLogin", json={}, timeout=5) + if r.json().get("code") == 1: + requests.post(WX_API + "/api/hookSyncMsg", json={"ip": "127.0.0.1", "port": 19001, "enableHttp": 1, "url": "", "timeout": 300}, timeout=5) + log(f"WATCHDOG: refreshed ({int(idle)}s)") + else: + log("WATCHDOG: re-injecting...") + pymem.process.inject_dll(pymem.Pymem("WeChat.exe").process_handle, DLL.encode()) + except: + pass + last_msg_time = time.time() + time.sleep(30) + +threading.Thread(target=watchdog, daemon=True).start() + +class RH(BaseHTTPRequestHandler): + def do_POST(self): + global last_msg_time + last_msg_time = time.time() + body = self.rfile.read(int(self.headers.get("Content-Length", 0))) + try: + d = json.loads(body) + 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: + f.write(f"{time.strftime('%H:%M:%S')} {msg}\n") + 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: + log(f"REPLY {to}: {msg[:50]}") + send_wx(to, msg) + except Exception as e: + log(f"RH ERR: {e}") + 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 + +threading.Thread(target=lambda: HTTPServer(("0.0.0.0", 5801), RH).serve_forever(), daemon=True).start() +log("HTTP :5801") + +log("Creating Bot...") +b = Bot() +WX_API = b.BASE_URL +log("Bot ready, API=" + WX_API) + +@b.handle([TEXT_MESSAGE, IMAGE_MESSAGE, VOICE_MESSAGE]) +def on_msg(_bot, event): + global last_msg_time + last_msg_time = time.time() + fu = event.fromUser or "" + if not fu or fu == BOT_WXID: + return + if event.type == VOICE_MESSAGE: + log(f"<- {fu}: [voice]") + reply = call_hermes(fu, "[voice message]") + if reply: send_wx(fu, reply) + return + if event.type == IMAGE_MESSAGE: + log(f"<- {fu}: [image]") + b64 = event.base64Img or "" + ocr_text = "" + if b64: + try: + import base64 + os.makedirs(r"C:\Users\hmo\Desktop\wechat_images", exist_ok=True) + fname = os.path.join(r"C:\Users\hmo\Desktop\wechat_images", str(int(time.time())) + ".jpg") + with open(fname, "wb") as f: + f.write(base64.b64decode(b64)) + api_key = os.environ.get("VOLCENGINE_API_KEY", "b0359bed-09f2-49e2-a53c-32ba057412e3") + with open(fname, "rb") as f: + img_b64 = base64.b64encode(f.read()).decode() + r = requests.post("https://ark.cn-beijing.volces.com/api/coding/v3/chat/completions", + json={"model": "doubao-seed-code", "messages": [{"role": "user", "content": [{"type": "text", "text": "描述图片"}, {"type": "image_url", "image_url": {"url": "data:image/jpeg;base64," + img_b64}}]}], "max_tokens": 500}, + headers={"Authorization": "Bearer " + api_key, "Content-Type": "application/json"}, timeout=60) + ocr_text = r.json()["choices"][0]["message"]["content"] + except Exception as e: + log(f"OCR err: {e}") + msg = "[image]" + ("\n" + ocr_text if ocr_text else "") + reply = call_hermes(fu, msg) + if reply: send_wx(fu, reply) + return + content = event.content or "" + if not content: + return + log(f"<- {fu}: {content[:50]}") + reply = call_hermes(fu, content) + if reply: + log(f"-> {fu}: {reply[:50]}") + img_match = re.search(r'\[IMG\](.*?)\[/IMG\]', reply) + if img_match: + img_cmd = img_match.group(1).strip() + clean = re.sub(r'\s*\[IMG\].*?\[/IMG\]\s*', '', reply).strip() + if clean: + send_wx(fu, clean) + try: + ir = requests.get(img_cmd, timeout=30, proxies={"http": None, "https": None}) + 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}") + with open(tmp, "wb") as f: + f.write(ir.content) + try: + _bot.send_image(fu, tmp) + except: + requests.post(WX_API + "/api/sendImagesMsg", json={"wxid": fu, "imagePath": tmp}, timeout=10) + os.remove(tmp) + except Exception as e: + log(f"IMG err: {e}") + else: + send_wx(fu, reply) + else: + log(f"-> {fu}: no reply") + +print("[Agent] Hermes API: " + HERMES_API) +log("Ready") +b.run()