#!/usr/bin/env python3
"""
XMPP Agent Core — 统一版
=========================
Single bot core for all agents. Supports --agent xxm|mohe|zhiwei|xiaoguo.

Usage:
    python xmpp_agent_core.py --agent xxm       # xxm, uses chat_bridge
    python xmpp_agent_core.py --agent mohe      # mohe, uses Hermes API
    python xmpp_agent_core.py --agent zhiwei    # zhiwei, uses Hermes API
    python xmpp_agent_core.py --agent xiaoguo   # xiaoguo, uses Hermes API

Shares: PID lock, reconnect, MUC join, dedup, batching,
        coordinator protocol (GRANT/REVOKE), HTTP bridge.
Differs only in LLM calling method (chat_bridge vs Hermes API).
"""
import os, sys, time, threading, asyncio, logging, json, re, ssl
import urllib.request, http.server, urllib.parse

# ── Windows selector loop (slixmpp needs it on Windows) ──
if sys.platform == "win32":
    asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())

# ── PATH: allow imports from gateway/scripts/ (proc_guard, chat_bridge) ──
_GATEWAY_SCRIPTS = os.path.join(os.path.dirname(os.path.abspath(__file__)),
                                "gateway", "scripts")
sys.path.insert(0, _GATEWAY_SCRIPTS)

# ═══════════════════════════════════════════════════════════════
#  AGENTS Configuration
# ═══════════════════════════════════════════════════════════════

AGENTS = {
    "mohe": {
        "jid": "mohe@yoin.fun",
        "password": "hermes123",
        "nick": "mohe",
        "name_cn": "莫荷",
        "http_port": 5804,
        "gateway": "http://localhost:8642/v1/chat/completions",
        "session_id": "xmpp-mohe-v2",
        "server": "127.0.0.1",
        "port": 5222,
        "muc_rooms": ["coregroup@conference.yoin.fun"],
        "mention": "@mohe/@莫荷",
    },
    "zhiwei": {
        "jid": "zhiwei@yoin.fun",
        "password": "hermes123",
        "nick": "zhiwei",
        "name_cn": "知微",
        "http_port": 5805,
        "gateway": "http://localhost:8643/v1/chat/completions",
        "session_id": "xmpp-zhiwei",
        "server": "127.0.0.1",
        "port": 5222,
        "muc_rooms": ["coregroup@conference.yoin.fun"],
        "mention": "@zhiwei/@知微",
    },
    "xiaoguo": {
        "jid": "xiaoguo@yoin.fun",
        "password": "hermes123",
        "nick": "xiaoguo",
        "name_cn": "小果",
        "http_port": 5806,
        "gateway": "http://localhost:8645/v1/chat/completions",
        "session_id": "xmpp-xiaoguo",
        "kanban_session_id": "xmpp-xiaoguo-kanban",
        "server": "127.0.0.1",
        "port": 5222,
        "muc_rooms": ["coregroup@conference.yoin.fun"],
        "mention": "@xiaoguo/@小果",
    },
    "xxm": {
        "jid": "xxm@yoin.fun",
        "password": "hermes123",
        "nick": "xxm",
        "name_cn": "笑笑",
        # NOTE: 5802 was historically used but is now occupied by wechat-hermes-gateway's
        # xmpp_bot.py (independent service). Switched to 5807 to avoid dual-listener conflict
        # that caused ~50% of /easytier and /rdp requests to 400. See AGENTS.md / rdp.json.
        "http_port": 5807,
        "bridge_api_key": "xxm_bridge_8f3a2c",
        "bridge": "chat_bridge",          # use local chat_bridge instead of Hermes API
        "session_id": "ses_xxm_xmpp",
        "kanban_session_id": "xmpp-xxm-kanban",
        "server": "192.168.1.246",         # LAN direct connect
        "port": 5222,
        "muc_rooms": [
            "coregroup@conference.yoin.fun",
            "jujidina@conference.yoin.fun",
        ],
        "mention": "@xxm/@笑笑",
    },
}

# ── Agent selection ──
_agent_name = "mohe"
if "--agent" in sys.argv:
    idx = sys.argv.index("--agent")
    if idx + 1 < len(sys.argv):
        _agent_name = sys.argv[idx + 1]
cfg = AGENTS.get(_agent_name, AGENTS["mohe"])

# ═══════════════════════════════════════════════════════════════
#  PID Lock — prevent duplicate instances
# ═══════════════════════════════════════════════════════════════

from proc_guard import guard as _proc_guard
_lock = _proc_guard(f"xmpp_bot_{_agent_name}")
if not _lock.ok:
    print(_lock.message, flush=True)
    sys.exit(1)

# ═══════════════════════════════════════════════════════════════
#  Logging
# ═══════════════════════════════════════════════════════════════

_LOG_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "gateway", "logs")
os.makedirs(_LOG_DIR, exist_ok=True)
_LOG_FILE = os.path.join(_LOG_DIR, f"xmpp_{_agent_name}.log")
_START_TIME = time.time()


def log(m: str):
    with open(_LOG_FILE, "a", encoding="utf-8") as f:
        f.write(f"{time.strftime('%H:%M:%S')} {m}\n")


logging.basicConfig(level=logging.INFO, format='%(asctime)s %(message)s')

# ═══════════════════════════════════════════════════════════════
#  LLM Bridge Init — abstracted per agent
# ═══════════════════════════════════════════════════════════════

_IS_CHAT_BRIDGE = cfg.get("bridge") == "chat_bridge"
_router = None  # set only for chat_bridge (xxm)

# ── Kanban session support ──
_CALL_SEQ = 0
_KANBAN_SESSION_ID = cfg.get("kanban_session_id", None)

if _IS_CHAT_BRIDGE:
    from chat_bridge import SessionBridge
    from session_router import SessionRouter
    _bridge = SessionBridge(session_id=cfg["session_id"])
    _router = SessionRouter(bridge=_bridge, default_session=cfg["session_id"])
    # Kanban-dedicated bridge + router for separate session
    _kanban_sid = _KANBAN_SESSION_ID or f"{cfg['session_id']}-kanban"
    _kanban_bridge = SessionBridge(session_id=_kanban_sid)
    _kanban_router = SessionRouter(bridge=_kanban_bridge, default_session=_kanban_sid)
    log(f"LLM: chat_bridge (session={cfg['session_id']})")
    log(f"Kanban: chat_bridge (session={_kanban_sid})")
else:
    _opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
    log(f"LLM: Hermes API ({cfg['gateway']})")


