""" WeChat Agent v2 - wxhelper DLL + Hermes API (:8642) """ import os, json, time, threading, requests, re, socketserver, subprocess, urllib.request, urllib.error, queue, locale import warnings warnings.filterwarnings("ignore", message=".*urllib3.*") os.environ["no_proxy"] = "*" os.environ["NO_PROXY"] = "*" from http.server import HTTPServer, BaseHTTPRequestHandler from urllib.parse import urlparse, parse_qs from chat_bridge import SessionBridge from session_router import SessionRouter from proc_guard import guard as _proc_guard # ── PID lock — prevent duplicate instances ── _lock = _proc_guard("wechat_agent") if not _lock.ok: print(_lock.message, flush=True) sys.exit(1) BOT_WXID = "wxid_5bhmquvkbude22" BLOCK_WXIDS = {"fmessage", "weixin", "wechat"} # ϵͳ?˺?/΢???Ŷӣ----ظ? WX_API = "http://127.0.0.1:19088" 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 # ── 莫荷↔小小莫 对话记忆 (本地持久化, 可检索) ── MEMORY_DIR = os.path.join(PROJECT_ROOT, "mohe_memory") MEMORY_FILE = os.path.join(MEMORY_DIR, "conversations.jsonl") os.makedirs(MEMORY_DIR, exist_ok=True) # 当前serve session ID (莫荷消息进这个session, LLM自动有上下文) ATTACH_SESSION = "ses_1d95d15c4ffehQaZ6hrbIbak5k" SESSION_CTX_FILE = os.path.join(MEMORY_DIR, "session_context.txt") _ctx_last_refresh = 0 _memory_counter = 0 def _next_memory_id(): global _memory_counter _memory_counter += 1 return _memory_counter def append_mohe_memory(direction, content): """Append one exchange to the append-only log.""" entry = {"id": _next_memory_id(), "ts": int(time.time()), "direction": direction, "content": content} try: with open(MEMORY_FILE, "a", encoding="utf-8") as f: f.write(json.dumps(entry, ensure_ascii=False) + "\n") except Exception as e: log(f"memory append ERR: {e}") def read_mohe_context(n=30): """Read last n exchanges, return as formatted context string.""" try: if not os.path.exists(MEMORY_FILE): return "" with open(MEMORY_FILE, "r", encoding="utf-8") as f: lines = f.readlines() recent = lines[-n:] if len(lines) > n else lines parts = [] for line in recent: line = line.strip() if not line: continue try: entry = json.loads(line) who = "莫荷" if entry.get("direction") == "mohe_to_xxm" else "小小莫" parts.append(f"{who}: {entry.get('content', '')[:200]}") except json.JSONDecodeError: continue return "\n".join(parts) except Exception as e: log(f"memory read ERR: {e}") return "" def search_mohe_memory(keyword, max_results=10): """Search conversation memory by keyword. Returns list of matching entries.""" results = [] try: if not os.path.exists(MEMORY_FILE): return results with open(MEMORY_FILE, "r", encoding="utf-8") as f: for line in f: line = line.strip() if not line or keyword not in line: continue try: entry = json.loads(line) results.append(entry) if len(results) >= max_results: break except json.JSONDecodeError: continue except Exception as e: log(f"memory search ERR: {e}") return results # Session bridge + router — shared with vc_webhook / xmpp attach_bridge = SessionBridge(session_id=ATTACH_SESSION, serve_url="http://127.0.0.1:4096") _router = SessionRouter( bridge=attach_bridge, default_session=ATTACH_SESSION, ) attach_queue = queue.Queue() _attach_worker_started = False def _attach_worker(): """Single worker: processes attach_queue one at a time.""" while True: try: msg_text = attach_queue.get() if msg_text is None: break qsize = attach_queue.qsize() if qsize > 0: log(f"attach_queue: {qsize} pending after this") do_attach(msg_text) except Exception as e: log(f"_attach_worker ERR: {e}") def queue_attach(msg_text): """Enqueue a message for serialized async processing (one at a time).""" global _attach_worker_started if not _attach_worker_started: _attach_worker_started = True threading.Thread(target=_attach_worker, daemon=True).start() attach_queue.put(msg_text) log(f"queue_attach: queued ({attach_queue.qsize()} pending)") def clear_attach_queue(): """Clear all pending messages in the attach queue (stop mechanism).""" n = 0 while not attach_queue.empty(): try: attach_queue.get_nowait() n += 1 except queue.Empty: break log(f"clear_attach_queue: cleared {n} pending messages") return n HERMES_API = "http://192.168.1.246:8642/v1/chat/completions" HERMES_KEY = "hermes123" SENSENOVA_KEY = "sk-aRNj3UwKSLPsDfh15QNTPwbHxahblfaO" SENSENOVA_URL = "https://token.sensenova.cn/v1" INJECTOR = r"D:\F\NewI\opencode\daily-workspace\projects\wechat-hermes-gateway\tools\Injector_x64.exe" WXHELPER_DLL = r"D:\F\NewI\opencode\daily-workspace\projects\wechat-hermes-gateway\tools\wxhelper_official_39581.dll" def log(m): with open(LOG_FILE, "a", encoding="utf-8") as f: f.write(f"{time.strftime('%H:%M:%S')} {m}\n") def wxpost(path, data=None, timeout=10): try: body = json.dumps(data or {}, ensure_ascii=False).encode("utf-8") r = urllib.request.urlopen(urllib.request.Request(WX_API + path, data=body, headers={"Content-Type": "application/json; charset=utf-8"}), 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: 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. # Also check ChatMsg.db (has ChatMsg table with different schema). # Prefer MSG*.db over MicroMsg.db (MicroMsg.db has "Msg" in name but no MSG table in new versions). candidate = None for db in dbs: dbname = db.get("databaseName", "") # Prefer MSG0.db/MSG1.db over MicroMsg.db if dbname.upper().startswith("MSG") and dbname.upper().endswith(".DB"): candidate = db.get("handle") log(f"History DB: {dbname} handle={candidate}") break # Fallback: check if any table is named MSG for t in (db.get("tables") or []): if t.get("tableName") == "MSG": candidate = db.get("handle") log(f"History DB: {dbname} handle={candidate}") break if candidate: break if candidate: db_handle_cache = candidate return candidate 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 msg = _re2.sub(r'weixin://[^\s]+', '[----?ѹ???]', msg) r = wxpost("/api/sendTextMsg", {"wxid": wxid, "msg": msg}) log(f"SEND {wxid}: {r.get('msg','')}") def get_nickname(wxid): 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 call_hermes(wxid, content): nickname = get_nickname(wxid) headers = {"Authorization": f"Bearer {HERMES_KEY}", "X-Hermes-Session-Id": "sisyphus", "Content-Type": "application/json"} # 群聊 vs 私聊自动适配:群聊有接龙游戏时直接参与,不分析规则 is_group = "@chatroom" in wxid if is_group: sys_prompt = "你是莫荷,女生。群聊中回复要短。有人玩成语接龙时,看到「接X字」直接以X开头接一个成语继续,不要分析规则、不要解释、不要评价。保持接龙节奏不打断。" else: sys_prompt = "你是莫荷,女生。回复简短自然,像朋友聊天。" body = {"model": "hermes-agent", "messages": [{"role": "system", "content": sys_prompt}, {"role": "user", "content": content}]} log(f"CALL_HERMES content[:120]={content[:120]}") log(f"CALL_HERMES body user_msg={json.dumps(body, ensure_ascii=False)[:200]}") try: r = requests.post(HERMES_API, json=body, headers=headers, proxies={"http": None, "https": None}) if r.status_code == 200: data = r.json() choice = data["choices"][0] # Observer pattern: Gateway returns finish_reason="silent" for group messages that don't need reply if choice.get("finish_reason") == "silent": log("Hermes: __SILENT__ (group, skip)") return None return choice["message"]["content"] except Exception as e: 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, 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(force=False): if not force: try: r = wxpost("/api/checkLogin", timeout=5) if r.get("code") == 1: log("wxhelper already injected") return True except: pass # Also check if port 19088 is just listening (wxhelper HTTP server alive) try: import socket as _sock s = _sock.create_connection(("127.0.0.1", 19088), timeout=2) s.close() r = wxpost("/api/checkLogin", timeout=5) if r.get("code") == 1: log("wxhelper HTTP server alive, login OK") return True except: pass # Wait a moment in case server is still starting time.sleep(3) try: r = wxpost("/api/checkLogin", timeout=5) if r.get("code") == 1: log("wxhelper responding after wait") return True except: pass try: # Injector_x64.exe: -n process_name -i dll_path result = subprocess.run([INJECTOR, "-n", "WeChat.exe", "-i", WXHELPER_DLL], capture_output=True, text=True, timeout=30) output = (result.stdout + result.stderr).strip() log(f"Inject: {output[:100]}") # Check if injection succeeded by looking for "success" in output if "success" not in output.lower(): log(f"Inject MAY HAVE FAILED (no 'success' in output), retrying...") time.sleep(2) result2 = subprocess.run([INJECTOR, "-n", "WeChat.exe", "-i", WXHELPER_DLL], capture_output=True, text=True, timeout=30) log(f"Inject retry: {(result2.stdout+result2.stderr).strip()[:100]}") time.sleep(3) r = wxpost("/api/checkLogin", timeout=5) if r.get("code") == 1: log("wxhelper injected OK") return True log(f"Inject check: {r}") return False except Exception as e: log(f"Inject FAIL: {e}") return False # ---- TCP Message Receiver ---- class MsgHandler(socketserver.BaseRequestHandler): def handle(self): try: data = b"" while True: c = self.request.recv(4096) data += c if not c or c[-1] == 10: break if data.strip(): threading.Thread(target=process_msg, args=(data,), daemon=True).start() self.request.sendall(b"200 OK\n") except: pass finally: self.request.close() # ---- Image OCR ---- WX_FILES_BASE = os.path.join(os.path.expanduser("~"), "Documents", "WeChat Files") BOT_WX_DIR = os.path.join(WX_FILES_BASE, BOT_WXID, "wxhelper") def ocr_image(base64_data): """OCR from in-memory base64 image data. Returns text or None.""" try: headers = {"Authorization": "Bearer b0359bed-09f2-49e2-a53c-32ba057412e3", "Content-Type": "application/json"} payload = { "model": "doubao-seed-code", "messages": [{ "role": "user", "content": [ {"type": "text", "text": "请识别这张图片中的所有中文和英文字符,保持原文输出,包括数字、表格、百分比的完整结构。严格逐行逐列输出所有数据,不要省略、不要总结。"}, {"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{base64_data}"}} ] }] } r = requests.post( "https://ark.cn-beijing.volces.com/api/coding/v3/chat/completions", json=payload, headers=headers, timeout=60, proxies={"http": None, "https": None} ) if r.status_code == 200: text = r.json()["choices"][0]["message"]["content"].strip() log(f"OCR OK ({len(text)} chars)") return text log(f"OCR HTTP {r.status_code}: {r.text[:200]}") except Exception as e: log(f"OCR ERR: {e}") return None def ocr_image_file(image_path): """OCR an image file on disk. Returns text or None.""" try: with open(image_path, "rb") as f: b64 = base64.b64encode(f.read()).decode() return ocr_image(b64) except Exception as e: log(f"ocr_image_file ERR: {e}") return None # ---- Full Image Download & Decode (wxhelper 3.9.5.81+) ---- def download_full_image(msg_id): """Download full image from CDN via downloadAttach. Returns encrypted .dat path or None. Retries both the API call (wxhelper may return -2 transiently) and file existence (async CDN download takes time). """ try: dat_path = os.path.join(BOT_WX_DIR, "image", f"{msg_id}.dat") # Phase 1: Retry API call (wxhelper may return -2 if msg not ready) for api_attempt in range(10): r = wxpost("/api/downloadAttach", {"msgId": int(msg_id)}, timeout=30) code = r.get("code", -1) if code >= 0: break log(f"downloadAttach attempt {api_attempt+1}: code={code} {r.get('msg','')}") time.sleep(1) else: log(f"downloadAttach FAILED after 10 attempts, last code={code}") return None # Phase 2: Wait for async CDN download log(f"downloadAttach queued, waiting for file...") for wait_attempt in range(20): if os.path.exists(dat_path): log(f"Download OK: {dat_path} ({os.path.getsize(dat_path)} bytes)") return dat_path time.sleep(1) log(f"downloadAttach: .dat not found after 20s for msgId={msg_id}") except Exception as e: log(f"downloadAttach ERR: {e}") return None def decode_image_file(dat_path): """Decrypt encrypted .dat to viewable image. Returns decoded path or None. Some .dat files are already valid PNG/JPEG images (not encrypted). Falls back to checking if .dat itself is a valid image. """ try: before_files = set(os.listdir(TEMP_DIR)) r = wxpost("/api/decodeImage", {"filePath": dat_path, "storeDir": TEMP_DIR}, timeout=30) if r.get("code", -1) > 0: base = os.path.splitext(os.path.basename(dat_path))[0] for ext in ['.jpg', '.jpeg', '.png', '.bmp']: cand = os.path.join(TEMP_DIR, base + ext) if os.path.exists(cand): log(f"Decoded: {cand}") return cand for f in os.listdir(TEMP_DIR): if f in before_files: continue if f.lower().endswith(('.jpg', '.jpeg', '.png')): cand = os.path.join(TEMP_DIR, f) log(f"Decoded (new): {cand}") return cand log("decodeImage OK but no new image file found") # Fallback: .dat file may already be a valid image (not encrypted) with open(dat_path, "rb") as f: header = f.read(4) ext = None if header[:2] == b'\xff\xd8': # JPEG ext = '.jpg' elif header[:4] == b'\x89PNG': # PNG ext = '.png' elif header[:4] == b'GIF8': # GIF ext = '.gif' elif header[:2] == b'BM': # BMP ext = '.bmp' if ext: out_path = os.path.join(TEMP_DIR, os.path.splitext(os.path.basename(dat_path))[0] + ext) import shutil shutil.copy(dat_path, out_path) log(f".dat is already {ext}, copied to {out_path}") return out_path log(f"decodeImage FAIL: code={r.get('code')} {r.get('msg','')}") except Exception as e: log(f"decodeImage ERR: {e}") return None def process_msg(raw_data): global last_msg_time, last_raw_msg_time last_msg_time = time.time() last_raw_msg_time = time.time() try: d = json.loads(raw_data) log(f"RAW: fromUser={d.get('fromUser','')} type={d.get('type','')} self={d.get('isSelf',d.get('self',0))}") fu = d.get("fromUser", "") or d.get("fromuser", "") or d.get("sender", "") ct = d.get("content", "") or d.get("msg", "") or d.get("text", "") msg_type = d.get("type", 1) is_self = d.get("isSelf", 0) or d.get("self", 0) if "@chatroom" in fu: log(f"GROUP RAW DUMP: keys={list(d.keys())} ct_len={len(ct)} ct[:100]={ct[:100]}") if not fu or not ct or fu == BOT_WXID or fu in BLOCK_WXIDS or fu.startswith("gh_") or is_self: log(f"SKIP: fu={fu} self={is_self}") return # Route by message type if msg_type == 34: # Voice log(f"<- {fu}: [voice]") reply = call_hermes(fu, "[voice message]") if reply and reply.strip(): send_wx(fu, reply.strip()) return if msg_type == 3: # Image msg_id = d.get("msgId", 0) or d.get("svrid", 0) log(f"IMAGE: msgId={msg_id} b64_len={len(d.get('base64Img',''))}") ocr_text = None # Full-image OCR via wxhelper 3.9.5.81 APIs if msg_id: dat_path = download_full_image(msg_id) if dat_path: decoded = decode_image_file(dat_path) if decoded: log(f"Full image OCR on {decoded}") ocr_text = ocr_image_file(decoded) if ocr_text: log(f"OCR result ({len(ocr_text)} chars): {ocr_text[:200]}") reply = call_hermes(fu, f"[老莫发送了一张图片,OCR识别结果如下]\n{ocr_text}") elif msg_id: log("Full-image OCR failed, skipping thumbnail (useless at 84x210)") reply = call_hermes(fu, "[老莫发送了一张图片,但全尺寸图片下载或OCR识别失败,无法读取内容]") else: log("No msgId available, cannot download full image") reply = call_hermes(fu, "[老莫发送了一张图片,但无法获取图片ID,无法识别]") if reply and reply.strip(): log(f"-> {fu}: {reply[:50]}") process_tags(reply, fu) else: log(f"-> {fu}: skip (blank image response)") return # Text - prepend sender wxid+name so Hermes knows who's talking sender_name = get_nickname(fu) chat_type = "Group" if "@chatroom" in fu else "Private" msg_with_sender = f"[{chat_type}][{fu}|{sender_name}] {ct}" log(f"<- {fu} ({sender_name}): {ct[:50]}") log(f"TO HERMES: [{chat_type}] {ct[:80]}") log(f"TO HERMES FULL: {msg_with_sender[:150]}") reply = call_hermes(fu, msg_with_sender) if reply and reply.strip(): log(f"-> {fu}: {reply[:50]}") process_tags(reply, fu) else: log(f"-> {fu}: no reply (blank/empty)") except Exception as e: log(f"MSG ERR: {e}") import traceback log(f"TRACE: {traceback.format_exc()[:200]}") def process_tags(reply, fu): if not reply: return clean = reply # [FILE] for tag, pattern, repl in [ ("FILE", r'\[FILE\](.*?)\[/FILE\]', lambda m: download_and_send_file(m, fu)), ("IMG", r'\[IMG\](.*?)\[/IMG\]', lambda m: handle_img(m, fu)), ("EMOJI", r'\[EMOJI\](.*?)\[/EMOJI\]', lambda m: download_emoji(m, fu)), ]: match = re.search(pattern, clean) if match: clean = re.sub(r'\s*' + pattern.replace('(.*?)', '.*?') + r'\s*', '', clean).strip() try: match = re.search(pattern, reply) # re-match against original if match: threading.Thread(target=repl, args=(match,), daemon=True).start() except Exception as e: log(f"[{tag}] Thread start ERR: {e}") # [CONTACT:wxid] cm = re.search(r'\[CONTACT:(\w+)\]', clean) if cm: clean = re.sub(r'\s*\[CONTACT:\w+\]\s*', '', clean).strip() r = wxpost("/api/getContactProfile", {"wxid": cm.group(1)}) cd = r.get("data", {}) send_wx(fu, f"?dz?: {cd.get('nickname','?')} ??ע: {cd.get('remark','')}") # [ROOM_MEMBERS:roomid] rm = re.search(r'\[ROOM_MEMBERS:(\S+)\]', clean) if rm: clean = re.sub(r'\s*\[ROOM_MEMBERS:\S+\]\s*', '', clean).strip() r = wxpost("/api/getMemberFromChatRoom", {"chatRoomId": rm.group(1)}) 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: clean = re.sub(r'\s*\[PAT:\S+:\S+\]\s*', '', clean).strip() wxpost("/api/sendPatMsg", {"receiver": pm.group(1), "wxid": pm.group(2)}) if clean.strip(): send_wx(fu, clean.strip()) def download_and_send_file(m, fu): url = m.group(1).strip() log(f"[FILE] Downloading: {url}") try: ir = requests.get(url, timeout=60, proxies={"http": None, "https": None}) log(f"[FILE] HTTP {ir.status_code}, size={len(ir.content)}") if ir.status_code == 200: # Preserve original file extension so wxhelper can detect file type ext = os.path.splitext(urlparse(url).path)[-1] or ".dat" if ext.lower() not in ('.pdf', '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx', '.txt', '.zip', '.rar', '.jpg', '.png', '.gif', '.mp3', '.mp4'): ext = ".dat" tmp = os.path.join(TEMP_DIR, f"send_file_{int(time.time())}{ext}") with open(tmp, "wb") as f: f.write(ir.content) log(f"[FILE] Saved to {tmp}, sending via wxhelper...") r = wxpost("/api/sendFileMsg", {"wxid": fu, "filePath": tmp}) log(f"[FILE] wxpost result: {r.get('code','?')} {r.get('msg','?')}") # Keep file alive briefly for async wxhelper read time.sleep(1) try: os.remove(tmp) except: pass else: log(f"[FILE] Download FAILED: HTTP {ir.status_code}") except Exception as e: log(f"[FILE] ERR: {e}") def handle_img(m, fu): cmd = m.group(1).strip() if cmd.startswith("generate:") or cmd.startswith("draw:"): parts = cmd.split(":", 1)[1].strip() ratio = "1:1" if "|" in parts: ratio = parts.split("|")[1].strip() prompt = parts.split("|")[0].strip() else: prompt = parts size_map = {"1:1":"2048x2048","16:9":"2752x1536","9:16":"1536x2752","3:2":"2496x1664","2:3":"1664x2496","3:4":"1760x2368","4:3":"2368x1760"} size = size_map.get(ratio, "2048x2048") log(f"GEN SenseNova: {prompt[:30]} [{ratio}]") r = requests.post(SENSENOVA_URL + "/images/generations", json={"model": "sensenova-u1-fast", "prompt": prompt, "size": size, "response_format": "url"}, headers={"Authorization": f"Bearer {SENSENOVA_KEY}", "Content-Type": "application/json"}, timeout=180) if r.status_code == 200: img_url = r.json()["data"][0]["url"] ir = requests.get(img_url, timeout=60) if ir.status_code == 200: 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) else: ir = requests.get(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(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) 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(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) # ---- Watchdog ---- def force_unhook(): """Switch wxhelper to HTTP mode to clear an existing TCP hook.""" try: wxpost("/api/hookSyncMsg", {"ip": "0.0.0.0", "port": 0, "enableHttp": 1}, timeout=5) time.sleep(1) return True except Exception as e: log(f"unhook ERR: {e}") return False def force_rehook(): """Forcefully reset the wxhelper sync hook. Strategy: switch to HTTP mode (breaks existing TCP hook), then switch back to TCP (forces fresh TCP push connection). This fixes the case where hookSyncMsg returns code:2 but the actual TCP push has silently died. """ log("FORCE REHOOK: resetting sync hook (HTTP to TCP flip)...") try: # Step 1: Switch to HTTP mode (clears TCP hook) force_unhook() # Step 2: Switch back to TCP mode (re-establishes TCP push) r = wxpost("/api/hookSyncMsg", {"ip": "127.0.0.1", "port": TCP_PORT, "enableHttp": 0}, timeout=5) log(f"FORCE REHOOK: hookSyncMsg returned {r}") time.sleep(2) # Verify r2 = wxpost("/api/checkLogin", timeout=5) if r2.get("code") == 1: log("FORCE REHOOK: OK") return True log(f"FORCE REHOOK: checkLogin after rehook: {r2}") except Exception as e: log(f"FORCE REHOOK ERR: {e}") return False def watchdog(): global last_msg_time, last_raw_msg_time _force_rehook_attempted = False while True: now = time.time() idle = now - last_msg_time raw_idle = now - last_raw_msg_time if idle > 120: try: # Detect: messages dried up for 5+ minutes if raw_idle > 300: log(f"WATCHDOG: no RAW msg for {int(raw_idle)}s (idle={int(idle)}s) -- force rehook") ok = force_rehook() if ok: _force_rehook_attempted = False elif _force_rehook_attempted: log("WATCHDOG: force_rehook failed twice, attempting DLL re-inject...") inject_wxhelper(force=True) _force_rehook_attempted = False else: log("WATCHDOG: force_rehook failed, retrying next cycle...") _force_rehook_attempted = True else: # Normal: wxhelper alive, just refresh hook r = wxpost("/api/checkLogin", timeout=5) if r.get("code") == 1: wxpost("/api/hookSyncMsg", {"ip": "127.0.0.1", "port": TCP_PORT, "enableHttp": 0}) log(f"WATCHDOG: refreshed ({int(idle)}s, raw_idle={int(raw_idle)}s)") else: log(f"WATCHDOG: checkLogin failed ({r}), re-injecting...") inject_wxhelper(force=True) except Exception as e: log(f"WATCHDOG EXC: {e}") last_msg_time = now time.sleep(30) # ---- Start ---- print("[Agent] starting...", flush=True) log("=== Agent v2 (wxhelper) ===") # Inject wxhelper inject_wxhelper() # Check login r = wxpost("/api/checkLogin") if r.get("code") == 1: log(f"Logged in: OK") else: log(f"Login check: {r}") log("Will retry via watchdog") # Start watchdog threading.Thread(target=watchdog, daemon=True).start() # Start TCP server for message receiving tcp_server = socketserver.ThreadingTCPServer(("127.0.0.1", TCP_PORT), MsgHandler) threading.Thread(target=tcp_server.serve_forever, daemon=True).start() log(f"TCP server on :{TCP_PORT}") # Hook sync messages (tell DLL to send events to our TCP server) r = wxpost("/api/hookSyncMsg", {"port": TCP_PORT, "ip": "127.0.0.1", "enableHttp": 0}) log(f"hookSyncMsg: {r}") # ── 5801 hermes-msg handler ── def do_attach(msg_text): """Inject → LLM → capture reply → Hermes forward (all in one flow).""" # Pre-process [FILE] tags clean_msg = msg_text fm = re.search(r'\[FILE\](.*?)\[/FILE\]', msg_text, re.IGNORECASE) if fm: url = fm.group(1).strip() log(f"[FILE] Detected in message: {url[:80]}") try: download_and_send_file(fm, "wxid_c0a6izmwd78y22") clean_msg = re.sub(r'\s*\[FILE\].*?\[/FILE\]\s*', ' ', msg_text).strip() except Exception as e: log(f"[FILE] process ERR: {e}") reply = _router.route("wechat", "mohe", clean_msg[:2000]) if not reply: log("do_attach: no text reply in time") return # Save to memory append_mohe_memory("mohe_to_xxm", msg_text[:500]) append_mohe_memory("xxm_to_mohe", reply[:500]) # Hermes forward log(f"do_attach: -> {reply[:80]}") try: requests.post(HERMES_API, json={"model": "hermes-agent", "messages": [{"role": "user", "content": f"[xxm] {reply[:500]}"}]}, headers={"Authorization": f"Bearer {HERMES_KEY}", "X-Hermes-Session-Id": "sisyphus"}, timeout=60, proxies={"http": None, "https": None}) except Exception as e: log(f"do_attach: Hermes forward fail ({e})") log("do_attach done") 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 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 == "/stop": n = clear_attach_queue() log(f"STOP via HTTP: cleared {n} pending") self._send_json({"ok": True, "cleared": n, "status": "stopped"}) return if self.path == "/hermes-msg": msg = d.get("message", "") or d.get("content", "") or str(d)[:200] log("<<< HERMES: " + msg[:200] if len(msg) > 200 else msg) 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") queue_attach(msg) # Also handle HISTORY_DATA tag in hermes messages 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", "") or str(d)[:200] if to and msg: # Has to field → direct WeChat forward (no LLM) log(f"REPLY {to}: {msg[:50]}") send_wx(to, msg) elif msg: # No to field → LLM processing (queue_attach handles reply + notification) queue_attach(msg) except Exception as e: log(f"RH ERR: {e}") self.send_response(200); self.end_headers() def do_GET(self): parsed = urlparse(self.path) if parsed.path == "/health": # Dashboard monitoring endpoint import urllib.request as _ur hermes_ok = False try: req = _ur.Request("http://192.168.1.246:8642/v1/models", headers={"Authorization": "Bearer hermes123"}) _ur.urlopen(req, timeout=3) hermes_ok = True except Exception: pass self._send_json({ "ok": True, "hermes_connected": hermes_ok, }) return 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() log("HTTP :5801") # Notify user send_wx("filehelper", "[Agent v2] wxhelper online") log("Ready") print(f"[Agent v2] wxhelper :19088 | Hermes :8642") try: while True: time.sleep(1) except KeyboardInterrupt: log("Bye")