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.datetime_utils import utc_now_naive
from yuxi.utils.logging_config import logger from yuxi.utils.logging_config import logger
from .allow_from import resolve_allow_from_entries
from .backoff import BackoffManager from .backoff import BackoffManager
from .bridge import BridgeClient from .bridge import BridgeClient
from .constants import ( 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 .exec_approval import create_exec_approval
from .group_cache import group_context_cache from .group_cache import group_context_cache
from .group_sync import sync_group_list from .group_sync import sync_group_list
from .allow_from import resolve_allow_from_entries
from .interactive_dispatch import ( from .interactive_dispatch import (
default_approval_handler, default_approval_handler,
default_model_selector_handler, default_model_selector_handler,
@ -67,8 +67,8 @@ from .session import (
check_mention_required, check_mention_required,
resolve_agent_route, resolve_agent_route,
) )
from .sticker_handler import augment_sticker_message
from .status_issues import collect_status_issues from .status_issues import collect_status_issues
from .sticker_handler import augment_sticker_message
from .text_styles import ( from .text_styles import (
has_markdown_syntax, has_markdown_syntax,
markdown_to_zalo_styles, markdown_to_zalo_styles,
@ -133,6 +133,10 @@ class ZaloUserAdapter(BaseChannelAdapter):
def __init__(self, config: dict[str, Any] | None = None): def __init__(self, config: dict[str, Any] | None = None):
super().__init__(config) 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._status = ChannelStatus.DISCONNECTED
self._bridge: BridgeClient | None = None self._bridge: BridgeClient | None = None
self._login_flow: LoginFlow | None = None self._login_flow: LoginFlow | None = None
@ -152,10 +156,6 @@ class ZaloUserAdapter(BaseChannelAdapter):
self._last_connected_at: float | None = None self._last_connected_at: float | None = None
self._last_message_at: float | None = None self._last_message_at: float | None = None
self._last_transport_activity_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._enabled = self._config.get("enabled", True)
self._msg_id_tracker = MessageIdTracker() self._msg_id_tracker = MessageIdTracker()
self._outbound_seq = OutboundSequencer() self._outbound_seq = OutboundSequencer()

View File

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

View File

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

View File

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

View File

@ -1,7 +1,7 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Awaitable, Callable
from typing import Any from typing import Any
from collections.abc import Callable, Awaitable
from yuxi.channels.models import ChannelMessage from yuxi.channels.models import ChannelMessage
from yuxi.utils.logging_config import logger 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 yuxi.utils.logging_config import logger
from .constants import ( from .constants import (
MAX_RECONNECT_DELAY,
WATCHDOG_INTERVAL, WATCHDOG_INTERVAL,
WATCHDOG_MAX_IDLE, WATCHDOG_MAX_IDLE,
WS_MAX_MESSAGE_SIZE, WS_MAX_MESSAGE_SIZE,
@ -107,6 +108,7 @@ class MessageMonitor:
logger.info("[ZaloUser] Message monitor stopped") logger.info("[ZaloUser] Message monitor stopped")
async def _listen_loop(self) -> None: async def _listen_loop(self) -> None:
consecutive_failures = 0
while self._running: while self._running:
try: try:
async with websockets.connect( async with websockets.connect(
@ -116,6 +118,7 @@ class MessageMonitor:
max_size=WS_MAX_MESSAGE_SIZE, max_size=WS_MAX_MESSAGE_SIZE,
) as ws: ) as ws:
self._ws = ws self._ws = ws
consecutive_failures = 0
self._last_activity = time.monotonic() self._last_activity = time.monotonic()
logger.info(f"[ZaloUser] WebSocket connected: {self._ws_url}") logger.info(f"[ZaloUser] WebSocket connected: {self._ws_url}")
async for raw_message in ws: async for raw_message in ws:
@ -126,9 +129,14 @@ class MessageMonitor:
except asyncio.CancelledError: except asyncio.CancelledError:
break break
except Exception as e: 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: if self._running:
await asyncio.sleep(WS_RECONNECT_DELAY) await asyncio.sleep(delay)
logger.info("[ZaloUser] Message monitor loop ended") 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_type=ChannelType.ZALO_USER,
channel_user_id=str(raw_payload.get("from_id", "")), channel_user_id=str(raw_payload.get("from_id", "")),
channel_chat_id=str(raw_payload.get("conversation_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") raw_timestamp = raw_payload.get("timestamp")

View File

@ -3,7 +3,9 @@ from __future__ import annotations
import asyncio import asyncio
import os import os
import time import time
from typing import Any, TYPE_CHECKING from typing import TYPE_CHECKING, Any
import httpx
from yuxi.channels.models import ( from yuxi.channels.models import (
ChannelResponse, ChannelResponse,
@ -19,6 +21,15 @@ from .rate_limiter import RateLimiter
if TYPE_CHECKING: if TYPE_CHECKING:
from yuxi.channels.infra.circuit_breaker import CircuitBreaker 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: class OutboundSequencer:
def __init__(self): def __init__(self):
@ -80,7 +91,16 @@ async def _send_one(
result = await bridge.send_message(payload) result = await bridge.send_message(payload)
if result.success: if result.success:
return result 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 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: except Exception as e:
last_error = str(e) last_error = str(e)
logger.warning(f"[ZaloUser] Send attempt {attempt + 1}/{max_attempts} failed: {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): if custom_styles and isinstance(custom_styles, list):
payload["custom_text_styles"] = custom_styles payload["custom_text_styles"] = custom_styles
_ATTACHMENT_TYPES: dict[MessageType, str] = { attachment_bridge_type = _ATTACHMENT_BRIDGE_TYPES.get(response.message_type)
MessageType.IMAGE: "image",
MessageType.VIDEO: "video",
MessageType.AUDIO: "audio",
MessageType.FILE: "file",
}
attachment_bridge_type = _ATTACHMENT_TYPES.get(response.message_type)
if attachment_bridge_type and response.attachments: 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] att = response.attachments[0]
payload["attachments"] = [ payload["attachments"] = [
{ {
@ -231,18 +249,14 @@ async def send_audio(
) -> DeliveryResult: ) -> DeliveryResult:
try: try:
derived_name = file_name or _derive_file_name(audio_url, "audio", "audio.mp3") 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: if caption:
cap_result = await bridge.send_message( payload["text"] = caption
{ return await bridge.send_message(payload)
"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
except Exception as e: except Exception as e:
return DeliveryResult(success=False, error=str(e)) return DeliveryResult(success=False, error=str(e))

View File

@ -68,13 +68,18 @@ def collect_status_issues(
).to_dict() ).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( issues.append(
StatusIssue( StatusIssue(
level="warn", level="warn",
message="Bridge URL 未使用 HTTPS", message="Bridge URL 未使用 HTTPS(非本地连接)",
detail=( detail=(
f"当前 Bridge URL ({bridge_url}) 使用 HTTP 协议" f"当前 Bridge URL ({bridge_url}) 使用 HTTP 协议且非本地地址"
"凭据和会话令牌将以明文传输。生产环境建议使用 HTTPS。" "凭据和会话令牌将以明文传输。生产环境建议使用 HTTPS。"
), ),
).to_dict() ).to_dict()