def _call_llm(content: str, sender: str, is_group: bool = False,
              session_id: str | None = None) -> str:
    """Abstract LLM call. Returns raw response text (or empty string).
    If session_id provided and differs from default, route to kanban handler."""
    if _IS_CHAT_BRIDGE:
        if session_id and session_id != cfg["session_id"]:
            # Prepends kanban-handler instructions so LLM knows how to handle
            kanban_content = (
                "【看板处理协议】\n"
                "收到卡片后三步判断：\n"
                "  A) 任务明确 → 直接执行 → 评论结果 + 更新状态\n"
                "  B) 信息不足 → 评论提问 + 设为 blocked\n"
                "  C) 不是我的活 → 评论说明 + 转派（如果能判断）\n"
                "\n"
                "汇报规则：\n"
                "  - 任务完成 → 简短 DM 给老莫摘要\n"
                "  - 评论/状态变更 → 不汇报\n"
                "  - 追问/转派 → 不汇报\n"
                "\n"
                "可用 API：\n"
                "  curl http://192.168.1.246:5803/api/kanban/t_xxx  查看卡片详情\n"
                "  更新操作走 Kanban Dashboard UI\n"
                "\n"
                "卡片上下文不够？→ 用 session_search 查历史\n"
                f"---\n{content}"
            )
            return _kanban_router.route("xmpp", sender, kanban_content) or ""
        return _router.route("xmpp", sender, content) or ""
    else:
        return _call_hermes_api(content, session_id)


def _call_hermes_api(content: str, session_id: str | None = None) -> str:
    """POST to Hermes API, return response text or empty string."""
    target_sid = session_id or cfg["session_id"]
    try:
        payload = json.dumps({
            "model": "hermes-agent",
            "messages": [{"role": "user", "content": content}]
        }).encode()
        req = urllib.request.Request(cfg["gateway"], data=payload, method="POST")
        req.add_header("Content-Type", "application/json")
        req.add_header("Authorization", "Bearer hermes123")
        req.add_header("X-Hermes-Session-Id", target_sid)
        result = _opener.open(req, timeout=600)
        data = json.loads(result.read())
        reply = data.get("choices", [{}])[0].get("message", {}).get("content", "")
        return reply.strip()
    except Exception as e:
        log(f"!!! Hermes API error: {e}")
        return ""


# ═══════════════════════════════════════════════════════════════
#  EasyTier control (Windows)
# ═══════════════════════════════════════════════════════════════

_EASYTIER_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
                             "tools", "easytier")
_EASYTIER_CORE = os.path.join(_EASYTIER_DIR, "easytier-core.exe")
_EASYTIER_PID_FILE = os.path.join(_EASYTIER_DIR, "easytier.pid")
_EASYTIER_NET = "--network-name mynet --network-secret ce75d0a5"
_EASYTIER_RELAY = "--peers tcp://47.115.32.206:11010"
_EASYTIER_IP = "--ipv4 10.144.144.3"
_EASYTIER_FLAGS = "--disable-encryption"  # TEMP: match relay+246; TODO remove after relay/246 also remove it



# ============================================================
#  RDP Toggle - remote desktop via SSH reverse tunnel (port 8080)
# ============================================================

_RDP_SSH_HOST = 'root@47.115.32.206'
_RDP_SSH_PORT = '8080'
_RDP_PID_FILE = os.path.join(os.path.dirname(__file__), 'gateway', 'scripts', 'rdp_tunnel.pid')

def _rdp_tunnel_pid():
    try:
        if os.path.exists(_RDP_PID_FILE):
            with open(_RDP_PID_FILE) as f:
                return int(f.read().strip())
    except Exception:
        pass
    return None

def _rdp_enable():
    import subprocess as _sp, winreg as _wr, time as _t
    try:
        k = _wr.OpenKey(_wr.HKEY_LOCAL_MACHINE, r'SYSTEM\CurrentControlSet\Control\Terminal Server', 0, _wr.KEY_SET_VALUE)
        _wr.SetValueEx(k, 'fDenyTSConnections', 0, _wr.REG_DWORD, 0)
        _wr.CloseKey(k)
    except Exception as e:
        log(f'RDP enable registry error: {e}')
    try:
        _sp.run(['net', 'localgroup', 'Remote Desktop Users', 'hmo', '/add'], capture_output=True, timeout=5)
    except Exception:
        pass
    _rdp_kill_tunnel()
    try:
        # ExitOnForwardFailure=yes: if remote port 8080 is already in use or
        #   sshd rejects the -R forwarding, ssh exits immediately instead of
        #   hanging with a dead tunnel (and xmpp_bot falsely reporting running).
        # DETACHED_PROCESS + CREATE_NEW_PROCESS_GROUP: ssh.exe survives parent
        #   (xmpp_bot) restart/crash — tunnel stays up even if bot auto-recovers.
        # stderr → log file: next failure is diagnosable without reproducing.
        # env注入: DETACHED_PROCESS 下的子进程可能会丢失 USERPROFILE/HOMEDRIVE/HOMEPATH,
        #   导致 ssh 找不到 ~/.ssh/id_rsa → "Permission denied (publickey)".
        # 找私钥: 在 LocalSystem 权限下 ~ 是 C:\WINDOWS\system32\config\systemprofile,
        #   不是 C:\Users\hmo, 所以要按候选路径搜索 .ssh/id_rsa.
        _ssh_key = None
        for _user_home in [os.path.expanduser('~'), r'C:\Users\hmo']:
            _candidate = os.path.join(_user_home, '.ssh', 'id_rsa')
            if os.path.isfile(_candidate):
                _ssh_key = _candidate
                break
        if not _ssh_key:
            return False, 'SSH private key (~/.ssh/id_rsa) not found in any candidate home directory'
        cmd = ['ssh.exe',
               '-i', _ssh_key,
               '-o', 'StrictHostKeyChecking=no',
               '-o', 'ServerAliveInterval=30',
               '-o', 'ExitOnForwardFailure=yes',
               '-o', 'IdentitiesOnly=yes',
               '-N', '-R', '0.0.0.0:8080:localhost:3389',
               'root@47.115.32.206']
        si = _sp.STARTUPINFO()
        si.dwFlags |= _sp.STARTF_USESHOWWINDOW
        _ssh_stderr_path = os.path.join(_LOG_DIR, 'rdp_tunnel_ssh.log')
        _ssh_err_fh = open(_ssh_stderr_path, 'a', encoding='utf-8')
        _ssh_err_fh.write(f"\n{'='*60}\n{_t.strftime('%Y-%m-%d %H:%M:%S')} RDP tunnel start\n")
        _ssh_err_fh.flush()
        # 显式复制父进程环境，补全 ssh 需要的 HOME/USERPROFILE
        _ssh_env = os.environ.copy()
        _ssh_env['HOME'] = os.path.expanduser('~')
        _ssh_env['USERPROFILE'] = os.path.expanduser('~')
        p = _sp.Popen(cmd, startupinfo=si,
                      stdout=_sp.DEVNULL, stderr=_ssh_err_fh,
                      env=_ssh_env,
                      creationflags=_sp.DETACHED_PROCESS | _sp.CREATE_NEW_PROCESS_GROUP)
        _t.sleep(3)
        if p.poll() is not None:
            _ssh_err_fh.close()
            return False, f'SSH tunnel exited immediately (code {p.poll()}). See {_ssh_stderr_path}'
        with open(_RDP_PID_FILE, 'w') as f:
            f.write(str(p.pid))
        log(f'RDP tunnel enabled (SSH reverse :8080, PID {p.pid}, detached)')
        return True, 'RDP access enabled'
    except Exception as e:
        return False, 'Failed: ' + str(e)

