新增 Zalo OA、Zoom Chat、Zulip 三个渠道扩展。 Zalo OA 渠道扩展主要模块:sidecar_client, config, gateway, outbound, streaming, pairing, security, auth, dedupe, directory, monitor, status, session, reactions, tools Zoom Chat 渠道扩展主要模块:config, gateway, webhook, outbound, streaming, pairing, security, crypto, dedupe, actions, media, mentions, monitor, status, session, reactions, threading Zulip 渠道扩展主要模块:client, config, gateway, outbound, streaming, pairing, security, monitor, status
798 lines
31 KiB
Python
798 lines
31 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import logging
|
|
from typing import Any
|
|
|
|
import httpx
|
|
|
|
from yuxi.channel.extensions.zalouser.errors import (
|
|
ZaloUserAuthError,
|
|
ZaloUserConnectionError,
|
|
ZaloUserQRDeclinedError,
|
|
ZaloUserQRExpiredError,
|
|
ZaloUserSendError,
|
|
classify_http_error,
|
|
)
|
|
from yuxi.channel.extensions.zalouser.types import (
|
|
ZaloFriend,
|
|
ZaloGroup,
|
|
ZaloGroupMember,
|
|
ZaloUserInfo,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
DEFAULT_TIMEOUT = 30.0
|
|
QR_POLL_INTERVAL = 0.4
|
|
|
|
|
|
class ZcaSidecarClient:
|
|
def __init__(self, sidecar_url: str, profile: str = "default"):
|
|
self._sidecar_url = sidecar_url.rstrip("/")
|
|
self._profile = profile
|
|
self._http: httpx.AsyncClient | None = None
|
|
|
|
async def _get_http(self) -> httpx.AsyncClient:
|
|
if self._http is None:
|
|
self._http = httpx.AsyncClient(timeout=httpx.Timeout(DEFAULT_TIMEOUT))
|
|
return self._http
|
|
|
|
@property
|
|
def sidecar_url(self) -> str:
|
|
return self._sidecar_url
|
|
|
|
async def close(self) -> None:
|
|
if self._http:
|
|
await self._http.aclose()
|
|
self._http = None
|
|
|
|
async def login_qr_start(self, force: bool = False, timeout_ms: int = 30_000) -> dict:
|
|
http = await self._get_http()
|
|
payload = {"profile": self._profile, "force": force, "timeout_ms": timeout_ms}
|
|
try:
|
|
resp = await http.post(
|
|
f"{self._sidecar_url}/api/qr/start",
|
|
json=payload,
|
|
timeout=30.0,
|
|
)
|
|
if resp.status_code >= 400:
|
|
raise classify_http_error(resp.status_code, resp.text)
|
|
return resp.json()
|
|
except httpx.RequestError as e:
|
|
raise ZaloUserConnectionError(f"Failed to start QR login: {e}") from e
|
|
|
|
async def login_qr_wait(self, timeout_ms: int = 120_000) -> dict:
|
|
http = await self._get_http()
|
|
deadline = asyncio.get_event_loop().time() + timeout_ms / 1000.0
|
|
|
|
while asyncio.get_event_loop().time() < deadline:
|
|
try:
|
|
resp = await http.get(f"{self._sidecar_url}/api/qr/status", timeout=10.0)
|
|
if resp.status_code >= 400:
|
|
raise classify_http_error(resp.status_code, resp.text)
|
|
status = resp.json()
|
|
state = status.get("state", "")
|
|
|
|
if state == "confirmed":
|
|
return {
|
|
"connected": True,
|
|
"user_id": status.get("userId", ""),
|
|
"display_name": status.get("displayName", ""),
|
|
}
|
|
if state == "declined":
|
|
raise ZaloUserQRDeclinedError("QR login was declined")
|
|
if state == "expired":
|
|
raise ZaloUserQRExpiredError("QR code expired")
|
|
except (httpx.RequestError, ZaloUserQRDeclinedError, ZaloUserQRExpiredError):
|
|
raise
|
|
except Exception:
|
|
pass
|
|
|
|
await asyncio.sleep(QR_POLL_INTERVAL)
|
|
|
|
raise ZaloUserQRExpiredError("QR login timed out")
|
|
|
|
async def login_qr_cancel(self) -> dict:
|
|
http = await self._get_http()
|
|
try:
|
|
resp = await http.post(
|
|
f"{self._sidecar_url}/api/qr/cancel",
|
|
json={"profile": self._profile},
|
|
timeout=10.0,
|
|
)
|
|
if resp.status_code >= 400:
|
|
raise classify_http_error(resp.status_code, resp.text)
|
|
return resp.json()
|
|
except httpx.RequestError as e:
|
|
raise ZaloUserConnectionError(f"Failed to cancel QR login: {e}") from e
|
|
|
|
async def ensure_session(self, timeout_ms: int = 20_000) -> dict:
|
|
http = await self._get_http()
|
|
payload = {"profile": self._profile, "timeout_ms": timeout_ms}
|
|
try:
|
|
resp = await http.post(
|
|
f"{self._sidecar_url}/api/ensure-session",
|
|
json=payload,
|
|
timeout=25.0,
|
|
)
|
|
if resp.status_code >= 400:
|
|
raise classify_http_error(resp.status_code, resp.text)
|
|
return resp.json()
|
|
except httpx.RequestError as e:
|
|
raise ZaloUserConnectionError(f"Failed to ensure session: {e}") from e
|
|
|
|
async def logout(self) -> dict:
|
|
http = await self._get_http()
|
|
try:
|
|
resp = await http.post(
|
|
f"{self._sidecar_url}/api/logout",
|
|
json={"profile": self._profile},
|
|
timeout=10.0,
|
|
)
|
|
if resp.status_code >= 400:
|
|
raise classify_http_error(resp.status_code, resp.text)
|
|
return resp.json()
|
|
except httpx.RequestError as e:
|
|
raise ZaloUserConnectionError(f"Failed to logout: {e}") from e
|
|
|
|
async def send_message(
|
|
self,
|
|
thread_id: str,
|
|
message: str,
|
|
*,
|
|
is_group: bool = False,
|
|
styles: list[dict] | None = None,
|
|
media_url: str | None = None,
|
|
quote: dict | None = None,
|
|
) -> dict:
|
|
http = await self._get_http()
|
|
payload: dict[str, Any] = {
|
|
"threadId": thread_id,
|
|
"type": "Group" if is_group else "User",
|
|
"message": message,
|
|
}
|
|
if styles:
|
|
payload["styles"] = styles
|
|
if media_url:
|
|
payload["mediaUrl"] = media_url
|
|
if quote:
|
|
payload["quote"] = quote
|
|
|
|
try:
|
|
resp = await http.post(
|
|
f"{self._sidecar_url}/api/message/send",
|
|
json=payload,
|
|
)
|
|
if resp.status_code >= 400:
|
|
raise classify_http_error(resp.status_code, resp.text)
|
|
return resp.json()
|
|
except httpx.RequestError as e:
|
|
raise ZaloUserSendError(f"Failed to send message: {e}") from e
|
|
|
|
async def send_image(
|
|
self,
|
|
thread_id: str,
|
|
media_url: str,
|
|
*,
|
|
caption: str = "",
|
|
is_group: bool = False,
|
|
) -> dict:
|
|
http = await self._get_http()
|
|
payload: dict[str, Any] = {
|
|
"threadId": thread_id,
|
|
"type": "Group" if is_group else "User",
|
|
"mediaUrl": media_url,
|
|
"mediaType": "image",
|
|
}
|
|
if caption:
|
|
payload["message"] = caption
|
|
|
|
try:
|
|
resp = await http.post(
|
|
f"{self._sidecar_url}/api/message/image",
|
|
json=payload,
|
|
)
|
|
if resp.status_code >= 400:
|
|
raise classify_http_error(resp.status_code, resp.text)
|
|
return resp.json()
|
|
except httpx.RequestError as e:
|
|
raise ZaloUserSendError(f"Failed to send image: {e}") from e
|
|
|
|
async def send_voice(self, thread_id: str, media_url: str, is_group: bool = False) -> dict:
|
|
http = await self._get_http()
|
|
payload: dict[str, Any] = {
|
|
"threadId": thread_id,
|
|
"type": "Group" if is_group else "User",
|
|
"mediaUrl": media_url,
|
|
"mediaType": "voice",
|
|
}
|
|
try:
|
|
resp = await http.post(
|
|
f"{self._sidecar_url}/api/message/voice",
|
|
json=payload,
|
|
)
|
|
if resp.status_code >= 400:
|
|
raise classify_http_error(resp.status_code, resp.text)
|
|
return resp.json()
|
|
except httpx.RequestError as e:
|
|
raise ZaloUserSendError(f"Failed to send voice: {e}") from e
|
|
|
|
async def send_attachment(
|
|
self, thread_id: str, media_url: str, media_type: str, is_group: bool = False
|
|
) -> dict:
|
|
http = await self._get_http()
|
|
payload: dict[str, Any] = {
|
|
"threadId": thread_id,
|
|
"type": "Group" if is_group else "User",
|
|
"mediaUrl": media_url,
|
|
"mediaType": media_type,
|
|
}
|
|
try:
|
|
resp = await http.post(
|
|
f"{self._sidecar_url}/api/message/attachment",
|
|
json=payload,
|
|
)
|
|
if resp.status_code >= 400:
|
|
raise classify_http_error(resp.status_code, resp.text)
|
|
return resp.json()
|
|
except httpx.RequestError as e:
|
|
raise ZaloUserSendError(f"Failed to send attachment: {e}") from e
|
|
|
|
async def send_link(
|
|
self, thread_id: str, url: str, caption: str = "", is_group: bool = False
|
|
) -> dict:
|
|
http = await self._get_http()
|
|
payload: dict[str, Any] = {
|
|
"threadId": thread_id,
|
|
"type": "Group" if is_group else "User",
|
|
"link": url,
|
|
"msg": caption,
|
|
}
|
|
try:
|
|
resp = await http.post(
|
|
f"{self._sidecar_url}/api/message/link",
|
|
json=payload,
|
|
)
|
|
if resp.status_code >= 400:
|
|
raise classify_http_error(resp.status_code, resp.text)
|
|
return resp.json()
|
|
except httpx.RequestError as e:
|
|
raise ZaloUserSendError(f"Failed to send link: {e}") from e
|
|
|
|
async def send_reaction(
|
|
self, thread_id: str, msg_id: str, cli_msg_id: str, icon: str, is_group: bool = False
|
|
) -> dict:
|
|
http = await self._get_http()
|
|
payload: dict[str, Any] = {
|
|
"threadId": thread_id,
|
|
"type": "Group" if is_group else "User",
|
|
"msgId": msg_id,
|
|
"cliMsgId": cli_msg_id,
|
|
"icon": icon,
|
|
}
|
|
try:
|
|
resp = await http.post(
|
|
f"{self._sidecar_url}/api/message/reaction",
|
|
json=payload,
|
|
)
|
|
if resp.status_code >= 400:
|
|
raise classify_http_error(resp.status_code, resp.text)
|
|
return resp.json()
|
|
except httpx.RequestError as e:
|
|
raise ZaloUserSendError(f"Failed to send reaction: {e}") from e
|
|
|
|
async def remove_reaction(
|
|
self, thread_id: str, msg_id: str, cli_msg_id: str, is_group: bool = False
|
|
) -> dict:
|
|
http = await self._get_http()
|
|
payload: dict[str, Any] = {
|
|
"threadId": thread_id,
|
|
"type": "Group" if is_group else "User",
|
|
"msgId": msg_id,
|
|
"cliMsgId": cli_msg_id,
|
|
"remove": True,
|
|
}
|
|
try:
|
|
resp = await http.post(
|
|
f"{self._sidecar_url}/api/message/reaction",
|
|
json=payload,
|
|
)
|
|
if resp.status_code >= 400:
|
|
raise classify_http_error(resp.status_code, resp.text)
|
|
return resp.json()
|
|
except httpx.RequestError as e:
|
|
raise ZaloUserSendError(f"Failed to remove reaction: {e}") from e
|
|
|
|
async def send_typing(self, thread_id: str, is_group: bool = False) -> dict:
|
|
http = await self._get_http()
|
|
payload: dict[str, Any] = {
|
|
"threadId": thread_id,
|
|
"type": "Group" if is_group else "User",
|
|
}
|
|
try:
|
|
resp = await http.post(
|
|
f"{self._sidecar_url}/api/typing",
|
|
json=payload,
|
|
)
|
|
return resp.json() if resp.text else {}
|
|
except httpx.RequestError:
|
|
return {}
|
|
|
|
async def send_delivered(self, thread_id: str, msg_id: str) -> dict:
|
|
http = await self._get_http()
|
|
try:
|
|
resp = await http.post(
|
|
f"{self._sidecar_url}/api/message/delivered",
|
|
json={"threadId": thread_id, "msgId": msg_id},
|
|
)
|
|
return resp.json() if resp.text else {}
|
|
except httpx.RequestError:
|
|
return {}
|
|
|
|
async def send_seen(self, thread_id: str, msg_id: str) -> dict:
|
|
http = await self._get_http()
|
|
try:
|
|
resp = await http.post(
|
|
f"{self._sidecar_url}/api/message/seen",
|
|
json={"threadId": thread_id, "msgId": msg_id},
|
|
)
|
|
return resp.json() if resp.text else {}
|
|
except httpx.RequestError:
|
|
return {}
|
|
|
|
async def get_me(self) -> ZaloUserInfo | None:
|
|
http = await self._get_http()
|
|
try:
|
|
resp = await http.get(f"{self._sidecar_url}/api/me")
|
|
if resp.status_code >= 400:
|
|
logger.warning("get_me returned %d", resp.status_code)
|
|
return None
|
|
data = resp.json()
|
|
return ZaloUserInfo(
|
|
user_id=data.get("userId", ""),
|
|
display_name=data.get("displayName", ""),
|
|
avatar_url=data.get("avatar"),
|
|
)
|
|
except Exception as e:
|
|
logger.warning("get_me failed: %s", e)
|
|
return None
|
|
|
|
async def list_friends(self, query: str | None = None) -> list[ZaloFriend]:
|
|
http = await self._get_http()
|
|
params = {}
|
|
if query:
|
|
params["query"] = query
|
|
try:
|
|
resp = await http.get(f"{self._sidecar_url}/api/friends", params=params)
|
|
if resp.status_code >= 400:
|
|
logger.warning("list_friends returned %d", resp.status_code)
|
|
return []
|
|
data = resp.json()
|
|
friends = data.get("friends", []) or data.get("data", [])
|
|
return [
|
|
ZaloFriend(
|
|
user_id=f.get("userId", ""),
|
|
display_name=f.get("displayName", ""),
|
|
avatar_url=f.get("avatar"),
|
|
)
|
|
for f in friends
|
|
]
|
|
except Exception as e:
|
|
logger.warning("list_friends failed: %s", e)
|
|
return []
|
|
|
|
async def list_groups(self, query: str | None = None) -> list[ZaloGroup]:
|
|
http = await self._get_http()
|
|
params = {}
|
|
if query:
|
|
params["query"] = query
|
|
try:
|
|
resp = await http.get(f"{self._sidecar_url}/api/groups", params=params)
|
|
if resp.status_code >= 400:
|
|
logger.warning("list_groups returned %d", resp.status_code)
|
|
return []
|
|
data = resp.json()
|
|
groups = data.get("groups", []) or data.get("data", [])
|
|
return [
|
|
ZaloGroup(
|
|
group_id=g.get("groupId", ""),
|
|
name=g.get("name", ""),
|
|
avatar_url=g.get("avatar"),
|
|
member_count=g.get("memberCount", 0),
|
|
)
|
|
for g in groups
|
|
]
|
|
except Exception as e:
|
|
logger.warning("list_groups failed: %s", e)
|
|
return []
|
|
|
|
async def list_group_members(self, group_id: str) -> list[ZaloGroupMember]:
|
|
http = await self._get_http()
|
|
try:
|
|
resp = await http.get(f"{self._sidecar_url}/api/groups/{group_id}/members")
|
|
if resp.status_code >= 400:
|
|
logger.warning("list_group_members returned %d", resp.status_code)
|
|
return []
|
|
data = resp.json()
|
|
members = data.get("members", []) or data.get("data", [])
|
|
return [
|
|
ZaloGroupMember(
|
|
user_id=m.get("userId", ""),
|
|
display_name=m.get("displayName", ""),
|
|
avatar_url=m.get("avatar"),
|
|
is_admin=m.get("isAdmin", False),
|
|
)
|
|
for m in members
|
|
]
|
|
except Exception as e:
|
|
logger.warning("list_group_members failed: %s", e)
|
|
return []
|
|
|
|
async def get_status(self) -> dict:
|
|
http = await self._get_http()
|
|
try:
|
|
resp = await http.get(f"{self._sidecar_url}/api/status", timeout=10.0)
|
|
if resp.status_code >= 400:
|
|
return {"authenticated": False, "connected": False}
|
|
data = resp.json() if resp.text else {}
|
|
return data
|
|
except Exception:
|
|
return {"authenticated": False, "connected": False}
|
|
|
|
async def check_auth(self) -> bool:
|
|
status = await self.get_status()
|
|
return status.get("authenticated", False) or status.get("connected", False)
|
|
|
|
async def delete_message(
|
|
self,
|
|
thread_id: str,
|
|
msg_id: str,
|
|
cli_msg_id: str = "",
|
|
is_group: bool = False,
|
|
is_owner: bool = True,
|
|
) -> dict:
|
|
http = await self._get_http()
|
|
payload: dict[str, Any] = {
|
|
"threadId": thread_id,
|
|
"type": "Group" if is_group else "User",
|
|
"msgId": msg_id,
|
|
"cliMsgId": cli_msg_id,
|
|
"isOwner": is_owner,
|
|
}
|
|
try:
|
|
resp = await http.post(
|
|
f"{self._sidecar_url}/api/message/delete",
|
|
json=payload,
|
|
)
|
|
if resp.status_code >= 400:
|
|
raise classify_http_error(resp.status_code, resp.text)
|
|
return resp.json()
|
|
except httpx.RequestError as e:
|
|
raise ZaloUserSendError(f"Failed to delete message: {e}") from e
|
|
|
|
async def poll_events(self, timeout: float = 30.0) -> list[dict]:
|
|
http = await self._get_http()
|
|
try:
|
|
resp = await http.get(
|
|
f"{self._sidecar_url}/api/events/poll",
|
|
timeout=timeout,
|
|
)
|
|
if resp.status_code == 200:
|
|
data = resp.json() if resp.text else {}
|
|
if isinstance(data, list):
|
|
return data
|
|
if isinstance(data, dict):
|
|
return [data]
|
|
elif resp.status_code >= 400:
|
|
logger.warning("poll_events returned %d", resp.status_code)
|
|
return []
|
|
except httpx.TimeoutException:
|
|
return []
|
|
except httpx.ConnectError:
|
|
raise
|
|
except Exception as e:
|
|
logger.warning("poll_events error: %s", e)
|
|
return []
|
|
|
|
async def create_group(self, name: str, member_ids: list[str]) -> dict:
|
|
http = await self._get_http()
|
|
payload = {"name": name, "memberIds": member_ids}
|
|
try:
|
|
resp = await http.post(f"{self._sidecar_url}/api/groups/create", json=payload)
|
|
if resp.status_code >= 400:
|
|
raise classify_http_error(resp.status_code, resp.text)
|
|
return resp.json()
|
|
except httpx.RequestError as e:
|
|
raise ZaloUserSendError(f"Failed to create group: {e}") from e
|
|
|
|
async def disband_group(self, group_id: str) -> dict:
|
|
http = await self._get_http()
|
|
try:
|
|
resp = await http.post(f"{self._sidecar_url}/api/groups/{group_id}/disband", json={})
|
|
if resp.status_code >= 400:
|
|
raise classify_http_error(resp.status_code, resp.text)
|
|
return resp.json()
|
|
except httpx.RequestError as e:
|
|
raise ZaloUserSendError(f"Failed to disband group: {e}") from e
|
|
|
|
async def add_user_to_group(self, user_id: str, group_id: str) -> dict:
|
|
http = await self._get_http()
|
|
payload = {"userId": user_id}
|
|
try:
|
|
resp = await http.post(f"{self._sidecar_url}/api/groups/{group_id}/members/add", json=payload)
|
|
if resp.status_code >= 400:
|
|
raise classify_http_error(resp.status_code, resp.text)
|
|
return resp.json()
|
|
except httpx.RequestError as e:
|
|
raise ZaloUserSendError(f"Failed to add user to group: {e}") from e
|
|
|
|
async def remove_user_from_group(self, user_id: str, group_id: str) -> dict:
|
|
http = await self._get_http()
|
|
payload = {"userId": user_id}
|
|
try:
|
|
resp = await http.post(f"{self._sidecar_url}/api/groups/{group_id}/members/remove", json=payload)
|
|
if resp.status_code >= 400:
|
|
raise classify_http_error(resp.status_code, resp.text)
|
|
return resp.json()
|
|
except httpx.RequestError as e:
|
|
raise ZaloUserSendError(f"Failed to remove user from group: {e}") from e
|
|
|
|
async def change_group_name(self, group_id: str, name: str) -> dict:
|
|
http = await self._get_http()
|
|
payload = {"name": name}
|
|
try:
|
|
resp = await http.put(f"{self._sidecar_url}/api/groups/{group_id}/name", json=payload)
|
|
if resp.status_code >= 400:
|
|
raise classify_http_error(resp.status_code, resp.text)
|
|
return resp.json()
|
|
except httpx.RequestError as e:
|
|
raise ZaloUserSendError(f"Failed to change group name: {e}") from e
|
|
|
|
async def change_group_avatar(self, group_id: str, avatar_url: str) -> dict:
|
|
http = await self._get_http()
|
|
payload = {"avatarUrl": avatar_url}
|
|
try:
|
|
resp = await http.put(f"{self._sidecar_url}/api/groups/{group_id}/avatar", json=payload)
|
|
if resp.status_code >= 400:
|
|
raise classify_http_error(resp.status_code, resp.text)
|
|
return resp.json()
|
|
except httpx.RequestError as e:
|
|
raise ZaloUserSendError(f"Failed to change group avatar: {e}") from e
|
|
|
|
async def change_group_owner(self, group_id: str, member_id: str) -> dict:
|
|
http = await self._get_http()
|
|
payload = {"memberId": member_id}
|
|
try:
|
|
resp = await http.put(f"{self._sidecar_url}/api/groups/{group_id}/owner", json=payload)
|
|
if resp.status_code >= 400:
|
|
raise classify_http_error(resp.status_code, resp.text)
|
|
return resp.json()
|
|
except httpx.RequestError as e:
|
|
raise ZaloUserSendError(f"Failed to change group owner: {e}") from e
|
|
|
|
async def add_group_deputies(self, group_id: str, member_ids: list[str]) -> dict:
|
|
http = await self._get_http()
|
|
payload = {"memberIds": member_ids}
|
|
try:
|
|
resp = await http.post(f"{self._sidecar_url}/api/groups/{group_id}/deputies/add", json=payload)
|
|
if resp.status_code >= 400:
|
|
raise classify_http_error(resp.status_code, resp.text)
|
|
return resp.json()
|
|
except httpx.RequestError as e:
|
|
raise ZaloUserSendError(f"Failed to add deputy: {e}") from e
|
|
|
|
async def remove_group_deputies(self, group_id: str, member_ids: list[str]) -> dict:
|
|
http = await self._get_http()
|
|
payload = {"memberIds": member_ids}
|
|
try:
|
|
resp = await http.post(f"{self._sidecar_url}/api/groups/{group_id}/deputies/remove", json=payload)
|
|
if resp.status_code >= 400:
|
|
raise classify_http_error(resp.status_code, resp.text)
|
|
return resp.json()
|
|
except httpx.RequestError as e:
|
|
raise ZaloUserSendError(f"Failed to remove deputy: {e}") from e
|
|
|
|
async def create_poll(self, group_id: str, question: str, options: list[str]) -> dict:
|
|
http = await self._get_http()
|
|
payload = {"groupId": group_id, "question": question, "options": options}
|
|
try:
|
|
resp = await http.post(f"{self._sidecar_url}/api/message/poll/create", json=payload)
|
|
if resp.status_code >= 400:
|
|
raise classify_http_error(resp.status_code, resp.text)
|
|
return resp.json()
|
|
except httpx.RequestError as e:
|
|
raise ZaloUserSendError(f"Failed to create poll: {e}") from e
|
|
|
|
async def lock_poll(self, poll_id: str, group_id: str) -> dict:
|
|
http = await self._get_http()
|
|
payload = {"pollId": poll_id, "groupId": group_id}
|
|
try:
|
|
resp = await http.post(f"{self._sidecar_url}/api/message/poll/lock", json=payload)
|
|
if resp.status_code >= 400:
|
|
raise classify_http_error(resp.status_code, resp.text)
|
|
return resp.json()
|
|
except httpx.RequestError as e:
|
|
raise ZaloUserSendError(f"Failed to lock poll: {e}") from e
|
|
|
|
async def search_stickers(self, keyword: str) -> list[dict]:
|
|
http = await self._get_http()
|
|
try:
|
|
resp = await http.get(f"{self._sidecar_url}/api/stickers/search", params={"keyword": keyword})
|
|
if resp.status_code >= 400:
|
|
return []
|
|
return resp.json().get("stickers", [])
|
|
except Exception:
|
|
return []
|
|
|
|
async def get_sticker_detail(self, sticker_id: str) -> dict | None:
|
|
http = await self._get_http()
|
|
try:
|
|
resp = await http.get(f"{self._sidecar_url}/api/stickers/{sticker_id}")
|
|
if resp.status_code >= 400:
|
|
return None
|
|
return resp.json()
|
|
except Exception:
|
|
return None
|
|
|
|
async def send_sticker(self, thread_id: str, sticker_obj: dict, is_group: bool = False) -> dict:
|
|
http = await self._get_http()
|
|
payload = {"threadId": thread_id, "type": "Group" if is_group else "User", "sticker": sticker_obj}
|
|
try:
|
|
resp = await http.post(f"{self._sidecar_url}/api/message/sticker", json=payload)
|
|
if resp.status_code >= 400:
|
|
raise classify_http_error(resp.status_code, resp.text)
|
|
return resp.json()
|
|
except httpx.RequestError as e:
|
|
raise ZaloUserSendError(f"Failed to send sticker: {e}") from e
|
|
|
|
async def find_user(self, phone_number: str) -> dict | None:
|
|
http = await self._get_http()
|
|
try:
|
|
resp = await http.get(f"{self._sidecar_url}/api/users/find", params={"phoneNumber": phone_number})
|
|
if resp.status_code >= 400:
|
|
return None
|
|
return resp.json()
|
|
except Exception as e:
|
|
logger.warning("find_user failed: %s", e)
|
|
return None
|
|
|
|
async def get_user_info(self, user_id: str) -> ZaloUserInfo | None:
|
|
http = await self._get_http()
|
|
try:
|
|
resp = await http.get(f"{self._sidecar_url}/api/users/{user_id}")
|
|
if resp.status_code >= 400:
|
|
return None
|
|
data = resp.json()
|
|
return ZaloUserInfo(
|
|
user_id=data.get("userId", ""),
|
|
display_name=data.get("displayName", ""),
|
|
avatar_url=data.get("avatar"),
|
|
)
|
|
except Exception as e:
|
|
logger.warning("get_user_info failed: %s", e)
|
|
return None
|
|
|
|
async def get_group_info(self, group_id: str) -> ZaloGroup | None:
|
|
http = await self._get_http()
|
|
try:
|
|
resp = await http.get(f"{self._sidecar_url}/api/groups/{group_id}/info")
|
|
if resp.status_code >= 400:
|
|
return None
|
|
data = resp.json()
|
|
return ZaloGroup(
|
|
group_id=data.get("groupId", ""),
|
|
name=data.get("name", ""),
|
|
avatar_url=data.get("avatar"),
|
|
member_count=data.get("memberCount", 0),
|
|
)
|
|
except Exception as e:
|
|
logger.warning("get_group_info failed: %s", e)
|
|
return None
|
|
|
|
async def block_user(self, user_id: str) -> dict:
|
|
http = await self._get_http()
|
|
try:
|
|
resp = await http.post(f"{self._sidecar_url}/api/users/{user_id}/block", json={})
|
|
if resp.status_code >= 400:
|
|
raise classify_http_error(resp.status_code, resp.text)
|
|
return resp.json()
|
|
except httpx.RequestError as e:
|
|
raise ZaloUserSendError(f"Failed to block user: {e}") from e
|
|
|
|
async def unblock_user(self, user_id: str) -> dict:
|
|
http = await self._get_http()
|
|
try:
|
|
resp = await http.post(f"{self._sidecar_url}/api/users/{user_id}/unblock", json={})
|
|
if resp.status_code >= 400:
|
|
raise classify_http_error(resp.status_code, resp.text)
|
|
return resp.json()
|
|
except httpx.RequestError as e:
|
|
raise ZaloUserSendError(f"Failed to unblock user: {e}") from e
|
|
|
|
async def change_friend_alias(self, user_id: str, alias: str) -> dict:
|
|
http = await self._get_http()
|
|
payload = {"alias": alias}
|
|
try:
|
|
resp = await http.put(f"{self._sidecar_url}/api/users/{user_id}/alias", json=payload)
|
|
if resp.status_code >= 400:
|
|
raise classify_http_error(resp.status_code, resp.text)
|
|
return resp.json()
|
|
except httpx.RequestError as e:
|
|
raise ZaloUserSendError(f"Failed to change alias: {e}") from e
|
|
|
|
async def send_friend_request(self, user_id: str, msg: str = "") -> dict:
|
|
http = await self._get_http()
|
|
payload = {"userId": user_id, "msg": msg}
|
|
try:
|
|
resp = await http.post(f"{self._sidecar_url}/api/friends/request", json=payload)
|
|
if resp.status_code >= 400:
|
|
raise classify_http_error(resp.status_code, resp.text)
|
|
return resp.json()
|
|
except httpx.RequestError as e:
|
|
raise ZaloUserSendError(f"Failed to send friend request: {e}") from e
|
|
|
|
async def accept_friend_request(self, user_id: str) -> dict:
|
|
http = await self._get_http()
|
|
payload = {"userId": user_id}
|
|
try:
|
|
resp = await http.post(f"{self._sidecar_url}/api/friends/request/accept", json=payload)
|
|
if resp.status_code >= 400:
|
|
raise classify_http_error(resp.status_code, resp.text)
|
|
return resp.json()
|
|
except httpx.RequestError as e:
|
|
raise ZaloUserSendError(f"Failed to accept friend request: {e}") from e
|
|
|
|
async def send_card(self, thread_id: str, user_id: str, phone: str, name: str, is_group: bool = False) -> dict:
|
|
http = await self._get_http()
|
|
payload = {"threadId": thread_id, "type": "Group" if is_group else "User", "userId": user_id, "phone": phone, "name": name}
|
|
try:
|
|
resp = await http.post(f"{self._sidecar_url}/api/message/card", json=payload)
|
|
if resp.status_code >= 400:
|
|
raise classify_http_error(resp.status_code, resp.text)
|
|
return resp.json()
|
|
except httpx.RequestError as e:
|
|
raise ZaloUserSendError(f"Failed to send card: {e}") from e
|
|
|
|
async def create_note(self, group_id: str, title: str, content: str, color: int = 0) -> dict:
|
|
http = await self._get_http()
|
|
payload = {"title": title, "content": content, "color": color}
|
|
try:
|
|
resp = await http.post(f"{self._sidecar_url}/api/groups/{group_id}/notes", json=payload)
|
|
if resp.status_code >= 400:
|
|
raise classify_http_error(resp.status_code, resp.text)
|
|
return resp.json()
|
|
except httpx.RequestError as e:
|
|
raise ZaloUserSendError(f"Failed to create note: {e}") from e
|
|
|
|
async def edit_note(self, group_id: str, note_id: str, title: str, content: str, color: int = 0) -> dict:
|
|
http = await self._get_http()
|
|
payload = {"title": title, "content": content, "color": color}
|
|
try:
|
|
resp = await http.put(f"{self._sidecar_url}/api/groups/{group_id}/notes/{note_id}", json=payload)
|
|
if resp.status_code >= 400:
|
|
raise classify_http_error(resp.status_code, resp.text)
|
|
return resp.json()
|
|
except httpx.RequestError as e:
|
|
raise ZaloUserSendError(f"Failed to edit note: {e}") from e
|
|
|
|
async def pin_conversations(self, thread_ids: list[str]) -> dict:
|
|
http = await self._get_http()
|
|
payload = {"threadIds": thread_ids}
|
|
try:
|
|
resp = await http.post(f"{self._sidecar_url}/api/conversations/pin", json=payload)
|
|
if resp.status_code >= 400:
|
|
raise classify_http_error(resp.status_code, resp.text)
|
|
return resp.json()
|
|
except httpx.RequestError as e:
|
|
raise ZaloUserSendError(f"Failed to pin conversations: {e}") from e
|
|
|
|
async def get_cookie(self) -> dict:
|
|
http = await self._get_http()
|
|
try:
|
|
resp = await http.get(f"{self._sidecar_url}/api/session/cookie")
|
|
if resp.status_code >= 400:
|
|
raise classify_http_error(resp.status_code, resp.text)
|
|
return resp.json()
|
|
except httpx.RequestError as e:
|
|
raise ZaloUserSendError(f"Failed to get cookie: {e}") from e |