新增 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
261 lines
9.1 KiB
Python
261 lines
9.1 KiB
Python
import asyncio
|
|
import logging
|
|
|
|
import httpx
|
|
|
|
from yuxi.channel.extensions.zoomchat.errors import (
|
|
classify_http_status,
|
|
is_retryable,
|
|
retry_delay_ms,
|
|
ZoomErrorKind,
|
|
MAX_RETRIES,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
ZOOM_MAX_MESSAGE_LENGTH = 4096
|
|
|
|
|
|
class ZoomOutbound:
|
|
text_chunk_limit: int = 3800
|
|
chunker_mode: str = "length"
|
|
|
|
def __init__(self):
|
|
self._account: dict = {}
|
|
self._get_token = None
|
|
self._client: httpx.AsyncClient | None = None
|
|
|
|
def bind(self, account: dict, get_token, client: httpx.AsyncClient):
|
|
self._account = account
|
|
self._get_token = get_token
|
|
self._client = client
|
|
|
|
async def send_text(
|
|
self,
|
|
target_id: str,
|
|
content: str,
|
|
*,
|
|
reply_to_id: str | None = None,
|
|
thread_id: str | None = None,
|
|
account_id: str | None = None,
|
|
**kwargs,
|
|
) -> str | None:
|
|
reply_id = reply_to_id or thread_id
|
|
|
|
if not content:
|
|
return None
|
|
|
|
chunks = self.chunker(content, self._account.get("text_chunk_limit", 3800))
|
|
|
|
last_message_id = None
|
|
for i, chunk in enumerate(chunks):
|
|
prefix = f"({i + 1}/{len(chunks)}) " if len(chunks) > 1 else ""
|
|
message_id = await self._send_message_internal(prefix + chunk, target_id, reply_id)
|
|
if message_id:
|
|
last_message_id = message_id
|
|
|
|
return last_message_id
|
|
|
|
async def send_media(
|
|
self,
|
|
target_id: str,
|
|
media_url: str,
|
|
media_type: str,
|
|
reply_to_id: str | None = None,
|
|
thread_id: str | None = None,
|
|
filename: str | None = None,
|
|
**kwargs,
|
|
) -> str | None:
|
|
reply_id = reply_to_id or thread_id
|
|
|
|
token = await self._get_token()
|
|
bot_user_id = self._account.get("bot_user_id", "me")
|
|
|
|
async with httpx.AsyncClient(timeout=httpx.Timeout(60.0)) as client:
|
|
fname = filename or _filename_from_url(media_url)
|
|
|
|
download_resp = await client.get(media_url)
|
|
download_resp.raise_for_status()
|
|
file_bytes = download_resp.content
|
|
|
|
upload_resp = await client.post(
|
|
f"https://api.zoom.us/v2/chat/users/{bot_user_id}/files",
|
|
files={"file": (fname, file_bytes, media_type)},
|
|
headers={"Authorization": f"Bearer {token}"},
|
|
)
|
|
if upload_resp.status_code not in (200, 201):
|
|
logger.error("Zoom file upload failed: status=%s, body=%s", upload_resp.status_code, upload_resp.text)
|
|
return None
|
|
|
|
file_id = upload_resp.json().get("id", "")
|
|
if not file_id:
|
|
return None
|
|
|
|
body: dict = {"message": f"[附件] {fname}", "file_ids": [file_id]}
|
|
if target_id.startswith("ch_"):
|
|
body["to_channel"] = target_id
|
|
else:
|
|
body["to_contact"] = target_id
|
|
if reply_id:
|
|
body["reply_main_message_id"] = reply_id
|
|
|
|
msg_resp = await client.post(
|
|
f"https://api.zoom.us/v2/chat/users/{bot_user_id}/messages",
|
|
json=body,
|
|
headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
|
|
)
|
|
if msg_resp.status_code in (200, 201):
|
|
return msg_resp.json().get("id", "")
|
|
return None
|
|
|
|
async def list_channels(self, page_size: int = 30, next_page_token: str = "") -> dict:
|
|
token = await self._get_token()
|
|
bot_user_id = self._account.get("bot_user_id", "me")
|
|
url = f"https://api.zoom.us/v2/chat/users/{bot_user_id}/channels"
|
|
|
|
params: dict = {"page_size": min(page_size, 100)}
|
|
if next_page_token:
|
|
params["next_page_token"] = next_page_token
|
|
|
|
resp = await self._client.get(
|
|
url,
|
|
params=params,
|
|
headers={"Authorization": f"Bearer {token}"},
|
|
)
|
|
if resp.status_code == 200:
|
|
data = resp.json()
|
|
return {
|
|
"channels": data.get("channels", []),
|
|
"next_page_token": data.get("next_page_token", ""),
|
|
}
|
|
logger.error("Zoom list channels failed: status=%s", resp.status_code)
|
|
return {"channels": [], "next_page_token": ""}
|
|
|
|
async def list_channel_members(self, channel_id: str, page_size: int = 30, next_page_token: str = "") -> dict:
|
|
token = await self._get_token()
|
|
url = f"https://api.zoom.us/v2/chat/channels/{channel_id}/members"
|
|
|
|
params: dict = {"page_size": min(page_size, 100)}
|
|
if next_page_token:
|
|
params["next_page_token"] = next_page_token
|
|
|
|
resp = await self._client.get(
|
|
url,
|
|
params=params,
|
|
headers={"Authorization": f"Bearer {token}"},
|
|
)
|
|
if resp.status_code == 200:
|
|
data = resp.json()
|
|
return {
|
|
"members": data.get("members", []),
|
|
"next_page_token": data.get("next_page_token", ""),
|
|
}
|
|
logger.error("Zoom list channel members failed: status=%s", resp.status_code)
|
|
return {"members": [], "next_page_token": ""}
|
|
|
|
async def invite_members(self, channel_id: str, members: list[dict]) -> bool:
|
|
token = await self._get_token()
|
|
url = f"https://api.zoom.us/v2/chat/channels/{channel_id}/members"
|
|
|
|
resp = await self._client.post(
|
|
url,
|
|
json={"members": members},
|
|
headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
|
|
)
|
|
if resp.status_code in (200, 201, 204):
|
|
return True
|
|
logger.error("Zoom invite members failed: status=%s, body=%s", resp.status_code, resp.text)
|
|
return False
|
|
|
|
async def _send_message_internal(self, text: str, target_id: str, reply_id: str | None) -> str | None:
|
|
token = await self._get_token()
|
|
bot_user_id = self._account.get("bot_user_id", "me")
|
|
url = f"https://api.zoom.us/v2/chat/users/{bot_user_id}/messages"
|
|
|
|
body: dict = {"message": text}
|
|
if target_id.startswith("ch_"):
|
|
body["to_channel"] = target_id
|
|
else:
|
|
body["to_contact"] = target_id
|
|
if reply_id:
|
|
body["reply_main_message_id"] = reply_id
|
|
|
|
for attempt in range(MAX_RETRIES):
|
|
try:
|
|
async with httpx.AsyncClient(timeout=httpx.Timeout(30.0)) as client:
|
|
resp = await client.post(
|
|
url,
|
|
json=body,
|
|
headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
|
|
)
|
|
|
|
if resp.status_code in (200, 201):
|
|
return resp.json().get("id", "")
|
|
|
|
error_kind = classify_http_status(resp.status_code)
|
|
if error_kind == ZoomErrorKind.AUTH:
|
|
token = await self._get_token()
|
|
continue
|
|
if is_retryable(error_kind):
|
|
delay = retry_delay_ms(attempt) / 1000
|
|
logger.warning(
|
|
"Zoom API retryable error (attempt=%s, status=%s), waiting %.1fs",
|
|
attempt + 1,
|
|
resp.status_code,
|
|
delay,
|
|
)
|
|
await asyncio.sleep(delay)
|
|
continue
|
|
|
|
logger.error("Zoom API permanent error: status=%s, body=%s", resp.status_code, resp.text)
|
|
return None
|
|
|
|
except Exception as e:
|
|
if attempt < MAX_RETRIES - 1:
|
|
delay = retry_delay_ms(attempt) / 1000
|
|
logger.warning(
|
|
"Zoom API network error (attempt=%s): %s, retrying in %.1fs",
|
|
attempt + 1,
|
|
e,
|
|
delay,
|
|
)
|
|
await asyncio.sleep(delay)
|
|
else:
|
|
logger.error("Zoom API send failed after %s attempts: %s", MAX_RETRIES, e)
|
|
return None
|
|
|
|
return None
|
|
|
|
@staticmethod
|
|
def chunker(text: str, limit: int, ctx: object | None = None) -> list[str]:
|
|
if len(text) <= limit:
|
|
return [text]
|
|
|
|
chunks = []
|
|
paragraphs = text.split("\n\n")
|
|
current = ""
|
|
|
|
for para in paragraphs:
|
|
if len(current) + len(para) + 2 <= limit:
|
|
current = f"{current}\n\n{para}" if current else para
|
|
else:
|
|
if current:
|
|
chunks.append(current)
|
|
current = para
|
|
while len(current) > limit:
|
|
split_at = limit - 100
|
|
chunks.append(current[:split_at] + "...")
|
|
current = "..." + current[split_at:]
|
|
|
|
if current:
|
|
chunks.append(current)
|
|
|
|
return chunks if chunks else [text[:limit]]
|
|
|
|
|
|
def _filename_from_url(url: str) -> str:
|
|
from urllib.parse import urlparse
|
|
|
|
filename = urlparse(url).path.split("/")[-1]
|
|
return filename or "file"
|