def _rdp_disable():
    import subprocess as _sp, winreg as _wr
    _rdp_kill_tunnel()
    try:
        k = _wr.OpenKey(_wr.HKEY_LOCAL_MACHINE, r'SYSTEM\CurrentControlSet\Control\Terminal Server', 0, _wr.KEY_SET_VALUE)
        _wr.SetValueEx(k, 'fDenyTSConnections', 0, _wr.REG_DWORD, 1)
        _wr.CloseKey(k)
        log('RDP access disabled')
        return True, 'RDP access disabled'
    except Exception as e:
        return False, 'Failed to disable RDP: ' + str(e)

def _rdp_kill_tunnel():
    import subprocess as _sp
    pid = _rdp_tunnel_pid()
    if pid:
        try:
            _sp.run(['taskkill', '/f', '/pid', str(pid)], capture_output=True, timeout=5)
            log('Killed RDP tunnel (PID ' + str(pid) + ')')
        except Exception:
            pass
        try:
            if os.path.exists(_RDP_PID_FILE):
                os.remove(_RDP_PID_FILE)
        except Exception:
            pass

def _rdp_status():
    import subprocess as _sp, winreg as _wr
    tunnel_on = False
    pid = _rdp_tunnel_pid()
    if pid:
        try:
            r = _sp.run(['tasklist', '/fi', 'PID eq ' + str(pid), '/fi', 'imagename eq ssh.exe'], capture_output=True, text=True, timeout=5)
            tunnel_on = 'ssh.exe' in r.stdout
        except Exception:
            pass
    rdp_on = False
    try:
        k = _wr.OpenKey(_wr.HKEY_LOCAL_MACHINE, r'SYSTEM\CurrentControlSet\Control\Terminal Server', 0, _wr.KEY_READ)
        v, _ = _wr.QueryValueEx(k, 'fDenyTSConnections')
        _wr.CloseKey(k)
        rdp_on = (v == 0)
    except Exception:
        pass
    return {'ok': True, 'tunnel_running': tunnel_on, 'rdp_enabled': rdp_on, 'rdp_port': 3389, 'tunnel_host': 'root@47.115.32.206', 'tunnel_port': 8080}

# ── OpenCode Go Usage Monitor helpers ──
# Reads cached aggregation from gateway/temp/usage_stats.json.
# Triggers asynchronous collection by spawning usage_collector.py in a daemon thread.
# Cache file path + collector script path:
_USAGE_CACHE_FILE = os.path.join(os.path.dirname(__file__), 'gateway', 'temp', 'usage_stats.json')
_USAGE_COLLECTOR_SCRIPT = os.path.join(_GATEWAY_SCRIPTS, 'usage_collector.py')
_usage_collector_running = False   # module-level lock flag

def _now_iso():
    """UTC ISO timestamp (avoids importing datetime everywhere we need this)."""
    from datetime import datetime, timezone
    return datetime.now(timezone.utc).isoformat(timespec='seconds')

def _usage_read_cache():
    """Return cached usage stats from temp/usage_stats.json, or empty payload if missing."""
    if not os.path.isfile(_USAGE_CACHE_FILE):
        return {'ok': False, 'error': 'no cached data yet (collect never ran)',
                'last_refresh_iso': None, 'accounts': []}
    try:
        with open(_USAGE_CACHE_FILE, 'r', encoding='utf-8') as f:
            data = json.load(f)
        # Don't pollute with no-stdout fields; just return the JSON object
        return data
    except Exception as e:
        return {'ok': False, 'error': f'failed to read cache: {e}',
                'last_refresh_iso': None, 'accounts': []}

def _usage_trigger_async():
    """
    Spawn a daemon thread that runs usage_collector.py via subprocess.
    Sets a module-level flag so concurrent calls don't overlap collections.

    The collector runs in its own process so it cannot crash the bot.
    Writes its result to gateway/temp/usage_stats.json (read by _usage_read_cache).
    """
    global _usage_collector_running
    if _usage_collector_running:
        log('usage: collect_now already in-flight, skipping trigger')
        return
    _usage_collector_running = True

    def _runner():
        global _usage_collector_running
        try:
            import subprocess as _sp
            log(f'usage: spawning usage_collector.py via {sys.executable}')
            # Run collector as one-shot --print so its stdout is captured but not displayed
            proc = _sp.run([sys.executable, _USAGE_COLLECTOR_SCRIPT],
                           capture_output=True, text=True, timeout=120)
            log(f'usage: collector finished (rc={proc.returncode}, '
                f'stdout_len={len(proc.stdout)}, stderr_len={len(proc.stderr)})')
            if proc.returncode != 0 and proc.stderr:
                log(f'usage_collector.py stderr:\n{proc.stderr[-1500:]}')
        except Exception as e:
            log(f'usage: collector thread crashed: {e}')
        finally:
            _usage_collector_running = False

    t = threading.Thread(target=_runner, name='usage_collector_runner', daemon=True)
    t.start()
    log('usage: collect_now background thread spawned')

# ── Auto-collect timer (every 5 min, keeps cookies alive) ──
_USAGE_AUTO_INTERVAL = 300  # 5 minutes

def _start_usage_auto_timer():
    """Start a daemon thread that auto-collects every 5 min (cookie keepalive + fresh data)."""
    def _loop():
        import time as _time
        _time.sleep(10)  # initial delay: let bot stabilize
        while True:
            try:
                _usage_trigger_async()
            except Exception as e:
                log(f'usage: auto-collect error: {e}')
            _time.sleep(_USAGE_AUTO_INTERVAL)
    t = threading.Thread(target=_loop, name='usage_auto_timer', daemon=True)
    t.start()
    log(f'usage: auto-timer started (interval={_USAGE_AUTO_INTERVAL}s)')

