refactor(zalo-adapter): 整理Zalo用户适配器代码并优化重连逻辑
1. 调整导入顺序与补全缺失依赖 2. 新增Zalo目标前缀映射与最大重连延迟配置 3. 优化反应消息ID获取逻辑与桥接URL检查 4. 改进WebSocket重连退避策略与错误处理 5. 重构发送消息逻辑,统一附件处理与音频发送流程 6. 调整类初始化代码位置与导入依赖顺序
This commit is contained in:
parent
c35f37bd31
commit
6cb56d5e41
@ -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()
|
||||
|
||||
@ -116,8 +116,8 @@ def template_confirmation_card(
|
||||
from .inline_buttons import (
|
||||
ButtonGroup,
|
||||
ButtonStyle,
|
||||
callback_button,
|
||||
InlineButtonRow,
|
||||
callback_button,
|
||||
)
|
||||
|
||||
card = ZaloCard(
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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")
|
||||
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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))
|
||||
|
||||
|
||||
@ -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()
|
||||
|
||||
Loading…
Reference in New Issue
Block a user