#!/usr/bin/env python3 """ Hermes WeChat 本地守护进程 — Pixel Termux 闭环运行 架构(HTTP API 模式): 1. 监控 Termux 通知 / 定期轮询 2. 复制+解密微信 DB 3. 读取新消息 4. 通过 HTTP POST 调用 hermes /api/master/chat(SSE) 5. 提取 hermes 返回的回复文本 6. 通过 am broadcast 发送回复 Usage: python3 hermes_wechat_daemon.py # 前台运行 python3 hermes_wechat_daemon.py --daemon # 后台运行 (fork) python3 hermes_wechat_daemon.py --once # 单次轮询(测试用) python3 hermes_wechat_daemon.py --config custom.json """ import subprocess, os, sys, json, time, sqlite3, logging, argparse, urllib.request, urllib.error from datetime import datetime from pathlib import Path # ── Configuration ───────────────────────────────────────────────────────────── DEFAULT_CONFIG = { "db_src_path": "", "db_copy_dir": "/data/data/com.termux/files/home/dbs", "msg_log_file": "/data/data/com.termux/files/home/wechat_msgs.jsonl", "sqlcipher_key": "e03ce81", "cipher_compatibility": 1, "cipher_page_size": 1024, "poll_interval_sec": 10, "max_msgs_per_poll": 50, "hermes": { "api_url": "http://192.168.110.15:3010/api/master/chat", "timeout_sec": 120, "default_session_prefix": "wx-", "system_hint": "你是一个微信智能助手。根据收到的消息,给出简短、自然、得体的中文回复。群聊中被@时要友好回应。" }, "auto_reply": { "enabled": False, "contacts": ["文件传输助手"], "exclude_types": [10000], "cooldown_sec": 5 }, "send": { "method": "am_broadcast", "broadcast_action": "com.hermes.wechat.SEND_MSG", "am_path": "/system/bin/am" }, "contact_display_names": { "文件传输助手": "文件传输助手" }, "logging": { "level": "INFO", "file": "/data/data/com.termux/files/home/daemon.log" } } # ── Globals ─────────────────────────────────────────────────────────────────── config = {} last_msg_id = 0 last_reply_time = {} # talker -> timestamp urgent_poll_needed = False known_notif_keys = set() # ── Logging ─────────────────────────────────────────────────────────────────── def setup_logging(cfg): level = getattr(logging, cfg.get("level", "INFO").upper(), logging.INFO) log_file = cfg.get("file", "/sdcard/hermes/daemon.log") os.makedirs(os.path.dirname(log_file), exist_ok=True) handlers = [logging.StreamHandler(sys.stdout)] try: handlers.append(logging.FileHandler(log_file)) except Exception as e: print(f"[WARN] Cannot open log file {log_file}: {e}") logging.basicConfig( level=level, format="%(asctime)s [%(levelname)s] %(message)s", handlers=handlers ) # ── Config ──────────────────────────────────────────────────────────────────── def load_config(path="config.json"): global config cfg = DEFAULT_CONFIG.copy() if os.path.exists(path): try: with open(path) as f: user_cfg = json.load(f) # Deep merge for nested dicts for key, val in user_cfg.items(): if isinstance(val, dict) and key in cfg and isinstance(cfg[key], dict): cfg[key].update(val) else: cfg[key] = val logging.info(f"Config loaded from {path}") except Exception as e: logging.error(f"Failed to load config: {e}") config = cfg return cfg # ── DB Discovery ────────────────────────────────────────────────────────────── def find_enmicromsg_db(): """Auto-discover EnMicroMsg.db path.""" result = subprocess.run( ["su", "-c", "find /data/data/com.tencent.mm/MicroMsg -name 'EnMicroMsg.db' -type f 2>/dev/null"], capture_output=True, text=True ) paths = [p.strip() for p in result.stdout.strip().split("\n") if p.strip()] return paths[0] if paths else None # ── DB Copy ─────────────────────────────────────────────────────────────────── def copy_db(src, dst_dir): """Copy DB + WAL + SHM. Returns list of copied file paths.""" os.makedirs(dst_dir, exist_ok=True) files = [src, src + "-wal", src + "-shm"] copied = [] for f in files: dst = os.path.join(dst_dir, os.path.basename(f)) subprocess.run(["su", "-c", f"cp '{f}' '{dst}' 2>/dev/null || true"], capture_output=True) if os.path.exists(dst): copied.append(dst) return copied # ── DB Decrypt ──────────────────────────────────────────────────────────────── def decrypt_db(encrypted_path, output_path, key, page_size=1024, compat=1): """Decrypt SQLCipher DB. Returns True/False.""" if os.path.exists(output_path): os.remove(output_path) sql = f""" PRAGMA key = "{key}"; PRAGMA cipher_compatibility = {compat}; PRAGMA cipher_page_size = {page_size}; PRAGMA wal_checkpoint(TRUNCATE); ATTACH DATABASE '{output_path}' AS plain KEY ''; SELECT sqlcipher_export('plain'); DETACH DATABASE plain; .quit """ # Use full path to sqlcipher from Termux sqlcipher_path = "/data/data/com.termux/files/usr/bin/sqlcipher" if not os.path.exists(sqlcipher_path): sqlcipher_path = "sqlcipher" r = subprocess.run( [sqlcipher_path, encrypted_path], input=sql, capture_output=True, text=True, timeout=60 ) ok = r.returncode == 0 and os.path.exists(output_path) and os.path.getsize(output_path) > 4096 print(f"[DECRYPT] rc={r.returncode} stderr={r.stderr[:80]!r} out_exists={os.path.exists(output_path)} out_size={os.path.getsize(output_path) if os.path.exists(output_path) else 0} ok={ok}", flush=True) if not ok: # Fallback: reuse existing decrypted DB from sdcard fallback = "/data/data/com.termux/files/home/EnMicroMsg.db.decrypted.db" if os.path.exists(fallback): try: import shutil shutil.copy2(fallback, output_path) print(f"[DECRYPT] Fallback to existing decrypted DB: {fallback} -> {output_path}", flush=True) return True except Exception as e: print(f"[DECRYPT] Fallback copy failed: {e}", flush=True) return ok # ── Message Query ───────────────────────────────────────────────────────────── def query_new_messages(db_path, after_msg_id, limit=50): """Query messages with msgId > after_msg_id, newest first.""" try: conn = sqlite3.connect(db_path) conn.row_factory = sqlite3.Row rows = conn.execute( """SELECT msgId, talker, content, type, isSend, createTime FROM message WHERE msgId > ? ORDER BY msgId DESC LIMIT ?""", (after_msg_id, limit) ).fetchall() conn.close() # Reverse to process in chronological order return list(reversed([dict(r) for r in rows])) except Exception as e: logging.error(f"DB query failed: {e}") return [] def get_max_msg_id(db_path): """Get current max msgId.""" try: conn = sqlite3.connect(db_path) row = conn.execute("SELECT MAX(msgId) FROM message").fetchone() conn.close() return row[0] or 0 except Exception: return 0 def get_recent_context(db_path, talker, before_msg_id, limit=5): """Get recent context messages for a talker before given msgId.""" try: conn = sqlite3.connect(db_path) conn.row_factory = sqlite3.Row rows = conn.execute( """SELECT msgId, talker, content, type, isSend, createTime FROM message WHERE talker = ? AND msgId < ? ORDER BY msgId DESC LIMIT ?""", (talker, before_msg_id, limit) ).fetchall() conn.close() return list(reversed([dict(r) for r in rows])) except Exception as e: logging.debug(f"Context query failed: {e}") return [] # ── Hermes API Call (primary) + LLM API Fallback ────────────────────────────── def _parse_sse(buffer): """Parse SSE response buffer, extract result.output.""" lines = buffer.decode("utf-8").split("\n") events = [] current_event = None current_data = "" for line in lines: line = line.strip() if line.startswith("event:"): if current_event is not None: events.append((current_event, current_data)) current_event = line[6:].strip() current_data = "" elif line.startswith("data:"): current_data += line[5:].strip() elif line == "" and current_event is not None: events.append((current_event, current_data)) current_event = None current_data = "" if current_event is not None: events.append((current_event, current_data)) for ev, data in events: if ev == "result": try: return json.loads(data).get("output", "") except Exception: return data return "" def _build_llm_messages(talker, content, context_msgs, system_hint): """Build messages array for direct LLM API call.""" messages = [] if system_hint: messages.append({"role": "system", "content": system_hint}) if context_msgs: ctx_lines = [] for m in context_msgs: who = "对方" if m.get("isSend") == 0 else "我" ctx_lines.append(f"{who}: {m.get('content', '')[:100]}") messages.append({"role": "system", "content": "最近对话上下文:\n" + "\n".join(ctx_lines)}) messages.append({"role": "user", "content": f"收到新消息(来自 {talker}):{content}\n请直接给出回复内容,不要加任何解释或格式标记。"}) return messages def _call_llm_api(talker, content, context_msgs=None): """Direct LLM API call (fallback when hermes is unavailable).""" hermes_cfg = config.get("hermes", {}) api_url = hermes_cfg.get("api_url", "") api_key = hermes_cfg.get("api_key", "") provider = hermes_cfg.get("provider", "anthropic") model = hermes_cfg.get("model", "") timeout = hermes_cfg.get("timeout_sec", 120) system_hint = hermes_cfg.get("system_hint", "") messages = _build_llm_messages(talker, content, context_msgs, system_hint) headers = {"Content-Type": "application/json"} if provider == "anthropic": if not api_url: api_url = "https://api.anthropic.com/v1/messages" if not model: model = "claude-sonnet-4-6-20251001" headers["x-api-key"] = api_key headers["anthropic-version"] = "2023-06-01" payload = json.dumps({ "model": model, "max_tokens": 1024, "messages": messages, }).encode("utf-8") elif provider in ("openai", "azure", "doubao"): headers["Authorization"] = f"Bearer {api_key}" payload = json.dumps({ "model": model, "messages": messages, "stream": False, "temperature": 0.7, }).encode("utf-8") else: logging.error(f"[LLM] Unknown provider: {provider}") return None req = urllib.request.Request(api_url, data=payload, headers=headers, method="POST") try: with urllib.request.urlopen(req, timeout=timeout) as resp: data = json.loads(resp.read().decode("utf-8")) if provider == "anthropic": return data.get("content", [{}])[0].get("text", "") elif provider in ("openai", "azure", "doubao"): return data.get("choices", [{}])[0].get("message", {}).get("content", "") except Exception as e: logging.error(f"[LLM] Fallback API failed: {e}") return None def call_hermes_api(talker, content, context_msgs=None): """ Generate reply via hermes API (primary) or direct LLM API (fallback). Returns reply text or None. """ hermes_cfg = config.get("hermes", {}) hermes_host = hermes_cfg.get("host", "localhost") hermes_port = hermes_cfg.get("port", 3010) timeout = hermes_cfg.get("timeout_sec", 120) prefix = hermes_cfg.get("default_session_prefix", "wx-") system_hint = hermes_cfg.get("system_hint", "") session_id = f"{prefix}{talker}" # Build prompt prompt_parts = [] if system_hint: prompt_parts.append(system_hint) if context_msgs: ctx_lines = [] for m in context_msgs: who = "对方" if m.get("isSend") == 0 else "我" ctx_lines.append(f"{who}: {m.get('content', '')[:100]}") prompt_parts.append("最近对话上下文:\n" + "\n".join(ctx_lines)) prompt_parts.append(f"收到新消息(来自 {talker}):{content}\n请直接给出回复内容,不要加任何解释或格式标记。") full_prompt = "\n\n".join(prompt_parts) # ── Primary: call hermes /api/master/chat ── hermes_url = f"http://{hermes_host}:{hermes_port}/api/master/chat" payload = json.dumps({"message": full_prompt, "sessionId": session_id}).encode("utf-8") headers = {"Content-Type": "application/json", "Content-Length": str(len(payload))} req = urllib.request.Request(hermes_url, data=payload, headers=headers, method="POST") try: print(f"[HERMES] Calling {hermes_url} for {talker}...", flush=True) with urllib.request.urlopen(req, timeout=timeout) as resp: sse_buffer = resp.read() reply = _parse_sse(sse_buffer) if reply: print(f"[HERMES] Reply for {talker}: {reply[:80]}", flush=True) logging.info(f"[HERMES] Reply for {talker}: {reply[:60]}") return reply print(f"[HERMES] Empty reply for {talker}, trying fallback...", flush=True) except urllib.error.HTTPError as e: body = e.read().decode("utf-8", errors="replace")[:200] print(f"[HERMES] HTTP {e.code}: {body}, falling back to LLM API...", flush=True) except urllib.error.URLError as e: print(f"[HERMES] Connection failed: {e}, falling back to LLM API...", flush=True) except Exception as e: print(f"[HERMES] API call failed: {e}, falling back to LLM API...", flush=True) # ── Fallback: direct LLM API ── print(f"[LLM] Fallback direct API for {talker}...", flush=True) reply = _call_llm_api(talker, content, context_msgs) if reply: print(f"[LLM] Fallback reply for {talker}: {reply[:80]}", flush=True) return reply # ── Send Message ────────────────────────────────────────────────────────────── def send_message(to_user, content, to_user_display_name=None): """Send text message via am broadcast.""" send_cfg = config.get("send", {}) method = send_cfg.get("method", "am_broadcast") am_path = send_cfg.get("am_path", "/system/bin/am") action = send_cfg.get("broadcast_action", "com.hermes.wechat.SEND_MSG") if to_user_display_name is None: to_user_display_name = to_user # Ensure WeChat is in foreground before sending if not is_wechat_foreground(): wake_wechat() time.sleep(2) if method == "am_broadcast": cmd = [ am_path, "broadcast", "-a", action, "--es", "toUser", to_user, "--es", "toUserDisplayName", to_user_display_name, "--es", "content", content ] else: logging.error(f"Unknown send method: {method}") return False try: r = subprocess.run(cmd, capture_output=True, text=True, timeout=10) ok = r.returncode == 0 if ok: print(f"[SEND] -> {to_user}: {content[:60]}", flush=True) logging.info(f"[SEND] -> {to_user}: {content[:40]}") else: print(f"[SEND FAIL] rc={r.returncode} stderr={r.stderr[:100]}", flush=True) logging.error(f"[SEND FAIL] rc={r.returncode} stderr={r.stderr[:100]}") return ok except Exception as e: print(f"[SEND ERROR] {e}", flush=True) logging.error(f"[SEND ERROR] {e}") return False # ── WeChat State ────────────────────────────────────────────────────────────── def is_wechat_foreground(): """Check if WeChat is in foreground.""" try: r = subprocess.run( ["dumpsys", "activity", "activities"], capture_output=True, text=True, timeout=3 ) return "com.tencent.mm/.ui.LauncherUI" in r.stdout except Exception: return False def wake_wechat(): """Bring WeChat to foreground so it syncs messages to DB.""" try: subprocess.run( ["/system/bin/am", "start", "-n", "com.tencent.mm/.ui.LauncherUI"], capture_output=True, timeout=5 ) print("[WECHAT] Woken up", flush=True) return True except Exception as e: print(f"[WECHAT] Failed to wake: {e}", flush=True) return False # ── Main Poll Cycle ─────────────────────────────────────────────────────────── def poll_cycle(): """One poll cycle: wake WeChat -> copy -> decrypt -> query -> LLM API -> send.""" global last_msg_id db_src = config.get("db_src_path", "") if not db_src: db_src = find_enmicromsg_db() if db_src: config["db_src_path"] = db_src try: with open("config.json", "w") as f: json.dump(config, f, indent=2, ensure_ascii=False) except Exception: pass if not db_src: logging.error("Cannot find EnMicroMsg.db") return False # 0. Wake WeChat if not in foreground if not is_wechat_foreground(): wake_wechat() time.sleep(3) # Give WeChat time to sync DB else: print("[WECHAT] Already in foreground", flush=True) copy_dir = config.get("db_copy_dir", "/sdcard/hermes/dbs") key = config.get("sqlcipher_key", "e03ce81") page_size = config.get("cipher_page_size", 1024) compat = config.get("cipher_compatibility", 1) # 1. Copy DB to Termux home (FUSE /sdcard is readonly for root) termux_home = "/data/data/com.termux/files/home" work_dir = os.path.join(termux_home, "hermes_work") os.makedirs(work_dir, exist_ok=True) copied = copy_db(db_src, work_dir) print(f"[POLL] copied={copied}", flush=True) if not copied: logging.error("DB copy failed") return False enc_path = os.path.join(work_dir, "EnMicroMsg.db") plain_path = os.path.join(termux_home, "EnMicroMsg.db.decrypted.db") print(f"[POLL] enc_path={enc_path} exists={os.path.exists(enc_path)} size={os.path.getsize(enc_path) if os.path.exists(enc_path) else 0}", flush=True) # 2. Decrypt if not decrypt_db(enc_path, plain_path, key, page_size, compat): fallback_db = "/data/data/com.termux/files/home/EnMicroMsg.db.decrypted.db" if os.path.exists(fallback_db): import shutil shutil.copy2(fallback_db, plain_path) print(f"[POLL] Using fallback DB: {fallback_db}", flush=True) else: logging.error("DB decryption failed — key may have changed.") return False # 3. Query new messages new_msgs = query_new_messages(plain_path, last_msg_id, config.get("max_msgs_per_poll", 50)) print(f"[POLL] last_msg_id={last_msg_id} new_msgs={len(new_msgs)}", flush=True) if not new_msgs: return True logging.info(f"[POLL] {len(new_msgs)} new messages (last_msg_id was {last_msg_id})") last_msg_id = max(m["msgId"] for m in new_msgs) # 4. Process via hermes API auto_reply = config.get("auto_reply", {}) if not auto_reply.get("enabled", False): return True allowed_contacts = auto_reply.get("contacts", []) exclude_types = auto_reply.get("exclude_types", [10000]) cooldown = auto_reply.get("cooldown_sec", 5) for msg in new_msgs: if msg.get("isSend") == 1: continue if msg.get("type") in exclude_types: continue talker = msg.get("talker", "") if allowed_contacts and talker not in allowed_contacts: continue # Cooldown check now = time.time() if talker in last_reply_time and (now - last_reply_time[talker]) < cooldown: logging.info(f"[COOLDOWN] Skip {talker}") continue content = msg.get("content", "") print(f"[MSG] from={talker}: {content[:60]}", flush=True) # Get context context = get_recent_context(plain_path, talker, msg["msgId"], limit=5) # Call hermes reply = call_hermes_api(talker, content, context) if reply: display_names = config.get("contact_display_names", {}) display_name = display_names.get(talker, talker) send_message(talker, reply, display_name) last_reply_time[talker] = now else: logging.warning(f"[HERMES] No reply generated for {talker}") return True # ── Notification Watcher (Termux API) ───────────────────────────────────────── def ensure_termux_api_service(): """Ensure Termux:API background service is running.""" try: subprocess.run( ["/system/bin/am", "startservice", "-n", "com.termux.api/.TermuxApiService"], capture_output=True, timeout=5 ) time.sleep(2) except Exception as e: logging.debug(f"[NOTIF] Failed to start TermuxApiService: {e}") def poll_notifications(): """ Poll notifications via termux-notification-list. When a WeChat notification is detected, wake WeChat then trigger urgent DB poll. Runs in a separate thread. """ global urgent_poll_needed, known_notif_keys termux_prefix = "/data/data/com.termux/files/usr/bin/" termux_bin = termux_prefix + "termux-notification-list" if not os.path.exists(termux_bin): logging.warning("[NOTIF] termux-notification-list not found. Skipping notification watcher.") logging.warning(" Install: pkg install termux-api + Termux:API app") return ensure_termux_api_service() logging.info("[NOTIF] Notification watcher started (3s interval)") while True: try: r = subprocess.run( [termux_bin], capture_output=True, text=True, timeout=10 ) if r.returncode == 0: try: notifs = json.loads(r.stdout) wechat_notifs = [n for n in notifs if n.get("pkgName") == "com.tencent.mm"] for n in wechat_notifs: title = n.get("title", "") content = n.get("content", "") key = f"{title}:{content}" if key not in known_notif_keys: known_notif_keys.add(key) if len(known_notif_keys) > 200: known_notif_keys = set(list(known_notif_keys)[-100:]) print(f"[NOTIF] WeChat: {title}: {content[:60]}", flush=True) logging.info(f"[NOTIF] WeChat notification: {title}: {content[:60]}") # Wake WeChat to sync, then trigger poll wake_wechat() time.sleep(3) # Give WeChat time to sync DB urgent_poll_needed = True except json.JSONDecodeError: pass except Exception as e: logging.debug(f"[NOTIF] poll error: {e}") time.sleep(3) # ── Daemon Loop ─────────────────────────────────────────────────────────────── def run_daemon(): """Main daemon loop with notification-triggered urgent polls.""" global urgent_poll_needed interval = config.get("poll_interval_sec", 10) use_notifications = config.get("notifications", {}).get("enabled", True) logging.info("=" * 50) logging.info("Hermes WeChat Daemon started (HTTP API mode)") logging.info(f"Poll interval: {interval}s") logging.info(f"Auto-reply: {config.get('auto_reply', {}).get('enabled', False)}") logging.info(f"Hermes API: {config.get('hermes', {}).get('api_url')}") logging.info(f"Notification watcher: {use_notifications}") logging.info("=" * 50) # Start notification watcher thread if use_notifications: import threading notif_thread = threading.Thread(target=poll_notifications, daemon=True) notif_thread.start() # Initial poll to establish baseline logging.info("[INIT] First poll to get current msgId baseline...") poll_cycle() logging.info(f"[INIT] Baseline msgId: {last_msg_id}") last_regular_poll = time.time() while True: try: now = time.time() regular_due = (now - last_regular_poll) >= interval if urgent_poll_needed: logging.info("[URGENT] Notification triggered DB poll") urgent_poll_needed = False poll_cycle() last_regular_poll = now elif regular_due: poll_cycle() last_regular_poll = now else: time.sleep(1) continue except Exception as e: logging.error(f"Poll cycle error: {e}") time.sleep(1) # ── Main ────────────────────────────────────────────────────────────────────── def main(): parser = argparse.ArgumentParser(description="Hermes WeChat Local Daemon") parser.add_argument("--config", default="config.json", help="Config file path") parser.add_argument("--once", action="store_true", help="Run one poll cycle and exit") parser.add_argument("--daemon", action="store_true", help="Run in background") args = parser.parse_args() cfg = load_config(args.config) setup_logging(cfg.get("logging", {})) if args.daemon: pid = os.fork() if pid > 0: print(f"Daemon started (PID={pid}). Log: {cfg.get('logging', {}).get('file')}") sys.exit(0) if args.once: logging.info("Running single poll cycle...") ok = poll_cycle() sys.exit(0 if ok else 1) run_daemon() if __name__ == "__main__": main()