ForcePilot/backend/package/yuxi/channels/adapters/zalo_oa/adapter.py
Kris c35f37bd31 refactor(zalo_oa): 整理导入顺序并修复部分代码逻辑
1. 调整多个文件的导入排序与导入项
2. 为命令注册添加名称小写处理与空命令拦截
3. 新增link消息、reaction和已读回执的事件映射
4. 实现消息发送熔断器与流式发送支持
5. 修复导入顺序与异常捕获细节
2026-05-13 16:18:04 +08:00

1062 lines
42 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 build_approval_request, check_approval_required
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.directory import ZaloOADirectory
from yuxi.channels.adapters.zalo_oa.formatter import ZaloOAMessageFormatter
from yuxi.channels.adapters.zalo_oa.media_vision import MediaVisionProcessor
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.polling import ZaloOAPoller
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.status_issues import collect_status_issues
from yuxi.channels.adapters.zalo_oa.voice import ZaloOAVoice
from yuxi.channels.adapters.zalo_oa.webhook import register_webhook, unregister_webhook
from yuxi.channels.adapters.zalo_oa.webhook_anomaly import WebhookAnomalyTracker
from yuxi.channels.adapters.zalo_oa.webhook_ratelimit import (
WebhookRateLimiter,
build_rate_limit_key,
resolve_client_ip,
)
from yuxi.channels.base import BaseChannelAdapter
from yuxi.channels.capabilities import ChannelCapabilities
from yuxi.channels.exceptions import (
ChannelAuthenticationError,
MessageFormatError,
)
from yuxi.channels.infra.circuit_breaker import CircuitBreaker, CircuitBreakerOpenError
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"],
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,
typing=True,
)
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()
self._circuit_breaker = CircuitBreaker(failure_threshold=5, recovery_timeout=60, channel_id="zalo_oa")
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")
async def _do_send() -> DeliveryResult:
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
try:
return await self._circuit_breaker.call(_do_send)
except CircuitBreakerOpenError:
return DeliveryResult(success=False, error="Circuit breaker open")
async def send_stream_chunk(
self,
chat_id: str,
msg_id: str,
chunk: str,
finished: bool = False,
) -> DeliveryResult:
if finished:
from yuxi.channels.models import ChannelIdentity, ChannelResponse
identity = ChannelIdentity(
channel_id=self.channel_id,
channel_type=self.channel_type,
channel_chat_id=chat_id,
channel_user_id=chat_id,
)
response = ChannelResponse(identity=identity, content=chunk)
return await self.send(response)
if not self._sender:
return DeliveryResult(success=False, error="Sender not initialized")
streaming_cfg = self.config.get("streaming", {})
if not isinstance(streaming_cfg, dict):
streaming_cfg = {}
if self.config.get("auto_typing", True):
await self._sender.send_typing_indicator(chat_id)
from yuxi.channels.adapters.zalo_oa.chunking import chunk_text
from yuxi.channels.models import ChannelIdentity, ChannelResponse
text_limit = self.text_chunk_limit
fallback_cfg = streaming_cfg.get("fallback", {})
if isinstance(fallback_cfg, dict):
text_limit = fallback_cfg.get("chunk_size", text_limit)
chunks = chunk_text(chunk, text_limit)
show_progress = streaming_cfg.get("progress_indicator", True)
total = len(chunks)
for i, ch in enumerate(chunks, 1):
content = ch
if show_progress and total > 1:
content = f"[{i}/{total}] {ch}"
identity = ChannelIdentity(
channel_id=self.channel_id,
channel_type=self.channel_type,
channel_chat_id=chat_id,
channel_user_id=chat_id,
)
response = ChannelResponse(identity=identity, content=content)
await self.send(response)
if i < total:
import asyncio
await asyncio.sleep(0.3)
return DeliveryResult(success=True)
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",
"user_send_link",
):
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 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