新增 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
165 lines
4.5 KiB
Python
165 lines
4.5 KiB
Python
from __future__ import annotations
|
|
|
|
import logging
|
|
import os
|
|
import tempfile
|
|
|
|
import httpx
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
ZOOM_FILE_UPLOAD_URL = "/chat/users/{user_id}/files"
|
|
ZOOM_FILE_DOWNLOAD_URL = "/chat/users/{user_id}/messages/{message_id}/files/{file_id}"
|
|
|
|
|
|
async def upload_file(
|
|
client: httpx.AsyncClient,
|
|
token: str,
|
|
bot_user_id: str,
|
|
file_path: str,
|
|
media_type: str = "application/octet-stream",
|
|
target_channel_id: str | None = None,
|
|
target_contact: str | None = None,
|
|
) -> str | None:
|
|
if file_path.startswith("file://"):
|
|
file_path = file_path[7:]
|
|
|
|
if file_path.startswith(("http://", "https://")):
|
|
file_path = await _download_to_tmp(client, file_path)
|
|
|
|
if not os.path.exists(file_path):
|
|
logger.error("Media file not found: %s", file_path)
|
|
return None
|
|
|
|
filename = os.path.basename(file_path)
|
|
url = ZOOM_FILE_UPLOAD_URL.format(user_id=bot_user_id)
|
|
|
|
with open(file_path, "rb") as f:
|
|
resp = await client.post(
|
|
url,
|
|
files={"file": (filename, f, media_type)},
|
|
headers={"Authorization": f"Bearer {token}"},
|
|
)
|
|
|
|
if resp.status_code not in (200, 201):
|
|
logger.error("Zoom file upload failed: status=%s, body=%s", resp.status_code, resp.text)
|
|
return None
|
|
|
|
file_id = resp.json().get("id", "")
|
|
if not file_id:
|
|
logger.error("Zoom file upload returned no file_id")
|
|
return None
|
|
|
|
logger.info("Zoom file uploaded: file_id=%s, name=%s", file_id, filename)
|
|
return file_id
|
|
|
|
|
|
async def download_file(
|
|
client: httpx.AsyncClient,
|
|
token: str,
|
|
bot_user_id: str,
|
|
message_id: str,
|
|
file_id: str,
|
|
save_dir: str | None = None,
|
|
) -> str | None:
|
|
url = ZOOM_FILE_DOWNLOAD_URL.format(user_id=bot_user_id, message_id=message_id, file_id=file_id)
|
|
|
|
resp = await client.get(
|
|
url,
|
|
headers={"Authorization": f"Bearer {token}"},
|
|
follow_redirects=True,
|
|
)
|
|
|
|
if resp.status_code != 200:
|
|
logger.error("Zoom file download failed: status=%s", resp.status_code)
|
|
return None
|
|
|
|
content_disposition = resp.headers.get("content-disposition", "")
|
|
filename = _extract_filename(content_disposition) or f"{file_id}.bin"
|
|
|
|
if save_dir:
|
|
os.makedirs(save_dir, exist_ok=True)
|
|
save_path = os.path.join(save_dir, filename)
|
|
else:
|
|
ext = _guess_extension(resp.content)
|
|
tmp = tempfile.NamedTemporaryFile(delete=False, suffix=ext)
|
|
save_path = tmp.name
|
|
tmp.close()
|
|
|
|
with open(save_path, "wb") as f:
|
|
f.write(resp.content)
|
|
|
|
logger.info("Zoom file downloaded: file_id=%s -> %s", file_id, save_path)
|
|
return save_path
|
|
|
|
|
|
async def send_file_message(
|
|
client: httpx.AsyncClient,
|
|
token: str,
|
|
bot_user_id: str,
|
|
file_id: str,
|
|
target_id: str,
|
|
caption: str = "",
|
|
reply_to_id: str | None = None,
|
|
) -> str | None:
|
|
url = f"/chat/users/{bot_user_id}/messages"
|
|
|
|
body: dict = {
|
|
"message": caption or "[附件]",
|
|
"file_ids": [file_id],
|
|
}
|
|
if target_id.startswith("ch_"):
|
|
body["to_channel"] = target_id
|
|
else:
|
|
body["to_contact"] = target_id
|
|
if reply_to_id:
|
|
body["reply_main_message_id"] = reply_to_id
|
|
|
|
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", "")
|
|
|
|
logger.error("Zoom file message send failed: status=%s, body=%s", resp.status_code, resp.text)
|
|
return None
|
|
|
|
|
|
async def _download_to_tmp(client: httpx.AsyncClient, url: str) -> str:
|
|
resp = await client.get(url, follow_redirects=True, timeout=60.0)
|
|
resp.raise_for_status()
|
|
data = resp.content
|
|
ext = _guess_extension(data)
|
|
|
|
tmp = tempfile.NamedTemporaryFile(delete=False, suffix=ext)
|
|
tmp.write(data)
|
|
tmp.close()
|
|
return tmp.name
|
|
|
|
|
|
def _extract_filename(content_disposition: str) -> str:
|
|
if not content_disposition:
|
|
return ""
|
|
for part in content_disposition.split(";"):
|
|
part = part.strip()
|
|
if part.startswith("filename="):
|
|
return part[len("filename=") :].strip('"')
|
|
return ""
|
|
|
|
|
|
def _guess_extension(data: bytes) -> str:
|
|
if data[:4] == b"\x89PNG":
|
|
return ".png"
|
|
if data[:2] == b"\xff\xd8":
|
|
return ".jpg"
|
|
if data[:4] == b"GIF8":
|
|
return ".gif"
|
|
if data[:4] == b"RIFF" and len(data) > 11 and data[8:12] == b"WEBP":
|
|
return ".webp"
|
|
if data[:5] == b"%PDF-":
|
|
return ".pdf"
|
|
return ".bin"
|