"""
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"
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
PROJECT_ROOT = os.path.dirname(SCRIPT_DIR)
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 = os.path.join(SCRIPT_DIR, "..", "tools", "Injector_x64.exe")
WXHELPER_DLL = os.path.join(SCRIPT_DIR, "..", "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)
        # DEBUG: capture Type 49 full XML for URL analysis
        if msg_type == 49:
            try:
                with open(LOG_DIR + "/t49_xml.txt", "a", encoding="utf-8") as _f:
                    _f.write(f"\n=== {time.time()} type=49 from={fu} ===\n{ct[:10000]}\n")
            except: pass
        if "@chatroom" in fu:
            log(f"GROUP RAW DUMP: keys={list(d.keys())} ct_len={len(ct)} ct[:100]={ct[:100]}")
            # DEBUG: capture full raw data for quote analysis
            try:
                with open(LOG_DIR + "/group_raw.jsonl", "a", encoding="utf-8") as _f:
                    _f.write(json.dumps({k: str(v)[:2000] for k, v in d.items()}, ensure_ascii=False) + "\n")
            except: pass
        # DEBUG: capture all raw msgs for field analysis
        try:
            with open(LOG_DIR + "/all_raw.jsonl", "a", encoding="utf-8") as _f:
                _f.write(json.dumps({k: str(v)[:500] for k, v in d.items()}, ensure_ascii=False) + "\n")
        except: pass
        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
        # Type 49 (forwarded article) - extract URL and process via article_processor
        if msg_type == 49 and ct.strip().startswith("<?xml"):
            try:
                import re
                # Try <url> first, then <shareUrlOriginal>, then <shareUrlOpen>
                urls = re.findall(r'<url>(https?://mp\.weixin\.qq\.com[^<]+)</url>', ct)
                if not urls:
                    urls = re.findall(r'<shareUrlOriginal>(https?://mp\.weixin\.qq\.com[^<]+)</shareUrlOriginal>', ct)
                if not urls:
                    urls = re.findall(r'<shareUrlOpen>(https?://mp\.weixin\.qq\.com[^<]+)</shareUrlOpen>', ct)
                url = urls[0] if urls else None
                # Decode HTML entities (&amp; → &) — WeChat XML uses &amp; in URLs
                if url:
                    import html as _html
                    url = _html.unescape(url)
                # Extract title from XML
                titles = re.findall(r'<title>(.*?)</title>', ct)
                title = _html.unescape(titles[0]) if titles else ""
                # Extract description 
                descs = re.findall(r'<des>(.*?)</des>', ct)
                desc = _html.unescape(descs[0]) if descs else ""
                
                if url:
                    log(f"ARTICLE URL: {url}")
                    # Call article_processor on localhost
                    import urllib.request as ur
                    req = ur.Request("http://127.0.0.1:5810/process",
                        data=json.dumps({"url": url}).encode("utf-8"),
                        headers={"Content-Type": "application/json"})
                    with ur.urlopen(req, timeout=180) as resp:
                        result = json.loads(resp.read().decode("utf-8"))
                    if result.get("status") == "ok":
                        content = result.get("content", "")[:3000]
                        title = result.get("title", "")
                        images = result.get("images_ocr", 0)
                        enriched = f"[老莫转发了一篇文章{(chr(10)+'标题: '+title) if title else ''}，{images}张图片已OCR]\n\n{content}"
                        log(f"ARTICLE processed: {len(content)} chars")
                        reply = call_hermes(fu, enriched)
                        if reply and reply.strip():
                            log(f"-> {fu}: {reply[:50]}")
                            send_wx(fu, reply.strip())
                        return
                    else:
                        log(f"ARTICLE process failed: {result.get('error','')[:100]}")
                        # Fallback: send title + description
                        fallback = f"[老莫转发了一篇文章]{(chr(10)+'标题: '+title) if title else ''}{(chr(10)+'摘要: '+desc[:200]) if desc else ''}\n(全文抓取失败: {result.get('error','')[:60]})"
                        reply = call_hermes(fu, fallback)
                        if reply and reply.strip():
                            send_wx(fu, reply.strip())
                        return
                else:
                    # No URL found, send title + description
                    if title:
                        log(f"ARTICLE: no URL, sending title+desc")
                        fallback = f"[老莫转发了一篇文章]{(chr(10)+'标题: '+title) if title else ''}{(chr(10)+'摘要: '+desc[:200]) if desc else ''}"
                        reply = call_hermes(fu, fallback)
                        if reply and reply.strip():
                            send_wx(fu, reply.strip())
                        return
            except Exception as e:
                log(f"ARTICLE handler error: {e}")
            # Fall through to text handler
        # 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"?ǳ?: {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")

