Files
HeXi/hexi/core/message_utils.py
T
sansenhoshiandClaude Code ed75a66982 feat(core): 出站媒体内联化全局钩子 / 本地文件改写为协议端可达 URL
协议端不在本机,而 NoneBot 的 f2s() 会把 Path 转成 file:///D:/...(file:// 的
语义是「协议端那台机器上的路径」),必然 ENOENT。受影响的不止本仓调用点,还有
装不了改不动的 pip 社区插件(本次报错来自 nonebot_plugin_doroending),只能全局拦截。

- 新增 hexi/core/outbound_media.py:挂 Bot.on_calling_api(公开钩子、适配器不覆盖),
  在真正调用适配器前原地改写发送类 API 的 data:默认改写成
  http://<本机可达IP>:<端口>/media/<token> 由协议端主动回拉(地址与 /hub 首页
  「协议端配对」同源,零新配置),拿不到可达地址时回退 base64://
- 文件上传类(upload_group_file / upload_private_file)单独处理:file 在顶层且
  只做 URL 不做 base64(几十上百 MB 不适合内联),覆盖 video_analysis 群文件
  「本地直传」兜底与 alconna 的 $onebot11:file
- 媒体路由 get_app().mount("/media", sub) 复用现有端口不新开服务;免鉴权是必须的
  (协议端登录不了 hub),安全模型改为「路径由 bot 改写那一刻自己登记、请求方只能
  出示不可猜 token」,配短 TTL;_mounted 为 False 时绝不发 URL
- 钩子内每个段独立 try/except,任何异常只告警不抛,绝不反过来把发送搞挂
- message_utils.common_proc_reply 占位图不再自己转 base64,改传 path= 交给钩子,
  避免两处各有一套策略
- tests/test_outbound_media.py:用 tmp_path 造真实文件而非 mock 文件系统,重点覆盖
  file:// 解析(空格/中文编码、手工未转义的字面 %XX)与 walker 各消息形态

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

180 lines
6.6 KiB
Python

