Files
HeXi/hexi/plugins/nonebot_plugin_video_analysis/handlers/sender.py
T
sansenhoshiandClaude Code 30899688a7 feat(video-analysis): 多文件媒体改走合并转发
抖音 note/小红书等多图、图+视频混合、多视频(动图)作品,下载产物
超过一个文件时直接拼成一条消息发送会失败(视频段不能与其他段混合),
统一改为合并转发: 一个图/视频一个节点,本地文件优先、S3 预签名链接
兜底; 单文件作品保持原有直发链路,多级发送链(本地→S3→回退)不变。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-08 14:21:57 +08:00

239 lines
8.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""媒体多级发送 — 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