2026-10-13 17:53:17 +08:00
|
|
|
|
"""媒体多级发送 — 本地优先,归档与发送解耦(并行),失败逐级降级
|
2026-09-01 13:13:40 +08:00
|
|
|
|
|
|
|
|
|
|
发送策略(2026-08-22 用户需求):
|
|
|
|
|
|
1. temp 本地文件直接发送(最快,不经 S3)
|
2026-10-13 17:53:17 +08:00
|
|
|
|
2. 失败 → 用**已归档**的 S3 预签名链接发送
|
2026-09-01 13:13:40 +08:00
|
|
|
|
3. 再失败 → 回退 temp 本地文件再发一次
|
|
|
|
|
|
|
2026-10-13 17:53:17 +08:00
|
|
|
|
**归档是前提,不是发送的副产品(2026-09-25 用户明确)**:媒体一旦下载成功就要
|
|
|
|
|
|
进本地 S3——与发送走哪一级、成功与否**无关**。因此 `archive_media` 在
|
|
|
|
|
|
`send_pending_media` 一进来就无条件开跑,与本地直发**并行**:本地发送不等它
|
|
|
|
|
|
(保住最快那条路),但它一定会跑完(发送全失败也照传)。原因:temp 只留 7 天
|
|
|
|
|
|
(cleanup.py 删),桶里那份才是长期存档;顺带产出公网下载链接(`upload_public`
|
|
|
|
|
|
开着时),「发送下载链接」策略直接用它。每个文件只上传一次——第 2 级用的就是
|
|
|
|
|
|
归档的预签名链接,没有"补传"这一步。
|
|
|
|
|
|
|
2026-09-23 18:47:12 +08:00
|
|
|
|
本地直发要真的通,得让协议端能拿到文件:2026-09-22 起由 `hexi/core/outbound_media`
|
|
|
|
|
|
的全局钩子把本地路径改写成 `/media/<token>` 链接(此前本地那级因协议端不在本机、
|
2026-10-13 17:53:17 +08:00
|
|
|
|
`file://` 必然 ENOENT 而恒失败,实际每次都落到第 2 级 S3)。注意那条 `/media`
|
|
|
|
|
|
链接**只给协议端拉取用**(TTL 300s、bot 自己的路由),不能当下载链接发给群友。
|
2026-09-23 18:47:12 +08:00
|
|
|
|
|
2026-09-08 14:21:57 +08:00
|
|
|
|
多媒体(2026-09-08 用户需求): 多图(图集)、图+视频混合、多视频(动图)
|
|
|
|
|
|
等一个作品落地成多个文件时,直接 UniMessage 拼成一个消息发送会报错
|
|
|
|
|
|
(视频段不能与其他段混合,一条消息也放不下多段视频/图集体验差)
|
|
|
|
|
|
→ 统一走合并转发,一个图/视频一个节点。
|
|
|
|
|
|
|
2026-09-22 14:23:32 +08:00
|
|
|
|
群文件(并行通道): `policy.upload_group_file` 开着时,消息链跑完后额外把文件
|
|
|
|
|
|
传到群文件(默认打包成一个 zip,可配解压密码,见 config.group_file_settings),
|
|
|
|
|
|
失败只记日志、不影响发送结果(打包/上传实现在 services/storage/group_file.py)。
|
|
|
|
|
|
|
2026-10-13 17:53:17 +08:00
|
|
|
|
**超时=结果未知,绝不降级重试**(2026-10-13):NoneBot 默认 `api_timeout=30s`,
|
|
|
|
|
|
而长视频在协议端要「下载 + 转存到 QQ」,实测 172MB 视频约 2 分钟。超时只是 bot
|
|
|
|
|
|
不再等待响应——**协议端会把这次发送做完**,于是三级降级把同一个视频送了三份
|
|
|
|
|
|
(群里收到三份 + 一条"媒体发送失败")。修法有两半:发送调用带 `_timeout`
|
|
|
|
|
|
(`send_timeout()`,默认 300s,`video_analysis_send_timeout` 可调)等它真正
|
|
|
|
|
|
发完;万一仍超时则按「可能已送达」处理——停在这一级、不发失败提示,只有
|
|
|
|
|
|
协议端**明确报错**(ActionFailed)才降级到下一级。
|
|
|
|
|
|
|
2026-09-01 13:13:40 +08:00
|
|
|
|
temp 下的文件发送成功后也不清理(用户手动处理 data/temp)。
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
2026-09-23 18:47:12 +08:00
|
|
|
|
import asyncio
|
2026-10-13 17:53:17 +08:00
|
|
|
|
import os
|
2026-09-01 13:13:40 +08:00
|
|
|
|
from dataclasses import dataclass
|
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
|
2026-09-08 14:21:57 +08:00
|
|
|
|
from nonebot import get_bot, get_driver, logger
|
|
|
|
|
|
from nonebot.adapters import Event
|
2026-10-13 17:53:17 +08:00
|
|
|
|
from nonebot.exception import NetworkError
|
2026-09-01 13:13:40 +08:00
|
|
|
|
from nonebot_plugin_alconna import UniMessage
|
|
|
|
|
|
|
2026-09-22 14:23:32 +08:00
|
|
|
|
from ..config import group_file_settings
|
|
|
|
|
|
from ..policy import Policy
|
|
|
|
|
|
from ..services.storage.group_file import upload_group_files
|
2026-09-03 00:44:38 +08:00
|
|
|
|
from ..services.storage.s3 import upload_with_plan
|
2026-09-22 14:23:32 +08:00
|
|
|
|
from ..utils import media_rel_dir_of
|
2026-09-01 13:13:40 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass
|
|
|
|
|
|
class PendingMedia:
|
|
|
|
|
|
"""待发送媒体:本地文件 + 上传元数据(发送前不做任何上传/清理)"""
|
|
|
|
|
|
|
|
|
|
|
|
files: list[Path]
|
|
|
|
|
|
image_post: bool = False
|
|
|
|
|
|
is_private: bool = False
|
2026-09-22 14:23:32 +08:00
|
|
|
|
policy: Policy | None = None
|
|
|
|
|
|
#: 平台规范标签(见 policy.match_platform),群文件限定平台用
|
|
|
|
|
|
platform: str | None = None
|
2026-09-01 13:13:40 +08:00
|
|
|
|
title: str = ""
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-08 14:21:57 +08:00
|
|
|
|
def _as_paths(file_paths: Path | list[Path]) -> list[Path]:
|
2026-09-01 13:13:40 +08:00
|
|
|
|
if isinstance(file_paths, list):
|
|
|
|
|
|
return [Path(p) for p in file_paths]
|
|
|
|
|
|
return [Path(file_paths)]
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-10-13 17:53:17 +08:00
|
|
|
|
# ─────────────────── 发送超时与「结果未知」判定 ───────────────────
|
|
|
|
|
|
|
|
|
|
|
|
#: 媒体发送 per-call 超时(秒)——长视频在协议端「下载 + 转存 QQ」要几十秒
|
|
|
|
|
|
#: 到几分钟(实测 172MB ≈ 140s),NoneBot 默认 api_timeout=30s 必超时。
|
|
|
|
|
|
_DEFAULT_SEND_TIMEOUT = 300.0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def send_timeout() -> float:
|
|
|
|
|
|
"""媒体发送的 per-call 超时(`_timeout` 关键字,OneBot 适配器原生支持)。
|
|
|
|
|
|
|
|
|
|
|
|
超时只是 bot 不再等响应,**协议端会把这次发送做完**;所以这个值同时是
|
|
|
|
|
|
「重试会重复发送」的边界,默认 300s,`video_analysis_send_timeout` 可调。
|
|
|
|
|
|
"""
|
|
|
|
|
|
raw = (
|
|
|
|
|
|
os.environ.get("video_analysis_send_timeout")
|
|
|
|
|
|
or os.environ.get("VIDEO_ANALYSIS_SEND_TIMEOUT")
|
|
|
|
|
|
or ""
|
|
|
|
|
|
).strip()
|
|
|
|
|
|
try:
|
|
|
|
|
|
value = float(raw)
|
|
|
|
|
|
except ValueError:
|
|
|
|
|
|
if raw:
|
|
|
|
|
|
logger.warning(
|
|
|
|
|
|
f"video_analysis_send_timeout={raw!r} 不是数字,"
|
|
|
|
|
|
f"按默认 {_DEFAULT_SEND_TIMEOUT:g}s 处理"
|
|
|
|
|
|
)
|
|
|
|
|
|
return _DEFAULT_SEND_TIMEOUT
|
|
|
|
|
|
return value if value > 0 else _DEFAULT_SEND_TIMEOUT
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def unknown_send_outcome(exc: BaseException) -> bool:
|
|
|
|
|
|
"""这次发送是否「结果未知」——协议端可能仍在处理并最终送达。
|
|
|
|
|
|
|
|
|
|
|
|
`NetworkError` 涵盖调用超时与连接异常:请求已经发出,只是 bot 放弃了
|
|
|
|
|
|
等待。长视频超时后协议端照样把视频发进群(2026-10-13 实测:172MB 视频
|
|
|
|
|
|
三级降级各超时一次,群里收到三份),所以这种结果**不能再降级重试**,
|
|
|
|
|
|
否则每试一级就多送一份;对外的"媒体发送失败"提示同理不能再发。
|
|
|
|
|
|
"""
|
|
|
|
|
|
return isinstance(exc, NetworkError) or isinstance(
|
|
|
|
|
|
exc, (asyncio.TimeoutError, TimeoutError)
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _log_unknown(stage: str, exc: BaseException) -> None:
|
|
|
|
|
|
logger.warning(
|
|
|
|
|
|
f"{stage}发送等待超时,结果未知(协议端可能仍在处理并最终送达);"
|
|
|
|
|
|
f"不再降级重试以免重复发送: {exc}"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-08 14:21:57 +08:00
|
|
|
|
# ─────────────────────── 合并转发(多媒体专用) ───────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-22 14:23:32 +08:00
|
|
|
|
#: 按视频段发送的扩展名(其余按图片发;直链下载可能落 webm/mov 等)
|
|
|
|
|
|
_VIDEO_SUFFIXES = {".mp4", ".webm", ".mov", ".flv", ".mkv", ".ts"}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-08 14:21:57 +08:00
|
|
|
|
def _is_video(fp: Path) -> bool:
|
2026-09-22 14:23:32 +08:00
|
|
|
|
return fp.suffix.lower() in _VIDEO_SUFFIXES
|
2026-09-08 14:21:57 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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,
|
2026-10-13 17:53:17 +08:00
|
|
|
|
) -> bool | None:
|
|
|
|
|
|
"""发送合并转发消息(群聊 / 私聊)。
|
|
|
|
|
|
|
|
|
|
|
|
返回 True=成功 / False=失败(协议端明确报错,可降级重试)/
|
|
|
|
|
|
None=结果未知(超时,调用方必须停止重试,见 `unknown_send_outcome`)。
|
|
|
|
|
|
"""
|
2026-09-08 14:21:57 +08:00
|
|
|
|
try:
|
|
|
|
|
|
bot = get_bot()
|
|
|
|
|
|
nodes = _build_forward_nodes(items, sender_name, bot.self_id)
|
2026-10-13 17:53:17 +08:00
|
|
|
|
timeout = send_timeout()
|
2026-09-08 14:21:57 +08:00
|
|
|
|
if group_id is not None:
|
|
|
|
|
|
await bot.call_api(
|
2026-10-13 17:53:17 +08:00
|
|
|
|
"send_group_forward_msg",
|
|
|
|
|
|
group_id=group_id,
|
|
|
|
|
|
messages=nodes,
|
|
|
|
|
|
_timeout=timeout,
|
2026-09-08 14:21:57 +08:00
|
|
|
|
)
|
|
|
|
|
|
else:
|
2026-10-13 17:53:17 +08:00
|
|
|
|
await bot.call_api(
|
|
|
|
|
|
"send_forward_msg", user_id=user_id, messages=nodes, _timeout=timeout
|
|
|
|
|
|
)
|
2026-09-08 14:21:57 +08:00
|
|
|
|
return True
|
|
|
|
|
|
except Exception as e:
|
2026-10-13 17:53:17 +08:00
|
|
|
|
if unknown_send_outcome(e):
|
|
|
|
|
|
_log_unknown("合并转发", e)
|
|
|
|
|
|
return None
|
2026-09-08 14:21:57 +08:00
|
|
|
|
logger.warning(f"合并转发发送失败: {e}")
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-10-13 17:53:17 +08:00
|
|
|
|
# ─────────────────────── 归档(发送的前提) ───────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass
|
|
|
|
|
|
class UploadedFile:
|
|
|
|
|
|
"""归档结果:本地文件 → 局域网预签名链接(发送兜底)+ 公网链接(下载链接)"""
|
|
|
|
|
|
|
|
|
|
|
|
path: Path
|
|
|
|
|
|
local_url: str | None = None
|
|
|
|
|
|
public_url: str | None = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def archive_media(media: PendingMedia) -> list[UploadedFile]:
|
|
|
|
|
|
"""逐文件归档到本地 S3 —— **无条件**执行,与发送成败无关。
|
|
|
|
|
|
|
|
|
|
|
|
这是「媒体必须进桶」这条前提的落点(用户 2026-09-25 定):temp 只留 7 天,
|
|
|
|
|
|
桶里那份才是长期存档。boto3 同步阻塞(大视频一次传几十秒),统一丢线程池。
|
|
|
|
|
|
单个文件失败只 warning 并**继续**后面的(多图作品不该因第 3 张失败丢掉后 7 张),
|
|
|
|
|
|
任何情况都不抛——归档失败不能反过来影响发送,发送失败也不影响归档。
|
|
|
|
|
|
"""
|
|
|
|
|
|
uploads: list[UploadedFile] = []
|
2026-09-08 14:21:57 +08:00
|
|
|
|
for fp in media.files:
|
2026-10-13 17:53:17 +08:00
|
|
|
|
item = UploadedFile(path=fp)
|
|
|
|
|
|
try:
|
|
|
|
|
|
item.local_url, item.public_url = await asyncio.to_thread(
|
|
|
|
|
|
upload_with_plan, fp, policy=media.policy
|
|
|
|
|
|
)
|
|
|
|
|
|
except Exception as e: # noqa: BLE001
|
|
|
|
|
|
logger.warning(f"归档到本地 S3 失败 {fp}: {e}")
|
|
|
|
|
|
uploads.append(item)
|
|
|
|
|
|
return uploads
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _presigned_items(uploads: list[UploadedFile]) -> list[tuple[Path, str | None]]:
|
|
|
|
|
|
"""归档结果 → 链接版消息/合并转发要的 [(文件, 预签名链接)](缺链接=该文件没传上)"""
|
|
|
|
|
|
return [(item.path, item.local_url) for item in uploads]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _last_public_url(uploads: list[UploadedFile]) -> str | None:
|
|
|
|
|
|
"""公网下载链接取最后一个非空值(`upload_public` 关时恒为 None)"""
|
|
|
|
|
|
public_url = None
|
|
|
|
|
|
for item in uploads:
|
|
|
|
|
|
if item.public_url:
|
|
|
|
|
|
public_url = item.public_url
|
|
|
|
|
|
return public_url
|
2026-09-08 14:21:57 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ───────────────────────────── 多级发送 ─────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-01 13:13:40 +08:00
|
|
|
|
def _build_local_msg(files: list[Path], image_post: bool) -> UniMessage:
|
2026-09-22 14:23:32 +08:00
|
|
|
|
"""本地文件版消息(视频扩展名 → 视频段,其余 → 图片段)"""
|
2026-09-01 13:13:40 +08:00
|
|
|
|
msg = UniMessage()
|
|
|
|
|
|
for fp in files:
|
2026-09-22 14:23:32 +08:00
|
|
|
|
if _is_video(fp):
|
2026-09-01 13:13:40 +08:00
|
|
|
|
msg.video(path=fp)
|
|
|
|
|
|
else:
|
|
|
|
|
|
msg.image(path=fp)
|
|
|
|
|
|
return msg
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-10-13 17:53:17 +08:00
|
|
|
|
def _build_s3_msg(items: list[tuple[Path, str | None]]) -> UniMessage:
|
|
|
|
|
|
"""用归档好的预签名链接构建链接版消息
|
2026-09-23 18:47:12 +08:00
|
|
|
|
|
2026-10-13 17:53:17 +08:00
|
|
|
|
有文件没拿到链接(归档时上传失败)就抛,交由调用方降级到下一级。
|
2026-09-23 18:47:12 +08:00
|
|
|
|
"""
|
2026-10-13 17:53:17 +08:00
|
|
|
|
urls: list[tuple[Path, str]] = []
|
|
|
|
|
|
for fp, url in items:
|
|
|
|
|
|
if not url:
|
|
|
|
|
|
raise RuntimeError(f"归档未拿到预签名链接: {fp}")
|
|
|
|
|
|
urls.append((fp, url))
|
2026-09-23 18:47:12 +08:00
|
|
|
|
|
2026-10-13 17:53:17 +08:00
|
|
|
|
msg = UniMessage()
|
|
|
|
|
|
for fp, url in urls:
|
|
|
|
|
|
if _is_video(fp):
|
|
|
|
|
|
msg.video(url=url)
|
|
|
|
|
|
else:
|
|
|
|
|
|
msg.image(url=url)
|
|
|
|
|
|
return msg
|
2026-09-23 18:47:12 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-09-22 14:23:32 +08:00
|
|
|
|
async def _send_media_core(
|
2026-10-13 17:53:17 +08:00
|
|
|
|
media: PendingMedia,
|
|
|
|
|
|
event: Event | None,
|
|
|
|
|
|
archive: "asyncio.Task[list[UploadedFile]]",
|
|
|
|
|
|
) -> bool:
|
|
|
|
|
|
"""多级发送主体,返回是否成功(public_url 由调用方从归档结果取)。
|
2026-09-01 13:13:40 +08:00
|
|
|
|
|
2026-10-13 17:53:17 +08:00
|
|
|
|
`archive` 是**已经在跑**的归档任务(见 `send_pending_media`):本地直发不等它,
|
|
|
|
|
|
需要链接时再 `await`——同一个 task 多次 await 安全且结果复用,所以每个文件
|
|
|
|
|
|
只上传一次,没有"补传"这一步。
|
2026-09-01 13:13:40 +08:00
|
|
|
|
temp 文件发送成功后保留(用户手动清理 data/temp)。
|
2026-09-08 14:21:57 +08:00
|
|
|
|
|
|
|
|
|
|
多媒体作品(多图 / 图+视频 / 多视频)先走合并转发(本地文件 → S3 链接),
|
|
|
|
|
|
失败后仍落回下方普通消息链路兜底。
|
2026-10-13 17:53:17 +08:00
|
|
|
|
|
|
|
|
|
|
**超时(结果未知)时按"可能已送达"处理**:停在这一级并返回 True——协议端
|
|
|
|
|
|
可能仍在把这份发完,降级重试会重复发送、发"发送失败"提示也会误导。只有
|
|
|
|
|
|
协议端明确报错(ActionFailed 等)才降级,见 `unknown_send_outcome`。
|
2026-09-01 13:13:40 +08:00
|
|
|
|
"""
|
|
|
|
|
|
if not media.files:
|
2026-10-13 17:53:17 +08:00
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
timeout = send_timeout()
|
2026-09-01 13:13:40 +08:00
|
|
|
|
|
2026-09-08 14:21:57 +08:00
|
|
|
|
# ── 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()
|
2026-10-13 17:53:17 +08:00
|
|
|
|
sent = await _send_forward_media(
|
2026-09-08 14:21:57 +08:00
|
|
|
|
[(fp, None) for fp in media.files],
|
|
|
|
|
|
sender_name,
|
|
|
|
|
|
group_id=group_id,
|
|
|
|
|
|
user_id=user_id,
|
2026-10-13 17:53:17 +08:00
|
|
|
|
)
|
|
|
|
|
|
if sent is None:
|
|
|
|
|
|
return True
|
|
|
|
|
|
if sent:
|
2026-09-08 14:21:57 +08:00
|
|
|
|
logger.info("媒体发送成功(合并转发,本地文件)")
|
2026-10-13 17:53:17 +08:00
|
|
|
|
return True
|
|
|
|
|
|
items = _presigned_items(await archive)
|
|
|
|
|
|
if all(url for _, url in items):
|
|
|
|
|
|
sent = await _send_forward_media(
|
|
|
|
|
|
items, sender_name, group_id=group_id, user_id=user_id
|
|
|
|
|
|
)
|
|
|
|
|
|
if sent is None:
|
|
|
|
|
|
return True
|
|
|
|
|
|
if sent:
|
2026-09-08 14:21:57 +08:00
|
|
|
|
logger.info("媒体发送成功(合并转发,S3 链接)")
|
2026-10-13 17:53:17 +08:00
|
|
|
|
return True
|
|
|
|
|
|
else:
|
|
|
|
|
|
logger.warning("归档未全部成功,跳过 S3 链接合并转发,走普通消息链路")
|
2026-09-08 14:21:57 +08:00
|
|
|
|
else:
|
|
|
|
|
|
logger.warning("无法确定群号/用户号,跳过合并转发,走普通消息链路")
|
|
|
|
|
|
|
2026-09-01 13:13:40 +08:00
|
|
|
|
# ── 1. temp 本地文件直接发送 ──────────────────────────────
|
|
|
|
|
|
try:
|
2026-10-13 17:53:17 +08:00
|
|
|
|
await _build_local_msg(media.files, media.image_post).send(_timeout=timeout)
|
2026-09-01 13:13:40 +08:00
|
|
|
|
logger.info("媒体发送成功(temp 本地文件直达)")
|
2026-10-13 17:53:17 +08:00
|
|
|
|
return True
|
2026-09-01 13:13:40 +08:00
|
|
|
|
except Exception as e:
|
2026-10-13 17:53:17 +08:00
|
|
|
|
if unknown_send_outcome(e):
|
|
|
|
|
|
_log_unknown("temp 本地文件直达", e)
|
|
|
|
|
|
return True
|
2026-09-01 13:13:40 +08:00
|
|
|
|
logger.warning(f"temp 本地文件发送失败,切换本地 S3 链接: {e}")
|
|
|
|
|
|
|
2026-10-13 17:53:17 +08:00
|
|
|
|
# ── 2. 用归档好的预签名链接发送 ─────────────────────────────
|
2026-09-01 13:13:40 +08:00
|
|
|
|
try:
|
2026-10-13 17:53:17 +08:00
|
|
|
|
s3_msg = _build_s3_msg(_presigned_items(await archive))
|
|
|
|
|
|
await s3_msg.send(_timeout=timeout)
|
2026-09-01 13:13:40 +08:00
|
|
|
|
logger.info("媒体发送成功(本地 S3 链接)")
|
2026-10-13 17:53:17 +08:00
|
|
|
|
return True
|
2026-09-01 13:13:40 +08:00
|
|
|
|
except Exception as e:
|
2026-10-13 17:53:17 +08:00
|
|
|
|
if unknown_send_outcome(e):
|
|
|
|
|
|
_log_unknown("本地 S3 链接", e)
|
|
|
|
|
|
return True
|
2026-09-01 13:13:40 +08:00
|
|
|
|
logger.warning(f"本地 S3 链接发送失败,回退 temp 本地文件: {e}")
|
|
|
|
|
|
|
|
|
|
|
|
# ── 3. 回退:temp 本地文件再发一次 ────────────────────────
|
|
|
|
|
|
try:
|
2026-10-13 17:53:17 +08:00
|
|
|
|
await _build_local_msg(media.files, media.image_post).send(_timeout=timeout)
|
2026-09-01 13:13:40 +08:00
|
|
|
|
logger.info("媒体发送成功(回退 temp 本地文件)")
|
2026-10-13 17:53:17 +08:00
|
|
|
|
return True
|
2026-09-01 13:13:40 +08:00
|
|
|
|
except Exception as e:
|
2026-10-13 17:53:17 +08:00
|
|
|
|
if unknown_send_outcome(e):
|
|
|
|
|
|
_log_unknown("回退 temp 本地文件", e)
|
|
|
|
|
|
return True
|
2026-09-01 13:13:40 +08:00
|
|
|
|
logger.exception(f"回退发送失败: {e}")
|
2026-10-13 17:53:17 +08:00
|
|
|
|
return False
|
2026-09-22 14:23:32 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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]:
|
2026-10-13 17:53:17 +08:00
|
|
|
|
"""归档 + 多级发送 + 群文件并行通道,返回 (是否成功, public_url)。
|
|
|
|
|
|
|
|
|
|
|
|
**归档无条件且先行开跑**(用户 2026-09-25 定的前提):媒体进了 temp 就必须
|
|
|
|
|
|
再进一次桶,与发送走哪一级、成功与否无关。它与本地直发并行,所以发送仍旧是
|
|
|
|
|
|
最快那条路;`public_url` 取自归档结果(`upload_public` 关时为 None),交给
|
|
|
|
|
|
entry.py 按策略决定是否发链接文本。
|
|
|
|
|
|
|
|
|
|
|
|
「是否成功」对**超时(结果未知)**按 True 算:协议端可能仍在发,调用方
|
|
|
|
|
|
不该再给群里发"媒体发送失败"(长视频实测就是超时后照样送达)。
|
|
|
|
|
|
"""
|
2026-09-22 14:23:32 +08:00
|
|
|
|
if not media.files:
|
|
|
|
|
|
return False, None
|
|
|
|
|
|
|
2026-10-13 17:53:17 +08:00
|
|
|
|
archive = asyncio.create_task(archive_media(media))
|
|
|
|
|
|
try:
|
|
|
|
|
|
ok = await _send_media_core(media, event, archive)
|
|
|
|
|
|
finally:
|
|
|
|
|
|
# 发送层任何异常都不能把归档半途丢下:归档自己的失败已在内部吞掉。
|
|
|
|
|
|
uploads = await archive
|
2026-09-22 14:23:32 +08:00
|
|
|
|
await _upload_group_files(media, event)
|
2026-10-13 17:53:17 +08:00
|
|
|
|
return ok, _last_public_url(uploads)
|