from pathlib import Path
from nonebot import get_bot
from nonebot import require
from nonebot.adapters import Event
from nonebot.adapters.onebot.v11 import Bot, Message, MessageEvent, MessageSegment
from nonebot.log import logger
from nonebot_plugin_alconna import UniMessage
from nonebot_plugin_alconna.uniseg import Receipt, Target
require("nonebot_plugin_htmlrender")
from nonebot_plugin_htmlrender import md_to_pic
# 「处理中」占位动图(任务驱动通用回复用)
THINKING_GIF = Path(__file__).parents[1] / "resource" / "imgs" / "thinking.gif"
async def send_markdown(
bot: Bot,
event: MessageEvent,
markdown: str,
fallback_text: str | None = None,
) -> None:
"""发送 Markdown
优先尝试 OneBot 后端的原生 markdown 消息段;后端不支持/失败时
自动降级为 htmlrender 把 Markdown 渲染成图片发送,保证任何后端都能看到。
"""
# 1. 尝试原生 markdown 消息段(不同后端字段可能不同,失败自动降级)
try:
seg = MessageSegment("markdown", {"content": markdown})
await bot.send(event=event, message=Message(seg))
return
except Exception as e:
logger.warning(f"原生 markdown 发送失败,尝试降级为图片: {e}")
# 2. 降级:htmlrender 渲染成图片
try:
img = await md_to_pic(md=markdown, width=540, device_scale_factor=4)
await bot.send(event=event, message=MessageSegment.image(img))
except Exception as e:
logger.error(f"Markdown 渲染图片失败,发送纯文本: {e}")
await bot.send(event=event, message=fallback_text or markdown)
async def send_poke(group_id: int | None = None, user_id: int = 0):
"""发送戳一戳(OneBot V11 send_poke API)
group_id 为空时向私聊发送戳一戳;失败返回 None
"""
bot = get_bot()
params = {"user_id": int(user_id)}
if group_id:
params["group_id"] = int(group_id)
logger.debug(f"send_poke: {params}")
try:
return await bot.call_api("send_poke", **params)
except Exception as e:
logger.error(f"send_poke 失败: {e}")
return None
def get_reply_message(event: MessageEvent) -> Message:
"""获取消息内容:若为回复消息则返回被回复的消息,否则返回当前消息"""
if event.reply is not None:
logger.info(f"此条消息为回复消息: {event.reply.message}")
return event.reply.message
logger.info(f"此条消息为普通消息: {event.message}")
return event.message
def build_forward_nodes(
self_id: int,
entries: list[tuple[str, str]],
sender_name: str | None = None,
) -> list[dict]:
"""构造合并转发节点列表
entries: [(发送者名, 消息内容), ...]
sender_name: 非空时所有节点统一使用该昵称(用于把插件名等放进 content,
而不是作为转发节点的发送者昵称)
"""
return [
{
"type": "node",
"data": {
"name": sender_name if sender_name is not None else name,
"uin": str(self_id),
"content": content,
},
}
for name, content in entries
]
async def send_forward_msg(
bot: Bot,
event: MessageEvent,
entries: list[tuple[str, str]],
fallback: str,
sender_name: str | None = None,
):
"""群聊发送合并转发消息,私聊或发送失败时回退纯文本
entries: [(发送者名, 消息内容), ...]
sender_name: 非空时统一用于节点发送者昵称(插件名等应写入 content)
"""
group_id = getattr(event, "group_id", None)
if group_id:
try:
await bot.send_group_forward_msg(
group_id=group_id,
messages=build_forward_nodes(event.self_id, entries, sender_name),
)
return
except Exception as e:
logger.warning(f"合并转发发送失败,回退文本: {e}")
await bot.send(event, fallback)
async def common_proc_reply(
message_id: str | None = None,
*,
target: Event | Target | None = None,
image: str | Path = THINKING_GIF,
text: str | None = None,
) -> Receipt | None:
"""任务驱动通用回复:引用指定消息并回一张「处理中」占位图(UniMessage 版)
引用哪条消息 / 发到哪:
- 事件处理中(有上下文):不传参数即引用当前触发消息,发回当前会话;
- 传 message_id 则引用指定消息(仍需事件上下文或用 target 指定会话);
- 后台任务(无事件上下文):必须显式传 target(Event 或 alconna Target),
如 `Target("872490448")`(群)/`Target("2931589710", private=True)`(私聊);
此时不传 message_id 即为不带引用的普通发送。
Args:
message_id: 要引用的消息 id,默认当前事件消息
target: 发送目标(Event/Target);仅后台任务需要
image: 占位图路径(或 http(s) 链接),默认 hexi/resource/imgs/thinking.gif
text: 附加文本,为空只发图
Returns:
alconna Receipt: 可 `.recall()` 撤回占位图、`.msg_ids` 取消息 id
(是否支持编辑见 `.editable`);发送失败返回 None
"""
def build() -> UniMessage:
# 每次重建:alconna 的 reply_to 会把引用段插入消息自身,降级重发需干净副本
# 本地文件统一交给 hexi.core.outbound_media 的 call_api 钩子改写
# (URL 优先/base64 兜底),这里不再自己转 base64——占位图本就是发图,
# 走同一套策略才不会出现「一处能发一处不能发」。关掉 HEXI_INLINE_MEDIA
# 的语义是「协议端就在本机」,此时 file:// 同样是有效的。
if isinstance(image, str) and image.startswith(("http://", "https://")):
img = UniMessage.image(url=image)
else:
img = UniMessage.image(path=Path(image))
return UniMessage.text(f"{text} ") + img if text else img
if message_id:
reply_to: str | bool = message_id
elif target is None:
reply_to = True # 从当前事件上下文取被引用消息
else:
reply_to = False # 后台任务且未指定消息 id → 普通发送
try:
return await build().send(target=target, reply_to=reply_to)
except Exception as e:
if not reply_to:
logger.error(f"占位图发送失败: {e}")
return None
logger.warning(f"占位回复引用发送失败,降级为不带引用: {e}")
try:
return await build().send(target=target)
except Exception as e:
logger.error(f"占位图发送失败: {e}")
return None