"""微信(ClawBot)入站长轮询启动器(§8.7;从 app.py lifespan 析出,2026-07-23)。 每个 active 绑定一条 getupdates 长轮询;收到消息 → 跑用户常驻「微信」task → 回复发回(渠道无关核心在 web/runs.py::run_channel_conversation)。 蓝绿双实例用 PG advisory lock 选主,单实例恒拿锁行为不变。 """ from __future__ import annotations import asyncio from typing import Optional from uuid import UUID from .runs import run_channel_conversation def start_wechat_inbound(app) -> tuple[Optional[asyncio.Task], asyncio.Event]: """ClawBot 入站启用时起 leader 选举 task;未启用返 (None, stop_event)。 stop_event:关停时先 set 再 cancel task(leader 循环与 keepalive 都在等它)。 """ from core.wechat.service import clawbot_enabled stop = asyncio.Event() if not clawbot_enabled(): return None, stop from core.wechat.inbound import run_inbound_manager async def _run_wechat_message(uid: UUID, text: str, attachments=None) -> str: """微信(ClawBot)入站一条消息 → 跑用户常驻「微信」task → 取回复。 attachments:已下载解密的入站附件(core.wechat.ilink.InboundAttachment,att.data 已回填)。 建/复用 task、落盘附件、抢 run 锁、跑 agent 全在渠道无关核心 `run_channel_conversation` 里(企业微信回调走同一核心,channel='wecom')。 """ return await run_channel_conversation(app, uid, text, attachments, channel="wechat") async def _wechat_leader() -> None: """ClawBot 入站长轮询单实例互斥(蓝绿 B 档,RUN.md)。 getupdates 长轮询是消费型的:蓝绿并存期两实例同时拉会抢消息 / 同一条 入站双跑 agent。用 PG advisory lock 选主:持锁实例才跑 inbound manager, 另一实例每 15s 重试;持锁连接断(DB 重启 / 网络抖 = 锁已自动释放)→ 停管理器重新竞争。单实例部署恒拿到锁,行为与之前完全一致。 """ from core.storage import get_engine engine = get_engine() # pg_try_advisory_lock(int4, int4) 的固定 key:('zc'=0x7A63, 1=wechat-inbound) lock_sql = "SELECT pg_try_advisory_lock(31331, 1)" def _acquire(): conn = engine.connect() # 从连接池摘出独占:advisory lock 是 DBAPI 连接级的,不 detach 的话 # close() 只是还池,锁挂在池化连接上既不释放也没人持有 conn.detach() try: if conn.exec_driver_sql(lock_sql).scalar(): return conn conn.close() return None except Exception: conn.close() raise async def _wait(seconds: float) -> None: try: await asyncio.wait_for(stop.wait(), timeout=seconds) except asyncio.TimeoutError: pass while not stop.is_set(): conn = None mgr = None try: conn = await asyncio.to_thread(_acquire) if conn is None: await _wait(15) continue print("[wechat] inbound leader lock acquired, manager starting") mgr = asyncio.create_task( run_inbound_manager(_run_wechat_message, stop), name="wechat-inbound", ) # 30s keepalive ping 锁连接;ping 挂 = 连接断 = 锁已丢 → 重选 while not stop.is_set() and not mgr.done(): await _wait(30) if stop.is_set() or mgr.done(): break try: await asyncio.to_thread( lambda: conn.exec_driver_sql("SELECT 1").scalar() ) except Exception as e: print(f"[wechat] leader lock lost " f"({type(e).__name__}), re-electing") break except asyncio.CancelledError: raise except Exception as e: print(f"[wechat] leader loop error: {type(e).__name__}: {e}") await _wait(15) finally: if mgr is not None and not mgr.done(): mgr.cancel() try: await mgr except (asyncio.CancelledError, Exception): pass if conn is not None: try: conn.close() except Exception: pass task = asyncio.create_task(_wechat_leader(), name="wechat-leader") print("[wechat] ClawBot inbound enabled (PG advisory lock leader election)") return task, stop