refactor(zalo-adapter): 整理Zalo用户适配器代码并优化重连逻辑

1. 调整导入顺序与补全缺失依赖
2. 新增Zalo目标前缀映射与最大重连延迟配置
3. 优化反应消息ID获取逻辑与桥接URL检查
4. 改进WebSocket重连退避策略与错误处理
5. 重构发送消息逻辑,统一附件处理与音频发送流程
6. 调整类初始化代码位置与导入依赖顺序
This commit is contained in:
Kris 2026-05-13 16:18:25 +08:00
parent c35f37bd31
commit 6cb56d5e41
9 changed files with 67 additions and 35 deletions

View File

@ -30,6 +30,7 @@ from yuxi.channels.registry import register_builtin_adapter
from yuxi.utils.datetime_utils import utc_now_naive
from yuxi.utils.logging_config import logger
from .allow_from import resolve_allow_from_entries
from .backoff import BackoffManager
from .bridge import BridgeClient
from .constants import (
@ -44,7 +45,6 @@ from .directory import get_self_info, list_group_members, list_groups, list_peer
from .exec_approval import create_exec_approval
from .group_cache import group_context_cache
from .group_sync import sync_group_list
from .allow_from import resolve_allow_from_entries
from .interactive_dispatch import (
default_approval_handler,
default_model_selector_handler,
@ -67,8 +67,8 @@ from .session import (
check_mention_required,
resolve_agent_route,
)
from .sticker_handler import augment_sticker_message
from .status_issues import collect_status_issues
from .sticker_handler import augment_sticker_message
from .text_styles import (
has_markdown_syntax,
markdown_to_zalo_styles,
@ -133,6 +133,10 @@ class ZaloUserAdapter(BaseChannelAdapter):
def __init__(self, config: dict[str, Any] | None = None):
super().__init__(config)
self._config = config or {}
self._profile = _env_fallback(
["ZALOUSER_PROFILE", "ZCA_PROFILE", "ZALO_PROFILE"], self._config, "profile", "default"
)
self._status = ChannelStatus.DISCONNECTED
self._bridge: BridgeClient | None = None
self._login_flow: LoginFlow | None = None
@ -152,10 +156,6 @@ class ZaloUserAdapter(BaseChannelAdapter):
self._last_connected_at: float | None = None
self._last_message_at: float | None = None
self._last_transport_activity_at: float | None = None
self._config = config or {}
self._profile = _env_fallback(
["ZALOUSER_PROFILE", "ZCA_PROFILE", "ZALO_PROFILE"], self._config, "profile", "default"
)
self._enabled = self._config.get("enabled", True)
self._msg_id_tracker = MessageIdTracker()
self._outbound_seq = OutboundSequencer()

View File

@ -116,8 +116,8 @@ def template_confirmation_card(
from .inline_buttons import (
ButtonGroup,
ButtonStyle,
callback_button,
InlineButtonRow,
callback_button,
)
card = ZaloCard(

View File

@ -9,6 +9,7 @@ WS_MAX_MESSAGE_SIZE = 10 * 1024 * 1024
WS_PING_INTERVAL = 30
WS_PING_TIMEOUT = 10
WS_RECONNECT_DELAY = 5
MAX_RECONNECT_DELAY = 120
WATCHDOG_INTERVAL = 30
WATCHDOG_MAX_IDLE = 35
@ -70,6 +71,7 @@ TARGET_PREFIXES: dict[str, str] = {
"dm:": "user",
"u:": "user",
"u-": "user",
"zalo:": "user",
"group:": "group",
"g:": "group",
"g-": "group",

View File

@ -1,8 +1,7 @@
from __future__ import annotations
from typing import Any
import asyncio
from typing import Any
from yuxi.utils.logging_config import logger

View File

@ -1,7 +1,7 @@
from __future__ import annotations
from collections.abc import Awaitable, Callable
from typing import Any
from collections.abc import Callable, Awaitable
from yuxi.channels.models import ChannelMessage
from yuxi.utils.logging_config import logger

View File

@ -12,6 +12,7 @@ from yuxi.channels.models import ChannelMessage
from yuxi.utils.logging_config import logger
from .constants import (
MAX_RECONNECT_DELAY,
WATCHDOG_INTERVAL,
WATCHDOG_MAX_IDLE,
WS_MAX_MESSAGE_SIZE,
@ -107,6 +108,7 @@ class MessageMonitor:
logger.info("[ZaloUser] Message monitor stopped")
async def _listen_loop(self) -> None:
consecutive_failures = 0
while self._running:
try:
async with websockets.connect(
@ -116,6 +118,7 @@ class MessageMonitor:
max_size=WS_MAX_MESSAGE_SIZE,
) as ws:
self._ws = ws
consecutive_failures = 0
self._last_activity = time.monotonic()
logger.info(f"[ZaloUser] WebSocket connected: {self._ws_url}")
async for raw_message in ws:
@ -126,9 +129,14 @@ class MessageMonitor:
except asyncio.CancelledError:
break
except Exception as e:
logger.warning(f"[ZaloUser] WebSocket disconnected: {e}")
consecutive_failures += 1
delay = min(WS_RECONNECT_DELAY * (2 ** min(consecutive_failures - 1, 5)), MAX_RECONNECT_DELAY)
logger.warning(
f"[ZaloUser] WebSocket disconnected (failures={consecutive_failures}), "
f"reconnecting in {delay}s: {e}"
)
if self._running:
await asyncio.sleep(WS_RECONNECT_DELAY)
await asyncio.sleep(delay)
logger.info("[ZaloUser] Message monitor loop ended")

View File

@ -220,7 +220,11 @@ def _normalize_reaction(channel_id: str, raw_payload: dict[str, Any]) -> Channel
channel_type=ChannelType.ZALO_USER,
channel_user_id=str(raw_payload.get("from_id", "")),
channel_chat_id=str(raw_payload.get("conversation_id", "")),
channel_message_id=str(raw_payload.get("reaction_message_id") or raw_payload.get("message_id", "")),
channel_message_id=str(
raw_payload.get("reaction_message_id")
if raw_payload.get("reaction_message_id") is not None
else raw_payload.get("message_id", "")
),
)
raw_timestamp = raw_payload.get("timestamp")

View File

@ -3,7 +3,9 @@ from __future__ import annotations
import asyncio
import os
import time
from typing import Any, TYPE_CHECKING
from typing import TYPE_CHECKING, Any
import httpx
from yuxi.channels.models import (
ChannelResponse,
@ -19,6 +21,15 @@ from .rate_limiter import RateLimiter
if TYPE_CHECKING:
from yuxi.channels.infra.circuit_breaker import CircuitBreaker
_ATTACHMENT_BRIDGE_TYPES: dict[MessageType, str] = {
MessageType.IMAGE: "image",
MessageType.VIDEO: "video",
MessageType.AUDIO: "audio",
MessageType.FILE: "file",
}
_NON_RETRIABLE_ERRORS = {"invalid_cookie", "session_expired", "bad_request", "forbidden", "unauthorized"}
class OutboundSequencer:
def __init__(self):
@ -80,7 +91,16 @@ async def _send_one(
result = await bridge.send_message(payload)
if result.success:
return result
error_tag = (result.error or "").lower()
if any(tag in error_tag for tag in _NON_RETRIABLE_ERRORS):
return result
last_error = result.error
except httpx.HTTPStatusError as e:
if 400 <= e.response.status_code < 500:
return DeliveryResult(success=False, error=f"Client error: {e}")
last_error = str(e)
except (httpx.ConnectError, httpx.TimeoutException) as e:
last_error = str(e)
except Exception as e:
last_error = str(e)
logger.warning(f"[ZaloUser] Send attempt {attempt + 1}/{max_attempts} failed: {e}")
@ -149,14 +169,12 @@ def _build_send_payload(response: ChannelResponse, config: dict[str, Any]) -> di
if custom_styles and isinstance(custom_styles, list):
payload["custom_text_styles"] = custom_styles
_ATTACHMENT_TYPES: dict[MessageType, str] = {
MessageType.IMAGE: "image",
MessageType.VIDEO: "video",
MessageType.AUDIO: "audio",
MessageType.FILE: "file",
}
attachment_bridge_type = _ATTACHMENT_TYPES.get(response.message_type)
attachment_bridge_type = _ATTACHMENT_BRIDGE_TYPES.get(response.message_type)
if attachment_bridge_type and response.attachments:
if len(response.attachments) > 1:
logger.warning(
f"[ZaloUser] Multiple attachments ({len(response.attachments)}) detected, only sending the first one."
)
att = response.attachments[0]
payload["attachments"] = [
{
@ -231,18 +249,14 @@ async def send_audio(
) -> DeliveryResult:
try:
derived_name = file_name or _derive_file_name(audio_url, "audio", "audio.mp3")
payload: dict[str, Any] = {
"conversation_id": conversation_id,
"message_type": "voice",
"attachments": [{"type": "voice", "url": audio_url, "filename": derived_name}],
}
if caption:
cap_result = await bridge.send_message(
{
"conversation_id": conversation_id,
"message_type": "text",
"text": caption,
}
)
if not cap_result.success:
return cap_result
voice_result = await bridge.send_voice_attachment(conversation_id, audio_url, derived_name)
return voice_result
payload["text"] = caption
return await bridge.send_message(payload)
except Exception as e:
return DeliveryResult(success=False, error=str(e))

View File

@ -68,13 +68,18 @@ def collect_status_issues(
).to_dict()
)
if bridge_url and not bridge_url.startswith("https://"):
if (
bridge_url
and not bridge_url.startswith("https://")
and "localhost" not in bridge_url
and "127.0.0.1" not in bridge_url
):
issues.append(
StatusIssue(
level="warn",
message="Bridge URL 未使用 HTTPS",
message="Bridge URL 未使用 HTTPS(非本地连接)",
detail=(
f"当前 Bridge URL ({bridge_url}) 使用 HTTP 协议"
f"当前 Bridge URL ({bridge_url}) 使用 HTTP 协议且非本地地址"
"凭据和会话令牌将以明文传输。生产环境建议使用 HTTPS。"
),
).to_dict()