ForcePilot/backend/package/yuxi/channels/adapters/zalo_oa/adapter.py
Kris 1f78c44b03 refactor: 整理并清理项目中的冗余代码与格式问题
这是一个批量整理提交,包含以下主要改动:
1.  删除多处冗余的空行和未使用的导入
2.  修复文件末尾缺少换行符的问题
3.  调整部分模块的导入顺序与代码排版
4.  修复部分配置默认值与策略逻辑
5.  新增多个功能模块与辅助工具
6.  完善异常处理与日志记录
7.  修复速率限制、消息缓存、权限校验等逻辑bug
8.  废弃部分旧有API与配置项并添加警告提示
2026-05-12 14:51:53 +08:00

991 lines
40 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.

from __future__ import annotations
import asyncio
import json
import os
import time
from hashlib import sha256
from typing import Any, ClassVar
from yuxi.channels.adapters.zalo_oa.approval import check_approval_required, build_approval_request
from yuxi.channels.adapters.zalo_oa.audit import AuditEventType, AuditLogger
from yuxi.channels.adapters.zalo_oa.client import ZaloOAClient
from yuxi.channels.adapters.zalo_oa.dedup import EventDeduplicator
from yuxi.channels.adapters.zalo_oa.formatter import ZaloOAMessageFormatter
from yuxi.channels.adapters.zalo_oa.normalizer import SkipMessageError, ZaloOAEventNormalizer
from yuxi.channels.adapters.zalo_oa.pairing import PairingStore, send_pairing_message, send_pairing_success
from yuxi.channels.adapters.zalo_oa.probe import probe_zalo_oa
from yuxi.channels.adapters.zalo_oa.security import DMPolicy, check_dm_allowed, load_allowlist, resolve_dm_policy
from yuxi.channels.adapters.zalo_oa.send import ZaloOASender
from yuxi.channels.adapters.zalo_oa.signature import verify_zalo_oa_signature
from yuxi.channels.adapters.zalo_oa.webhook import register_webhook, unregister_webhook
from yuxi.channels.adapters.zalo_oa.webhook_ratelimit import (
WebhookRateLimiter,
build_rate_limit_key,
resolve_client_ip,
)
from yuxi.channels.adapters.zalo_oa.webhook_anomaly import WebhookAnomalyTracker
from yuxi.channels.adapters.zalo_oa.directory import ZaloOADirectory
from yuxi.channels.adapters.zalo_oa.polling import ZaloOAPoller
from yuxi.channels.adapters.zalo_oa.voice import ZaloOAVoice
from yuxi.channels.adapters.zalo_oa.media_vision import MediaVisionProcessor
from yuxi.channels.adapters.zalo_oa.status_issues import collect_status_issues
from yuxi.channels.base import BaseChannelAdapter
from yuxi.channels.capabilities import ChannelCapabilities
from yuxi.channels.exceptions import (
ChannelAuthenticationError,
MessageFormatError,
)
from yuxi.channels.meta import ChannelMeta
from yuxi.channels.models import (
ChannelMessage,
ChannelResponse,
ChannelStatus,
ChannelType,
DeliveryResult,
HealthStatus,
)
from yuxi.channels.registry import register_builtin_adapter
from yuxi.utils.datetime_utils import utc_now_naive
from yuxi.utils.logging_config import logger
WEBHOOK_MAX_BODY_BYTES = 1_048_576
REQUIRED_WEBHOOK_CONTENT_TYPE = "application/json"
PROBE_DEFAULT_TIMEOUT_MS = 2500
_webhook_path_refs: dict[str, int] = {}
def _acquire_webhook_path(path: str) -> int:
"""获取 webhook 路径引用计数。多账户共享同一 webhook 路径时递增."""
count = _webhook_path_refs.get(path, 0) + 1
_webhook_path_refs[path] = count
return count
def _release_webhook_path(path: str) -> int:
"""释放 webhook 路径引用计数。计数归零时清除."""
count = _webhook_path_refs.get(path, 0) - 1
if count <= 0:
_webhook_path_refs.pop(path, None)
return 0
_webhook_path_refs[path] = count
return count
def get_webhook_path_refs(path: str) -> int:
"""获取指定 webhook 路径的当前引用计数."""
return _webhook_path_refs.get(path, 0)
@register_builtin_adapter
class ZaloOAAdapter(BaseChannelAdapter):
channel_id: ClassVar[str] = "zalo_oa"
channel_type: ClassVar[ChannelType] = ChannelType.ZALO_OA
webhook_path: ClassVar[str | None] = "zalo_oa"
text_chunk_limit: ClassVar[int] = 2000
supports_markdown: ClassVar[bool] = False
supports_streaming: ClassVar[bool] = False
streaming_modes: ClassVar[list[str]] = ["off"]
max_media_size_mb: ClassVar[int] = 10
reply_to_mode: ClassVar[str] = "off"
capabilities = ChannelCapabilities(
chat_types=["direct"], # Zalo OA API 仅支持 direct1对1私聊不支持 group
reply=False,
unsend=True,
media=True,
polls=False,
reactions=False,
edit=False,
effects=False,
group_management=False,
threads=False,
block_streaming=True,
native_commands=True,
supports_markdown=False,
supports_streaming=False,
streaming_modes=["off"],
text_chunk_limit=2000,
max_media_size_mb=10,
)
meta = ChannelMeta(
id="zalo_oa",
label="Zalo OA",
selection_label="Zalo OA (Official Account)",
blurb="Zalo Official Account 渠道适配器,支持文本/图片/文件/贴纸/模板消息等富媒体交互",
order=25,
docs_path="docs/channels/zalo-oa",
docs_label="Zalo OA 文档",
quickstart_allow_from=[],
)
def __init__(self, config: dict[str, Any] | None = None):
super().__init__(config)
self._status = ChannelStatus.DISCONNECTED
self._client: ZaloOAClient | None = None
self._normalizer = ZaloOAEventNormalizer(channel_id=self.channel_id)
self._formatter = ZaloOAMessageFormatter(max_text_length=self.text_chunk_limit)
self._sender: ZaloOASender | None = None
self._oa_info: dict[str, Any] = {}
self._connected_at: float | None = None
self._last_health_result: tuple[float, HealthStatus] | None = None
self._dedup_window_ms = config.get("dedup_window_ms", 300000) if config else 300000
self._dedup = EventDeduplicator(window_ms=self._dedup_window_ms)
self._pairing_store = PairingStore()
self._dm_policy: DMPolicy = DMPolicy.OPEN
self._allowlist: set[str] = set()
self._message_queues: dict[str, asyncio.Queue] = {}
self._message_workers: dict[str, asyncio.Task] = {}
self._directory: ZaloOADirectory | None = None
self._last_inbound_at: float | None = None
self._last_outbound_at: float | None = None
self._inbound_count: int = 0
self._outbound_count: int = 0
self._rate_limiter: WebhookRateLimiter | None = None
self._anomaly_tracker = WebhookAnomalyTracker()
self._poller: ZaloOAPoller | None = None
self._voice: ZaloOAVoice | None = None
self._enabled = config.get("enabled", True) if config else True
self._approval_enabled = config.get("requireExecApproval", False) if config else False
self._trusted_proxies: list[str] = config.get("trustedProxies", []) if config else []
self._media_vision = MediaVisionProcessor(config)
self._audit = AuditLogger(
enabled=config.get("audit_enabled", True) if config else True,
log_level=config.get("audit_log_level", "info") if config else "info",
)
self._heartbeat_interval = config.get("heartbeat_interval_sec", 30) if config else 30
self._heartbeat_task: asyncio.Task | None = None
self._last_heartbeat_at: float | None = None
self._heartbeat_count = 0
self._heartbeat_failures = 0
self._config_hash = self._compute_config_hash()
def _compute_config_hash(self) -> str:
"""计算当前配置的哈希值,用于检测配置变更."""
serializable = {
k: v for k, v in self.config.items() if isinstance(v, (str, int, float, bool, list, dict, type(None)))
}
try:
raw = json.dumps(serializable, sort_keys=True, default=str)
return sha256(raw.encode()).hexdigest()
except Exception:
return ""
def check_config_changed(self) -> bool:
"""检查自上次连接以来配置是否发生变化."""
return self._compute_config_hash() != self._config_hash
def reload_config(self, new_config: dict[str, Any] | None = None) -> dict[str, Any]:
"""热重载配置并检测变更项."""
if new_config is not None:
self.config.update(new_config)
changed_keys: list[str] = []
old_hash = self._config_hash
new_hash = self._compute_config_hash()
if old_hash != new_hash:
self._config_hash = new_hash
dm_policy = resolve_dm_policy(self.config)
if dm_policy != self._dm_policy:
changed_keys.append("dm_policy")
self._dm_policy = dm_policy
new_allowlist = load_allowlist(self.config)
if new_allowlist != self._allowlist:
changed_keys.append("allowFrom")
self._allowlist = new_allowlist
approval_enabled = self.config.get("requireExecApproval", False)
if approval_enabled != self._approval_enabled:
changed_keys.append("requireExecApproval")
self._approval_enabled = approval_enabled
logger.info(f"[ZaloOA] Config reloaded, changes: {changed_keys if changed_keys else 'none'}")
return {"changed": bool(changed_keys), "changed_keys": changed_keys}
@property
def status(self) -> ChannelStatus:
return self._status
def _resolve_credentials(self) -> tuple[str, str]:
app_id = self.config.get("app_id", "") or os.getenv("ZALO_OA_APP_ID", "")
secret_key = self.config.get("secret_key", "") or os.getenv("ZALO_OA_SECRET_KEY", "")
app_id_file = self.config.get("app_id_file", "")
if app_id_file and not app_id:
try:
with open(app_id_file) as f:
app_id = f.read().strip()
except OSError:
pass
secret_key_file = self.config.get("secret_key_file", "")
if secret_key_file and not secret_key:
try:
with open(secret_key_file) as f:
secret_key = f.read().strip()
except OSError:
pass
token_file = self.config.get("token_file", "")
if token_file and not secret_key:
try:
with open(token_file) as f:
lines = f.read().strip().splitlines()
if len(lines) >= 2:
app_id = app_id or lines[0].strip()
secret_key = lines[1].strip()
elif lines:
secret_key = lines[0].strip()
except OSError:
pass
return app_id, secret_key
def _resolve_webhook_path(self) -> str:
webhook_config = self.config.get("webhook", {})
explicit_path = webhook_config.get("path", "")
if explicit_path:
return explicit_path.lstrip("/")
webhook_url = webhook_config.get("url", "")
if webhook_url:
from urllib.parse import urlparse
parsed = urlparse(webhook_url)
pathname = parsed.path.strip("/")
if pathname:
return pathname
return "zalo_oa"
async def connect(self) -> None:
if self._status == ChannelStatus.CONNECTED:
return
if not self._enabled:
self._status = ChannelStatus.DISABLED
logger.info("[ZaloOA] Channel disabled, skipping connect")
return
self._status = ChannelStatus.CONNECTING
logger.info("[ZaloOA] Starting channel (connect phase)...")
app_id, secret_key = self._resolve_credentials()
if not app_id:
raise ChannelAuthenticationError("Zalo OA App ID not configured")
if not secret_key:
raise ChannelAuthenticationError("Zalo OA Secret Key not configured")
self._dm_policy = resolve_dm_policy(self.config)
self._allowlist = load_allowlist(self.config)
sync_interval = self.config.get("directory", {}).get("sync_interval_sec", 600)
self._directory = ZaloOADirectory(list(self._allowlist), sync_interval_sec=sync_interval)
logger.info(
f"[ZaloOA] DM policy: {self._dm_policy.value}, "
f"allowlist entries: {len(self._allowlist)}, "
f"enabled: {self._enabled}"
)
rate_limit_config = self.config.get("rate_limit", {})
window_ms = rate_limit_config.get("window_ms", self.config.get("rate_limit_window_ms", 60000))
max_req = rate_limit_config.get("max_requests", self.config.get("rate_limit_max_requests", 60))
self._rate_limiter = WebhookRateLimiter(window_ms=window_ms, max_requests=max_req)
logger.info(f"[ZaloOA] Rate limiter: window={window_ms}ms, max={max_req}")
self._client = ZaloOAClient(app_id, secret_key, self.config, max_media_size_mb=self.max_media_size_mb)
await self._client.__aenter__()
await self._client.fetch_access_token()
self._oa_info = await self._client.get_oa_profile()
logger.info(
f"[ZaloOA] OA verified: {self._oa_info.get('name')} "
f"(ID: {self._oa_info.get('oa_id')}, "
f"followers: {self._oa_info.get('follower_count', 0)})"
)
token_config = self.config.get("token", {})
refresh_before = token_config.get("refresh_before_expiry_sec", 1800)
check_interval = token_config.get("refresh_check_interval_sec", 300)
self._client.start_token_refresh(refresh_before, check_interval)
self._sender = ZaloOASender(self._client, self._formatter, self.config)
self._voice = ZaloOAVoice(self.config)
if self._directory and self._client:
directory_config = self.config.get("directory", {})
if directory_config.get("auto_sync", True):
await self._directory.start_auto_sync(self._client)
webhook_config = self.config.get("webhook", {})
webhook_url = webhook_config.get("url", "")
if webhook_url:
path = self._resolve_webhook_path()
ref = _acquire_webhook_path(path)
logger.info(f"[ZaloOA] Webhook path '{path}' acquired, active refs: {ref}")
await register_webhook(self._client, webhook_url)
else:
polling_enabled = self.config.get("polling_enabled", True)
if polling_enabled:
self._poller = ZaloOAPoller(self._client, self.config)
await self._poller.start()
logger.info("[ZaloOA] No webhook URL configured, polling mode enabled")
self._connected_at = time.time()
self._status = ChannelStatus.CONNECTED
self._start_heartbeat()
logger.info(f"[ZaloOA] Channel connected successfully at {self._connected_at}")
async def disconnect(self) -> None:
if self._status == ChannelStatus.DISCONNECTED:
return
logger.info("[ZaloOA] Stopping channel...")
self._status = ChannelStatus.DISCONNECTED
self._stop_heartbeat()
if self._poller:
await self._poller.stop()
self._poller = None
if self._directory:
await self._directory.stop_auto_sync()
if self._client:
webhook_config = self.config.get("webhook", {})
webhook_url = webhook_config.get("url", "")
if webhook_url:
await unregister_webhook(self._client, webhook_url)
path = self._resolve_webhook_path()
ref = _release_webhook_path(path)
logger.info(f"[ZaloOA] Webhook path '{path}' released, remaining refs: {ref}")
for worker in self._message_workers.values():
if not worker.done():
worker.cancel()
self._message_workers.clear()
self._message_queues.clear()
if self._client:
await self._client.__aexit__()
self._client = None
self._sender = None
self._oa_info = {}
logger.info("[ZaloOA] Channel stopped")
async def send(self, response: ChannelResponse) -> DeliveryResult:
if self._status != ChannelStatus.CONNECTED or not self._sender:
return DeliveryResult(success=False, error="Zalo OA not connected")
result = await self._sender.send(response)
self._last_outbound_at = time.time()
self._outbound_count += 1
self._audit.record(
AuditEventType.MESSAGE_SENT if result.success else AuditEventType.MESSAGE_FAILED,
{
"recipient": response.identity.channel_user_id,
"message_id": result.message_id,
"success": result.success,
},
)
return result
def validate_webhook_request(self, headers: dict, body: bytes) -> None:
content_type = headers.get("content-type", "")
if not content_type.startswith(REQUIRED_WEBHOOK_CONTENT_TYPE):
raise MessageFormatError(f"Invalid Content-Type: {content_type}, expected {REQUIRED_WEBHOOK_CONTENT_TYPE}")
if len(body) > WEBHOOK_MAX_BODY_BYTES:
raise MessageFormatError(f"Body size {len(body)} bytes exceeds max {WEBHOOK_MAX_BODY_BYTES} bytes")
def normalize_inbound(self, raw: bytes, headers: dict | None = None) -> ChannelMessage:
body_str = raw.decode("utf-8") if isinstance(raw, bytes) else raw
if len(body_str.encode("utf-8")) > WEBHOOK_MAX_BODY_BYTES:
raise MessageFormatError("Webhook body exceeds max size")
try:
data = json.loads(body_str)
except json.JSONDecodeError as e:
raise MessageFormatError() from e
event_name = data.get("event_name", "")
if event_name.startswith("oa_"):
raise SkipMessageError("Ignoring OA own message echo")
sender = data.get("sender", {})
sender_id = sender.get("id", "unknown")
if self._rate_limiter:
client_ip = resolve_client_ip(headers, self._trusted_proxies)
rate_key = build_rate_limit_key(sender_id, client_ip)
if not self._rate_limiter.is_allowed(rate_key):
logger.warning(f"[ZaloOA] Rate limit blocked: key={rate_key}")
raise SkipMessageError("Rate limit exceeded")
if event_name in (
"user_send_text",
"user_send_image",
"user_send_file",
"user_send_sticker",
"user_send_gif",
"user_send_location",
"user_send_business_card",
"user_send_video",
"user_send_audio",
"user_forward_message",
):
if not self._dedup.claim(data):
logger.debug("[ZaloOA] Duplicate event skipped")
raise SkipMessageError("Duplicate event")
if not check_dm_allowed(sender_id, self._dm_policy, self._allowlist):
self._dedup.release(data)
if self._dm_policy == DMPolicy.PAIRING:
self._schedule_pairing(sender_id, sender.get("display_name", ""))
logger.info(f"[ZaloOA] DM rejected: user={sender_id}, policy={self._dm_policy.value}")
raise SkipMessageError(f"DM not allowed by policy: {self._dm_policy.value}")
if self._approval_enabled:
action = "send" if event_name == "user_send_text" else "media"
if check_approval_required(action, self.config):
self._dedup.release(data)
_approval_req = build_approval_request(
action, sender_id, {"event_name": event_name, "message": data.get("message", {})}
)
logger.info(f"[ZaloOA] Approval required for {sender_id}: {action}")
raise SkipMessageError(f"Approval required: {action}")
self._dedup.commit(data)
self._last_inbound_at = time.time()
self._inbound_count += 1
return self._normalizer.normalize(data)
def _schedule_pairing(self, user_id: str, display_name: str):
async def _pairing_flow():
try:
code = self._pairing_store.generate_code(user_id)
oa_name = self._oa_info.get("name", "")
if self._client:
await send_pairing_message(self._client, user_id, code, oa_name)
logger.info(f"[ZaloOA] Pairing code generated for {user_id}: {code}")
except Exception as e:
logger.error(f"[ZaloOA] Pairing flow failed for {user_id}: {e}")
try:
loop = asyncio.get_running_loop()
loop.create_task(_pairing_flow())
except RuntimeError:
pass
def _trigger_typing_indicator(self, user_id: str):
async def _send_typing():
try:
if self._sender and self._sender.send_typing_indicator:
await self._sender.send_typing_indicator(user_id)
except Exception:
pass
try:
loop = asyncio.get_running_loop()
loop.create_task(_send_typing())
except RuntimeError:
pass
def _trigger_vision_description(self, message: ChannelMessage):
if not self._media_vision.active:
return
attachments = message.attachments or []
image_attachments = [att for att in attachments if att.type == "image" and att.url]
if not image_attachments:
return
async def _describe_images():
descriptions = []
for att in image_attachments:
try:
desc = await self._media_vision.describe_image(att.url or "")
if desc:
descriptions.append(desc)
except Exception as e:
logger.debug(f"[ZaloOA] Vision description skipped for {att.url}: {e}")
if descriptions:
vision_text = " | ".join(descriptions)
if self._sender:
await self._sender.silent_send(message.identity.channel_user_id, vision_text)
logger.debug(f"[ZaloOA] Vision description sent: {vision_text[:100]}...")
try:
loop = asyncio.get_running_loop()
loop.create_task(_describe_images())
except RuntimeError:
pass
async def handle_pairing_code(self, user_id: str, code: str) -> bool:
if self._pairing_store.verify_code(code, user_id):
self._allowlist.add(user_id)
if self._directory:
self._directory.add_peer(user_id, source="pairing")
oa_name = self._oa_info.get("name", "")
if self._client:
await send_pairing_success(self._client, user_id, oa_name)
logger.info(f"[ZaloOA] Pairing verified and allowlisted for {user_id}")
return True
return False
async def enqueue_inbound(self, raw: bytes, headers: dict | None = None) -> ChannelMessage:
if headers:
self.validate_webhook_request(headers, raw)
body_str = raw.decode("utf-8") if isinstance(raw, bytes) else raw
try:
data = json.loads(body_str)
except json.JSONDecodeError as e:
self._anomaly_tracker.record(
400, client_ip=resolve_client_ip(headers, self._trusted_proxies), message="JSON decode error"
)
raise MessageFormatError() from e
sender = data.get("sender", {})
sender_id = sender.get("id", "unknown")
if self._sender and self.config.get("auto_typing", True):
self._trigger_typing_indicator(sender_id)
if sender_id not in self._message_queues:
self._message_queues[sender_id] = asyncio.Queue()
queue = self._message_queues[sender_id]
future: asyncio.Future = asyncio.get_running_loop().create_future()
await queue.put((raw, headers, future))
if sender_id not in self._message_workers or self._message_workers[sender_id].done():
self._message_workers[sender_id] = asyncio.create_task(self._process_user_queue(sender_id))
return await future
async def _process_user_queue(self, user_id: str):
queue = self._message_queues.get(user_id)
if not queue:
return
idle_timeout = 60
while True:
try:
try:
raw, headers, future = await asyncio.wait_for(queue.get(), timeout=idle_timeout)
except asyncio.TimeoutError:
break
try:
if headers:
self.validate_webhook_request(headers, raw)
message = self.normalize_inbound(raw, headers)
self._trigger_vision_description(message)
if not future.done():
future.set_result(message)
except (SkipMessageError, MessageFormatError) as e:
if not future.done():
future.set_exception(e)
except Exception as e:
if not future.done():
future.set_exception(e)
finally:
queue.task_done()
except asyncio.CancelledError:
break
except Exception:
if queue.empty():
break
self._message_queues.pop(user_id, None)
self._message_workers.pop(user_id, None)
def format_outbound(self, response: ChannelResponse) -> Any:
return self._formatter.format(response)
async def health_check(self) -> HealthStatus:
if self._status != ChannelStatus.CONNECTED or not self._client:
return HealthStatus(status="unhealthy", last_error="Not connected")
health_ttl = self.config.get("health_check_ttl_sec", 60)
if self._last_health_result:
cached_at, cached_result = self._last_health_result
if time.monotonic() - cached_at < health_ttl:
return cached_result
start = time.monotonic()
try:
await self._client.get_oa_profile()
latency_ms = (time.monotonic() - start) * 1000
result = HealthStatus(
status="healthy",
latency_ms=latency_ms,
last_connected_at=utc_now_naive(),
metadata={
"oa_name": self._oa_info.get("name", ""),
"oa_id": self._oa_info.get("oa_id", ""),
"dm_policy": self._dm_policy.value,
"allowlist_count": len(self._allowlist),
"inbound_count": self._inbound_count,
"outbound_count": self._outbound_count,
},
)
except Exception as e:
result = HealthStatus(status="unhealthy", last_error=str(e))
self._last_health_result = (time.monotonic(), result)
return result
async def verify_webhook_signature(self, headers: dict, body: bytes) -> bool:
mac_key = self.config.get("webhook", {}).get("mac_key", "") or os.getenv("ZALO_WEBHOOK_MAC_KEY", "")
# ZALO_WEBHOOK_MAC_KEY 等同于 OpenClaw 的 ZALO_WEBHOOK_SECRET命名差异源于 Zalo OA 协议术语
if not mac_key:
logger.warning("[ZaloOA] webhook: MAC key not configured, verification cannot be performed")
return False
signature = headers.get("x-zevent-signature", "")
if not signature:
logger.warning("[ZaloOA] webhook: missing X-ZEvent-Signature header")
return False
body_str = body.decode("utf-8") if isinstance(body, bytes) else body
app_id, _ = self._resolve_credentials()
try:
data = json.loads(body_str)
timestamp = data.get("timestamp", "")
except json.JSONDecodeError:
timestamp = ""
return verify_zalo_oa_signature(body_str, signature, app_id, mac_key, timestamp)
async def get_user_info(self, channel_user_id: str) -> dict[str, Any]:
if not self._client:
return {}
try:
profile = await self._client.get_follower_profile(channel_user_id)
except Exception:
logger.warning(f"[ZaloOA] Failed to get user info for {channel_user_id}", exc_info=True)
return {}
if profile:
return {
"user_id": profile.get("user_id", channel_user_id),
"user_name": profile.get("user_name", ""),
"display_name": profile.get("display_name", ""),
"user_gender": profile.get("user_gender", 0),
"birthday": profile.get("birthday", ""),
"avatar": profile.get("avatar", ""),
"is_follower": profile.get("is_follower", False),
}
return {}
async def recall_message(self, message_id: str, user_id: str) -> bool:
if not self._client:
return False
return await self._client.recall_message(message_id, user_id)
async def synthesize_voice(self, text: str, user_id: str) -> DeliveryResult:
if not self._voice or not self._sender:
return DeliveryResult(success=False, error="Voice or sender not initialized")
return await self._voice.synthesize_and_send(text, user_id, self._sender)
async def send_inline_keyboard(self, user_id: str, text: str, buttons: list[dict[str, Any]]) -> DeliveryResult:
if self._status != ChannelStatus.CONNECTED or not self._sender:
return DeliveryResult(success=False, error="Zalo OA not connected")
template = self._formatter.build_inline_keyboard(user_id, text, buttons)
return await self._sender._do_send(template)
async def send_media(self, chat_id: str, media_type: str, data: Any) -> DeliveryResult:
if self._status != ChannelStatus.CONNECTED or not self._sender:
return DeliveryResult(success=False, error="Zalo OA not connected")
if isinstance(data, str):
import aiofiles
async with aiofiles.open(data, "rb") as f:
file_data = await f.read()
elif isinstance(data, bytes):
file_data = data
else:
return DeliveryResult(success=False, error=f"Unsupported data type: {type(data).__name__}")
size_mb = len(file_data) / (1024 * 1024)
if size_mb > self.max_media_size_mb:
return DeliveryResult(
success=False,
error=f"Media size {size_mb:.1f}MB exceeds max {self.max_media_size_mb}MB",
)
upload_method = {"image": self._client.upload_image, "file": self._client.upload_file}.get(
media_type, self._client.upload_file
)
try:
attachment_id = await upload_method(file_data)
except Exception as e:
return DeliveryResult(success=False, error=f"Upload failed: {e}")
formatted = self._formatter._build_media(chat_id, media_type, attachment_id)
return await self._sender._do_send(formatted)
async def download_media(self, file_id: str) -> bytes:
if not self._client or not self._client._http_client:
raise ChannelAuthenticationError("Zalo OA not connected")
response = await self._client._http_client.get(file_id)
response.raise_for_status()
return response.content
async def pre_connect(self) -> dict:
app_id, secret_key = self._resolve_credentials()
if not app_id:
return {"status": "error", "message": "Missing Zalo OA App ID"}
if not secret_key:
return {"status": "error", "message": "Missing Zalo OA Secret Key"}
probe_timeout_ms = self.config.get("probe_timeout_ms", PROBE_DEFAULT_TIMEOUT_MS)
start = time.monotonic()
try:
probe_result = await probe_zalo_oa(app_id, secret_key, self.config, timeout_ms=probe_timeout_ms)
except TimeoutError:
return {
"status": "error",
"message": f"Probe timed out after {probe_timeout_ms}ms",
"elapsed_ms": (time.monotonic() - start) * 1000,
"timeout_ms": probe_timeout_ms,
}
elapsed_ms = (time.monotonic() - start) * 1000
probe_result["elapsed_ms"] = elapsed_ms
return probe_result
def get_snapshot(self) -> dict[str, Any]:
from yuxi.channels.models import ChannelAccountSnapshot
snapshot = ChannelAccountSnapshot(
account_id="zalo_oa",
name=self._oa_info.get("name", ""),
configured=bool(self._resolve_credentials()[0]),
enabled=self._enabled,
linked=self._status == ChannelStatus.CONNECTED,
running=self._status == ChannelStatus.CONNECTED,
connected=self._status == ChannelStatus.CONNECTED,
status_state=self._status.value if self._status else "not-configured",
health_state="healthy" if self._status == ChannelStatus.CONNECTED else "stopped",
last_connected_at_s=self._connected_at,
last_inbound_at=self._last_inbound_at,
last_outbound_at=self._last_outbound_at,
dm_policy=self._dm_policy.value,
group_policy="not-applicable",
allow_from_count=len(self._allowlist),
token_source=self._client.token_source if self._client else "",
webhook_path=self._resolve_webhook_path(),
profile=self._oa_info,
probe={
"inbound_count": self._inbound_count,
"outbound_count": self._outbound_count,
"anomaly_count": self._anomaly_tracker.total_anomalies,
"rate_limiter_active": self._rate_limiter is not None,
"approval_enabled": self._approval_enabled,
"dedup_window_ms": self._dedup_window_ms,
"polling": self._poller.get_poll_metrics() if self._poller else None,
},
)
return snapshot.model_dump()
async def get_status_issues(self) -> list[dict[str, Any]]:
return collect_status_issues(self.config, self._oa_info)
def _start_heartbeat(self):
if self._heartbeat_task and not self._heartbeat_task.done():
return
self._heartbeat_task = asyncio.create_task(self._heartbeat_loop())
logger.info(f"[ZaloOA] Heartbeat started, interval={self._heartbeat_interval}s")
def _stop_heartbeat(self):
if self._heartbeat_task and not self._heartbeat_task.done():
self._heartbeat_task.cancel()
self._heartbeat_task = None
if self._heartbeat_count > 0:
logger.info(
f"[ZaloOA] Heartbeat stopped: {self._heartbeat_count} beats, {self._heartbeat_failures} failures"
)
async def _heartbeat_loop(self):
while self._status == ChannelStatus.CONNECTED:
try:
await asyncio.sleep(self._heartbeat_interval)
if self._status != ChannelStatus.CONNECTED:
break
await self._send_heartbeat()
except asyncio.CancelledError:
break
except Exception as e:
self._heartbeat_failures += 1
logger.debug(f"[ZaloOA] Heartbeat error: {e}")
async def _send_heartbeat(self):
if not self._client or not self._client._http_client:
return
try:
await self._client.get_oa_profile()
self._heartbeat_count += 1
self._last_heartbeat_at = time.time()
if self._heartbeat_count % 10 == 0:
logger.debug(
f"[ZaloOA] Heartbeat #{self._heartbeat_count}: healthy, failures={self._heartbeat_failures}"
)
except Exception:
self._heartbeat_failures += 1
def get_heartbeat_metrics(self) -> dict[str, Any]:
return {
"active": self._heartbeat_task is not None and not self._heartbeat_task.done(),
"count": self._heartbeat_count,
"failures": self._heartbeat_failures,
"last_at": self._last_heartbeat_at,
"interval_sec": self._heartbeat_interval,
}
async def broadcast(self, text: str, media_attachments: list[dict[str, Any]] | None = None) -> DeliveryResult:
if self._status != ChannelStatus.CONNECTED or not self._sender:
return DeliveryResult(success=False, error="Zalo OA not connected")
return await self._sender.broadcast(text, media_attachments)
def list_pairing_codes(self) -> list[dict[str, Any]]:
return [
{
"code": code,
"user_id": entry.get("user_id", ""),
"created_at": entry.get("created_at", 0),
"used": entry.get("used", False),
}
for code, entry in self._pairing_store._codes.items()
]
def revoke_pairing_code(self, code: str) -> bool:
if code in self._pairing_store._codes:
del self._pairing_store._codes[code]
logger.info(f"[ZaloOA] Pairing code revoked: {code}")
return True
return False
def migrate_from_config(self, old_config: dict[str, Any]) -> dict[str, Any]:
new_config: dict[str, Any] = {}
for old_key, new_path in [
("app_id", "app_id"),
("secret_key", "secret_key"),
]:
if old_key in old_config:
new_config[new_path] = old_config[old_key]
for old_section, new_section in [
("retry", "retry"),
("token", "token"),
("network", "network"),
]:
if old_section in old_config:
new_config[new_section] = old_config[old_section]
if "webhook" in old_config:
webhook = old_config["webhook"]
if "mac_key" in webhook:
new_config.setdefault("webhook", {})["mac_key"] = webhook["mac_key"]
if "url" in webhook:
new_config.setdefault("webhook", {})["url"] = webhook["url"]
if "health_check_ttl_sec" in old_config:
new_config["health_check_ttl_sec"] = old_config["health_check_ttl_sec"]
if "dm_policy" in old_config:
new_config["dm_policy"] = old_config["dm_policy"]
if "dmPolicy" in old_config:
new_config["dm_policy"] = old_config["dmPolicy"]
if "allowFrom" in old_config:
new_config["allowFrom"] = old_config["allowFrom"]
for old_key in [
"enabled",
"token_file",
"app_id_file",
"secret_key_file",
"dedup_window_ms",
"rate_limit_window_ms",
"rate_limit_max_requests",
"anomaly_tracking_enabled",
"audit_enabled",
"audit_log_level",
"media_vision_enabled",
"media_vision_model",
]:
if old_key in old_config:
new_config[old_key] = old_config[old_key]
return new_config
@staticmethod
def migrate_to_config(config: dict[str, Any]) -> dict[str, Any]:
old_config: dict[str, Any] = {}
for new_path, old_key in [
("app_id", "app_id"),
("secret_key", "secret_key"),
]:
if new_path in config:
old_config[old_key] = config[new_path]
for new_section, old_section in [
("retry", "retry"),
("token", "token"),
("network", "network"),
]:
if new_section in config:
old_config[old_section] = config[new_section]
if "webhook" in config:
webhook = config["webhook"]
if "mac_key" in webhook:
old_config.setdefault("webhook", {})["mac_key"] = webhook["mac_key"]
if "url" in webhook:
old_config.setdefault("webhook", {})["url"] = webhook["url"]
for key in [
"enabled",
"dedup_window_ms",
"dm_policy",
"allowFrom",
"health_check_ttl_sec",
"token_file",
"app_id_file",
"secret_key_file",
"rate_limit_window_ms",
"rate_limit_max_requests",
"anomaly_tracking_enabled",
"audit_enabled",
"audit_log_level",
"media_vision_enabled",
"media_vision_model",
"media_vision_api_url",
"media_vision_api_key",
"polling_enabled",
"polling_timeout_ms",
"polling_interval_sec",
"voice_tts_enabled",
"voice_tts_model",
]:
if key in config:
old_config[key] = config[key]
return old_config