zcbot/web/wechat_runner.py

118 lines
4.8 KiB
Python

"""微信(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