def _start_easytier():
    """Start EasyTier on Windows."""
    import subprocess as _sp
    import time as _time
    if not os.path.exists(_EASYTIER_CORE):
        log(f"EasyTier binary not found: {_EASYTIER_CORE}")
        return
    # Kill ALL existing easytier-core.exe processes FIRST (prevents duplicate conflicts)
    _sp.run(["taskkill", "/f", "/im", "easytier-core.exe"], capture_output=True, timeout=5)
    _time.sleep(1)
    # Clean stale PID file
    try:
        if os.path.exists(_EASYTIER_PID_FILE):
            os.remove(_EASYTIER_PID_FILE)
    except Exception:
        pass
    # Start
    cmd = f'start /b "" "{_EASYTIER_CORE}" {_EASYTIER_NET} {_EASYTIER_RELAY} {_EASYTIER_IP} {_EASYTIER_FLAGS} --no-listener'
    try:
        _sp.run(cmd, shell=True, timeout=5)
        log("EasyTier start command issued")
        # Write PID file so future calls can track this instance
        _time.sleep(2)
        r = _sp.run(["tasklist", "/fi", "imagename eq easytier-core.exe", "/fo", "csv"],
                     capture_output=True, text=True, timeout=5)
        import csv as _csv
        for row in _csv.reader(r.stdout.splitlines()):
            if len(row) >= 2 and row[0].strip('"') == "easytier-core.exe":
                pid = int(row[1])
                with open(_EASYTIER_PID_FILE, "w") as f:
                    f.write(str(pid))
                log(f"EasyTier started (PID {pid})")
                break
    except Exception as e:
        log(f"EasyTier start error: {e}")


def _stop_easytier():
    """Stop EasyTier on Windows."""
    import subprocess as _sp
    try:
        _sp.run(["taskkill", "/f", "/im", "easytier-core.exe"], capture_output=True, timeout=5)
        log("EasyTier stopped (taskkill)")
    except Exception as e:
        log(f"EasyTier stop error: {e}")
    # Clean up PID file
    try:
        if os.path.exists(_EASYTIER_PID_FILE):
            os.remove(_EASYTIER_PID_FILE)
    except Exception:
        pass


def _check_easytier() -> bool:
    """Check if EasyTier is running on Windows."""
    import subprocess as _sp
    try:
        r = _sp.run(["tasklist", "/fi", "imagename eq easytier-core.exe"],
                     capture_output=True, text=True, timeout=5)
        return "easytier-core.exe" in r.stdout
    except Exception:
        return False


# ═══════════════════════════════════════════════════════════════
#  Message Dedup
# ═══════════════════════════════════════════════════════════════

_DEDUP_CACHE: set[str] = set()
_DEDUP_LOCK = threading.Lock()


def _is_duplicate(msg_id: str) -> bool:
    if not msg_id:
        return False
    with _DEDUP_LOCK:
        if msg_id in _DEDUP_CACHE:
            return True
        _DEDUP_CACHE.add(msg_id)
        if len(_DEDUP_CACHE) > 100:
            _DEDUP_CACHE.clear()
        return False


# ═══════════════════════════════════════════════════════════════
#  Coordinator Protocol (shared across all agents)
# ═══════════════════════════════════════════════════════════════

_COORDINATOR: str = "mohe"
_GRANTED: str | None = None
_REVOKED_UNTIL: float = 0.0
_SHUTUP_PATTERNS = ["闭嘴", "别说话", "安静", "shut", "stfu", "别说了", "停"]


def _process_coordinator_signals(nickname: str, body: str) -> bool:
    """Parse coordinator/GRANT/REVOKE from incoming messages.
    Returns True if message was a control signal (consumed, no further processing)."""
    global _COORDINATOR, _GRANTED, _REVOKED_UNTIL
    # 1. hmo switches coordinator
    if nickname == 'hmo' and 'coordinator=' in body.lower():
        for name in ('mohe', 'zhiwei', 'xxm'):
            if f'coordinator={name}' in body.lower():
                _COORDINATOR = name
                _GRANTED = None
                log(f"Coordinator switched to {name} by hmo")
                return True
    # 2. GRANT signal (overrides REVOKE)
    gm = re.search(r'\[GRANT:(\w+)\]', body)
    if gm:
        _GRANTED = gm.group(1)
        _REVOKED_UNTIL = 0
        log(f"GRANT: {_GRANTED}")
        return True
    # 3. REVOKE signal (5min auto-restore)
    rm = re.search(r'\[REVOKE:(\w+)\]', body)
    if rm and rm.group(1) == cfg["nick"]:
        _REVOKED_UNTIL = time.time() + 300
        log(f"REVOKEd: {cfg['nick']} silenced for 5min")
        return True
    return False


def _check_shutup(body: str) -> bool:
    """hmo says shut up → 5min silence."""
    lower = body.lower().strip()
    for pat in _SHUTUP_PATTERNS:
        if pat.lower() in lower:
            _REVOKED_UNTIL = time.time() + 300
            log(f"(shutup: '{pat}' → 5min silence)")
            return True
    return False


def _process_llm_grant(response: str):
    """Parse GRANT signal from LLM's own response."""
    global _GRANTED
    gm = re.search(r'\[GRANT:(\w+)\]', response)
    if gm:
        _GRANTED = gm.group(1)
        _REVOKED_UNTIL = 0
        log(f"LLM GRANT: {_GRANTED}")


# ═══════════════════════════════════════════════════════════════
#  Response Extraction
# ═══════════════════════════════════════════════════════════════

_SILENCE_PATTERNS = [
    "保持沉默", "不应[该]?回复", "没有.*@.*我", "不是对[我我说]",
    "跟我无关", "我不用回复", "不该回复", "不参与",
    "不是我[应]?该[说回]",
]
_EXEC_RE = re.compile(r"##exec:(.+?)##", re.DOTALL)
_DELAY_RE = re.compile(r"##delay:?(\d+)?##")
_DELAY_DEFAULT = 15
_EXEC_TIMEOUT = 60


def _strip_toolcall_xml(text: str) -> str:
    t = text
    t = re.sub(r'<invoke\s+[^>]*>.*?(</invoke>|$)', '', t, flags=re.DOTALL)
    t = re.sub(r'<tool_calls>.*?(</tool_calls>|$)', '', t, flags=re.DOTALL)
    t = re.sub(r'<parameter\s+[^>]*>.*?(</parameter>|$)', '', t, flags=re.DOTALL)
    t = re.sub(r'<result>.*?(</result>|$)', '', t, flags=re.DOTALL)
    return t.strip()


