ForcePilot/backend/package/yuxi/channels/adapters/zalo_user/bridge.py
Kris 9285da5c55 feat(zalo-user): 实现完整的Zalo用户频道适配器
新增了Zalo用户频道的完整适配器实现,包括:
- 基础的适配器初始化与导出结构
- 群组同步与成员获取功能
- 请求限流与退避重试机制
- 健康检查与状态探针
- 消息反应/表情处理工具
- 贴纸缓存与消息去重功能
- 消息ID格式化与追踪
- TTS语音合成支持
- 消息发送权限校验
- 长文本分块发送
- 操作审批流程
- 常量配置与国际化支持
- 图像视觉分析功能
- 贴纸消息处理
- 登录与配置向导
- 群组上下文缓存
- 网关连接管理
- 配置Schema校验
- 状态问题与安全审计
- 内联按钮与交互组件
- 交互式回调分发
- 联系人与群组目录管理
- 富媒体卡片消息支持
2026-05-12 00:53:13 +08:00

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)