ForcePilot/backend/package/yuxi/channel/extensions/zoomchat/outbound.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

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"