def _extract_response(text: str) -> str | None:
    """Strip __SILENT__, reasoning blocks, natural language silence.
    Returns actual content to send, or None to stay silent."""
    if not text:
        return None
    t = text.strip()
    if not t:
        return None
    t = _strip_toolcall_xml(t)
    # Natural language silence detection
    if not t.startswith("__SILENT__"):
        first = t.split("\n", 1)[0]
        for pat in _SILENCE_PATTERNS:
            if re.search(pat, first):
                return None
        return t
    # Has __SILENT__ prefix
    parts = t.split("\n", 1)
    if len(parts) < 2:
        return None
    rest = parts[1].strip()
    while True:
        m = re.match(r'^（[^）]*）\s*', rest)
        if m:
            rest = rest[m.end():]
            continue
        m = re.match(r'^\([^)]*\)\s*', rest)
        if m:
            rest = rest[m.end():]
            continue
        break
    return rest.strip() or None


# ═══════════════════════════════════════════════════════════════
#  Message Batching (3s debounce + serialized processing)
# ═══════════════════════════════════════════════════════════════

_BATCH_WINDOW = 3.0
_BATCH_TIMEOUT = 300
_batch_entries: dict[str, list[str]] = {}
_batch_timers: dict[str, threading.Timer] = {}
_batch_processing: set[str] = set()
_batch_pending: dict[str, list[str]] = {}
_batch_lock = threading.Lock()
_BOT_NICK = cfg["nick"]


def _run_command(cmd: str) -> str:
    """Execute ##exec:command## shell command."""
    log(f"(exec: {cmd[:120]})")
    try:
        import subprocess
        r = subprocess.run(cmd, shell=True, capture_output=True,
                           timeout=_EXEC_TIMEOUT, text=True, encoding='utf-8', errors='replace')
        out = (r.stdout or "") + (r.stderr or "")
        out = out.strip() or f"(no output, exit={r.returncode})"
        log(f"(exec done: {len(out)} bytes, exit={r.returncode})")
        return out
    except subprocess.TimeoutExpired:
        log(f"(exec timeout >{_EXEC_TIMEOUT}s)")
        return "(命令超时)"
    except Exception as e:
        log(f"(exec error: {e})")
        return f"(命令执行失败: {e})"


def _schedule_delayed(delay_sec: int, room: str):
    """Schedule ##delay:N## re-invocation."""
    global _xmpp_ref
    import subprocess as _sp

    def _fire():
        bot = _xmpp_ref
        if not bot:
            return
        try:
            prompt = "时间到，请根据最新的信息汇报结果。"
            raw = _call_llm(prompt, room, is_group=True)
            reply = _extract_response(raw)
            if reply:
                text = reply.strip()
                bot.send_message(mto=room, mbody=text, mtype='groupchat')
                log(f"-> [Delay][{room}]: {text[:80]}")
        except Exception as e:
            log(f"!! delay err: {e}")

    t = threading.Timer(delay_sec, _fire)
    t.daemon = True
    t.start()
    log(f"(delay +{delay_sec}s → {room})")


def _batch_done(room: str):
    """Called when batch LLM finishes. Flush pending if any."""
    with _batch_lock:
        _batch_processing.discard(room)
        pending = _batch_pending.pop(room, None)
        if pending:
            _batch_entries[room] = pending
            t = threading.Timer(0.1, _fire_batch, args=[room])
            t.daemon = True
            t.start()
            _batch_timers[room] = t
            return
    log(f"[Batch][{room}] (idle)")


def _fire_batch(room: str):
    """Collect batched entries and call LLM."""
    with _batch_lock:
        entries = _batch_entries.pop(room, None)
        _batch_timers.pop(room, None)
        if not entries:
            return
        _batch_processing.add(room)
    combined = "\n".join(entries)

    def _handle():
        timed_out = [False]

        def _timeout():
            timed_out[0] = True
            log(f"[Batch][{room}] TIMEOUT ({_BATCH_TIMEOUT}s)")
            _batch_done(room)

        timer = threading.Timer(_BATCH_TIMEOUT, _timeout)
        timer.daemon = True
        timer.start()
        try:
            raw = _call_llm(combined, room, is_group=True)
            if not timed_out[0]:
                timer.cancel()
                _process_llm_reply(raw, room)
            else:
                log(f"[Batch][{room}] route returned after timeout, discarded")
        except Exception as e:
            log(f"!!! BATCH: {e}")
            if not timed_out[0]:
                timer.cancel()
                _batch_done(room)

    threading.Thread(target=_handle, daemon=True).start()


def _batch_group_message(room: str, nickname: str, body: str) -> bool:
    """Add message to room batch. Returns True if batched, False if @mention (immediate)."""
    if f"@{_BOT_NICK}" in body or body.startswith(_BOT_NICK):
        return False
    formatted = f"[{nickname}]: {body}"
    with _batch_lock:
        if room in _batch_processing:
            _batch_pending.setdefault(room, []).append(formatted)
            return True
        timer = _batch_timers.pop(room, None)
        if timer:
            timer.cancel()
        _batch_entries.setdefault(room, []).append(formatted)
        t = threading.Timer(_BATCH_WINDOW, _fire_batch, args=[room])
        t.daemon = True
        t.start()
        _batch_timers[room] = t
    return True


# ═══════════════════════════════════════════════════════════════
#  MAM Recovery — fetch recent history on startup
# ═══════════════════════════════════════════════════════════════

_MAM_RECOVERY = True
_MAM_RECOVERY_LOCK = threading.Lock()
_MAM_MARK_DONE = False
_MAM_TIMEOUT = 30


def _set_mam_done():
    global _MAM_RECOVERY
    with _MAM_RECOVERY_LOCK:
        _MAM_RECOVERY = False


def _is_mam_recovery() -> bool:
    if time.time() - _START_TIME > _MAM_TIMEOUT:
        global _MAM_RECOVERY
        with _MAM_RECOVERY_LOCK:
            if _MAM_RECOVERY:
                _MAM_RECOVERY = False
                log("(MAM recovery timed out, force-disabled)")
            return _MAM_RECOVERY
    with _MAM_RECOVERY_LOCK:
        return _MAM_RECOVERY


# ═══════════════════════════════════════════════════════════════
#  XML Escape
# ═══════════════════════════════════════════════════════════════

def _escape(text: str) -> str:
    return (text.replace("&", "&amp;").replace("<", "&lt;")
            .replace(">", "&gt;").replace('"', "&quot;"))


# ═══════════════════════════════════════════════════════════════
#  HTTP Bridge — health, presence, messages, POST send
# ═══════════════════════════════════════════════════════════════

