118 lines
4.8 KiB
Python
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
|