ForcePilot/backend/package/yuxi/channels/adapters/zalo_oa/polling.py
Kris 30dd9e16f4 feat(zalo-oa): 实现完整的 Zalo OA 渠道适配器模块
新增 Zalo OA 官方账号完整集成能力,包含:
1. 基础通信能力:消息编解码、目标归一化、文本分块
2. 安全与校验:Webhook 签名验证、DM 策略管理、配对流程
3. 辅助工具:重复事件去重、请求限流、异常告警
4. 管理功能:账号多实例管理、配置验证、健康诊断
5. 扩展能力:媒体托管、视觉识别、TTS 语音合成
6. 运维支持:审计日志、状态监控、目录同步
2026-05-12 00:52:47 +08:00

159 lines
5.8 KiB
Python

from __future__ import annotations
import asyncio
import time
from typing import Any
from collections.abc import Callable
from yuxi.utils.logging_config import logger
DEFAULT_POLLING_INTERVAL_SEC = 5
DEFAULT_POLLING_TIMEOUT_MS = 30000
DEFAULT_HEALTH_CHECK_INTERVAL = 10
class ZaloOAPoller:
"""Zalo OA Long Polling 模式 — 当没有配置 Webhook URL 时的消息轮询回退方案.
由于 Zalo OA API 无原生 getUpdates 端点,轮询通过:
1. 周期性同步 follower 列表检测新用户
2. 监控 follower 数量变化作为活动信号
3. 周期性 OA profile 健康探测验证连通性
"""
def __init__(
self,
client: Any,
config: dict[str, Any] | None = None,
message_callback: Callable[[bytes, dict | None], Any] | None = None,
):
self._client = client
self._config = config or {}
self._message_callback = message_callback
self._interval = self._config.get("polling_interval_sec", DEFAULT_POLLING_INTERVAL_SEC)
self._timeout_ms = self._config.get("polling_timeout_ms", DEFAULT_POLLING_TIMEOUT_MS)
self._health_check_interval = self._config.get("polling_health_check_interval", DEFAULT_HEALTH_CHECK_INTERVAL)
self._running = False
self._task: asyncio.Task | None = None
self._last_follower_count = 0
self._poll_count = 0
self._last_poll_at: float | None = None
self._error_count = 0
self._last_health_status: dict[str, Any] = {}
self._health_check_count = 0
self._follower_delta_total = 0
@property
def is_running(self) -> bool:
return self._running
@property
def poll_count(self) -> int:
return self._poll_count
async def start(self):
if self._running:
return
self._running = True
self._task = asyncio.create_task(self._poll_loop())
logger.info(
f"[ZaloOA] Polling started: interval={self._interval}s, "
f"timeout={self._timeout_ms}ms, health_check_interval={self._health_check_interval}"
)
async def stop(self):
self._running = False
if self._task and not self._task.done():
self._task.cancel()
try:
await self._task
except asyncio.CancelledError:
pass
self._task = None
logger.info(f"[ZaloOA] Polling stopped after {self._poll_count} cycles")
async def _poll_loop(self):
while self._running:
try:
await self._poll_once()
self._error_count = 0
except asyncio.CancelledError:
break
except Exception as e:
self._error_count += 1
backoff = min(self._interval * (2 ** min(self._error_count, 4)), 300)
logger.warning(f"[ZaloOA] Poll cycle failed (error #{self._error_count}): {e}, backing off {backoff}s")
await asyncio.sleep(backoff)
continue
await asyncio.sleep(self._interval)
async def _poll_once(self):
try:
follower_data = await self._client.get_followers(offset=0, count=1)
follower_count = follower_data.get("total", 0)
except Exception:
follower_count = self._last_follower_count
follower_delta = follower_count - self._last_follower_count
self._last_follower_count = follower_count
self._follower_delta_total += follower_delta
self._poll_count += 1
self._last_poll_at = time.time()
if abs(follower_delta) > 0:
logger.info(
f"[ZaloOA] Poll #{self._poll_count}: follower_count={follower_count} "
f"(delta={follower_delta:+d}), errors={self._error_count}"
)
if self._poll_count % self._health_check_interval == 0:
await self._run_health_check()
if self._poll_count % 50 == 0:
logger.info(
f"[ZaloOA] Poll summary: cycles={self._poll_count}, "
f"followers={follower_count}, errors={self._error_count}, "
f"health_status={self._last_health_status.get('status', 'unknown')}, "
f"running={self._running}"
)
async def _run_health_check(self):
try:
self._health_check_count += 1
profile = await self._client.get_oa_profile()
self._last_health_status = {
"status": "healthy",
"oa_name": profile.get("name", ""),
"oa_id": profile.get("oa_id", ""),
"follower_count": profile.get("follower_count", 0),
"checked_at": time.time(),
"check_count": self._health_check_count,
}
logger.debug(
f"[ZaloOA] Poll health check #{self._health_check_count}: OA={profile.get('name', '')} healthy"
)
except Exception as e:
self._last_health_status = {
"status": "unhealthy",
"error": str(e),
"checked_at": time.time(),
"check_count": self._health_check_count,
}
logger.warning(f"[ZaloOA] Poll health check #{self._health_check_count} failed: {e}")
def get_poll_metrics(self) -> dict[str, Any]:
return {
"running": self._running,
"poll_count": self._poll_count,
"last_poll_at": self._last_poll_at,
"error_count": self._error_count,
"last_follower_count": self._last_follower_count,
"follower_delta_total": self._follower_delta_total,
"interval_sec": self._interval,
"timeout_ms": self._timeout_ms,
"health_check_count": self._health_check_count,
"last_health_status": self._last_health_status,
}