新增 Zalo OA 官方账号完整集成能力,包含: 1. 基础通信能力:消息编解码、目标归一化、文本分块 2. 安全与校验:Webhook 签名验证、DM 策略管理、配对流程 3. 辅助工具:重复事件去重、请求限流、异常告警 4. 管理功能:账号多实例管理、配置验证、健康诊断 5. 扩展能力:媒体托管、视觉识别、TTS 语音合成 6. 运维支持:审计日志、状态监控、目录同步
159 lines
5.8 KiB
Python
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,
|
|
}
|