ForcePilot/backend/package/yuxi/channel/extensions/zulip/client.py
Kris d5e36d33b7 feat(channel): 添加 Zalo OA、Zoom Chat 和 Zulip 渠道扩展
新增 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
2026-05-21 12:06:26 +08:00

395 lines
13 KiB
Python

from __future__ import annotations
import json
import logging
from pathlib import Path
from typing import Any
import httpx
from yuxi.channel.extensions.zulip.errors import (
ZulipAPIError,
ZulipAuthError,
ZulipQueueExpiredError,
ZulipRateLimitError,
)
logger = logging.getLogger(__name__)
class ZulipAsyncClient:
def __init__(
self,
realm_url: str,
bot_email: str,
bot_api_key: str,
timeout: float = 90.0,
connect_timeout: float = 10.0,
):
self.realm_url = realm_url.rstrip("/")
self.api_url = f"{self.realm_url}/api/v1"
self.bot_email = bot_email
limits = httpx.Limits(max_keepalive_connections=5, max_connections=20)
transport = httpx.AsyncHTTPTransport(limits=limits, retries=0)
self._client = httpx.AsyncClient(
base_url=self.api_url,
auth=(bot_email, bot_api_key),
timeout=httpx.Timeout(timeout, connect=connect_timeout),
transport=transport,
)
async def close(self) -> None:
await self._client.aclose()
async def _request(
self,
method: str,
path: str,
data: dict | None = None,
params: dict | None = None,
files: dict | None = None,
timeout: httpx.Timeout | None = None,
) -> dict:
kwargs: dict[str, Any] = {"method": method, "url": path}
if data:
kwargs["data"] = data
if params:
kwargs["params"] = params
if files:
kwargs["files"] = files
if timeout:
kwargs["timeout"] = timeout
resp = await self._client.request(**kwargs)
if resp.status_code >= 400:
try:
body = resp.json()
except Exception:
body = {"code": "UNKNOWN", "msg": resp.text}
if resp.status_code == 401:
raise ZulipAuthError(resp.status_code, body)
if resp.status_code == 429:
raise ZulipRateLimitError(resp.status_code, body)
if body.get("code") == "BAD_EVENT_QUEUE_ID":
raise ZulipQueueExpiredError(resp.status_code, body)
raise ZulipAPIError(resp.status_code, body)
return resp.json()
async def register_queue(
self,
event_types: list[str] | None = None,
narrow: list | None = None,
fetch_event_types: list[str] | None = None,
) -> dict:
data: dict[str, str] = {}
if event_types:
data["event_types"] = json.dumps(event_types)
if narrow:
data["narrow"] = json.dumps(narrow)
if fetch_event_types:
data["fetch_event_types"] = json.dumps(fetch_event_types)
return await self._request("POST", "/register", data=data)
async def get_events(
self,
queue_id: str,
last_event_id: int,
dont_block: bool = False,
) -> dict:
params: dict[str, Any] = {
"queue_id": queue_id,
"last_event_id": last_event_id,
}
if dont_block:
params["dont_block"] = "true"
return await self._request(
"GET",
"/events",
params=params,
timeout=httpx.Timeout(90.0),
)
async def delete_queue(self, queue_id: str) -> dict:
return await self._request("DELETE", "/events", data={"queue_id": queue_id})
async def send_message(self, payload: dict) -> dict:
return await self._request("POST", "/messages", data=payload)
async def update_message(
self,
message_id: int,
content: str,
propagate_mode: str = "change_one",
) -> dict:
data: dict[str, Any] = {
"content": content,
"propagate_mode": propagate_mode,
}
return await self._request("PATCH", f"/messages/{message_id}", data=data)
async def delete_message(self, message_id: int) -> dict:
return await self._request("DELETE", f"/messages/{message_id}")
async def upload_file(self, file_path: str) -> dict:
path = Path(file_path)
with open(path, "rb") as f:
files = {"file": (path.name, f)}
return await self._request("POST", "/user_uploads", files=files)
async def add_reaction(self, message_id: int, emoji_name: str) -> dict:
data = {
"emoji_name": emoji_name,
"reaction_type": "unicode_emoji",
}
return await self._request("POST", f"/messages/{message_id}/reactions", data=data)
async def remove_reaction(self, message_id: int, emoji_name: str) -> dict:
params = {
"emoji_name": emoji_name,
"reaction_type": "unicode_emoji",
}
return await self._request("DELETE", f"/messages/{message_id}/reactions", params=params)
async def get_own_user(self) -> dict:
return await self._request("GET", "/users/me")
async def get_users(self) -> dict:
return await self._request("GET", "/users")
async def get_user_by_id(self, user_id: int) -> dict:
return await self._request("GET", f"/users/{user_id}")
async def get_streams(self, include_public: bool = True) -> dict:
params: dict[str, Any] = {}
if include_public:
params["include_public"] = "true"
return await self._request("GET", "/streams", params=params)
async def get_stream_id(self, stream_name: str) -> dict:
return await self._request("GET", "/get_stream_id", params={"stream": stream_name})
async def get_subscriptions(self) -> dict:
return await self._request("GET", "/users/me/subscriptions")
async def get_messages(
self,
narrow: list[dict] | None = None,
anchor: str | None = None,
num_before: int = 0,
num_after: int = 20,
include_anchor: bool = True,
apply_markdown: bool = True,
client_gravatar: bool = True,
) -> dict:
params: dict[str, Any] = {
"num_before": num_before,
"num_after": num_after,
"include_anchor": str(include_anchor).lower(),
"apply_markdown": str(apply_markdown).lower(),
"client_gravatar": str(client_gravatar).lower(),
}
if anchor:
params["anchor"] = anchor
if narrow:
params["narrow"] = json.dumps(narrow)
return await self._request("GET", "/messages", params=params)
async def send_typing(
self,
op: str,
to: list[str] | str,
topic: str | None = None,
) -> dict:
data: dict[str, Any] = {"op": op, "to": to}
if topic:
data["topic"] = topic
return await self._request("POST", "/typing", data=data)
async def get_bot_storage(self, key: str | None = None) -> dict:
params: dict[str, str] = {}
if key:
params["key"] = key
return await self._request("GET", "/bot_storage", params=params)
async def put_bot_storage(self, key: str, value: str) -> dict:
return await self._request("PUT", "/bot_storage", data={"key": key, "value": value})
async def delete_bot_storage(self, key: str) -> dict:
return await self._request("DELETE", "/bot_storage", data={"key": key})
async def get_message(
self,
message_id: int,
apply_markdown: bool = True,
) -> dict:
params = {"apply_markdown": str(apply_markdown).lower()}
return await self._request("GET", f"/messages/{message_id}", params=params)
async def get_message_history(self, message_id: int) -> dict:
return await self._request("GET", f"/messages/{message_id}/history")
async def subscribe(self, subscriptions: list[dict]) -> dict:
data = {"subscriptions": json.dumps(subscriptions)}
return await self._request("POST", "/users/me/subscriptions", data=data)
async def unsubscribe(self, stream_names: list[str]) -> dict:
return await self._request(
"DELETE",
"/users/me/subscriptions",
data={"subscriptions": json.dumps(stream_names)},
)
async def get_topics(self, stream_id: int) -> dict:
return await self._request("GET", f"/users/me/{stream_id}/topics")
async def get_stream_members(self, stream_id: int) -> dict:
return await self._request("GET", f"/streams/{stream_id}/members")
async def get_user_by_email(self, email: str) -> dict:
return await self._request("GET", f"/users/{email}")
async def update_message_flags(
self,
messages: list[int],
op: str,
flag: str,
) -> dict:
data = {
"messages": json.dumps(messages),
"op": op,
"flag": flag,
}
return await self._request("POST", "/messages/flags", data=data)
async def mark_all_as_read(self) -> dict:
return await self._request("POST", "/mark_all_as_read", data={})
async def mark_stream_as_read(self, stream_id: int) -> dict:
return await self._request("POST", "/mark_stream_as_read", data={"stream_id": stream_id})
async def mark_topic_as_read(self, stream_id: int, topic_name: str) -> dict:
return await self._request(
"POST",
"/mark_topic_as_read",
data={"stream_id": stream_id, "topic_name": topic_name},
)
async def get_user_status(self, user_id: int) -> dict:
return await self._request("GET", f"/users/{user_id}/status")
async def update_bot_status(
self,
status_text: str = "",
away: bool = False,
emoji_name: str = "",
emoji_code: str = "",
) -> dict:
data: dict[str, Any] = {}
if status_text:
data["status_text"] = status_text
if away:
data["away"] = str(away).lower()
if emoji_name:
data["emoji_name"] = emoji_name
if emoji_code:
data["emoji_code"] = emoji_code
return await self._request("POST", "/users/me/status", data=data)
async def get_realm_emoji(self) -> dict:
return await self._request("GET", "/realm/emoji")
async def get_server_settings(self) -> dict:
return await self._request("GET", "/server_settings")
async def get_stream_by_id(self, stream_id: int) -> dict:
return await self._request("GET", f"/streams/{stream_id}")
def get_file_url(self, realm_id_str: str, filename: str) -> str:
return f"{self.realm_url}/user_uploads/{realm_id_str}/{filename}"
async def report_message(self, message_id: int, reason: str) -> dict:
return await self._request(
"POST",
"/report/message",
data={"message_id": message_id, "reason": reason},
)
async def create_stream(
self,
name: str,
description: str = "",
invite_only: bool = False,
history_public_to_subscribers: bool = False,
) -> dict:
data = {
"subscriptions": json.dumps([{"name": name, "description": description}]),
"invite_only": str(invite_only).lower(),
"history_public_to_subscribers": str(history_public_to_subscribers).lower(),
}
return await self._request("POST", "/users/me/subscriptions", data=data)
async def update_stream(
self,
stream_id: int,
*,
name: str | None = None,
description: str | None = None,
is_private: bool | None = None,
) -> dict:
data: dict[str, Any] = {}
if name is not None:
data["new_name"] = name
if description is not None:
data["description"] = description
if is_private is not None:
data["is_private"] = str(is_private).lower()
return await self._request("PATCH", f"/streams/{stream_id}", data=data)
async def archive_stream(self, stream_id: int) -> dict:
return await self._request("DELETE", f"/streams/{stream_id}")
async def get_stream_email(self, stream_id: int) -> dict:
return await self._request("GET", f"/streams/{stream_id}/email_address")
async def mute_topic(
self,
stream_name: str,
topic: str,
op: str = "add",
) -> dict:
return await self._request(
"PATCH",
"/users/me/subscriptions/muted_topics",
data={"stream": stream_name, "topic": topic, "op": op},
)
async def update_user_topic(
self,
stream_id: int,
topic: str,
visibility_policy: int,
) -> dict:
return await self._request(
"POST",
"/user_topics",
data={
"stream_id": stream_id,
"topic": topic,
"visibility_policy": visibility_policy,
},
)
async def delete_topic(self, stream_id: int, topic_name: str) -> dict:
return await self._request(
"DELETE",
f"/streams/{stream_id}/delete_topic",
data={"topic_name": topic_name},
)
async def get_subscription_status(self, user_id: int, stream_id: int) -> dict:
return await self._request("GET", f"/users/{user_id}/subscriptions/{stream_id}")