"""WeCom media: inbound attachment caching and outbound upload/send. Mixed into :class:`WeComAdapter`. Outbound media goes through the chunked ``aibot_upload_media_*`` flow and is then sent natively (image/video/voice/file). """ from __future__ import annotations import asyncio import base64 import hashlib import logging import mimetypes import re import uuid from pathlib import Path from typing import Any, Dict, List, Optional, Tuple from urllib.parse import unquote, urlparse from gateway.platforms.base import SendResult, cache_document_from_bytes, cache_image_from_bytes logger = logging.getLogger("plugins.platforms.wecom.adapter") APP_CMD_SEND = "aibot_send_msg" APP_CMD_UPLOAD_MEDIA_INIT = "aibot_upload_media_init" APP_CMD_UPLOAD_MEDIA_CHUNK = "aibot_upload_media_chunk" APP_CMD_UPLOAD_MEDIA_FINISH = "aibot_upload_media_finish" IMAGE_MAX_BYTES = 10 * 1024 * 1024 VIDEO_MAX_BYTES = 10 * 1024 * 1024 VOICE_MAX_BYTES = 2 * 1024 * 1024 FILE_MAX_BYTES = 20 * 1024 * 1024 ABSOLUTE_MAX_BYTES = FILE_MAX_BYTES UPLOAD_CHUNK_SIZE = 512 * 1024 MAX_UPLOAD_CHUNKS = 100 VOICE_SUPPORTED_MIMES = {"audio/amr"} def _size_verdict(final_type: str, *, reject: Optional[str] = None, downgrade: Optional[str] = None) -> Dict[str, Any]: return { "final_type": final_type, "rejected": reject is not None, "reject_reason": reject, "downgraded": downgrade is not None, "downgrade_note": downgrade, } class WeComMediaMixin: """Media helpers for WeComAdapter (expects ``_http_client``, ``_send_request``, ``_send_reply_request``, ``send``, ``_reply_req_id_for_message``, ``_last_chat_req_ids``, ``_stream_expired_chats``, ``_find_active_turn_for_chat``).""" # ── Inbound ────────────────────────────────────────────────────────── async def _extract_media(self, body: Dict[str, Any]) -> Tuple[List[str], List[str]]: """Best-effort extraction of inbound media to local cache paths.""" refs: List[Tuple[str, Dict[str, Any]]] = [] msgtype = str(body.get("msgtype") or "").lower() def _ref(kind: str, container: Dict[str, Any]) -> bool: if isinstance(container.get(kind), dict): refs.append((kind, container[kind])) return True return False if msgtype == "mixed": mixed = body.get("mixed") if isinstance(body.get("mixed"), dict) else {} items = mixed.get("msg_item") if isinstance(mixed.get("msg_item"), list) else [] for item in items: if isinstance(item, dict) and str(item.get("msgtype") or "").lower() == "image": _ref("image", item) else: _ref("image", body) if msgtype == "file": _ref("file", body) # appmsg = WeCom AI Bot attachments (PDF/Word/Excel) if msgtype == "appmsg" and isinstance(body.get("appmsg"), dict): _ref("file", body["appmsg"]) or _ref("image", body["appmsg"]) quote = body.get("quote") if isinstance(body.get("quote"), dict) else {} quote_type = str(quote.get("msgtype") or "").lower() if quote_type in ("image", "file"): _ref(quote_type, quote) media_paths: List[str] = [] media_types: List[str] = [] for kind, ref in refs: cached = await self._cache_media(kind, ref) if cached: media_paths.append(cached[0]) media_types.append(cached[1]) return media_paths, media_types async def _cache_media(self, kind: str, media: Dict[str, Any]) -> Optional[Tuple[str, str]]: """Cache an inbound image/file reference (inline base64 or URL) to local storage.""" if media.get("base64"): try: raw = self._decode_base64(media["base64"]) except Exception as exc: logger.debug("[%s] Failed to decode %s base64 media: %s", self.name, kind, exc) return None if kind == "image": ext = self._detect_image_ext(raw) return self._cache_image(raw, ext, self._mime_for_ext(ext, fallback="image/jpeg"), "") filename = str(media.get("filename") or media.get("name") or "wecom_file") return cache_document_from_bytes(raw, filename), mimetypes.guess_type(filename)[0] or "application/octet-stream" url = str(media.get("url") or "").strip() if not url: return None try: raw, headers = await self._download_remote_bytes(url, max_bytes=ABSOLUTE_MAX_BYTES) except Exception as exc: logger.debug("[%s] Failed to download %s from %s: %s", self.name, kind, url, exc) return None aes_key = str(media.get("aeskey") or "").strip() if aes_key: try: raw = self._decrypt_file_bytes(raw, aes_key) except Exception as exc: logger.debug("[%s] Failed to decrypt %s from %s: %s", self.name, kind, url, exc) return None content_type = str(headers.get("content-type") or "").split(";", 1)[0].strip() or "application/octet-stream" if kind == "image": ext = self._guess_extension(url, content_type, fallback=self._detect_image_ext(raw)) return self._cache_image(raw, ext, content_type or self._mime_for_ext(ext, fallback="image/jpeg"), f" from {url}") filename = self._guess_filename(url, headers.get("content-disposition"), content_type) return cache_document_from_bytes(raw, filename), content_type def _cache_image(self, raw: bytes, ext: str, mime: str, origin: str) -> Optional[Tuple[str, str]]: try: return cache_image_from_bytes(raw, ext), mime except ValueError as exc: logger.warning("[%s] Rejected non-image bytes%s: %s", self.name, origin, exc) return None @staticmethod def _decode_base64(data: str) -> bytes: return base64.b64decode(data.split(",", 1)[-1].strip()) @staticmethod def _detect_image_ext(data: bytes) -> str: if data.startswith(b"\x89PNG\r\n\x1a\n"): return ".png" if data.startswith(b"\xff\xd8\xff"): return ".jpg" if data.startswith((b"GIF87a", b"GIF89a")): return ".gif" if data.startswith(b"RIFF") and data[8:12] == b"WEBP": return ".webp" return ".jpg" @staticmethod def _mime_for_ext(ext: str, fallback: str = "application/octet-stream") -> str: return mimetypes.types_map.get(ext.lower(), fallback) @staticmethod def _guess_extension(url: str, content_type: str, fallback: str) -> str: ext = mimetypes.guess_extension(content_type) if content_type else None return ext or Path(urlparse(url).path).suffix or fallback @staticmethod def _guess_filename(url: str, content_disposition: Optional[str], content_type: str) -> str: if content_disposition: match = re.search(r'filename="?([^";]+)"?', content_disposition) if match: return match.group(1) name = Path(urlparse(url).path).name or "document" if "." not in name: name = f"{name}{mimetypes.guess_extension(content_type) or '.bin'}" return name @staticmethod def _decrypt_file_bytes(encrypted_data: bytes, aes_key: str) -> bytes: if not encrypted_data: raise ValueError("encrypted_data is empty") if not aes_key: raise ValueError("aes_key is required") # WeCom doesn't pad base64 keys; add padding if needed aes_key = aes_key + '=' * ((4 - len(aes_key) % 4) % 4) key = base64.b64decode(aes_key) if len(key) != 32: raise ValueError(f"Invalid WeCom AES key length: expected 32 bytes, got {len(key)}") try: from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes except ImportError as exc: # pragma: no cover - dependency is environment-specific raise RuntimeError("cryptography is required for WeCom media decryption") from exc decryptor = Cipher(algorithms.AES(key), modes.CBC(key[:16])).decryptor() decrypted = decryptor.update(encrypted_data) + decryptor.finalize() pad_len = decrypted[-1] if pad_len < 1 or pad_len > 32 or pad_len > len(decrypted): raise ValueError(f"Invalid PKCS#7 padding value: {pad_len}") if any(byte != pad_len for byte in decrypted[-pad_len:]): raise ValueError("Invalid PKCS#7 padding: padding bytes mismatch") return decrypted[:-pad_len] async def _download_remote_bytes(self, url: str, max_bytes: int) -> Tuple[bytes, Dict[str, str]]: from gateway.platforms.base import _ssrf_redirect_guard from tools.url_safety import create_ssrf_safe_async_client, is_safe_url from plugins.platforms.wecom import adapter as _adapter_mod if not is_safe_url(url): raise ValueError(f"Blocked unsafe URL (SSRF protection): {url[:80]}") if not _adapter_mod.HTTPX_AVAILABLE: raise RuntimeError("httpx is required for WeCom media download") client = self._http_client or create_ssrf_safe_async_client( timeout=30.0, follow_redirects=True, event_hooks={"response": [_ssrf_redirect_guard]}, ) created_client = client is not self._http_client try: async with client.stream( "GET", url, headers={"User-Agent": "HermesAgent/1.0", "Accept": "*/*"}, ) as response: response.raise_for_status() headers = {key.lower(): value for key, value in response.headers.items()} content_length = headers.get("content-length") if content_length and content_length.isdigit() and int(content_length) > max_bytes: raise ValueError( f"Remote media exceeds WeCom limit: {int(content_length)} bytes > {max_bytes} bytes" ) data = bytearray() async for chunk in response.aiter_bytes(): data.extend(chunk) if len(data) > max_bytes: raise ValueError( f"Remote media exceeds WeCom limit while downloading: {len(data)} bytes > {max_bytes} bytes" ) return bytes(data), headers finally: if created_client: await client.aclose() # ── Outbound classification ────────────────────────────────────────── @staticmethod def _guess_mime_type(filename: str) -> str: mime_type = mimetypes.guess_type(filename)[0] if mime_type: return mime_type if Path(filename).suffix.lower() == ".amr": return "audio/amr" return "application/octet-stream" @staticmethod def _normalize_content_type(content_type: str, filename: str) -> str: normalized = str(content_type or "").split(";", 1)[0].strip().lower() if not normalized or normalized in {"application/octet-stream", "text/plain"}: return WeComMediaMixin._guess_mime_type(filename) return normalized @staticmethod def _detect_wecom_media_type(content_type: str) -> str: mime_type = str(content_type or "").strip().lower() if mime_type.startswith("image/"): return "image" if mime_type.startswith("video/"): return "video" if mime_type.startswith("audio/") or mime_type == "application/ogg": return "voice" return "file" @staticmethod def _apply_file_size_limits(file_size: int, detected_type: str, content_type: Optional[str] = None) -> Dict[str, Any]: file_size_mb = file_size / (1024 * 1024) normalized_type = str(detected_type or "file").lower() normalized_content_type = str(content_type or "").strip().lower() if file_size > ABSOLUTE_MAX_BYTES: return _size_verdict(normalized_type, reject=( f"文件大小 {file_size_mb:.2f}MB 超过了企业微信允许的最大限制 20MB,无法发送。" "请尝试压缩文件或减小文件大小。" )) if normalized_type == "image" and file_size > IMAGE_MAX_BYTES: return _size_verdict("file", downgrade=f"图片大小 {file_size_mb:.2f}MB 超过 10MB 限制,已转为文件格式发送") if normalized_type == "video" and file_size > VIDEO_MAX_BYTES: return _size_verdict("file", downgrade=f"视频大小 {file_size_mb:.2f}MB 超过 10MB 限制,已转为文件格式发送") if normalized_type == "voice": if normalized_content_type and normalized_content_type not in VOICE_SUPPORTED_MIMES: return _size_verdict("file", downgrade=( f"语音格式 {normalized_content_type} 不支持,企微仅支持 AMR 格式,已转为文件格式发送" )) if file_size > VOICE_MAX_BYTES: return _size_verdict("file", downgrade=f"语音大小 {file_size_mb:.2f}MB 超过 2MB 限制,已转为文件格式发送") return _size_verdict(normalized_type) @staticmethod def _looks_like_url(media_source: str) -> bool: return urlparse(str(media_source or "")).scheme in {"http", "https"} async def _load_outbound_media(self, media_source: str, file_name: Optional[str] = None) -> Tuple[bytes, str, str]: source = str(media_source or "").strip() if not source: raise ValueError("media source is required") if re.fullmatch(r"<[^>\n]+>", source): raise ValueError(f"Media placeholder was not replaced with a real file path: {source}") parsed = urlparse(source) if parsed.scheme in {"http", "https"}: data, headers = await self._download_remote_bytes(source, max_bytes=ABSOLUTE_MAX_BYTES) content_disposition = headers.get("content-disposition") resolved_name = file_name or self._guess_filename(source, content_disposition, headers.get("content-type", "")) content_type = self._normalize_content_type(headers.get("content-type", ""), resolved_name) return data, content_type, resolved_name local_path = Path(unquote(parsed.path) if parsed.scheme == "file" else source).expanduser() if not local_path.is_absolute(): local_path = (Path.cwd() / local_path).resolve() if not local_path.exists() or not local_path.is_file(): raise FileNotFoundError(f"Media file not found: {local_path}") data = local_path.read_bytes() resolved_name = file_name or local_path.name return data, self._normalize_content_type("", resolved_name), resolved_name async def _prepare_outbound_media(self, media_source: str, file_name: Optional[str] = None) -> Dict[str, Any]: data, content_type, resolved_name = await self._load_outbound_media(media_source, file_name=file_name) detected_type = self._detect_wecom_media_type(content_type) size_check = self._apply_file_size_limits(len(data), detected_type, content_type) return {"data": data, "content_type": content_type, "file_name": resolved_name, "detected_type": detected_type, **size_check} # ── Outbound upload + send ─────────────────────────────────────────── async def _upload_media_bytes(self, data: bytes, media_type: str, filename: str) -> Dict[str, Any]: if not data: raise ValueError("Cannot upload empty media") total_size = len(data) total_chunks = (total_size + UPLOAD_CHUNK_SIZE - 1) // UPLOAD_CHUNK_SIZE if total_chunks > MAX_UPLOAD_CHUNKS: raise ValueError(f"File too large: {total_chunks} chunks exceeds maximum of {MAX_UPLOAD_CHUNKS} chunks") init_response = await self._send_request(APP_CMD_UPLOAD_MEDIA_INIT, { "type": media_type, "filename": filename, "total_size": total_size, "total_chunks": total_chunks, "md5": hashlib.md5(data).hexdigest(), }) self._raise_for_wecom_error(init_response, "media upload init") init_body = init_response.get("body") if isinstance(init_response.get("body"), dict) else {} upload_id = str(init_body.get("upload_id") or "").strip() if not upload_id: raise RuntimeError(f"media upload init failed: missing upload_id in response {init_response}") for chunk_index, start in enumerate(range(0, total_size, UPLOAD_CHUNK_SIZE)): chunk_response = await self._send_request(APP_CMD_UPLOAD_MEDIA_CHUNK, { "upload_id": upload_id, "chunk_index": chunk_index, # official SDK uses 0-based chunk indexes "base64_data": base64.b64encode(data[start : start + UPLOAD_CHUNK_SIZE]).decode("ascii"), }) self._raise_for_wecom_error(chunk_response, f"media upload chunk {chunk_index}") finish_response = await self._send_request(APP_CMD_UPLOAD_MEDIA_FINISH, {"upload_id": upload_id}) self._raise_for_wecom_error(finish_response, "media upload finish") finish_body = finish_response.get("body") if isinstance(finish_response.get("body"), dict) else {} media_id = str(finish_body.get("media_id") or "").strip() if not media_id: raise RuntimeError(f"media upload finish failed: missing media_id in response {finish_response}") return {"type": str(finish_body.get("type") or media_type), "media_id": media_id, "created_at": finish_body.get("created_at")} async def _send_media_message(self, chat_id: str, media_type: str, media_id: str) -> Dict[str, Any]: response = await self._send_request( APP_CMD_SEND, {"chatid": chat_id, "msgtype": media_type, media_type: {"media_id": media_id}}, ) self._raise_for_wecom_error(response, "send media message") return response async def _send_reply_media_message(self, reply_req_id: str, media_type: str, media_id: str) -> Dict[str, Any]: response = await self._send_reply_request(reply_req_id, {"msgtype": media_type, media_type: {"media_id": media_id}}) self._raise_for_wecom_error(response, "send reply media message") return response async def _send_followup_markdown(self, chat_id: str, content: str, reply_to: Optional[str] = None) -> Optional[SendResult]: if not content: return None result = await self.send(chat_id=chat_id, content=content, reply_to=reply_to) if not result.success: logger.warning("[%s] Follow-up markdown send failed: %s", self.name, result.error) return result async def _send_media_source( self, chat_id: str, media_source: str, caption: Optional[str] = None, file_name: Optional[str] = None, reply_to: Optional[str] = None, ) -> SendResult: if not chat_id: return SendResult(success=False, error="chat_id is required") try: prepared = await self._prepare_outbound_media(media_source, file_name=file_name) except FileNotFoundError as exc: return SendResult(success=False, error=str(exc)) except Exception as exc: logger.error("[%s] Failed to prepare outbound media %s: %s", self.name, media_source, exc) return SendResult(success=False, error=str(exc)) if prepared["rejected"]: await self._send_followup_markdown(chat_id, f"⚠️ {prepared['reject_reason']}", reply_to=reply_to) return SendResult(success=False, error=prepared["reject_reason"]) reply_req_id = self._reply_req_id_for_message(reply_to) if not reply_req_id and chat_id in self._last_chat_req_ids: reply_req_id = self._last_chat_req_ids[chat_id] # Media MUST use the proactive path when a stream was/is active for the # chat: passive replyMedia cannot overwrite a replyStream thinking bubble # and the stream "owns" the req_id (server ignores or never acks). if self._find_active_turn_for_chat(chat_id) or chat_id in self._stream_expired_chats: reply_req_id = None try: upload_result = await self._upload_media_bytes(prepared["data"], prepared["final_type"], prepared["file_name"]) logger.info("[%s] upload_media_bytes OK: media_id=%s type=%s", self.name, upload_result.get("media_id"), prepared["final_type"]) if reply_req_id: media_response = await self._send_reply_media_message(reply_req_id, prepared["final_type"], upload_result["media_id"]) logger.info("[%s] send_reply_media OK: %s", self.name, media_response) else: media_response = await self._send_media_message(chat_id, prepared["final_type"], upload_result["media_id"]) logger.info("[%s] send_media_message OK: %s", self.name, media_response) except asyncio.TimeoutError: logger.error("[%s] TIMEOUT in _send_media_source for %s", self.name, media_source) return SendResult(success=False, error="Timeout sending media to WeCom") except Exception as exc: logger.error("[%s] Failed to send media %s: %s", self.name, media_source, exc) return SendResult(success=False, error=str(exc)) caption_result = downgrade_result = None if caption: caption_result = await self._send_followup_markdown(chat_id, caption, reply_to=reply_to) if prepared["downgraded"] and prepared["downgrade_note"]: downgrade_result = await self._send_followup_markdown(chat_id, f"ℹ️ {prepared['downgrade_note']}", reply_to=reply_to) return SendResult( success=True, message_id=self._payload_req_id(media_response) or uuid.uuid4().hex[:12], raw_response={ "upload": upload_result, "media": media_response, "caption": caption_result.raw_response if caption_result else None, "caption_error": caption_result.error if caption_result and not caption_result.success else None, "downgrade": downgrade_result.raw_response if downgrade_result else None, "downgrade_error": downgrade_result.error if downgrade_result and not downgrade_result.success else None, }, ) async def send_image( self, chat_id: str, image_url: str, caption: Optional[str] = None, reply_to: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None, ) -> SendResult: del metadata result = await self._send_media_source(chat_id=chat_id, media_source=image_url, caption=caption, reply_to=reply_to) if result.success or not self._looks_like_url(image_url): return result logger.warning("[%s] Falling back to text send for image URL %s: %s", self.name, image_url, result.error) fallback_text = f"{caption}\n{image_url}" if caption else image_url return await self.send(chat_id=chat_id, content=fallback_text, reply_to=reply_to) async def send_image_file(self, chat_id: str, image_path: str, caption: Optional[str] = None, reply_to: Optional[str] = None, **kwargs) -> SendResult: del kwargs return await self._send_media_source(chat_id=chat_id, media_source=image_path, caption=caption, reply_to=reply_to) async def send_document( self, chat_id: str, file_path: str, caption: Optional[str] = None, file_name: Optional[str] = None, reply_to: Optional[str] = None, **kwargs, ) -> SendResult: del kwargs logger.info("[%s] send_document called: chat=%s file=%s", self.name, chat_id, file_path) return await self._send_media_source( chat_id=chat_id, media_source=file_path, caption=caption, file_name=file_name, reply_to=reply_to, ) async def send_voice(self, chat_id: str, audio_path: str, caption: Optional[str] = None, reply_to: Optional[str] = None, **kwargs) -> SendResult: del kwargs return await self._send_media_source(chat_id=chat_id, media_source=audio_path, caption=caption, reply_to=reply_to) async def send_video(self, chat_id: str, video_path: str, caption: Optional[str] = None, reply_to: Optional[str] = None, **kwargs) -> SendResult: del kwargs return await self._send_media_source(chat_id=chat_id, media_source=video_path, caption=caption, reply_to=reply_to)