from __future__ import annotations import os from collections.abc import AsyncIterator from typing import Any, ClassVar import httpx from yuxi.channels.base import BaseChannelAdapter from yuxi.channels.capabilities import ChannelCapabilities from yuxi.channels.exceptions import ( ChannelAuthenticationError, ChannelConnectionError, ) from yuxi.channels.infra.circuit_breaker import CircuitBreaker, CircuitBreakerOpenError from yuxi.channels.meta import ChannelMeta from yuxi.channels.models import ( Attachment, ChannelAccountSnapshot, ChannelMessage, ChannelResponse, ChannelStatus, ChannelType, DeliveryResult, EventType, HealthStatus, MessageType, ) 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 .backoff import BackoffManager from .bridge import BridgeClient from .constants import ( DEFAULT_BRIDGE_URL, DEFAULT_CIRCUIT_BREAKER_RECOVERY, DEFAULT_CIRCUIT_BREAKER_THRESHOLD, DEFAULT_MAX_SEND_PER_MINUTE, ) from .credentials import CredentialManager from .dedup import MessageDedup from .directory import get_self_info, list_group_members, list_groups, list_peers 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, default_pagination_handler, ) from .login import LoginFlow from .message_sid import MessageIdTracker from .monitor import MessageMonitor from .normalize import ( normalize_inbound, ) from .probe import check_bridge_health from .rate_limiter import RateLimiter from .reaction import is_delete_reaction, normalize_reaction_icon from .security_audit import collect_security_audit_findings from .send import OutboundSequencer, _build_send_payload, send_delivered_event, send_seen_event, send_with_retry from .send_cache import SendCache from .session import ( check_group_policy, check_mention_required, resolve_agent_route, ) from .sticker_handler import augment_sticker_message from .status_issues import collect_status_issues from .text_styles import ( has_markdown_syntax, markdown_to_zalo_styles, styled_message_to_send_payload, ) from .vision import augment_message_with_vision def _env_fallback(env_keys: str | list[str], config: dict[str, Any], config_key: str, default: Any) -> Any: if isinstance(env_keys, str): env_keys = [env_keys] if not config: for key in env_keys: if key in os.environ: return os.environ[key] return default val = config.get(config_key) if val is not None: return val for key in env_keys: env_val = os.environ.get(key) if env_val is not None: return env_val return default @register_builtin_adapter class ZaloUserAdapter(BaseChannelAdapter): channel_id: ClassVar[str] = "zalo_user" channel_type: ClassVar[ChannelType] = ChannelType.ZALO_USER text_chunk_limit: ClassVar[int] = 2000 supports_markdown: ClassVar[bool] = True supports_streaming: ClassVar[bool] = True streaming_modes: ClassVar[list[str]] = ["off", "partial", "block"] max_media_size_mb: ClassVar[int] = 50 webhook_path: ClassVar[str | None] = None capabilities = ChannelCapabilities( chat_types=["direct", "group"], reply=True, # Bridge supports reply via quote_message_id (differs from zca-js native capabilities) media=True, reactions=True, edit=True, # Bridge supports message editing (differs from zca-js native capabilities) unsend=True, # Bridge supports message deletion (differs from zca-js native capabilities) supports_markdown=True, supports_streaming=True, streaming_modes=["off", "partial", "block"], text_chunk_limit=2000, max_media_size_mb=50, polls=False, threads=False, group_management=False, native_commands=False, ) meta = ChannelMeta( id="zalo_user", label="Zalo User", blurb="通过自建 Bridge 服务间接集成 zca-js,支持 QR/Cookie 认证、文本/媒体消息收发、反应系统、流式输出", ) def __init__(self, config: dict[str, Any] | None = None): super().__init__(config) self._status = ChannelStatus.DISCONNECTED self._bridge: BridgeClient | None = None self._login_flow: LoginFlow | None = None self._credential_mgr = CredentialManager(config=self._config, profile=self._profile) self._monitor: MessageMonitor | None = None self._circuit_breaker = CircuitBreaker( failure_threshold=DEFAULT_CIRCUIT_BREAKER_THRESHOLD, recovery_timeout=DEFAULT_CIRCUIT_BREAKER_RECOVERY, ) self._rate_limiter: RateLimiter | None = None self._account_info: dict[str, Any] = {} self._group_cache: list[dict[str, Any]] = [] self._paired_users: set[str] = set() self._friends_cache: list[dict[str, Any]] = [] self._reconnect_attempts = 0 self._last_error: str | None = None 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() self._backoff = BackoffManager(base_delay=1.0, max_delay=60.0) self._exec_approval = create_exec_approval(timeout=self._config.get("exec_approval_timeout", 30.0)) self._history_limit = self._config.get("history_limit", 100) self._dedup = MessageDedup() self._send_cache = SendCache() def _resolve_bridge_url(self) -> str: return _env_fallback("ZALO_BRIDGE_URL", self._config, "bridge_url", DEFAULT_BRIDGE_URL) def _resolve_auth_type(self) -> str: return _env_fallback("ZALO_AUTH_TYPE", self._config, "auth_type", "qr") async def reload_config(self, new_config: dict[str, Any]) -> None: old_bridge_url = self._resolve_bridge_url() self._config = new_config or {} self._profile = _env_fallback( ["ZALOUSER_PROFILE", "ZCA_PROFILE", "ZALO_PROFILE"], self._config, "profile", "default" ) self._enabled = self._config.get("enabled", True) self._history_limit = self._config.get("history_limit", 100) new_bridge_url = self._resolve_bridge_url() if old_bridge_url != new_bridge_url and self._bridge is not None: logger.info("[ZaloUser] Bridge URL changed, reconnecting") was_connected = self._status == ChannelStatus.CONNECTED await self.disconnect() if was_connected: await self.connect() self._circuit_breaker = CircuitBreaker( failure_threshold=self._config.get("circuit_breaker_threshold", DEFAULT_CIRCUIT_BREAKER_THRESHOLD), recovery_timeout=self._config.get("circuit_breaker_recovery", DEFAULT_CIRCUIT_BREAKER_RECOVERY), ) max_per_minute = self._config.get("max_send_per_minute", DEFAULT_MAX_SEND_PER_MINUTE) self._rate_limiter = RateLimiter(max_per_minute) @property def account_id(self) -> str: return self._account_info.get("user_id", "") @property def account_name(self) -> str: return self._account_info.get("display_name", "unknown") @property def config(self) -> dict[str, Any]: return self._config @config.setter def config(self, value: dict[str, Any] | None) -> None: self._config = value or {} @property def dm_policy(self) -> str: return self._config.get("dm_policy", "pairing") @property def group_policy(self) -> str: return self._config.get("group_policy", "allowlist") async def pre_connect(self) -> dict: bridge_url = self._resolve_bridge_url() timeout = self._config.get("network", {}).get("connect_timeout", 10) self_listen = self._config.get("bridge_self_listen", True) bridge = BridgeClient(bridge_url, timeout, self_listen=self_listen) await bridge.start() self._bridge = bridge login_flow = LoginFlow(bridge, self._config, self._credential_mgr) self._login_flow = login_flow auth_type = self._resolve_auth_type() if auth_type == "cookie": try: await login_flow.login_with_cookie() return {"status": "ok", "mode": "cookie"} except ChannelAuthenticationError as e: self._last_error = str(e) return {"status": "error", "message": str(e)} try: result = await login_flow.start_qr_login() return {"status": "pending", "qr_url": result["qr_url"], "qr_id": result["qr_id"]} except Exception as e: self._last_error = str(e) return {"status": "error", "message": str(e)} async def connect(self) -> None: if self._status == ChannelStatus.CONNECTED: return self._status = ChannelStatus.CONNECTING logger.info(f"[ZaloUser] Starting channel '{self.channel_id}'") if self._bridge is None or not self._bridge.is_started: bridge_url = self._resolve_bridge_url() timeout = self._config.get("network", {}).get("connect_timeout", 10) self_listen = self._config.get("bridge_self_listen", True) self._bridge = BridgeClient(bridge_url, timeout, self_listen=self_listen) await self._bridge.start() if self._login_flow is None: self._login_flow = LoginFlow(self._bridge, self._config, self._credential_mgr) bridge = self._bridge try: health = await check_bridge_health(bridge) if health.status == "unhealthy": raise ChannelConnectionError(f"Bridge service unhealthy at {bridge.base_url}") except httpx.ConnectError: raise ChannelConnectionError(f"Cannot connect to bridge at {bridge.base_url}") login_status = await bridge.check_login_status() if not login_status.get("logged_in"): if self._credential_mgr.load_from_file(): try: verify = await bridge.check_login_status() if verify.get("logged_in"): logger.info("[ZaloUser] Session restored from persisted credentials") else: logger.info("[ZaloUser] Persisted credentials invalid, re-authenticating") self._credential_mgr.mark_logged_out() except Exception: pass if not self._credential_mgr.is_authenticated: auth_type = self._resolve_auth_type() if auth_type == "cookie": await self._login_flow.login_with_cookie() else: try: await self._login_flow.wait_for_login( poll_interval=self._config.get("login_poll_interval", 2.0), timeout=self._config.get("login_timeout", 120.0), ) except ChannelAuthenticationError as e: self._last_error = str(e) self._status = ChannelStatus.ERROR raise try: account_resp = await bridge.get_account_info() self._account_info = account_resp self._credential_mgr.mark_connected(account_resp) except Exception: self._account_info = {"display_name": "unknown", "user_id": "unknown"} self._group_cache = await sync_group_list(bridge) try: self._friends_cache = await list_peers(bridge) except Exception: self._friends_cache = [] group_context_cache.set_profile(self._profile) max_per_minute = self._config.get("max_send_per_minute", DEFAULT_MAX_SEND_PER_MINUTE) self._rate_limiter = RateLimiter(max_per_minute) import time self._last_connected_at = time.time() monitor = MessageMonitor( channel_id=self.channel_id, ws_url=bridge.get_websocket_url(), on_message=self._handle_message, profile=self._profile, ) self._monitor = monitor await monitor.start() # Security audit integration security_issues = collect_security_audit_findings(self._config) if security_issues: logger.warning(f"[ZaloUser] Security audit post-connect findings: {security_issues}") self._status = ChannelStatus.CONNECTED self._reconnect_attempts = 0 logger.info(f"[ZaloUser] Channel started. Account: {self._account_info.get('display_name', 'unknown')}") async def disconnect(self) -> None: if self._status == ChannelStatus.DISCONNECTED: return logger.info(f"[ZaloUser] Stopping channel '{self.channel_id}'") if self._monitor: try: await self._monitor.stop() except Exception: logger.exception("[ZaloUser] Error stopping message monitor") finally: self._monitor = None group_context_cache.clear() if self._bridge: try: await self._bridge.stop() except Exception: logger.exception("[ZaloUser] Error stopping bridge client") finally: self._bridge = None self._credential_mgr.mark_logged_out() self._status = ChannelStatus.DISCONNECTED logger.info(f"[ZaloUser] Channel '{self.channel_id}' stopped") async def logout(self) -> dict[str, Any]: logger.info(f"[ZaloUser] Logging out channel '{self.channel_id}'") if self._login_flow: await self._login_flow.logout() await self.disconnect() return {"status": "logged_out"} async def send(self, response: ChannelResponse) -> DeliveryResult: if not self._bridge or self._status != ChannelStatus.CONNECTED: return DeliveryResult(success=False, error="Not connected") seq = self._outbound_seq.next() if not self._send_cache.check_and_set(response.identity.channel_chat_id, response.content, seq): return DeliveryResult(success=True, message_id=f"cached:{seq}") async def _do_send(): return await send_with_retry(self._bridge, response, self._config, self._rate_limiter) try: import time self._last_message_at = time.time() return await self._circuit_breaker.call(_do_send) except CircuitBreakerOpenError: return DeliveryResult(success=False, error="Circuit breaker open") except httpx.ConnectError as e: return DeliveryResult(success=False, error=f"Connection error: {e}") except httpx.TimeoutException as e: return DeliveryResult(success=False, error=f"Request timeout: {e}") except Exception as e: return DeliveryResult(success=False, error=str(e)) async def receive(self) -> AsyncIterator[ChannelMessage]: if False: yield def normalize_inbound(self, raw: dict[str, Any]) -> ChannelMessage: return normalize_inbound(self.channel_id, raw) def format_outbound(self, response: ChannelResponse) -> dict[str, Any]: markdown_enabled = self._config.get("markdown", True) if markdown_enabled and has_markdown_syntax(response.content): styled = markdown_to_zalo_styles(response.content) payload = _build_send_payload(response, self._config) payload["styled_paragraphs"] = styled_message_to_send_payload(styled, response.identity.channel_chat_id)[ "styled_paragraphs" ] return payload return _build_send_payload(response, self._config) async def health_check(self) -> HealthStatus: if not self._bridge: return HealthStatus(status="unhealthy", last_error="Bridge not initialized") health = await check_bridge_health(self._bridge) health.metadata["adapter_status"] = self._status.value health.metadata["credential_stage"] = self._credential_mgr.stage.value health.metadata["reconnect_attempts"] = self._reconnect_attempts health.metadata["profile"] = self._profile if self._account_info: health.metadata["account"] = self._account_info.get("display_name", "unknown") health.metadata["account_id"] = self._account_info.get("user_id", "unknown") security_findings = collect_security_audit_findings(self._config) if security_findings: health.metadata["security_audit"] = security_findings status_issues = collect_status_issues( credential_stage=self._credential_mgr.stage.value, adapter_status=self._status.value, dm_policy=self.dm_policy, group_policy=self.group_policy, last_error=self._last_error, reconnect_attempts=self._reconnect_attempts, bridge_url=self._resolve_bridge_url(), ) if status_issues: health.metadata["status_issues"] = status_issues health.last_connected_at = utc_now_naive() return health def get_status_snapshot(self) -> ChannelAccountSnapshot: return ChannelAccountSnapshot( account_id=self.account_id, name=self.account_name, configured=bool(self._bridge), enabled=True, linked=self._credential_mgr.is_authenticated, running=self._status == ChannelStatus.CONNECTED, connected=self._status == ChannelStatus.CONNECTED, status_state=self._status.value, health_state=self._status.value, last_connected_at_s=self._last_connected_at, last_message_at=self._last_message_at, last_error=self._last_error, reconnect_attempts=self._reconnect_attempts, dm_policy=self.dm_policy, group_policy=self.group_policy, allow_from_count=len(self._config.get("allow_from", [])), profile=self._account_info if self._account_info else None, ) async def edit_message(self, chat_id: str, msg_id: str, content: str) -> DeliveryResult: if not self._bridge or self._status != ChannelStatus.CONNECTED: return DeliveryResult(success=False, error="Not connected") try: self._outbound_seq.next(chat_id) resp = await self._bridge.edit_message(chat_id, content) return DeliveryResult(success=True, message_id=resp.get("message_id")) except Exception as e: return DeliveryResult(success=False, error=str(e)) async def delete_message(self, chat_id: str, msg_id: str) -> DeliveryResult: if not self._bridge or self._status != ChannelStatus.CONNECTED: return DeliveryResult(success=False, error="Not connected") try: return await self._bridge.delete_message(chat_id, msg_id) except Exception as e: return DeliveryResult(success=False, error=str(e)) async def send_reaction(self, chat_id: str, msg_id: str, emoji: str) -> DeliveryResult: if not self._bridge or self._status != ChannelStatus.CONNECTED: return DeliveryResult(success=False, error="Not connected") normalized = normalize_reaction_icon(emoji) if is_delete_reaction(emoji): return await self.remove_reaction(chat_id, msg_id) try: return await self._bridge.send_reaction(chat_id, msg_id, normalized) except Exception as e: return DeliveryResult(success=False, error=str(e)) async def remove_reaction(self, chat_id: str, msg_id: str) -> DeliveryResult: if not self._bridge or self._status != ChannelStatus.CONNECTED: return DeliveryResult(success=False, error="Not connected") try: return await self._bridge.send_reaction(chat_id, msg_id, "") except Exception as e: return DeliveryResult(success=False, error=str(e)) async def send_media(self, chat_id: str, media_type: str, data: Any) -> DeliveryResult: if not self._bridge or self._status != ChannelStatus.CONNECTED: return DeliveryResult(success=False, error="Not connected") identity = self._build_stream_identity(chat_id, "") response = ChannelResponse( identity=identity, message_type=MessageType(media_type) if media_type in MessageType else MessageType.FILE, content="", ) if media_type == "image": response.message_type = MessageType.IMAGE elif media_type == "video": response.message_type = MessageType.VIDEO elif media_type == "audio": response.message_type = MessageType.AUDIO if isinstance(data, str) and data.startswith("http"): response.attachments = [Attachment(type=media_type, url=data)] elif isinstance(data, bytes): response.content = "media" else: response.attachments = [Attachment(type=media_type, url=str(data))] return await self.send(response) async def download_media(self, file_id: str) -> bytes: if not self._bridge: raise ChannelConnectionError("Bridge not initialized") return await self._bridge.download_media(file_id) def _send_pairing_approval(self, chat_id: str) -> None: import asyncio async def _do(): try: if self._bridge and self._status == ChannelStatus.CONNECTED: await self._bridge.send_message( { "conversation_id": chat_id, "message_type": "text", "text": "Your pairing request has been approved.", } ) except Exception: logger.exception("[ZaloUser] Failed to send pairing approval") asyncio.create_task(_do()) async def get_user_info(self, channel_user_id: str) -> dict[str, Any]: if not self._bridge: return {"id": channel_user_id, "display_name": channel_user_id} try: return await self._bridge.get_user_info(channel_user_id) except Exception: return {"id": channel_user_id, "display_name": channel_user_id} async def get_account_info(self) -> dict[str, Any]: if not self._bridge: return self._account_info or {"display_name": "unknown", "user_id": "unknown"} try: account = await get_self_info(self._bridge) self._account_info = account return account except Exception: return self._account_info or {"display_name": "unknown", "user_id": "unknown"} async def list_friends(self, query: str = "") -> list[dict[str, Any]]: if not self._bridge: return self._friends_cache try: friends = await list_peers(self._bridge, query) self._friends_cache = friends return friends except Exception: return self._friends_cache async def list_groups(self, query: str = "") -> list[dict[str, Any]]: if not self._bridge: return self._group_cache try: return await list_groups(self._bridge, query) except Exception: return self._group_cache async def list_group_members(self, group_id: str) -> list[dict[str, Any]]: if not self._bridge: return [] return await list_group_members(self._bridge, group_id) async def _handle_message(self, message: ChannelMessage) -> None: import time self._last_transport_activity_at = time.time() msg_id = message.identity.channel_message_id user_id = message.identity.channel_user_id chat_id = message.identity.channel_chat_id if msg_id: if self._dedup.check_and_mark(msg_id): logger.debug(f"[ZaloUser] Duplicate message ignored: {msg_id}") return self._msg_id_tracker.track(msg_id, chat_id) if self._bridge and self._status == ChannelStatus.CONNECTED: try: await send_delivered_event(self._bridge, chat_id, msg_id) await send_seen_event(self._bridge, chat_id, msg_id) except Exception: logger.debug("[ZaloUser] Failed to send delivered/seen events") event_type = message.event_type if event_type in (EventType.REACTION_ADDED, EventType.REACTION_REMOVED): self._msg_id_tracker.track(message.metadata.get("reaction_message_id", ""), chat_id) elif event_type == EventType.CARD_ACTION: await self._handle_callback_query(message) return message = augment_sticker_message(message) message = await augment_message_with_vision(self._bridge, message, self._config) chat_type = message.metadata.get("zalo_chat_type", "direct") content = message.content if chat_type == "direct": paired_key = f"zalo:{user_id}" is_new_pair = paired_key not in self._paired_users self._paired_users.add(paired_key) dm_policy = self._config.get("dm_policy", "pairing") if dm_policy == "pairing": if is_new_pair: self._send_pairing_approval(chat_id) elif dm_policy == "disabled": logger.warning(f"[ZaloUser] DM blocked by policy: user={user_id}") return elif dm_policy == "allowlist": allow_from = self._config.get("allow_from", []) resolved = resolve_allow_from_entries(allow_from, self._friends_cache) if paired_key not in allow_from and user_id not in resolved: logger.warning(f"[ZaloUser] DM blocked (not in allowlist): user={user_id}") return elif dm_policy != "open": logger.warning(f"[ZaloUser] DM blocked by policy: user={user_id}") return elif chat_type == "group": if not check_group_policy(chat_id, user_id, self._config): logger.warning(f"[ZaloUser] Group message blocked by policy: chat={chat_id}, user={user_id}") return bot_name = self._account_info.get("display_name", "") bot_names = [bot_name] if bot_name else [] if not check_mention_required(chat_id, content, self._config, bot_names): logger.debug(f"[ZaloUser] Group message ignored: @mention required for chat={chat_id}") return route = resolve_agent_route(self.channel_id, chat_id, user_id, chat_type, self._config) message.metadata["agent_route"] = route if self._message_handler: await self._message_handler(message) async def _handle_callback_query(self, message: ChannelMessage) -> None: from .interactive_dispatch import InteractiveDispatch callback_data = message.metadata.get("callback_data", "") logger.info(f"[ZaloUser] Callback query received: data={callback_data}") dispatch = InteractiveDispatch() dispatch.register_prefix("approve_", default_approval_handler) dispatch.register_prefix("reject_", default_approval_handler) dispatch.register_prefix("model_", default_model_selector_handler) dispatch.register_prefix("page_", default_pagination_handler) ctx = {"bridge": self._bridge, "adapter": self, "exec_approval": getattr(self, "_exec_approval", None)} result = await dispatch.dispatch(message, ctx) if result.get("handled") and self._bridge and self._status == ChannelStatus.CONNECTED: action = result.get("action", "") chat_id = message.identity.channel_chat_id if action == "approved": await self._bridge.send_message( { "conversation_id": chat_id, "message_type": "text", "text": "Approved.", } ) elif action == "rejected": await self._bridge.send_message( { "conversation_id": chat_id, "message_type": "text", "text": "Rejected.", } )