Files
HeXi/hexi/plugins/nonebot_plugin_video_analysis/handlers/sender.py
T
sansenhoshiandClaude Code 4badcfcf32 feat(video-analysis): 群策略 v3 / 群文件投递通道 / Web 管理页
- policy.py:per-group 正交策略(自动解析 / 自动策略 / 禁用策略 / 存储 A·B·C /
  公网 / 链接 / 群文件 + 平台限定),list.json v1/v2 → v3 自动迁移,
  写入统一走 PolicyStore(加锁 + .tmp 原子替换 + 字段归一)
- 群文件并行通道 group_file.py:打包 zip(可选 pyzipper AES-256)后优先走 S3 预签名、
  本地直传兜底;设了密码但 pyzipper 不可用就放弃上传,不退化成明文
- list_proc.py 收敛到「视频策略」统一入口,权限判定改走 policy
- Web 管理页 /hub/video_analysis(群策略 + 链接解析面板)与 services/web_jobs.py
  (只复用纯函数层,Web 上下文不发消息;内存任务表 + 并发闸门 + 超时)
- 媒体命名统一到 utils.py({作者}_{作者id}/{作品名}[_短码]),cleanup 回收空目录
- 测试:policy / 命名 / 群文件 / web_jobs 四组

顺带 pyproject 的 pytest 加 testpaths=tests(避免收进 debug/ 下的调试脚本)。

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

285 lines
11 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 拼成一个消息发送会报错
(视频段不能与其他段混合,一条消息也放不下多段视频/图集体验差)
→ 统一走合并转发,一个图/视频一个节点。
群文件(并行通道): `policy.upload_group_file` 开着时,消息链跑完后额外把文件
传到群文件(默认打包成一个 zip,可配解压密码,见 config.group_file_settings),
失败只记日志、不影响发送结果(打包/上传实现在 services/storage/group_file.py)。
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 ..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 _send_media_core(
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
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