from __future__ import annotations import asyncio import io import logging import os import re from typing import Any from yuxi.channels.exceptions import DeliveryFailedError logger = logging.getLogger(__name__) DEFAULT_MAX_MEDIA_MB = 50 IMAGE_MAX_MB = 10 FILE_MAX_MB = 50 DEFAULT_MEDIA_TIMEOUT_S = float(os.environ.get("FEISHU_MEDIA_HTTP_TIMEOUT_MS", "120000")) / 1000.0 MIME_TO_MEDIA_KIND = { "image/png": "img", "image/jpeg": "img", "image/gif": "img", "image/webp": "img", "image/bmp": "img", "audio/ogg": "opus", "audio/opus": "opus", "audio/mpeg": "stream", "audio/mp3": "stream", "audio/wav": "stream", "audio/mp4": "stream", "video/mp4": "mp4", "video/quicktime": "mp4", } EXT_TO_MEDIA_KIND = { ".png": "img", ".jpg": "img", ".jpeg": "img", ".gif": "img", ".webp": "img", ".bmp": "img", ".ogg": "opus", ".opus": "opus", ".mp3": "stream", ".wav": "stream", ".m4a": "stream", ".mp4": "mp4", ".mov": "mp4", ".webm": "mp4", } _CONTROL_CHARS_RE = re.compile(r"[\x00-\x1f\x7f-\x9f]") _UNSAFE_FILENAME_CHARS_RE = re.compile(r'["\\]') _LATIN1_MOJIBAKE_RE = re.compile(rb"[\xc3\xc5][\x80-\xbf]{2,}") class MediaSizeError(DeliveryFailedError): def __init__(self, size_mb: float, max_mb: float): super().__init__(f"Media size {size_mb:.1f}MB exceeds limit {max_mb:.0f}MB") self.size_mb = size_mb self.max_mb = max_mb def validate_media_size(data: bytes, max_mb: float = DEFAULT_MAX_MEDIA_MB, label: str = "file") -> None: size_mb = len(data) / (1024 * 1024) if size_mb > max_mb: raise MediaSizeError(size_mb, max_mb) def validate_image_size(data: bytes) -> None: validate_media_size(data, IMAGE_MAX_MB, "image") def validate_file_size(data: bytes) -> None: validate_media_size(data, FILE_MAX_MB, "file") def resolve_feishu_outbound_media_kind( filename: str = "", mime_type: str = "", ) -> str: ext = os.path.splitext(filename)[1].lower() if ext and ext in EXT_TO_MEDIA_KIND: return EXT_TO_MEDIA_KIND[ext] if mime_type and mime_type in MIME_TO_MEDIA_KIND: return MIME_TO_MEDIA_KIND[mime_type] if ext in (".pdf", ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx", ".txt", ".csv"): return "stream" return "stream" def sanitize_filename_for_upload(filename: str) -> str: name, ext = os.path.splitext(filename) name = _CONTROL_CHARS_RE.sub("", name) name = _UNSAFE_FILENAME_CHARS_RE.sub("_", name) if not name.strip(): name = "file" ext = _CONTROL_CHARS_RE.sub("", ext) return f"{name}{ext}" def recover_utf8_filename_from_latin1_header(filename: str) -> str: try: raw = filename.encode("latin-1") if _LATIN1_MOJIBAKE_RE.search(raw): decoded = raw.decode("utf-8", errors="replace") if decoded != filename: logger.debug("[FeishuMedia] Recovered UTF-8 filename from Latin-1 header: %s -> %s", filename, decoded) return decoded except (UnicodeEncodeError, UnicodeDecodeError): pass return filename async def upload_image(client: Any, image_data: bytes) -> str: validate_image_size(image_data) token = await _get_tenant_token(client) url = f"https://{client.domain}/open-apis/im/v1/images" resp = await _do_upload(url, token, "image", image_data, "image.png") image_key = resp.get("data", {}).get("image_key", "") if not image_key: raise DeliveryFailedError("Image upload: no image_key in response") logger.debug(f"[Feishu] Image uploaded, image_key={image_key}") return image_key async def upload_file(client: Any, file_data: bytes, filename: str, file_type: str = "stream") -> str: validate_file_size(file_data) valid_types = {"opus", "mp4", "pdf", "doc", "xls", "ppt", "stream"} if file_type not in valid_types: file_type = "stream" filename = sanitize_filename_for_upload(filename) token = await _get_tenant_token(client) url = f"https://{client.domain}/open-apis/im/v1/files" resp = await _do_upload(url, token, "file", file_data, filename, file_type=file_type) file_key = resp.get("data", {}).get("file_key", "") if not file_key: raise DeliveryFailedError("File upload: no file_key in response") logger.debug(f"[Feishu] File uploaded, file_key={file_key}") return file_key async def download_media(client: Any, message_id: str, file_key: str, file_type: str) -> bytes: resp = await _download_with_fallback(client, message_id, file_key, file_type) if resp is not None and resp.success(): return resp.file.read() if file_type == "file": logger.info("[FeishuMedia] File download failed, trying media type fallback for %s", file_key) resp = await _download_with_fallback(client, message_id, file_key, "media") if resp is not None and resp.success(): return resp.file.read() raise DeliveryFailedError(f"Media download failed: file_key={file_key}") async def _download_with_fallback(client: Any, message_id: str, file_key: str, file_type: str) -> Any: import lark_oapi try: request = ( lark_oapi.api.im.v1.GetMessageResourceRequest.builder() .message_id(message_id) .file_key(file_key) .type(file_type) .build() ) resp = client.im.v1.message_resource.get(request) http_status = getattr(resp, "http_status", 0) or getattr(resp, "status_code", 0) if resp.success(): return resp if http_status == 502: logger.warning("[FeishuMedia] 502 error downloading type=%s for %s", file_type, file_key) return None except Exception as e: logger.warning("[FeishuMedia] Download error (type=%s): %s", file_type, e) return None async def _get_tenant_token(client: Any) -> str: resp = await asyncio.to_thread(client.auth.tenant_access_token_internal) if not resp.success(): raise RuntimeError(f"Failed to get tenant token: {resp.msg}") return resp.token async def _do_upload( url: str, token: str, field_name: str, file_data: bytes, filename: str, *, timeout: float = DEFAULT_MEDIA_TIMEOUT_S, **extra_fields: str, ) -> dict[str, Any]: import httpx headers = {"Authorization": f"Bearer {token}"} async with httpx.AsyncClient(timeout=httpx.Timeout(timeout)) as http_client: files = {field_name: (filename, io.BytesIO(file_data), "application/octet-stream")} data: dict[str, str] = {"image_type": "message"} if field_name == "image" else {} data.update(extra_fields) resp = await http_client.post(url, headers=headers, files=files, data=data) if resp.status_code != 200: raise RuntimeError(f"Upload failed: HTTP {resp.status_code}, {resp.text[:300]}") return resp.json()