_HTTP_PORT = cfg["http_port"]
_MSG_BUF: list[dict] = []
_MSG_BUF_LOCK = threading.Lock()
_xmpp_ref = None  # set after bot creation


def _record_group_msg(nickname: str, body: str):
    ts = time.strftime("%H:%M:%S")
    with _MSG_BUF_LOCK:
        _MSG_BUF.append({"ts": ts, "from": nickname, "body": body})
        if len(_MSG_BUF) > 200:
            _MSG_BUF[:] = _MSG_BUF[-150:]



_BRIDGE_API_KEY = cfg.get('bridge_api_key', '')

def _bridge_auth(self) -> bool:
    if not _BRIDGE_API_KEY:
        return True
    hdr = self.headers.get('X-Api-Key', '')
    if hdr == _BRIDGE_API_KEY:
        return True
    parsed = urllib.parse.urlparse(self.path)
    qs = urllib.parse.parse_qs(parsed.query)
    if qs.get('key', [None])[0] == _BRIDGE_API_KEY:
        return True
    return False

class _BridgeHandler(http.server.BaseHTTPRequestHandler):
    def do_GET(self):
        parsed = urllib.parse.urlparse(self.path)
        # /health 免认证，供监控系统使用
        if parsed.path == "/health":
            try:
                bot = _xmpp_ref
                session_ok = bot.session_started_event.is_set() if (bot and hasattr(bot, 'session_started_event')) else False
                socket_ok = bot.is_connected() if (bot and hasattr(bot, 'is_connected')) else False
                self._reply(200, {
                    "ok": True, "xmpp_connected": session_ok or socket_ok,
                    "agent": _agent_name, "jid": cfg["jid"],
                    "uptime_sec": int(time.time() - _START_TIME),
                    "muc_rooms": cfg["muc_rooms"],
                })
            except Exception as e:
                self._reply(500, {"ok": False, "error": str(e)})
            return
        if not _bridge_auth(self):
            self._reply(401, {'ok': False, 'error': 'unauthorized'})
            return
        if parsed.path == "/muc":
            try:
                muc_info = {"rooms": {}}
                bot = _xmpp_ref
                if bot is not None and 'xep_0045' in bot.plugin:
                    muc_plugin = bot.plugin['xep_0045']
                    for room_jid in cfg["muc_rooms"]:
                        room_data = {"jid": room_jid, "participants": []}
                        try:
                            if room_jid in muc_plugin.rooms:
                                room = muc_plugin.rooms[room_jid]
                                for nick, info in room.get('roster', {}).items():
                                    room_data["participants"].append({
                                        "nick": nick,
                                        "jid": str(info.get('jid', '')),
                                        "affiliation": str(info.get('affiliation', '')),
                                        "role": str(info.get('role', '')),
                                    })
                        except Exception as e:
                            room_data["error"] = str(e)
                        muc_info["rooms"][room_jid] = room_data
                self._reply(200, muc_info)
            except Exception as e:
                self._reply(500, {"ok": False, "error": str(e)})
            return
        if parsed.path.startswith("/presence"):
            jid_to_check = parsed.path[len("/presence/"):].strip()
            if not jid_to_check:
                self._reply(400, {"ok": False, "error": "missing JID"})
                return
            try:
                info = {"jid": jid_to_check, "online": False, "resources": []}
                bot = _xmpp_ref
                if bot and hasattr(bot, 'client_roster'):
                    roster = bot.client_roster
                    if jid_to_check in roster:
                        resources = list(roster[jid_to_check].resources.keys())
                        info["online"] = len(resources) > 0
                        info["resources"] = resources
                self._reply(200, info)
            except Exception as e:
                self._reply(500, {"ok": False, "error": str(e)})
            return
        if parsed.path == "/messages":
            try:
                qs = urllib.parse.parse_qs(parsed.query)
                sender = qs.get("from", [None])[0]
                with _MSG_BUF_LOCK:
                    msgs = list(_MSG_BUF)
                if sender:
                    msgs = [m for m in msgs if m["from"] == sender]
                self._reply(200, {"ok": True, "count": len(msgs), "messages": msgs[-50:]})
            except Exception as e:
                self._reply(500, {"ok": False, "error": str(e)})
            return
        self._reply(404, {"ok": False, "error": "not found"})

    def do_POST(self):
        if not _bridge_auth(self):
            self._reply(401, {'ok': False, 'error': 'unauthorized'})
            return
        try:
            length = int(self.headers.get('Content-Length', 0))
            body = json.loads(self.rfile.read(length))
            path = urllib.parse.urlparse(self.path).path.rstrip('/')


            if path == '/rdp':
                action = body.get('action', '')
                if action == 'start':
                    ok, msg = _rdp_enable()
                    self._reply(200, {'ok': ok, 'message': msg})
                elif action == 'stop':
                    ok, msg = _rdp_disable()
                    self._reply(200, {'ok': ok, 'message': msg})
                elif action == 'status':
                    self._reply(200, _rdp_status())
                else:
                    self._reply(400, {'ok': False, 'error': 'action must be start|stop|status'})
                return
            # /usage endpoint — OpenCode Go usage monitor (read cache / trigger collection)
            # Pattern mirrors /rdp and /easytier. Two actions:
            #   - status: return cached gateway/temp/usage_stats.json (instant, ~1ms)
            #   - collect_now: spawn background thread running usage_collector.py (non-blocking,
            #     refresh takes ~10-15s); client should poll status a few seconds later
            if path == "/usage":
                action = body.get('action', 'status')
                if action == 'status':
                    self._reply(200, _usage_read_cache())
                elif action == 'collect_now':
                    _usage_trigger_async()
                    self._reply(200, {'ok': True,
                                       'message': 'collection triggered (takes ~10-15s); poll GET /usage action=status shortly',
                                       'triggered_at': _now_iso()})
                else:
                    self._reply(400, {'ok': False, 'error': 'action must be status|collect_now'})
                return
            # /easytier endpoint — execute EasyTier action locally (no XMPP DM)
            if path == "/easytier":
                action = body.get("action", "")
                if action == "start":
                    _start_easytier()
                    self._reply(200, {"ok": True, "message": "EasyTier started"})
                elif action == "stop":
                    _stop_easytier()
                    self._reply(200, {"ok": True, "message": "EasyTier stopped"})
                elif action == "status":
                    running = _check_easytier()
                    self._reply(200, {"ok": True, "running": running})
                else:
                    self._reply(400, {"ok": False, "error": "action must be start|stop|status"})
                return
            # /send — 发送消息到 MUC 群聊
            if path == "/send":
                to = body.get('to', '')
                if not to:
                    rooms = cfg.get("muc_rooms", [])
                    to = rooms[0] if rooms else "coregroup@conference.yoin.fun"
                msg = body.get('message', '') or body.get('body', '')
                msg_type = body.get('type', 'groupchat')
                if not msg:
                    self._reply(400, {"ok": False, "error": "empty message"})
                    return
                bot = _xmpp_ref
                if bot:
                    bot.send_message(mto=to, mbody=msg.strip(), mtype=msg_type)
                _record_group_msg(cfg["nick"], msg)
                log(f"[http] → [{to.split('@')[0]}]: {msg[:80]} (type={msg_type})")
                self._reply(200, {"ok": True})
                return

            to = body.get('to', cfg["muc_rooms"][0])
            msg = body.get('message', '') or body.get('body', '')
            msg_type = body.get('type', 'groupchat')
            if not msg:
                self._reply(400, {"ok": False, "error": "empty message"})
                return
            safe = _escape(msg.strip())
            bot = _xmpp_ref
            if bot:
                bot.send_message(mto=to, mbody=msg.strip(), mtype=msg_type)
            _record_group_msg(cfg["nick"], msg)
            log(f"[http] → [{to.split('@')[0]}]: {msg[:80]} (type={msg_type})")
            self._reply(200, {"ok": True})
        except Exception as e:
            self._reply(500, {"ok": False, "error": str(e)})

    def _reply(self, code, data):
        body = json.dumps(data, ensure_ascii=False).encode('utf-8')
        self.send_response(code)
        self.send_header('Content-Type', 'application/json; charset=utf-8')
        self.send_header('Content-Length', len(body))
        self.end_headers()
        self.wfile.write(body)

    def log_message(self, format, *args):
        pass


