这是一个批量整理提交,包含以下主要改动: 1. 删除多处冗余的空行和未使用的导入 2. 修复文件末尾缺少换行符的问题 3. 调整部分模块的导入顺序与代码排版 4. 修复部分配置默认值与策略逻辑 5. 新增多个功能模块与辅助工具 6. 完善异常处理与日志记录 7. 修复速率限制、消息缓存、权限校验等逻辑bug 8. 废弃部分旧有API与配置项并添加警告提示
991 lines
40 KiB
Python
991 lines
40 KiB
Python
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 仅支持 direct(1对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
|