"""媒体多级发送 — temp 本地文件优先,失败逐级降级 发送策略(2026-08-22 用户需求): 1. temp 本地文件直接发送(最快,不经 S3) 2. 失败 → 上传本地 S3,用预签名链接发送 3. 再失败 → 回退 temp 本地文件再发一次 多媒体(2026-09-08 用户需求): 多图(图集)、图+视频混合、多视频(动图) 等一个作品落地成多个文件时,直接 UniMessage 拼成一个消息发送会报错 (视频段不能与其他段混合,一条消息也放不下多段视频/图集体验差) → 统一走合并转发,一个图/视频一个节点。 temp 下的文件发送成功后也不清理(用户手动处理 data/temp)。 """ from dataclasses import dataclass from pathlib import Path from nonebot import get_bot, get_driver, logger from nonebot.adapters import Event from nonebot_plugin_alconna import UniMessage from ..services.storage.s3 import upload_with_plan @dataclass class PendingMedia: """待发送媒体:本地文件 + 上传元数据(发送前不做任何上传/清理)""" files: list[Path] image_post: bool = False is_private: bool = False plan: str | None = None title: str = "" def _as_paths(file_paths: Path | list[Path]) -> list[Path]: if isinstance(file_paths, list): return [Path(p) for p in file_paths] return [Path(file_paths)] # ─────────────────────── 合并转发(多媒体专用) ─────────────────────── def _is_video(fp: Path) -> bool: return fp.suffix.lower() == ".mp4" def _needs_forward(files: list[Path]) -> bool: """是否需要走合并转发:多图/图+视频混合/多视频(动图)等多媒体作品 落地成多个文件(>1 个)统一一个媒体一个节点转发; 单图/单视频保持原有直发链路。 """ return len(files) > 1 def _forward_sender_name() -> str: """合并转发节点的发送者昵称(用机器人配置昵称)""" nickname = get_driver().config.nickname return next(iter(nickname), "Bot") if nickname else "Bot" def _media_segment(fp: Path, url: str | None = None) -> dict: """onebot v11 消息段字典:image / video,file 为本地路径或链接""" media_type = "video" if _is_video(fp) else "image" return {"type": media_type, "data": {"file": url or str(fp)}} def _build_forward_nodes( items: list[tuple[Path, str | None]], sender_name: str, self_id: int, ) -> list[dict]: """合并转发节点列表:一个媒体一个节点,content 为该媒体的消息段数组""" return [ { "type": "node", "data": { "name": sender_name, "uin": str(self_id), "content": [_media_segment(fp, url)], }, } for fp, url in items ] async def _send_forward_media( items: list[tuple[Path, str | None]], sender_name: str, *, group_id: int | None = None, user_id: int | None = None, ) -> bool: """发送合并转发消息(群聊 / 私聊),失败返回 False""" try: bot = get_bot() nodes = _build_forward_nodes(items, sender_name, bot.self_id) if group_id is not None: await bot.call_api( "send_group_forward_msg", group_id=group_id, messages=nodes ) else: await bot.call_api("send_forward_msg", user_id=user_id, messages=nodes) return True except Exception as e: logger.warning(f"合并转发发送失败: {e}") return False async def _build_s3_forward_items( media: PendingMedia, ) -> tuple[list[tuple[Path, str | None]], str | None]: """上传本地 S3,返回 ([(文件, 预签名链接)...], public_url)""" items: list[tuple[Path, str | None]] = [] public_url = None for fp in media.files: local_url, pub = upload_with_plan( fp, plan=media.plan, is_private=media.is_private, title=media.title, image_post=media.image_post, ) if not local_url: raise RuntimeError(f"上传本地 S3 失败: {fp}") if pub: public_url = pub items.append((fp, local_url)) return items, public_url # ───────────────────────────── 多级发送 ───────────────────────────── def _build_local_msg(files: list[Path], image_post: bool) -> UniMessage: """本地文件版消息(mp4 → 视频,其余 → 图片)""" msg = UniMessage() for fp in files: if fp.suffix.lower() == ".mp4": msg.video(path=fp) else: msg.image(path=fp) return msg def _build_s3_msg( media: PendingMedia, ) -> tuple[UniMessage, str | None]: """上传本地 S3 并构建链接版消息,返回 (message, public_url)""" msg = UniMessage() public_url = None for fp in media.files: local_url, pub = upload_with_plan( fp, plan=media.plan, is_private=media.is_private, title=media.title, image_post=media.image_post, ) if not local_url: raise RuntimeError(f"上传本地 S3 失败: {fp}") if pub: public_url = pub if fp.suffix.lower() == ".mp4": msg.video(url=local_url) else: msg.image(url=local_url) return msg, public_url async def send_pending_media( media: PendingMedia, event: Event | None = None ) -> tuple[bool, str | None]: """多级发送,返回 (是否成功, public_url) public_url 仅在走 S3 链接发送成功时返回(调用方决定是否发文字)。 temp 文件发送成功后保留(用户手动清理 data/temp)。 多媒体作品(多图 / 图+视频 / 多视频)先走合并转发(本地文件 → S3 链接), 失败后仍落回下方普通消息链路兜底。 """ if not media.files: return False, None # ── 0. 多媒体 → 合并转发(一个图/视频一个节点)───────────────── if event is not None and _needs_forward(media.files): group_id = getattr(event, "group_id", None) user_id = None if group_id is not None else getattr(event, "user_id", None) if group_id is not None or user_id is not None: sender_name = _forward_sender_name() if await _send_forward_media( [(fp, None) for fp in media.files], sender_name, group_id=group_id, user_id=user_id, ): logger.info("媒体发送成功(合并转发,本地文件)") return True, None try: s3_items, public_url = await _build_s3_forward_items(media) if await _send_forward_media( s3_items, sender_name, group_id=group_id, user_id=user_id ): logger.info("媒体发送成功(合并转发,S3 链接)") return True, public_url except Exception as e: logger.warning(f"S3 链接合并转发失败,走普通消息链路: {e}") else: logger.warning("无法确定群号/用户号,跳过合并转发,走普通消息链路") # ── 1. temp 本地文件直接发送 ────────────────────────────── try: await _build_local_msg(media.files, media.image_post).send() logger.info("媒体发送成功(temp 本地文件直达)") return True, None except Exception as e: logger.warning(f"temp 本地文件发送失败,切换本地 S3 链接: {e}") # ── 2. 上传本地 S3 → 链接发送 ───────────────────────────── try: s3_msg, public_url = _build_s3_msg(media) await s3_msg.send() logger.info("媒体发送成功(本地 S3 链接)") return True, public_url except Exception as e: logger.warning(f"本地 S3 链接发送失败,回退 temp 本地文件: {e}") # ── 3. 回退:temp 本地文件再发一次 ──────────────────────── try: await _build_local_msg(media.files, media.image_post).send() logger.info("媒体发送成功(回退 temp 本地文件)") return True, None except Exception as e: logger.exception(f"回退发送失败: {e}") return False, None