feat(video-analysis): 多文件媒体改走合并转发
抖音 note/小红书等多图、图+视频混合、多视频(动图)作品,下载产物 超过一个文件时直接拼成一条消息发送会失败(视频段不能与其他段混合), 统一改为合并转发: 一个图/视频一个节点,本地文件优先、S3 预签名链接 兜底; 单文件作品保持原有直发链路,多级发送链(本地→S3→回退)不变。 Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -4,7 +4,6 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import html
|
import html
|
||||||
import re
|
import re
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from nonebot import on_message, logger
|
from nonebot import on_message, logger
|
||||||
from nonebot.adapters import Event
|
from nonebot.adapters import Event
|
||||||
@@ -151,7 +150,7 @@ async def match_message(event: Event, plan: str | None = None):
|
|||||||
return
|
return
|
||||||
|
|
||||||
if isinstance(message, PendingMedia):
|
if isinstance(message, PendingMedia):
|
||||||
ok, pub = await send_pending_media(message)
|
ok, pub = await send_pending_media(message, event)
|
||||||
if pub:
|
if pub:
|
||||||
await UniMessage.text(f"{pub}").send()
|
await UniMessage.text(f"{pub}").send()
|
||||||
if not ok:
|
if not ok:
|
||||||
@@ -166,7 +165,7 @@ async def dispatch_url(
|
|||||||
url: str,
|
url: str,
|
||||||
is_private: bool,
|
is_private: bool,
|
||||||
plan: str | None = None,
|
plan: str | None = None,
|
||||||
) -> tuple[Optional[UniMessage], Optional[str]]:
|
) -> tuple[UniMessage | None, str | None]:
|
||||||
"""按平台分派解析(文本链接与小程序卡片共用)。"""
|
"""按平台分派解析(文本链接与小程序卡片共用)。"""
|
||||||
url = url.rstrip(",。!?、;:)】》\"')")
|
url = url.rstrip(",。!?、;:)】》\"')")
|
||||||
|
|
||||||
@@ -246,7 +245,7 @@ async def dispatch_url(
|
|||||||
return None, None
|
return None, None
|
||||||
|
|
||||||
|
|
||||||
async def _extract_xcx_url(msg: str) -> Optional[str]:
|
async def _extract_xcx_url(msg: str) -> str | None:
|
||||||
"""从 CQ 卡片消息中提取跳转 URL(保留 query 参数)。"""
|
"""从 CQ 卡片消息中提取跳转 URL(保留 query 参数)。"""
|
||||||
match = re.search(r'"qqdocurl":"(.*?)"', msg)
|
match = re.search(r'"qqdocurl":"(.*?)"', msg)
|
||||||
if not match:
|
if not match:
|
||||||
|
|||||||
@@ -5,14 +5,19 @@
|
|||||||
2. 失败 → 上传本地 S3,用预签名链接发送
|
2. 失败 → 上传本地 S3,用预签名链接发送
|
||||||
3. 再失败 → 回退 temp 本地文件再发一次
|
3. 再失败 → 回退 temp 本地文件再发一次
|
||||||
|
|
||||||
|
多媒体(2026-09-08 用户需求): 多图(图集)、图+视频混合、多视频(动图)
|
||||||
|
等一个作品落地成多个文件时,直接 UniMessage 拼成一个消息发送会报错
|
||||||
|
(视频段不能与其他段混合,一条消息也放不下多段视频/图集体验差)
|
||||||
|
→ 统一走合并转发,一个图/视频一个节点。
|
||||||
|
|
||||||
temp 下的文件发送成功后也不清理(用户手动处理 data/temp)。
|
temp 下的文件发送成功后也不清理(用户手动处理 data/temp)。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
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 nonebot_plugin_alconna import UniMessage
|
||||||
|
|
||||||
from ..services.storage.s3 import upload_with_plan
|
from ..services.storage.s3 import upload_with_plan
|
||||||
@@ -25,16 +30,111 @@ class PendingMedia:
|
|||||||
files: list[Path]
|
files: list[Path]
|
||||||
image_post: bool = False
|
image_post: bool = False
|
||||||
is_private: bool = False
|
is_private: bool = False
|
||||||
plan: Optional[str] = None
|
plan: str | None = None
|
||||||
title: str = ""
|
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):
|
if isinstance(file_paths, list):
|
||||||
return [Path(p) for p in file_paths]
|
return [Path(p) for p in file_paths]
|
||||||
return [Path(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:
|
def _build_local_msg(files: list[Path], image_post: bool) -> UniMessage:
|
||||||
"""本地文件版消息(mp4 → 视频,其余 → 图片)"""
|
"""本地文件版消息(mp4 → 视频,其余 → 图片)"""
|
||||||
msg = UniMessage()
|
msg = UniMessage()
|
||||||
@@ -48,7 +148,7 @@ def _build_local_msg(files: list[Path], image_post: bool) -> UniMessage:
|
|||||||
|
|
||||||
def _build_s3_msg(
|
def _build_s3_msg(
|
||||||
media: PendingMedia,
|
media: PendingMedia,
|
||||||
) -> tuple[UniMessage, Optional[str]]:
|
) -> tuple[UniMessage, str | None]:
|
||||||
"""上传本地 S3 并构建链接版消息,返回 (message, public_url)"""
|
"""上传本地 S3 并构建链接版消息,返回 (message, public_url)"""
|
||||||
msg = UniMessage()
|
msg = UniMessage()
|
||||||
public_url = None
|
public_url = None
|
||||||
@@ -71,15 +171,46 @@ def _build_s3_msg(
|
|||||||
return msg, public_url
|
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)
|
||||||
|
|
||||||
public_url 仅在走 S3 链接发送成功时返回(调用方决定是否发文字)。
|
public_url 仅在走 S3 链接发送成功时返回(调用方决定是否发文字)。
|
||||||
temp 文件发送成功后保留(用户手动清理 data/temp)。
|
temp 文件发送成功后保留(用户手动清理 data/temp)。
|
||||||
|
|
||||||
|
多媒体作品(多图 / 图+视频 / 多视频)先走合并转发(本地文件 → S3 链接),
|
||||||
|
失败后仍落回下方普通消息链路兜底。
|
||||||
"""
|
"""
|
||||||
if not media.files:
|
if not media.files:
|
||||||
return False, None
|
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 本地文件直接发送 ──────────────────────────────
|
# ── 1. temp 本地文件直接发送 ──────────────────────────────
|
||||||
try:
|
try:
|
||||||
await _build_local_msg(media.files, media.image_post).send()
|
await _build_local_msg(media.files, media.image_post).send()
|
||||||
|
|||||||
Reference in New Issue
Block a user