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