def _start_http_bridge():
    _httpd = http.server.HTTPServer(('0.0.0.0', _HTTP_PORT), _BridgeHandler)
    _t = threading.Thread(target=_httpd.serve_forever, daemon=True)
    _t.start()
    log(f"HTTP bridge ready on :{_HTTP_PORT}")


# ═══════════════════════════════════════════════════════════════
#  Reply Processing (shared for all LLM responses)
# ═══════════════════════════════════════════════════════════════

def _process_llm_reply(raw_reply: str, room: str):
    """Process LLM response: check silence/delay/exec/send."""
    global _xmpp_ref
    if not raw_reply:
        _batch_done(room)
        return
    # Parse GRANT signal from LLM response
    _process_llm_grant(raw_reply)
    # ##delay:N## → schedule later
    delay_m = _DELAY_RE.search(raw_reply)
    if delay_m:
        sec = int(delay_m.group(1)) if delay_m.group(1) else _DELAY_DEFAULT
        _schedule_delayed(sec, room)
        _batch_done(room)
        return
    # ##exec:command## → run command, use output as reply
    exec_m = _EXEC_RE.search(raw_reply)
    if exec_m:
        output = _run_command(exec_m.group(1))
        raw_reply = _EXEC_RE.sub(output, raw_reply, count=1)
    # Extract actual response
    reply_text = _extract_response(raw_reply)
    if reply_text:
        text = reply_text.strip()
        bot = _xmpp_ref
        if bot:
            bot.send_message(mto=room, mbody=text, mtype='groupchat')
            log(f"-> [{room.split('@')[0]}]: {text[:80]}")
    else:
        log(f"-> [{room.split('@')[0]}]: (silent)")
    _batch_done(room)


# ═══════════════════════════════════════════════════════════════
#  Group message handler
# ═══════════════════════════════════════════════════════════════

def _handle_group_message(msg):
    """Process a groupchat message (runs in thread)."""
    global _COORDINATOR, _GRANTED, _REVOKED_UNTIL
    if _is_mam_recovery():
        return
    msg_id = msg.get("id", "")
    if _is_duplicate(msg_id):
        return
    body = str(msg["body"]).strip()
    if not body:
        return
    full_from = str(msg["from"])
    room = full_from.split("/")[0]
    nickname = full_from.split("/")[1] if "/" in full_from else ""
    # Self-message skip
    if nickname == cfg["nick"]:
        log(f"(self) {body[:80]}")
        return
    _record_group_msg(nickname, body)
    # Coordinator signals
    if _process_coordinator_signals(nickname, body):
        return
    # Revoke check
    is_revoked = time.time() < _REVOKED_UNTIL
    if is_revoked and _GRANTED == cfg["nick"]:
        _GRANTED = None
        is_revoked = False
        log(f"GRANT overrides REVOKE for {cfg['nick']}")
    if _check_shutup(body):
        return
    if is_revoked:
        body = f"【只读消息】你被收回发言权。只需了解内容。输出 __SILENT__。\n\n[核心群 {room}] {nickname} 说: {body}"
    # Batch or immediate (@mention)
    if _batch_group_message(room, nickname, body):
        log(f"[{room.split('@')[0]}] {nickname}: {body[:80]} (batched)")
        return
    log(f"[{room.split('@')[0]}] {nickname}: {body[:80]}")
    raw = _call_llm(body, full_from, is_group=True)
    _process_llm_reply(raw, room)


# ═══════════════════════════════════════════════════════════════
#  Private message handler
# ═══════════════════════════════════════════════════════════════

def _handle_private_message(msg):
    """Process a private chat message."""
    global _CALL_SEQ
    if msg["type"] == "groupchat":
        return
    msg_id = msg.get("id", "")
    if _is_duplicate(msg_id):
        return
    body = str(msg["body"]).strip()
    sender = str(msg["from"]).split("/")[0]
    log(f"<{sender}> {body[:80]}")
    if sender == cfg["jid"]:
        log("(skipped self)")
        return
    if time.time() < _REVOKED_UNTIL:
        log(f"(silenced) <{sender}> dropped")
        return
    if _check_shutup(body):
        return
    # ── Kanban routing ──
    _CALL_SEQ += 1
    is_kanban = body.startswith('[Kanban]')
    target_sid = _KANBAN_SESSION_ID if is_kanban else cfg["session_id"]
    if is_kanban:
        log(f"📋 看板通知(#{_CALL_SEQ}): {body[:80]}")
    # ── EasyTier toggle ──
    if body.startswith('[EasyTier]'):
        action = body.replace('[EasyTier]', '').strip().lower()
        log(f"🔌 EasyTier command: {action}")
        if action == 'start':
            _start_easytier()
            reply_text = "[EasyTier] started on Windows"
        elif action == 'stop':
            _stop_easytier()
            reply_text = "[EasyTier] stopped on Windows"
        else:
            reply_text = f"[EasyTier] unknown action: {action}"
        bot = _xmpp_ref
        if bot:
            bot.send_message(mto=sender, mbody=reply_text, mtype='chat')
            log(f"-> {sender}: {reply_text}")
        return
    raw = _call_llm(body, sender, is_group=False, session_id=target_sid)
    if raw:
        reply = _extract_response(raw)
        if reply:
            text = reply.strip()
            bot = _xmpp_ref
            if bot:
                bot.send_message(mto=sender, mbody=text, mtype='chat')
                log(f"-> {sender}: {text[:80]}")


