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