Files
HeXi/hexi/plugins/nonebot_plugin_video_analysis/handlers/sender.py
T
sansenhoshiandClaude Code 574216645c feat(video-analysis): 本地直发成功且群策略要下载链接时补传 S3
本地直发经 hexi/core/outbound_media 钩子改写后已真正可用,此前它因协议端不在本机
恒失败,public_url 因此总有值(每次实际都落到第 2 级 S3)。本地通道通了以后若不补,
public_url 会恒为 None,entry.py 的 `if pub and policy.sends_link` 会让「发送下载
链接」静默失效。

- _local_sent / _public_link_for 把「取链接」与「发送」解耦:只有策略真要链接
  (policy.sends_link)时才补一次 S3 上传,其余情况一概不碰 S3 —— 媒体不再进
  S3 归档是有意的,temp 保留交给 cleanup
- 补传失败只记日志返回 None:发送本身已经成功,不能反过来判失败
- tests/test_video_sender_link.py:覆盖 sends_link 开/关、多文件逐个补传、
  拿不到 public_url、上传抛异常、policy 为 None 五条路径

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-23 18:47:12 +08:00

322 lines
12 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-22 起由 `hexi/core/outbound_media`
的全局钩子把本地路径改写成 `/media/<token>` 链接(此前本地那级因协议端不在本机、
`file://` 必然 ENOENT 而恒失败,实际每次都落到第 2 级 S3)。因此**媒体不再进 S3
归档是有意的**——只有群策略真要发下载链接(`policy.sends_link`)时才补传一次拿
public_url,见 `_local_sent`。
多媒体(2026-09-08 用户需求): 多图(图集)、图+视频混合、多视频(动图)
等一个作品落地成多个文件时,直接 UniMessage 拼成一个消息发送会报错
(视频段不能与其他段混合,一条消息也放不下多段视频/图集体验差)
→ 统一走合并转发,一个图/视频一个节点。
群文件(并行通道): `policy.upload_group_file` 开着时,消息链跑完后额外把文件
传到群文件(默认打包成一个 zip,可配解压密码,见 config.group_file_settings),
失败只记日志、不影响发送结果(打包/上传实现在 services/storage/group_file.py)。
temp 下的文件发送成功后也不清理(用户手动处理 data/temp)。
"""
import asyncio
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 ..config import group_file_settings
from ..policy import Policy
from ..services.storage.group_file import upload_group_files
from ..services.storage.s3 import upload_with_plan
from ..utils import media_rel_dir_of
@dataclass
class PendingMedia:
"""待发送媒体:本地文件 + 上传元数据(发送前不做任何上传/清理)"""
files: list[Path]
image_post: bool = False
is_private: bool = False
policy: Policy | None = None
#: 平台规范标签(见 policy.match_platform),群文件限定平台用
platform: 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)]
# ─────────────────────── 合并转发(多媒体专用) ───────────────────────
#: 按视频段发送的扩展名(其余按图片发;直链下载可能落 webm/mov 等)
_VIDEO_SUFFIXES = {".mp4", ".webm", ".mov", ".flv", ".mkv", ".ts"}
def _is_video(fp: Path) -> bool:
return fp.suffix.lower() in _VIDEO_SUFFIXES
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, policy=media.policy)
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:
"""本地文件版消息(视频扩展名 → 视频段,其余 → 图片段)"""
msg = UniMessage()
for fp in files:
if _is_video(fp):
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, policy=media.policy)
if not local_url:
raise RuntimeError(f"上传本地 S3 失败: {fp}")
if pub:
public_url = pub
if _is_video(fp):
msg.video(url=local_url)
else:
msg.image(url=local_url)
return msg, public_url
async def _public_link_for(media: PendingMedia) -> str | None:
"""发送已由本地通道完成后,为「发送下载链接」策略补一次 S3 上传。
本地直发不经 S3(最快),但群策略开了链接就需要公网 URL——这里**只取链接**,
不重建消息。失败只记日志并返回 None:发送本身已经成功了,不能反过来判失败。
"""
public_url = None
for fp in media.files:
try:
_, pub = await asyncio.to_thread(upload_with_plan, fp, policy=media.policy)
except Exception as e: # noqa: BLE001
logger.warning(f"补传 S3 取下载链接失败 {fp}: {e}")
return None
if pub:
public_url = pub
return public_url
async def _local_sent(media: PendingMedia) -> tuple[bool, str | None]:
"""本地通道发送成功后的返回。
只有策略真的要发下载链接(`sends_link` = send_link 且已开公网)时才补 S3;
其余情况一概不碰 S3 —— 媒体不走 S3 归档是有意为之(temp 保留由 cleanup 管)。
"""
if media.policy is not None and media.policy.sends_link:
return True, await _public_link_for(media)
return True, None
async def _send_media_core(
media: PendingMedia, event: Event | None = None
) -> tuple[bool, str | None]:
"""多级发送主体,返回 (是否成功, public_url)
public_url 有两种来源:走 S3 链接发送成功,或本地发送成功但策略要链接时
补传 S3(见 `_local_sent`);其余情况为 None(调用方据 `sends_link` 决定是否发文字)。
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 await _local_sent(media)
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 await _local_sent(media)
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 await _local_sent(media)
except Exception as e:
logger.exception(f"回退发送失败: {e}")
return False, None
async def _upload_group_files(media: PendingMedia, event: Event | None) -> None:
"""群文件并行通道:消息链跑完后按策略额外传一份(失败只记日志)。
是否打包成 zip / 是否加密由全局配置决定(config.group_file_settings);
平台清单(policy.group_file_platforms)非空时只传清单里的平台,其余平台
照常走消息、不传群文件。
"""
policy = media.policy
if policy is None or event is None:
return
if not policy.allows_group_file(media.platform):
if policy.upload_group_file:
logger.info(
f"群文件限定平台 {policy.group_file_platforms},"
f"本次为 {media.platform or '未知平台'},跳过群文件上传"
)
return
group_id = getattr(event, "group_id", None)
if group_id is None:
return
zip_files, password = group_file_settings()
await upload_group_files(
media.files,
int(group_id),
title=media.title,
zip_files=zip_files,
password=password,
policy=policy,
rel_dir=media_rel_dir_of(media.files[0]),
)
async def send_pending_media(
media: PendingMedia, event: Event | None = None
) -> tuple[bool, str | None]:
"""多级发送 + 群文件并行通道,返回 (是否成功, public_url)。"""
if not media.files:
return False, None
ok, public_url = await _send_media_core(media, event)
await _upload_group_files(media, event)
return ok, public_url