- hexi core: message handling, rate limiting, cooldown, plugin manager - Custom plugins: BF stats, daily check-in, quotes, persona cards, etc. - Community plugins vendored under hexi/plugins with local fixes - Web admin frontends (learning-chat, persona-admin), unified hexi/web - Tests for rate_limit/cooldown/memes/persona; poetry.lock Co-Authored-By: Claude <noreply@anthropic.com>
103 lines
3.9 KiB
Python
103 lines
3.9 KiB
Python
import random
|
|
import secrets
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from nonebot import require
|
|
|
|
# 顺序很重要:先 require 声明依赖,再 import(与 war_renderer 一致)
|
|
require("nonebot_plugin_htmlrender")
|
|
|
|
from nonebot_plugin_htmlrender import get_new_page # noqa: E402
|
|
|
|
from .war_renderer import _font_faces, _logo_data_uri, _rel
|
|
|
|
basic_path = Path(__file__).resolve().parent
|
|
template_path = basic_path / "templates"
|
|
img_path = basic_path / "img"
|
|
|
|
# 战备类型 -> (标签, 主题色)
|
|
TYPE_STYLE: dict[str, dict[str, str]] = {
|
|
"blue": {"label": "武装·补给", "color": "#6bb7ea"},
|
|
"red": {"label": "轨道·飞鹰", "color": "#fe6d6a"},
|
|
"green": {"label": "战术·设备", "color": "#7ecb3f"},
|
|
}
|
|
|
|
|
|
def _load_template(name: str) -> str:
|
|
"""读取 templates/ 下的模板文件(UTF-8),每次渲染实时读取,便于改模板即时生效。"""
|
|
return (template_path / name).read_text(encoding="utf-8")
|
|
|
|
|
|
def _equip_icon(equip: dict[str, Any]) -> str:
|
|
"""战备图标:读取 img/helldivers/ 下的 PNG,配合 <base href> 用相对路径链接。"""
|
|
raw = equip.get("path") or ""
|
|
if not raw:
|
|
return ""
|
|
p = basic_path / raw.replace("\\", "/")
|
|
if not p.exists():
|
|
return ""
|
|
return f"<img class='eq-ic' src='{_rel(p)}' alt='' />"
|
|
|
|
|
|
def _item_html(equip: dict[str, Any]) -> str:
|
|
name = equip.get("name") or "未知战备"
|
|
alias = (random.choice(equip["alias"]) if equip.get("alias") else None) or ""
|
|
type_key = equip.get("type") or ""
|
|
style = TYPE_STYLE.get(type_key, {"label": "其他", "color": "#cccccc"})
|
|
label = style["label"]
|
|
color = style["color"]
|
|
backpack = "背包位" if equip.get("backpack") else ""
|
|
alias_html = f"<span class='eq-alias'>{alias}</span>" if alias else ""
|
|
meta_tail = f" · {backpack}" if backpack else ""
|
|
return f"""
|
|
<div class="eq-row">
|
|
<div class="eq-icwrap">{_equip_icon(equip)}</div>
|
|
<div class="eq-info">
|
|
<div class="eq-name">{name}</div>
|
|
<div class="eq-meta">
|
|
<span class="eq-badge" style="color:{color};border-color:{color}">{label}</span>
|
|
{alias_html}{meta_tail}
|
|
</div>
|
|
</div>
|
|
</div>"""
|
|
|
|
|
|
def _build_html(items: list[dict[str, Any]]) -> str:
|
|
rows = "".join(_item_html(e) for e in items)
|
|
return (
|
|
_load_template("equip_card.html")
|
|
.replace("@@BASE@@", basic_path.as_uri() + "/")
|
|
.replace("@@FONT_FACES@@", _font_faces())
|
|
.replace("@@CSS@@", _load_template("equip_card.css"))
|
|
.replace("@@LOGO@@", _logo_data_uri())
|
|
.replace("@@ROWS@@", rows)
|
|
)
|
|
|
|
|
|
async def _html_to_png(html: str, width: int = 640) -> bytes:
|
|
async with get_new_page() as page:
|
|
# 初始视口高度给一个小于内容的值,便于按 scrollHeight 收紧,避免截出多余空白
|
|
await page.set_viewport_size({"width": width, "height": 300})
|
|
# 图片/字体用相对路径 + <base href> 解析;写临时 .html 到插件根目录,
|
|
# 使页面与 img/、data/ 同目录树,file:// 同源即可加载(无需额外标志)。
|
|
tmp = basic_path / f"_equip_render_{secrets.token_hex(6)}.html"
|
|
tmp.write_text(html, encoding="utf-8")
|
|
try:
|
|
await page.goto(tmp.as_uri(), wait_until="networkidle")
|
|
# 视口高度收紧到内容实际高度,避免 full_page 截出多余空白
|
|
height = await page.evaluate("document.documentElement.scrollHeight")
|
|
await page.set_viewport_size({"width": width, "height": height})
|
|
return await page.screenshot(full_page=True, type="png")
|
|
finally:
|
|
try:
|
|
tmp.unlink()
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
async def render_equipment_card(items: list[dict[str, Any]]) -> bytes:
|
|
"""把随机/组合出的战备渲染成一张 PNG 图(使用 nonebot_plugin_htmlrender)。"""
|
|
html = _build_html(items)
|
|
return await _html_to_png(html)
|