- 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>
285 lines
11 KiB
Python
285 lines
11 KiB
Python
"""媒体多级发送 — 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
|