feat(core): 定时任务薄封装 scheduler / 跨插件任务槽 task_slots / 出站媒体显式发送方式
- scheduler.py:apscheduler 薄封装(统一 Asia/Shanghai、misfire_grace_time、起止/异常日志) - task_slots.py:长任务任务槽(进度上报 / 同槽去重 / SSE 推送 / 取消), video_analysis temp 清理等长任务接入 - outbound_media:新增 HEXI_MEDIA_MODE 显式发送方式(http/base64/file,/hub 可改), 修复 alconna 对 base64 载荷补 https:// 前缀导致的 fetch failed(repair_mangled_url) - learning_chat 定时任务迁入 hexi.core.scheduler - 测试:新增 test_scheduler / test_task_slots,扩充 test_outbound_media Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
1 parent
a789a44f5d
commit
0ac1958932
8 files changed
+1353
-12
No files matched your search
+112
-10
@@ -10,6 +10,10 @@
|
||||
`bot.send` / alconna `finish` / 直接 `send_group_msg` 全从这里过)上,
|
||||
在真正调用适配器前改写发送类 API 里的本地文件:
|
||||
|
||||
发送方式由 `HEXI_MEDIA_MODE` 显式配置(`http` / `base64` / `file`,见 `_mode()`,
|
||||
/hub 全局设置页可改):默认 `http`,即下面的两级改写;`base64` 跳过第 1 级;
|
||||
`file` 整体不改写(协议端就在本机时才用)。
|
||||
|
||||
1. **默认改写为 `http://<本机可达IP>:<端口>/media/<token>`**,由协议端主动来拉。
|
||||
地址取自 `driver.config` 的 host/port + 本机网卡,与 /hub 首页「协议端配对」
|
||||
展示的是同一来源;反向 WS 部署下协议端本就连得到这个地址(它连的就是它),
|
||||
@@ -29,6 +33,14 @@
|
||||
且**只做 URL 不做 base64**——文件体积不适合内联。覆盖这条让
|
||||
`video_analysis` 的群文件「本地直传」兜底和 alconna 的 `$onebot11:file`
|
||||
一起受益(后者传的是 `Path.as_posix()` 裸路径)。
|
||||
|
||||
另有一类「必死值」需要顺手还原:alconna 的 `Media.__post_init__` 会给解析不出
|
||||
hostname 的 url 补 `https://` 前缀(本意是补全裸域名),而 JPEG 的 base64 载荷
|
||||
恒以 `/9j/` 开头——`urlparse("base64:///9j/...")` 的 netloc 为空,于是被补成
|
||||
`https://base64:///9j/...`,协议端拿 `base64` 当域名去 fetch,必报
|
||||
`ActionFailed ... fetch failed`(2026-10-10 实测:group_tools 的老婆图)。
|
||||
见 `repair_mangled_url`;它修的是**构造期就已损坏的值**,与发送方式无关,
|
||||
file 模式下同样执行。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -104,14 +116,44 @@ def _env_flag(name: str, default: bool) -> bool:
|
||||
return raw.strip().lower() not in {"", "0", "false", "no", "off"}
|
||||
|
||||
|
||||
#: 资源发送方式(显式配置,仿 HoshinoBot 的 RES_PROTOCOL)
|
||||
MEDIA_MODES: tuple[str, ...] = ("http", "base64", "file")
|
||||
|
||||
|
||||
def _mode() -> str:
|
||||
"""当前发送方式:`http`(默认)/ `base64` / `file`。
|
||||
|
||||
- `http` 改写为 `http://<可达地址>:<端口>/media/<token>`,协议端来拉;
|
||||
拿不到可达地址时回退 base64(媒体服务没挂上 / 无网卡)
|
||||
- `base64` 一律内联,字节由 bot 自己携带,不依赖协议端回拨
|
||||
- `file` 完全不改写,直接把本地路径交给适配器 —— 只有**协议端就在本机**
|
||||
时才是对的(跨机必 ENOENT,见模块文档)
|
||||
|
||||
配置键 `HEXI_MEDIA_MODE` 优先;没配时按旧的 `HEXI_INLINE_MEDIA` /
|
||||
`HEXI_INLINE_MEDIA_URL` 两个布尔开关推断,老 .env 行为不变。
|
||||
"""
|
||||
raw = (os.environ.get("HEXI_MEDIA_MODE") or "").strip().lower()
|
||||
if raw in MEDIA_MODES:
|
||||
return raw
|
||||
if raw:
|
||||
logger.warning(
|
||||
f"HEXI_MEDIA_MODE={raw!r} 不是 {'/'.join(MEDIA_MODES)} 之一,按默认 http 处理"
|
||||
)
|
||||
if not _env_flag("HEXI_INLINE_MEDIA", True):
|
||||
return "file"
|
||||
if not _env_flag("HEXI_INLINE_MEDIA_URL", True):
|
||||
return "base64"
|
||||
return "http"
|
||||
|
||||
|
||||
def _enabled() -> bool:
|
||||
"""总开关(默认开)。关闭的语义是「协议端就在本机」,此时 file:// 是有效的。"""
|
||||
return _env_flag("HEXI_INLINE_MEDIA", True)
|
||||
"""是否启用改写。`file` 模式=「协议端就在本机」,此时 file:// 本来就是对的。"""
|
||||
return _mode() != "file"
|
||||
|
||||
|
||||
def _url_enabled() -> bool:
|
||||
"""URL 优先模式(默认开);关掉则一律走 base64。"""
|
||||
return _env_flag("HEXI_INLINE_MEDIA_URL", True)
|
||||
"""是否用 URL(`/media/<token>`):只有 http 模式用,其余走 base64 / 原样。"""
|
||||
return _mode() == "http"
|
||||
|
||||
|
||||
def _ttl() -> float:
|
||||
@@ -187,12 +229,31 @@ def _is_private(ip: str) -> bool:
|
||||
return a == 10 or (a == 172 and 16 <= b <= 31) or (a == 192 and b == 168)
|
||||
|
||||
|
||||
def _route_ip() -> str | None:
|
||||
"""默认路由出口 IP —— 连外网时内核会挑的那张网卡。
|
||||
|
||||
多网卡机器只看网卡列表挑不出「协议端该往哪连」:VPN / 虚拟机 / WSL /
|
||||
EasyTier / Tailscale 的地址**同样是私有段**,排序时纯按字符串比大小,
|
||||
`10.0.0.129`(EasyTier 虚拟网卡)就会赢过真实局域网的 `192.168.2.186`,
|
||||
协议端拉不到 → `ActionFailed ... fetch failed`(2026-10-08 实测)。
|
||||
UDP connect 不发任何包,只是让内核回答"出口在哪";无网络时返回 None。
|
||||
"""
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
try:
|
||||
sock.connect(("8.8.8.8", 80))
|
||||
return str(sock.getsockname()[0])
|
||||
except OSError:
|
||||
return None
|
||||
finally:
|
||||
sock.close()
|
||||
|
||||
|
||||
def _local_ips() -> list[str]:
|
||||
"""本机可被协议端访问的 IPv4 列表(私有网段优先,排除环回/链路本地)。
|
||||
"""本机可被协议端访问的 IPv4 列表(默认路由网卡最前,其次私有网段优先)。
|
||||
|
||||
与 hexi/web_hub/dashboard.py 的 `_local_ips` 同源逻辑(那边供 /hub 首页
|
||||
「协议端配对」展示,即用户已经验证过能用的地址);此处刻意不 import,
|
||||
避免 hexi.core → hexi.web_hub 的反向依赖。
|
||||
避免 hexi.core → hexi.web_hub 的反向依赖。改这里记得同步那边。
|
||||
"""
|
||||
ips: list[str] = []
|
||||
try:
|
||||
@@ -223,6 +284,11 @@ def _local_ips() -> list[str]:
|
||||
if ip not in out:
|
||||
out.append(ip)
|
||||
out.sort(key=lambda ip: (not _is_private(ip), ip))
|
||||
route = _route_ip()
|
||||
if route in out:
|
||||
# 默认路由那张网卡排最前:它才是协议端大概率连得到的地址(见 _route_ip)
|
||||
out.remove(route)
|
||||
out.insert(0, route)
|
||||
return out
|
||||
|
||||
|
||||
@@ -288,7 +354,9 @@ def mount_media_endpoint() -> None:
|
||||
global _mounted
|
||||
|
||||
if not _enabled():
|
||||
logger.info("出站媒体内联化已禁用 (HEXI_INLINE_MEDIA=false),按原值发送")
|
||||
logger.info(
|
||||
f"出站媒体内联化已禁用 (HEXI_MEDIA_MODE={_mode()}),按原值发送"
|
||||
)
|
||||
return
|
||||
if _mounted:
|
||||
return
|
||||
@@ -349,6 +417,8 @@ async def _to_base64(path: Path) -> str:
|
||||
|
||||
async def rewrite_file(value: Any) -> str | None:
|
||||
"""改写单个 file 字段值;无需改写(或改写失败)返回 None。"""
|
||||
if not _enabled():
|
||||
return None # file 模式:路径原样交给适配器
|
||||
path = local_file_of(value)
|
||||
if path is None:
|
||||
return None
|
||||
@@ -386,6 +456,32 @@ async def rewrite_upload_file(value: Any) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
# alconna 的 Media.__post_init__ 会给解析不出 hostname 的 url 补 `https://`
|
||||
# 前缀(本意是补全 example.com/a.png 这类裸域名),而 OneBot file 字段认的
|
||||
# 两个伪协议都可能解析不出 hostname:
|
||||
# - `base64://` + JPEG 载荷(恒以 "/9j/" 开头)→ netloc 为空 → 变成
|
||||
# `https://base64:///9j/...`,协议端只会把 "base64" 当域名去 fetch,必死
|
||||
# - `file:///D:/x.jpg`(netloc 也为空)同理被补头
|
||||
# 带上 http(s) 外衣后这两个值不再有任何一方能解释,还原回伪协议本身。
|
||||
_MANGLED_SCHEMES = ("base64://", "file://")
|
||||
|
||||
|
||||
def repair_mangled_url(value: Any) -> str | None:
|
||||
"""还原被 alconna 补了 http(s):// 头的伪协议值;无需还原返回 None。
|
||||
|
||||
只认「http(s):// 前缀 + 伪协议」这种必然非法的组合——裸域名补成
|
||||
https:// 是 alconna 的正常行为,不受影响。
|
||||
"""
|
||||
if not isinstance(value, str):
|
||||
return None
|
||||
for prefix in ("https://", "http://"):
|
||||
if value.startswith(prefix):
|
||||
rest = value[len(prefix) :]
|
||||
if rest.startswith(_MANGLED_SCHEMES):
|
||||
return rest
|
||||
return None
|
||||
|
||||
|
||||
def _as_segment(node: Any) -> tuple[str, dict] | None:
|
||||
"""识别消息段对象 → (type, data);不是消息段返回 None。
|
||||
|
||||
@@ -404,7 +500,12 @@ def _as_segment(node: Any) -> tuple[str, dict] | None:
|
||||
|
||||
async def _walk_segment(seg_type: str, seg_data: dict, depth: int) -> None:
|
||||
if seg_type in MEDIA_SEG_TYPES:
|
||||
rewritten = await rewrite_file(seg_data.get("file"))
|
||||
value = seg_data.get("file")
|
||||
# 先还原构造期被 alconna 补坏的伪协议,再按需改写本地文件
|
||||
repaired = repair_mangled_url(value)
|
||||
if repaired is not None:
|
||||
value = seg_data["file"] = repaired
|
||||
rewritten = await rewrite_file(value)
|
||||
if rewritten is not None:
|
||||
seg_data["file"] = rewritten
|
||||
# 合并转发节点:content 可能是 str / list[dict] / Message,里面还可能嵌媒体
|
||||
@@ -455,9 +556,10 @@ async def _inline_outbound_media(bot: BaseBot, api: str, data: dict[str, Any]) -
|
||||
|
||||
钩子拿到的是 `_call_api` 之前**同一个可变 data dict**,且 hook 的 task group
|
||||
在 `_call_api` 之前 await 完成,所以原地改生效。
|
||||
|
||||
这里不按 `_enabled()` 提前退出:两个改写函数各自按发送方式把关(file 模式
|
||||
原样放行),而 `repair_mangled_url` 修的是构造期就坏掉的值,任何模式都该修。
|
||||
"""
|
||||
if not _enabled():
|
||||
return
|
||||
is_send = api in _SEND_APIS
|
||||
is_upload = api in _FILE_UPLOAD_APIS
|
||||
if not (is_send or is_upload) or not isinstance(data, dict):
|
||||
|
||||
Reference in new issue
Block a user