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
@@ -113,6 +113,134 @@ def test_non_string_values_are_left_alone(om, value):
|
||||
assert om.local_file_of(value) is None
|
||||
|
||||
|
||||
# ------------------------------------------------------------ 可达地址选择
|
||||
|
||||
|
||||
def _fake_psutil(monkeypatch, ips_by_iface: dict[str, list[str]]) -> None:
|
||||
"""把 psutil.net_if_addrs 换成假网卡表(`_local_ips` 在函数内 import psutil)。"""
|
||||
import socket as _socket
|
||||
import types
|
||||
|
||||
fake = types.ModuleType("psutil")
|
||||
fake.net_if_addrs = lambda: {
|
||||
name: [
|
||||
types.SimpleNamespace(family=_socket.AF_INET, address=ip) for ip in ips
|
||||
]
|
||||
for name, ips in ips_by_iface.items()
|
||||
}
|
||||
monkeypatch.setitem(sys.modules, "psutil", fake)
|
||||
|
||||
|
||||
def test_route_ip_wins_over_virtual_adapter(om, monkeypatch):
|
||||
"""回归 2026-10-08:EasyTier 的 10.0.0.129 按字符串排序赢过真实局域网,
|
||||
协议端拉不到媒体(ActionFailed fetch failed)。默认路由网卡必须排最前。"""
|
||||
_fake_psutil(
|
||||
monkeypatch,
|
||||
{
|
||||
"EasyTier": ["10.0.0.129"],
|
||||
"以太网": ["192.168.2.186"],
|
||||
"WLAN": ["169.254.65.98"], # 链路本地,应被过滤
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(om, "_route_ip", lambda: "192.168.2.186")
|
||||
|
||||
ips = om._local_ips()
|
||||
assert ips[0] == "192.168.2.186"
|
||||
assert ips == ["192.168.2.186", "10.0.0.129"]
|
||||
|
||||
|
||||
def test_local_ips_without_route_falls_back_to_private_first(om, monkeypatch):
|
||||
"""探不到默认路由(无网络/异常)时退回原排序:私有段优先、其余按字符串。"""
|
||||
_fake_psutil(
|
||||
monkeypatch,
|
||||
{"Ethernet": ["192.168.2.186"], "VPN": ["10.0.0.129"], "eth1": ["203.0.113.9"]},
|
||||
)
|
||||
monkeypatch.setattr(om, "_route_ip", lambda: None)
|
||||
assert om._local_ips() == ["10.0.0.129", "192.168.2.186", "203.0.113.9"]
|
||||
|
||||
|
||||
def test_local_ips_filters_loopback_and_ipv6(om, monkeypatch):
|
||||
_fake_psutil(
|
||||
monkeypatch,
|
||||
{"Loopback": ["127.0.0.1", "::1"], "Ethernet": ["192.168.2.186", "fe80::1"]},
|
||||
)
|
||||
monkeypatch.setattr(om, "_route_ip", lambda: None)
|
||||
assert om._local_ips() == ["192.168.2.186"]
|
||||
|
||||
|
||||
def test_route_ip_absent_from_candidates_is_ignored(om, monkeypatch):
|
||||
"""路由出口不在网卡表里(异常环境)时不该崩,也不该把结果搞乱。"""
|
||||
_fake_psutil(monkeypatch, {"Ethernet": ["192.168.2.186"]})
|
||||
monkeypatch.setattr(om, "_route_ip", lambda: "10.9.9.9")
|
||||
assert om._local_ips() == ["192.168.2.186"]
|
||||
|
||||
|
||||
def test_route_ip_smoke(om):
|
||||
"""真跑一次 UDP connect(不发包):有默认路由时应给出本机 IPv4。"""
|
||||
ip = om._route_ip()
|
||||
assert ip is None or (ip.count(".") == 3 and not ip.startswith("127."))
|
||||
|
||||
|
||||
# ------------------------------------------------------------ 发送方式
|
||||
|
||||
|
||||
def _clear_mode_env(monkeypatch) -> None:
|
||||
for key in ("HEXI_MEDIA_MODE", "HEXI_INLINE_MEDIA", "HEXI_INLINE_MEDIA_URL"):
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("env", "expected"),
|
||||
[
|
||||
# 默认 http(协议端跨机来拉)
|
||||
({}, "http"),
|
||||
({"HEXI_MEDIA_MODE": "http"}, "http"),
|
||||
({"HEXI_MEDIA_MODE": " BASE64 "}, "base64"), # 大小写/空白容忍
|
||||
({"HEXI_MEDIA_MODE": "file"}, "file"),
|
||||
# 新键没配时按旧开关推断(老 .env 行为不变)
|
||||
({"HEXI_INLINE_MEDIA": "false"}, "file"),
|
||||
({"HEXI_INLINE_MEDIA_URL": "0"}, "base64"),
|
||||
({"HEXI_INLINE_MEDIA": "false", "HEXI_INLINE_MEDIA_URL": "false"}, "file"),
|
||||
# 新键优先于旧开关
|
||||
({"HEXI_MEDIA_MODE": "http", "HEXI_INLINE_MEDIA": "false"}, "http"),
|
||||
# 非法值退回旧开关/默认
|
||||
({"HEXI_MEDIA_MODE": "ftp"}, "http"),
|
||||
({"HEXI_MEDIA_MODE": "ftp", "HEXI_INLINE_MEDIA_URL": "false"}, "base64"),
|
||||
],
|
||||
)
|
||||
def test_mode_resolution(om, monkeypatch, env, expected):
|
||||
_clear_mode_env(monkeypatch)
|
||||
for key, value in env.items():
|
||||
monkeypatch.setenv(key, value)
|
||||
assert om._mode() == expected
|
||||
|
||||
|
||||
async def test_file_mode_passes_through(om, media_file: Path, monkeypatch):
|
||||
"""file 模式(协议端就在本机)完全不改写:消息与文件上传都原样交给适配器。"""
|
||||
monkeypatch.setenv("HEXI_MEDIA_MODE", "file")
|
||||
monkeypatch.setattr(om, "_mounted", True)
|
||||
monkeypatch.setenv("HEXI_MEDIA_BASE_URL", "http://192.168.2.15:39697")
|
||||
assert await om.rewrite_file(str(media_file)) is None
|
||||
assert await om.rewrite_upload_file(str(media_file)) is None
|
||||
|
||||
|
||||
async def test_base64_mode_skips_url(om, media_file: Path, monkeypatch):
|
||||
"""base64 模式即使媒体服务可用也不发 URL(协议端拨不通本机的场合)。"""
|
||||
monkeypatch.setenv("HEXI_MEDIA_MODE", "base64")
|
||||
monkeypatch.setattr(om, "_mounted", True)
|
||||
monkeypatch.setenv("HEXI_MEDIA_BASE_URL", "http://192.168.2.15:39697")
|
||||
assert await om.rewrite_file(str(media_file)) == "base64://aGVsbG8="
|
||||
# 文件上传不做 base64(太大)→ base64 模式下只能原样放行
|
||||
assert await om.rewrite_upload_file(str(media_file)) is None
|
||||
|
||||
|
||||
async def test_http_mode_still_falls_back_to_base64(om, media_file: Path, monkeypatch):
|
||||
"""http 模式拿不到可达地址时仍回退 base64(没挂上媒体服务/无网卡)。"""
|
||||
monkeypatch.setenv("HEXI_MEDIA_MODE", "http")
|
||||
monkeypatch.setattr(om, "_mounted", False)
|
||||
assert await om.rewrite_file(str(media_file)) == "base64://aGVsbG8="
|
||||
|
||||
|
||||
# ------------------------------------------------------------ 改写目标
|
||||
|
||||
|
||||
@@ -278,6 +406,67 @@ async def test_walk_respects_depth_limit(om, media_file: Path, monkeypatch):
|
||||
assert deepest["data"]["file"] == str(media_file) # 没被改写
|
||||
|
||||
|
||||
# ------------------------------------------------------------ 伪协议还原
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("value", "expected"),
|
||||
[
|
||||
# JPEG 的 base64 载荷恒以 /9j/ 开头,alconna 构造 Image(url=...) 时
|
||||
# 会因 netloc 为空补上 https:// 前缀(2026-10-10 实测 fetch failed 的根因)
|
||||
("https://base64:///9j/4AAQSkZJRg", "base64:///9j/4AAQSkZJRg"),
|
||||
("http://base64:///9j/4AAQSkZJRg", "base64:///9j/4AAQSkZJRg"),
|
||||
("https://file:///D:/x.jpg", "file:///D:/x.jpg"),
|
||||
],
|
||||
)
|
||||
def test_repair_mangled_pseudo_scheme(om, value: str, expected: str):
|
||||
assert om.repair_mangled_url(value) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"value",
|
||||
[
|
||||
# 裸域名补 https:// 是 alconna 的本意,正常链接一律不碰
|
||||
"https://example.com/a.jpg",
|
||||
# 主机名恰好是 base64 的正常链接不能误伤(只有 base64:// 才算伪协议)
|
||||
"https://base64.com/a.png",
|
||||
# 没被补头的原值无需还原
|
||||
"base64:///9j/4AAQSkZJRg",
|
||||
"file:///D:/x.jpg",
|
||||
None,
|
||||
123,
|
||||
],
|
||||
)
|
||||
def test_repair_leaves_normal_values_alone(om, value):
|
||||
assert om.repair_mangled_url(value) is None
|
||||
|
||||
|
||||
async def test_walk_repairs_mangled_base64(om, monkeypatch):
|
||||
"""补头后的 base64 值进了 file 字段,walk 必须还原回伪协议本身。"""
|
||||
monkeypatch.setattr(om, "_mounted", False)
|
||||
seg = {"type": "image", "data": {"file": "https://base64:///9j/4AAQSkZJRg"}}
|
||||
await om._walk(seg)
|
||||
assert seg["data"]["file"] == "base64:///9j/4AAQSkZJRg"
|
||||
|
||||
|
||||
async def test_walk_repairs_real_jpeg_base64(om, tmp_path: Path, monkeypatch):
|
||||
"""用真实 JPEG 魔数验证前提:载荷以 /9j/ 开头 → urlparse 解析不出 hostname
|
||||
→ 正是 alconna 会补 https:// 的情形;还原后应与原始 base64:// 串一致。"""
|
||||
import base64 as _b64
|
||||
from urllib.parse import urlparse
|
||||
|
||||
fp = tmp_path / "a.jpg"
|
||||
fp.write_bytes(b"\xff\xd8\xff\xe0" + b"\x00" * 20)
|
||||
payload = _b64.b64encode(fp.read_bytes()).decode()
|
||||
assert payload.startswith("/9j/") # JPEG 恒如此,补头的前提
|
||||
assert urlparse(f"base64://{payload}").hostname is None # netloc 为空才会被补头
|
||||
|
||||
monkeypatch.setattr(om, "_mounted", False)
|
||||
seg = {"type": "image", "data": {"file": f"https://base64://{payload}"}}
|
||||
await om._walk(seg)
|
||||
assert seg["data"]["file"] == f"base64://{payload}"
|
||||
|
||||
|
||||
# ------------------------------------------------------------ 钩子
|
||||
|
||||
|
||||
@@ -316,6 +505,29 @@ async def test_hook_disabled_by_env(om, media_file: Path, monkeypatch):
|
||||
assert before.startswith("file://")
|
||||
|
||||
|
||||
async def test_hook_repairs_mangled_value_even_in_file_mode(
|
||||
om, media_file: Path, monkeypatch
|
||||
):
|
||||
"""file 模式不改写本地文件,但构造期就坏掉的伪协议任何模式都该修。
|
||||
|
||||
2026-10-10 老婆图报 `ActionFailed ... fetch failed` 发的就是这种值:
|
||||
与协议端在哪台机器无关,不还原则必死。
|
||||
"""
|
||||
monkeypatch.setenv("HEXI_MEDIA_MODE", "file")
|
||||
data = {
|
||||
"message": Message(
|
||||
[
|
||||
MessageSegment.image(media_file),
|
||||
MessageSegment.image("https://base64:///9j/4AAQSkZJRg"),
|
||||
]
|
||||
)
|
||||
}
|
||||
before = data["message"][0].data["file"]
|
||||
await om._inline_outbound_media(_FakeBot(), "send_group_msg", data)
|
||||
assert data["message"][0].data["file"] == before # 本地文件原样
|
||||
assert data["message"][1].data["file"] == "base64:///9j/4AAQSkZJRg"
|
||||
|
||||
|
||||
async def test_hook_handles_forward_messages_key(om, media_file: Path, monkeypatch):
|
||||
monkeypatch.setattr(om, "_mounted", False)
|
||||
data = {
|
||||
|
||||
Reference in new issue
Block a user