# ═══════════════════════════════════════════════════════════════
#  AgentBot Class
# ═══════════════════════════════════════════════════════════════

import slixmpp


class AgentBot(slixmpp.ClientXMPP):
    def __init__(self):
        super().__init__(cfg["jid"], cfg["password"])
        # Connection settings
        self.enable_direct_tls = False
        self.enable_starttls = True
        self.auto_reconnect = True
        self.reconnect_max_delay = 10
        self.whitespace_keepalive = True
        self.whitespace_keepalive_interval = 30
        # SSL: accept self-signed certs
        ctx = ssl.create_default_context()
        ctx.check_hostname = False
        ctx.verify_mode = ssl.CERT_NONE
        self.ssl_context = ctx
        # Event handlers
        self.add_event_handler("session_start", self._on_session_start)
        self.add_event_handler("message", self._on_any_message)
        self.add_event_handler("groupchat_message", self._on_group_msg)
        self.add_event_handler("disconnected", self._on_disconnected)
        self.add_event_handler("connected", self._on_connected)
        self.add_event_handler("session_end", self._on_session_end)
        self.add_event_handler("connection_failed", self._on_conn_failed)
        # MUC plugin
        self.register_plugin('xep_0045')

    def _on_connected(self, event):
        log("connection established")

    def _on_session_start(self, event):
        self.send_presence()
        self.get_roster()
        log(f"{cfg['jid']} online")
        # Register MAM plugin lazily
        try:
            self.register_plugin('xep_0313')
        except Exception:
            log("(MAM: xep_0313 not available)")
        # Join MUC rooms
        async def _join_all():
            for room_jid in cfg["muc_rooms"]:
                try:
                    self.plugin['xep_0045'].join_muc(room_jid, cfg["nick"])
                    presence = (
                        f"<presence to='{room_jid}/{cfg['nick']}'>"
                        f"<x xmlns='http://jabber.org/protocol/muc'>"
                        f"<history maxstanzas='0'/>"
                        f"</x></presence>"
                    )
                    self.send_raw(presence)
                    log(f"Joined {room_jid}")
                except Exception as e:
                    log(f"MUC join failed {room_jid}: {e}")
                await asyncio.sleep(2)
            await asyncio.sleep(3)
            await self._fetch_mam_history()
        asyncio.ensure_future(_join_all())

    async def _fetch_mam_history(self):
        """Query MAM for recent MUC messages to rebuild context."""
        if 'xep_0313' not in self.plugin:
            log("(MAM: no plugin)")
            _set_mam_done()
            return
        # MAM recovery used in _on_session_start
        try:
            for room_jid in cfg["muc_rooms"]:
                log(f"(MAM: querying {room_jid} for last 50 messages...)")
                results = await self.plugin['xep_0313'].retrieve(
                    jid=room_jid, rsm={'max': 50},
                )
                count = 0
                for msg in results['mam']['results']:
                    forwarded = msg['mam_result']['forwarded']
                    body = str(forwarded['stanza']['body'] or '').strip()
                    if not body:
                        continue
                    nick = str(forwarded['stanza']['from']).split('/')[-1] if '/' in str(forwarded['stanza']['from']) else '?'
                    # Feed into context for chat_bridge (xxm)
                    if _IS_CHAT_BRIDGE:
                        role = 'user' if nick != cfg["nick"] else 'assistant'
                        try:
                            _bridge._append_to_log(role, f"[{nick}]: {body[:300]}")
                        except Exception:
                            pass
                    count += 1
                log(f"(MAM: loaded {count} msgs from {room_jid})")
            _set_mam_done()
            log("(MAM recovery complete)")
        except Exception as e:
            log(f"(MAM error: {e})")
            _set_mam_done()

    def _on_group_msg(self, msg):
        threading.Thread(target=_handle_group_message, args=[msg], daemon=True).start()

    def _on_any_message(self, msg):
        threading.Thread(target=_handle_private_message, args=[msg], daemon=True).start()

    def _on_session_end(self, event):
        log(f"session ended")

    def _on_conn_failed(self, event):
        log(f"connection failed: {event}")

    def _on_disconnected(self, event):
        log(f"disconnected, reconnecting...")


# ═══════════════════════════════════════════════════════════════
#  Main
# ═══════════════════════════════════════════════════════════════

def main():
    log(f"Starting {cfg['jid']} ({cfg['name_cn']}) — agent={_agent_name}")
    if _IS_CHAT_BRIDGE:
        log(f"  LLM: chat_bridge (session={cfg['session_id']})")
    else:
        log(f"  LLM: Hermes API ({cfg['gateway']})")
    log(f"  Server: {cfg['server']}:{cfg['port']}")
    log(f"  Rooms: {cfg['muc_rooms']}")

    bot = AgentBot()
    global _xmpp_ref
    _xmpp_ref = bot

    _start_http_bridge()
    _start_usage_auto_timer()

    bot.connect(host=cfg["server"], port=cfg["port"])
    log(f"Connecting {cfg['jid']}@{cfg['server']}:{cfg['port']}")

    loop = asyncio.get_event_loop()

    async def _status_check():
        skipped = 0
        while True:
            await asyncio.sleep(60)
            alive = bot and bot.is_connected() if hasattr(bot, 'is_connected') else False
            if not alive:
                skipped += 1
                log(f"[R02] XMPP 连接已断开 ({skipped}/3)，等待重连...")
                if skipped >= 3:
                    log("[R02] 连续 3 次检测到连接断开，退出进程让 systemd 重启")
                    os._exit(1)
            else:
                if skipped > 0:
                    log(f"(连接已恢复，跳过 {skipped} 次检测)")
                    skipped = 0

    asyncio.ensure_future(_status_check())

    try:
        loop.run_forever()
    except KeyboardInterrupt:
        log("Shutdown by user")
    except Exception as e:
        log(f"!!! MAIN LOOP CRASH: {e}")
        import traceback
        log(f"!!! {traceback.format_exc()[:500]}")
        raise


if __name__ == "__main__":
    main()
