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"