新增了Zalo用户频道的完整适配器实现,包括: - 基础的适配器初始化与导出结构 - 群组同步与成员获取功能 - 请求限流与退避重试机制 - 健康检查与状态探针 - 消息反应/表情处理工具 - 贴纸缓存与消息去重功能 - 消息ID格式化与追踪 - TTS语音合成支持 - 消息发送权限校验 - 长文本分块发送 - 操作审批流程 - 常量配置与国际化支持 - 图像视觉分析功能 - 贴纸消息处理 - 登录与配置向导 - 群组上下文缓存 - 网关连接管理 - 配置Schema校验 - 状态问题与安全审计 - 内联按钮与交互组件 - 交互式回调分发 - 联系人与群组目录管理 - 富媒体卡片消息支持
213 lines
7.9 KiB
Python
213 lines
7.9 KiB
Python
from __future__ import annotations
|
|
|
|
from typing import Any
|
|
from urllib.parse import urlparse, urlunparse
|
|
|
|
import httpx
|
|
|
|
from yuxi.channels.models import DeliveryResult
|
|
from yuxi.utils.logging_config import logger
|
|
|
|
DEFAULT_BRIDGE_URL = "http://localhost:5556"
|
|
DEFAULT_TIMEOUT = 30.0
|
|
|
|
|
|
def _ws_url_from_http(http_url: str, self_listen: bool = True) -> str:
|
|
parsed = urlparse(http_url)
|
|
scheme = "wss" if parsed.scheme == "https" else "ws"
|
|
ws_parsed = parsed._replace(scheme=scheme)
|
|
base = urlunparse(ws_parsed) + "/messages/stream"
|
|
if not self_listen:
|
|
base += "?self_listen=false"
|
|
return base
|
|
|
|
|
|
class BridgeClient:
|
|
def __init__(
|
|
self,
|
|
bridge_url: str = DEFAULT_BRIDGE_URL,
|
|
timeout: float = DEFAULT_TIMEOUT,
|
|
verify_tls: bool = True,
|
|
self_listen: bool = True,
|
|
):
|
|
self._bridge_url = bridge_url.rstrip("/")
|
|
self._timeout = timeout
|
|
self._verify_tls = verify_tls
|
|
self._self_listen = self_listen
|
|
self._http: httpx.AsyncClient | None = None
|
|
|
|
if not self._bridge_url.startswith("https://") and not self._bridge_url.startswith("http://"):
|
|
raise ValueError(f"bridge_url must start with http:// or https://, got: {self._bridge_url}")
|
|
|
|
if not self._bridge_url.startswith("https://"):
|
|
logger.warning(
|
|
f"[ZaloUser] Bridge URL is not using HTTPS ({self._bridge_url}). "
|
|
"Credentials including cookies and session tokens will be transmitted in plaintext. "
|
|
"Use https:// in production environments."
|
|
)
|
|
|
|
@property
|
|
def base_url(self) -> str:
|
|
return self._bridge_url
|
|
|
|
async def start(self) -> None:
|
|
self._http = httpx.AsyncClient(timeout=self._timeout, verify=self._verify_tls)
|
|
logger.info(f"[ZaloUser] Bridge client initialized for {self._bridge_url}")
|
|
|
|
async def stop(self) -> None:
|
|
if self._http:
|
|
await self._http.aclose()
|
|
self._http = None
|
|
logger.info("[ZaloUser] Bridge client stopped")
|
|
|
|
@property
|
|
def is_started(self) -> bool:
|
|
return self._http is not None
|
|
|
|
async def check_health(self) -> dict[str, Any]:
|
|
resp = await self._http.get(f"{self._bridge_url}/health")
|
|
return resp.json()
|
|
|
|
async def start_qr_login(self) -> dict[str, Any]:
|
|
resp = await self._http.post(f"{self._bridge_url}/login/qr", timeout=10.0)
|
|
return resp.json()
|
|
|
|
async def check_login_status(self, qr_id: str = "") -> dict[str, Any]:
|
|
params = {"qr_id": qr_id} if qr_id else {}
|
|
resp = await self._http.get(f"{self._bridge_url}/login/status", params=params)
|
|
return resp.json()
|
|
|
|
async def login_with_cookie(self, cookie: str, imei: str = "", user_agent: str = "") -> dict[str, Any]:
|
|
payload: dict[str, str] = {"cookie": cookie}
|
|
if imei:
|
|
payload["imei"] = imei
|
|
if user_agent:
|
|
payload["user_agent"] = user_agent
|
|
resp = await self._http.post(
|
|
f"{self._bridge_url}/login/cookie",
|
|
json=payload,
|
|
)
|
|
return resp.json()
|
|
|
|
async def get_account_info(self) -> dict[str, Any]:
|
|
resp = await self._http.get(f"{self._bridge_url}/account/info")
|
|
return resp.json()
|
|
|
|
async def send_message(self, payload: dict[str, Any]) -> DeliveryResult:
|
|
resp = await self._http.post(
|
|
f"{self._bridge_url}/messages/send",
|
|
json=payload,
|
|
)
|
|
data = resp.json()
|
|
return DeliveryResult(
|
|
success=data.get("success", False),
|
|
message_id=data.get("message_id"),
|
|
error=data.get("error"),
|
|
)
|
|
|
|
async def edit_message(self, conversation_id: str, text: str) -> dict[str, Any]:
|
|
resp = await self._http.post(
|
|
f"{self._bridge_url}/messages/edit",
|
|
json={"conversation_id": conversation_id, "text": text},
|
|
)
|
|
return resp.json()
|
|
|
|
async def send_typing(self, conversation_id: str) -> dict[str, Any]:
|
|
resp = await self._http.post(
|
|
f"{self._bridge_url}/messages/typing",
|
|
json={"conversation_id": conversation_id},
|
|
)
|
|
return resp.json()
|
|
|
|
async def send_delivered(self, conversation_id: str, message_id: str) -> dict[str, Any]:
|
|
resp = await self._http.post(
|
|
f"{self._bridge_url}/messages/delivered",
|
|
json={"conversation_id": conversation_id, "message_id": message_id},
|
|
)
|
|
return resp.json()
|
|
|
|
async def send_seen(self, conversation_id: str, message_id: str) -> dict[str, Any]:
|
|
resp = await self._http.post(
|
|
f"{self._bridge_url}/messages/seen",
|
|
json={"conversation_id": conversation_id, "message_id": message_id},
|
|
)
|
|
return resp.json()
|
|
|
|
async def send_voice_attachment(self, conversation_id: str, audio_url: str, file_name: str) -> DeliveryResult:
|
|
resp = await self._http.post(
|
|
f"{self._bridge_url}/messages/send",
|
|
json={
|
|
"conversation_id": conversation_id,
|
|
"message_type": "voice",
|
|
"attachments": [{"type": "voice", "url": audio_url, "filename": file_name}],
|
|
},
|
|
)
|
|
data = resp.json()
|
|
return DeliveryResult(
|
|
success=data.get("success", False),
|
|
message_id=data.get("message_id"),
|
|
error=data.get("error"),
|
|
)
|
|
|
|
async def get_group_list(self) -> dict[str, Any]:
|
|
resp = await self._http.get(f"{self._bridge_url}/groups/list")
|
|
return resp.json()
|
|
|
|
async def get_group_info(self, group_id: str) -> dict[str, Any]:
|
|
resp = await self._http.get(f"{self._bridge_url}/groups/{group_id}")
|
|
return resp.json()
|
|
|
|
async def delete_message(self, conversation_id: str, message_id: str) -> DeliveryResult:
|
|
resp = await self._http.post(
|
|
f"{self._bridge_url}/messages/delete",
|
|
json={"conversation_id": conversation_id, "message_id": message_id},
|
|
)
|
|
data = resp.json()
|
|
return DeliveryResult(
|
|
success=data.get("success", False),
|
|
message_id=data.get("message_id"),
|
|
error=data.get("error"),
|
|
)
|
|
|
|
async def send_reaction(self, conversation_id: str, message_id: str, emoji: str) -> DeliveryResult:
|
|
resp = await self._http.post(
|
|
f"{self._bridge_url}/messages/react",
|
|
json={
|
|
"conversation_id": conversation_id,
|
|
"message_id": message_id,
|
|
"emoji": emoji,
|
|
},
|
|
)
|
|
data = resp.json()
|
|
return DeliveryResult(
|
|
success=data.get("success", False),
|
|
message_id=data.get("message_id"),
|
|
error=data.get("error"),
|
|
)
|
|
|
|
async def download_media(self, file_id: str) -> bytes:
|
|
resp = await self._http.get(f"{self._bridge_url}/media/download/{file_id}")
|
|
resp.raise_for_status()
|
|
return resp.content
|
|
|
|
async def get_user_info(self, user_id: str) -> dict[str, Any]:
|
|
resp = await self._http.get(f"{self._bridge_url}/users/{user_id}")
|
|
return resp.json()
|
|
|
|
async def get_message_info(self, message_id: str) -> dict[str, Any]:
|
|
resp = await self._http.get(f"{self._bridge_url}/messages/{message_id}")
|
|
return resp.json()
|
|
|
|
async def get_websocket_url(self) -> str:
|
|
return _ws_url_from_http(self._bridge_url, self._self_listen)
|
|
|
|
async def post(self, path: str, json: dict | None = None, **kwargs) -> httpx.Response:
|
|
if not self._http:
|
|
raise RuntimeError("BridgeClient not started, call start() before making requests")
|
|
return await self._http.post(f"{self._bridge_url}{path}", json=json, **kwargs)
|
|
|
|
async def get(self, path: str, **kwargs) -> httpx.Response:
|
|
if not self._http:
|
|
raise RuntimeError("BridgeClient not started, call start() before making requests")
|
|
return await self._http.get(f"{self._bridge_url}{path}", **kwargs)
|