Files
DadaoTask/backend/app/services/wechat_bot.py
T
Steven 07b01db7cc
CI / backend (push) Canceled after 0s
fix(integrations): 一个通道只让一个进程收消息,gunicorn 双进程不再抢同一条会话
生产是 `gunicorn -w 2`,两个进程各自跑 lifespan,start_all() 会把同一个通道的
长轮询拉两遍:消息被其中一个消费、回复从另一个发出,游标还会互相覆盖。

改成 Postgres 会话级 advisory lock 做租约:抢到的进程收消息,另一个每 10 秒重试,
主进程一挂连接断、锁释放,备用进程立刻接上。页面上的"接收中"只认抢到租约的那个
(`manager.polling()`),不再把等锁的也算进去。

补测试 `test_only_one_process_holds_the_polling_lease`:
第一个抢到、第二个抢不到、第一个放手后第三个能接上。
2026-11-05 23:28:35 +08:00

335 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""微信机器人的后台轮询:登录之后由它把消息捞进来。
为什么是长轮询而不是 webhook:iLink Bot 协议就是"登录后由我们主动去拉",
服务端只在下一条消息到达时返回(最长等 75 秒)。所以每个通道一个常驻任务。
几条必须守住的规矩:
1. **游标要落库**:`get_updates_buf` 是下次从哪里接着读的凭据,丢了就会重复收、
把同一条任务记两遍。
2. **一条消息只处理一次**:`bot_message.external_id` 有唯一索引,重复投递时先撞唯一键。
3. **token 失效要停**:服务端 errcode `-14` 表示登录态作废,这时停轮询、把通道标记成
需要重新扫码,别拿死 token 一直撞墙。
4. **回复失败不影响已经落库的事项**:任务记下了就是记下了,回不回得去另说。
5. **一个通道只能有一个进程在收**:生产是 gunicorn `-w 2`,两个进程各自跑 lifespan,
不加锁就会出现两条 getupdates 同时拉同一个会话——消息被其中一个消费、回复却从另一个发出,
游标还会互相覆盖。用 Postgres 的 advisory lock 做租约:抢到的那个进程收,另一个等着接班。
"""
from __future__ import annotations
import asyncio
import contextlib
import uuid
from typing import Any
from sqlalchemy import select
from sqlalchemy import text as sa_text
from ..config import get_settings
from ..db import SessionFactory, bind_tenant, engine
from ..logging_setup import get_logger
from ..models import Workspace
from ..models_integrations import BotChannel, BotMessage
from . import wechat_ilink
from .wechat_intake import handle_text
logger = get_logger("wechat_bot")
# 长轮询一次最多等这么久(服务端 75 秒超时,客户端留点余量)。
POLL_TIMEOUT_SECONDS = 80.0
RETRY_DELAY_SECONDS = 5.0
# 抢租约失败时隔多久再试:主进程挂了,备用进程要能在一两个呼吸之内接上。
LEASE_RETRY_SECONDS = 10.0
LEASE_NAMESPACE = "dadao.wechat_bot"
def site_base() -> str:
"""回复里的链接用哪个域名。生产必须配 PUBLIC_BASE_URL,否则只能退回 CORS 里的第一个。"""
settings = get_settings()
if settings.public_base_url:
return settings.public_base_url.rstrip("/")
if settings.cors_origins:
return str(settings.cors_origins[0]).rstrip("/")
return ""
async def acquire_lease(channel_id: uuid.UUID):
"""抢"这个通道由我收消息"的租约;抢到返回连接(关掉就是释放),抢不到返回 None。
用会话级 advisory lock:锁挂在连接上,进程崩了连接断,锁自然释放,
不会留下"死进程占着锁没人收消息"的局面。
"""
conn = await engine.connect()
try:
result = await conn.execute(
sa_text("SELECT pg_try_advisory_lock(hashtext(:ns), hashtext(:cid))"),
{"ns": LEASE_NAMESPACE, "cid": str(channel_id)},
)
if result.scalar():
return conn
except Exception:
await conn.close()
raise
await conn.close()
return None
class BotManager:
"""一个进程里的轮询任务表:一个通道一个任务。"""
def __init__(self) -> None:
self._tasks: dict[uuid.UUID, asyncio.Task[None]] = {}
# 抢到租约、真正在拉消息的通道;备用进程的任务在等租约,不在这里。
self._polling: set[uuid.UUID] = set()
# ── 生命周期 ──────────────────────────────────────────
async def start(self, channel_id: uuid.UUID) -> None:
task = self._tasks.get(channel_id)
if task is not None and not task.done():
return
self._tasks[channel_id] = asyncio.create_task(
self._run(channel_id), name=f"wechat-bot-{channel_id}"
)
async def stop(self, channel_id: uuid.UUID) -> None:
task = self._tasks.pop(channel_id, None)
if task is None:
return
task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await task
async def start_all(self) -> None:
"""进程启动时把已登录的通道都拉起来,不然重启就静默不收消息了。"""
async with SessionFactory() as session:
rows = await session.execute(
select(BotChannel.id).where(
BotChannel.status == "active",
BotChannel.bot_token_enc.is_not(None),
)
)
ids = [row[0] for row in rows]
for channel_id in ids:
await self.start(channel_id)
if ids:
logger.info("微信机器人已恢复", extra={"extra_fields": {"channels": len(ids)}})
async def shutdown(self) -> None:
for channel_id in list(self._tasks):
await self.stop(channel_id)
def running(self, channel_id: uuid.UUID) -> bool:
task = self._tasks.get(channel_id)
return task is not None and not task.done()
def polling(self, channel_id: uuid.UUID) -> bool:
"""只有真在拉消息的那个进程才算,"页面上的接收中"认这个。"""
return channel_id in self._polling
# ── 轮询主体 ──────────────────────────────────────────
async def _load(self, channel_id: uuid.UUID) -> tuple[BotChannel, str] | None:
"""读通道并解出 token。返回 None 表示这个通道不该再轮询了。"""
async with SessionFactory() as session:
channel = await self._get_channel(session, channel_id)
if channel is None or channel.status != "active" or not channel.bot_token_enc:
return None
try:
token = wechat_ilink.decrypt_token(channel.bot_token_enc)
except wechat_ilink.WechatError:
await self._mark_relogin(channel_id, "登录态解不开(JWT_SECRET 可能换过)")
return None
session.expunge(channel)
return channel, token
async def _run(self, channel_id: uuid.UUID) -> None:
lease = await self._wait_for_lease(channel_id)
if lease is None:
self._tasks.pop(channel_id, None)
return
try:
await self._poll_forever(channel_id)
finally:
self._polling.discard(channel_id)
self._tasks.pop(channel_id, None)
await lease.close()
async def _wait_for_lease(self, channel_id: uuid.UUID):
"""抢不到就等着——主进程一挂,最多 LEASE_RETRY_SECONDS 就由这里接上。"""
while True:
lease = await acquire_lease(channel_id)
if lease is not None:
self._polling.add(channel_id)
return lease
await asyncio.sleep(LEASE_RETRY_SECONDS)
async def _poll_forever(self, channel_id: uuid.UUID) -> None:
loaded = await self._load(channel_id)
if loaded is None:
return
channel, token = loaded
cursor = channel.sync_buf or ""
failures = 0
while True:
try:
update = await wechat_ilink.get_updates(
token, cursor, base_url=channel.base_url, timeout=POLL_TIMEOUT_SECONDS
)
except asyncio.CancelledError:
raise
except wechat_ilink.WechatError as exc:
if exc.stale_token:
await self._mark_relogin(channel_id, str(exc))
return
failures += 1
await self._note_error(channel_id, str(exc))
await asyncio.sleep(RETRY_DELAY_SECONDS if failures < 10 else 60.0)
continue
except Exception as exc: # 轮询任务不能死,死了就永远收不到消息
failures += 1
logger.warning("微信长轮询异常:%s", exc)
await asyncio.sleep(RETRY_DELAY_SECONDS)
continue
failures = 0
for message in update.messages:
try:
await self._handle(channel_id, message, token, channel.base_url)
except Exception as exc: # 单条失败不拖垮整轮
logger.warning("微信消息处理失败:%s", exc)
if update.cursor and update.cursor != cursor:
cursor = update.cursor
await self._save_cursor(channel_id, cursor)
# ── 单条消息 ──────────────────────────────────────────
async def _handle(
self,
channel_id: uuid.UUID,
message: wechat_ilink.Inbound,
token: str,
base_url: str | None,
) -> None:
async with SessionFactory() as session:
channel = await self._get_channel(session, channel_id)
if channel is None or channel.status != "active":
return
# 读通道时只设了 app.bot_channel;workspace 表同样有 RLS,
# 得先把通道所属工作区告诉数据库,才能读到那一行。
await session.execute(
sa_text("SELECT set_config('app.ws_id', :ws, true)"),
{"ws": str(channel.workspace_id)},
)
workspace = await session.get(Workspace, channel.workspace_id)
if workspace is None:
return
actor = channel.created_by or workspace.owner_id
await bind_tenant(session, channel.workspace_id, actor)
existing = await session.execute(
select(BotMessage).where(
BotMessage.channel_id == channel_id,
BotMessage.external_id == message.message_id,
)
)
if existing.scalars().first() is not None:
logger.info("微信消息重复投递,跳过:%s", message.message_id)
return
result: dict[str, Any]
if message.text:
result = await handle_text(session, channel, message.text, base_url=site_base())
elif message.kinds:
result = {
"action": "ignored",
"item_id": None,
"reply": "图片、文件、语音暂时还没搬进来,发文字最快。",
}
else:
result = {"action": "ignored", "item_id": None, "reply": "没看懂这条消息。"}
session.add(
BotMessage(
workspace_id=channel.workspace_id,
channel_id=channel_id,
external_id=message.message_id,
sender=message.sender[:128],
text=message.text[:4000],
action=str(result["action"]),
item_id=result.get("item_id"),
reply=str(result.get("reply") or "")[:2000],
raw={"kinds": message.kinds, "message_id": message.message_id},
)
)
channel.last_seen_at = _now()
channel.context_token = message.context_token
channel.last_error = None
await session.commit()
reply = str(result.get("reply") or "")
if reply:
try:
await wechat_ilink.send_text(
token,
message.sender,
message.context_token,
reply,
base_url=base_url,
)
except wechat_ilink.WechatError as exc:
await self._note_error(channel_id, f"回复失败:{exc}")
async def _get_channel(self, session, channel_id: uuid.UUID) -> BotChannel | None:
"""按主键取通道。
轮询任务一开始没有工作区上下文,RLS 会把行藏起来;迁移 0014 给了一条
`app.bot_channel` 的按主键口子,所以先设 GUC 再读。
"""
await session.execute(
sa_text("SELECT set_config('app.bot_channel', :cid, true)"), {"cid": str(channel_id)}
)
return await session.get(BotChannel, channel_id)
# ── 落库小工具 ────────────────────────────────────────
async def _save_cursor(self, channel_id: uuid.UUID, cursor: str) -> None:
async with SessionFactory() as session:
channel = await self._get_channel(session, channel_id)
if channel is None:
return
channel.sync_buf = cursor[:4000]
await session.commit()
async def _note_error(self, channel_id: uuid.UUID, message: str) -> None:
async with SessionFactory() as session:
channel = await self._get_channel(session, channel_id)
if channel is None:
return
channel.last_error = message[:500]
await session.commit()
async def _mark_relogin(self, channel_id: uuid.UUID, message: str) -> None:
"""登录态作废:停用这条通道并记下原因,界面上提示重新扫码。"""
async with SessionFactory() as session:
channel = await self._get_channel(session, channel_id)
if channel is None:
return
channel.bot_token_enc = None
channel.sync_buf = None
channel.status = "expired"
channel.context_token = None
channel.last_error = message[:500]
await session.commit()
def _now():
from datetime import datetime, timezone
return datetime.now(timezone.utc)
manager = BotManager()