ForcePilot/backend/package/yuxi/channels/adapters/zalo_oa/client.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

342 lines
13 KiB
Python

from __future__ import annotations
import asyncio
import io
import json
import time
from typing import Any
import httpx
from yuxi.channels.exceptions import (
ChannelAuthenticationError,
ChannelRateLimitError,
DeliveryFailedError,
TokenExpiredError,
)
from yuxi.utils.logging_config import logger
OAUTH_TOKEN_URL = "https://oauth.zaloapp.com/v4/oa/access_token"
API_BASE_URL = "https://openapi.zalo.me/v2.0/oa"
TOKEN_REFRESH_MAX_BACKOFF = 600
AUTH_FAILURE_MAX_BACKOFF = 300
AUTH_FAILURE_CIRCUIT_BREAKER = 10
class ZaloOAClient:
def __init__(self, app_id: str, secret_key: str, config: dict[str, Any] | None = None, max_media_size_mb: int = 10):
self._app_id = app_id
self._secret_key = secret_key
self._config = config or {}
self._max_media_size_mb = max_media_size_mb
self._http_client: httpx.AsyncClient | None = None
self._access_token: str | None = None
self._token_expires_at: float = 0
self._refresh_task: asyncio.Task | None = None
self._oa_info: dict[str, Any] = {}
self._token_refresh_failures = 0
self._auth_failure_count = 0
self._auth_circuit_open = False
self._auth_circuit_open_at: float = 0
self._token_source: str = "config"
@property
def access_token(self) -> str | None:
return self._access_token
@property
def oa_info(self) -> dict[str, Any]:
return self._oa_info
@property
def token_source(self) -> str:
return self._token_source
async def __aenter__(self):
network_config = self._config.get("network", {})
connect_timeout = network_config.get("connect_timeout_ms", 5000) / 1000
read_timeout = network_config.get("read_timeout_ms", 10000) / 1000
max_connections = network_config.get("max_connections", 10)
proxy = network_config.get("proxy")
self._http_client = httpx.AsyncClient(
timeout=httpx.Timeout(read_timeout, connect=connect_timeout),
limits=httpx.Limits(max_connections=max_connections),
proxy=proxy,
)
return self
async def __aexit__(self, *args):
await self._cancel_refresh_task()
if self._http_client:
await self._http_client.aclose()
self._http_client = None
async def _cancel_refresh_task(self):
if self._refresh_task and not self._refresh_task.done():
self._refresh_task.cancel()
try:
await self._refresh_task
except asyncio.CancelledError:
pass
async def _request(self, method: str, url: str, **kwargs) -> httpx.Response:
if not self._http_client:
raise RuntimeError("Client not initialized")
if self._auth_circuit_open:
elapsed = time.time() - self._auth_circuit_open_at
cooldown = min(
AUTH_FAILURE_MAX_BACKOFF * (1.5 ** min(self._auth_failure_count - AUTH_FAILURE_CIRCUIT_BREAKER, 5)),
AUTH_FAILURE_MAX_BACKOFF * 10,
)
if elapsed < cooldown:
raise ChannelAuthenticationError(f"Auth circuit breaker open, retry after {cooldown - elapsed:.0f}s")
self._auth_circuit_open = False
self._auth_failure_count = 0
logger.info("[ZaloOA] Auth circuit breaker reset after cooldown")
response = await self._http_client.request(method, url, **kwargs)
if response.status_code in (401, 403):
self._auth_failure_count += 1
if self._auth_failure_count >= AUTH_FAILURE_CIRCUIT_BREAKER:
self._auth_circuit_open = True
self._auth_circuit_open_at = time.time()
logger.warning(
f"[ZaloOA] Auth circuit breaker opened after {self._auth_failure_count} consecutive auth failures"
)
raise ChannelAuthenticationError(
f"Auth circuit breaker opened after {self._auth_failure_count} failures"
)
backoff = min(
AUTH_FAILURE_MAX_BACKOFF * (1.5 ** min(self._auth_failure_count, 6)),
AUTH_FAILURE_MAX_BACKOFF * 5,
)
logger.warning(
f"[ZaloOA] Auth failure {self._auth_failure_count}/{AUTH_FAILURE_CIRCUIT_BREAKER}, "
f"backing off {backoff:.0f}s"
)
await asyncio.sleep(backoff)
raise ChannelAuthenticationError(f"HTTP {response.status_code} (failure #{self._auth_failure_count})")
if response.status_code == 429:
raise ChannelRateLimitError()
self._auth_failure_count = 0
response.raise_for_status()
return response
async def fetch_access_token(self) -> str:
response = await self._request(
"POST",
OAUTH_TOKEN_URL,
data={
"secret_key": self._secret_key,
"app_id": self._app_id,
},
)
result = response.json()
if result.get("error") != 0:
raise ChannelAuthenticationError(f"Failed to get access_token: {result.get('message', 'unknown')}")
self._access_token = result["access_token"]
expires_in = int(result.get("expires_in", 36000))
self._token_expires_at = time.time() + expires_in
self._token_refresh_failures = 0
self._auth_failure_count = 0
logger.info("[ZaloOA] Access token obtained")
return self._access_token
def start_token_refresh(self, refresh_before_sec: int = 1800, check_interval_sec: int = 300):
async def _refresh_loop():
while True:
try:
if self._access_token and self._token_expires_at:
remaining = self._token_expires_at - time.time()
if remaining < refresh_before_sec:
await self.fetch_access_token()
logger.info("[ZaloOA] Access token refreshed")
except asyncio.CancelledError:
break
except Exception as e:
self._token_refresh_failures += 1
backoff = min(
check_interval_sec * (2 ** min(self._token_refresh_failures, 4)),
TOKEN_REFRESH_MAX_BACKOFF,
)
logger.error(
f"[ZaloOA] Token refresh failed (attempt {self._token_refresh_failures}): {e}, "
f"next retry in {backoff}s"
)
await asyncio.sleep(backoff)
continue
await asyncio.sleep(check_interval_sec)
self._refresh_task = asyncio.create_task(_refresh_loop())
async def get_oa_profile(self) -> dict[str, Any]:
response = await self._request(
"GET",
f"{API_BASE_URL}/getprofile",
headers={"access_token": self._access_token},
)
result = response.json()
if result.get("error") != 0:
raise ChannelAuthenticationError(f"Failed to get OA profile: {result.get('message', 'unknown')}")
data = result["data"]
self._oa_info = {
"oa_id": data.get("oa_id", data.get("id", "")),
"name": data.get("name", ""),
"description": data.get("description", ""),
"avatar": data.get("avatar", ""),
"category": data.get("category", ""),
"follower_count": data.get("follower_count", 0),
}
return self._oa_info
async def send_message(self, template: dict[str, Any]) -> dict[str, Any]:
response = await self._request(
"POST",
f"{API_BASE_URL}/message",
headers={"access_token": self._access_token},
json=template,
)
result = response.json()
error_code = result.get("error", -1)
if error_code == -216:
await self.fetch_access_token()
raise TokenExpiredError()
if error_code != 0:
raise DeliveryFailedError(f"Zalo API error [{error_code}]: {result.get('message', 'unknown')}")
return result.get("data", {})
async def upload_image(self, file_data: bytes, filename: str = "image") -> str:
return await self._upload_media(file_data, "image", filename)
async def upload_file(self, file_data: bytes, filename: str = "file") -> str:
return await self._upload_media(file_data, "file", filename)
async def upload_gif(self, file_data: bytes, filename: str = "gif") -> str:
return await self._upload_media(file_data, "gif", filename)
async def _upload_media(self, file_data: bytes, media_type: str, filename: str) -> str:
size_mb = len(file_data) / (1024 * 1024)
if size_mb > self._max_media_size_mb:
raise DeliveryFailedError(f"Media size {size_mb:.1f}MB exceeds max {self._max_media_size_mb}MB")
endpoint_map = {
"image": f"{API_BASE_URL}/upload/image",
"gif": f"{API_BASE_URL}/upload/gif",
"file": f"{API_BASE_URL}/upload/file",
}
endpoint = endpoint_map.get(media_type, endpoint_map["file"])
response = await self._request(
"POST",
endpoint,
params={"access_token": self._access_token},
files={"file": (filename, io.BytesIO(file_data))},
)
result = response.json()
if result.get("error") != 0:
raise DeliveryFailedError(f"Zalo upload failed: {result.get('message', 'unknown')}")
return result["data"]["attachment_id"]
async def get_follower_profile(self, user_id: str) -> dict[str, Any]:
response = await self._request(
"GET",
f"{API_BASE_URL}/getfollowerprofile",
headers={"access_token": self._access_token},
params={"data": json.dumps({"user_id": user_id})},
)
result = response.json()
if result.get("error") != 0:
return {}
return result.get("data", {})
async def get_followers(self, offset: int = 0, count: int = 50) -> dict[str, Any]:
response = await self._request(
"GET",
f"{API_BASE_URL}/getfollowers",
headers={"access_token": self._access_token},
params={"data": json.dumps({"offset": offset, "count": count})},
)
result = response.json()
if result.get("error") != 0:
logger.warning(f"[ZaloOA] get_followers failed: {result.get('message', 'unknown')}")
return {"followers": [], "total": 0, "error": result.get("message", "")}
data = result.get("data", {})
return {
"followers": data.get("followers", []),
"total": data.get("total", 0),
}
async def recall_message(self, message_id: str, user_id: str) -> bool:
try:
await self._request(
"DELETE",
f"{API_BASE_URL}/message",
headers={"access_token": self._access_token},
json={"message_id": message_id, "user_id": user_id},
)
logger.info(f"[ZaloOA] Message recalled: {message_id}")
return True
except Exception as e:
logger.warning(f"[ZaloOA] Message recall failed for {message_id}: {e}")
return False
async def upload_audio(self, audio_data: bytes, filename: str = "audio.mp3") -> str:
endpoint = f"{API_BASE_URL}/upload/file"
response = await self._request(
"POST",
endpoint,
params={"access_token": self._access_token},
files={"file": (filename, io.BytesIO(audio_data))},
)
result = response.json()
if result.get("error") != 0:
raise DeliveryFailedError(f"Zalo audio upload failed: {result.get('message', 'unknown')}")
return result["data"]["attachment_id"]
async def send_chat_action(self, user_id: str, action: str = "typing") -> bool:
"""发送聊天动作指示器.
Zalo OA API 无原生 sendChatAction 端点,通过轻量消息模拟 typing/upload_photo 状态指示。
支持的动作: typing, upload_photo.
"""
try:
action_texts = {
"typing": "...",
"upload_photo": "[Photo uploading...]",
}
indicator = action_texts.get(action, "...")
await self._request(
"POST",
f"{API_BASE_URL}/message",
headers={"access_token": self._access_token},
json={
"recipient": {"user_id": user_id},
"message": {"text": indicator},
},
)
logger.debug(f"[ZaloOA] Chat action '{action}' sent to {user_id}")
return True
except Exception as e:
logger.debug(f"[ZaloOA] Chat action '{action}' failed for {user_id}: {e}")
return False