diff --git a/hexi/plugins/nonebot_plugin_video_analysis/handlers/entry.py b/hexi/plugins/nonebot_plugin_video_analysis/handlers/entry.py index 4d56cf0..8c0d86a 100644 --- a/hexi/plugins/nonebot_plugin_video_analysis/handlers/entry.py +++ b/hexi/plugins/nonebot_plugin_video_analysis/handlers/entry.py @@ -4,7 +4,6 @@ from __future__ import annotations import html import re -from typing import Optional from nonebot import on_message, logger from nonebot.adapters import Event @@ -151,7 +150,7 @@ async def match_message(event: Event, plan: str | None = None): return if isinstance(message, PendingMedia): - ok, pub = await send_pending_media(message) + ok, pub = await send_pending_media(message, event) if pub: await UniMessage.text(f"{pub}").send() if not ok: @@ -166,7 +165,7 @@ async def dispatch_url( url: str, is_private: bool, plan: str | None = None, -) -> tuple[Optional[UniMessage], Optional[str]]: +) -> tuple[UniMessage | None, str | None]: """按平台分派解析(文本链接与小程序卡片共用)。""" url = url.rstrip(",。!?、;:)】》\"')") @@ -246,7 +245,7 @@ async def dispatch_url( return None, None -async def _extract_xcx_url(msg: str) -> Optional[str]: +async def _extract_xcx_url(msg: str) -> str | None: """从 CQ 卡片消息中提取跳转 URL(保留 query 参数)。""" match = re.search(r'"qqdocurl":"(.*?)"', msg) if not match: diff --git a/hexi/plugins/nonebot_plugin_video_analysis/handlers/sender.py b/hexi/plugins/nonebot_plugin_video_analysis/handlers/sender.py index dc2ad89..99cc0fc 100644 --- a/hexi/plugins/nonebot_plugin_video_analysis/handlers/sender.py +++ b/hexi/plugins/nonebot_plugin_video_analysis/handlers/sender.py @@ -5,14 +5,19 @@ 2. 失败 → 上传本地 S3,用预签名链接发送 3. 再失败 → 回退 temp 本地文件再发一次 +多媒体(2026-09-08 用户需求): 多图(图集)、图+视频混合、多视频(动图) +等一个作品落地成多个文件时,直接 UniMessage 拼成一个消息发送会报错 +(视频段不能与其他段混合,一条消息也放不下多段视频/图集体验差) +→ 统一走合并转发,一个图/视频一个节点。 + temp 下的文件发送成功后也不清理(用户手动处理 data/temp)。 """ from dataclasses import dataclass from pathlib import Path -from typing import Optional, Union -from nonebot import logger +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 @@ -25,16 +30,111 @@ class PendingMedia: files: list[Path] image_post: bool = False is_private: bool = False - plan: Optional[str] = None + plan: str | None = None title: str = "" -def _as_paths(file_paths: Union[Path, list[Path]]) -> list[Path]: +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() @@ -48,7 +148,7 @@ def _build_local_msg(files: list[Path], image_post: bool) -> UniMessage: def _build_s3_msg( media: PendingMedia, -) -> tuple[UniMessage, Optional[str]]: +) -> tuple[UniMessage, str | None]: """上传本地 S3 并构建链接版消息,返回 (message, public_url)""" msg = UniMessage() public_url = None @@ -71,15 +171,46 @@ def _build_s3_msg( return msg, public_url -async def send_pending_media(media: PendingMedia) -> tuple[bool, Optional[str]]: +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()