diff --git a/.gitignore b/.gitignore index da1fb72..4bbb47b 100644 --- a/.gitignore +++ b/.gitignore @@ -205,4 +205,7 @@ cython_debug/ marimo/_static/ marimo/_lsp/ __marimo__/ -.DS_Store +.DS_Store + +# Runtime data (plugin local DB/cache/host config; never commit secrets) +data/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 89c1603..7e2ccbe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,18 +1,32 @@ -# Changelog - -## [2.1.1] - 2026-07-15 - -### Fixed -- **移除 t2i 渲染器禁用机制**: 单次网络或服务异常不再累计失败次数,也不会阻断后续渲染;每次请求都会重新调用 AstrBot 内置 t2i - - 停止读取和写入 `renderer_state.json`,已有遗留文件保持不变且不再生效 - - 删除 `/重置渲染器`、`/reset_renderer`、`/重置t2i` 命令 - - 删除 `RendererDisabledError` 及禁用状态查询、计数和重置接口 - -### Changed -- 删除 `DeerPipeHTMLRenderer` 与 `get_html_renderer()` 中废弃的 `use_t2i`、`jpeg_quality` 参数和属性 -- 增加渲染恢复路径及无 Playwright 运行依赖的回归测试 - -## [2.1.0] - 2026-06-14 +# Changelog + +## [2.1.2] - 2026-07-21 + +### Added +- **固定帮助图命令**: 新增 `/鹿帮助`(别名 `🦌帮助` / `鹿菜单` / `deer_help` / `deerhelp`) + - 纯文本触发:`鹿帮助`、`🦌帮助`、`鹿菜单`、`deer_help`、`deerhelp`、`deer help` + - 运行时直接发送 `assets/help.png`,不依赖 t2i / Playwright + - 资源缺失时回退为文本提示 + - 本地重生成脚本:`python scripts/gen_help_image.py`(仅开发机,非运行时依赖) + +### Fixed +- **私聊/唤醒双发**: slash 命令与纯文本 regex 在私聊或 @Bot 时会同时激活;现在对帮助/打卡/鹿历共用 `event.extra` 幂等键,并在处理后 `stop_event`,避免重复发送 +- 纯文本英文帮助触发改为大小写不敏感;允许「鹿 帮助」类中文空格;「鹿 帮助」不再误走打卡 +- 纯文本打卡支持无空格 `🦌@用户` / `帮🦌@用户`(适配 message_str 把 @ 拼进文本的情况) + +## [2.1.1] - 2026-07-15 + +### Fixed +- **移除 t2i 渲染器禁用机制**: 单次网络或服务异常不再累计失败次数,也不会阻断后续渲染;每次请求都会重新调用 AstrBot 内置 t2i + - 停止读取和写入 `renderer_state.json`,已有遗留文件保持不变且不再生效 + - 删除 `/重置渲染器`、`/reset_renderer`、`/重置t2i` 命令 + - 删除 `RendererDisabledError` 及禁用状态查询、计数和重置接口 + +### Changed +- 删除 `DeerPipeHTMLRenderer` 与 `get_html_renderer()` 中废弃的 `use_t2i`、`jpeg_quality` 参数和属性 +- 增加渲染恢复路径及无 Playwright 运行依赖的回归测试 + +## [2.1.0] - 2026-06-14 ### Changed - **移除 Playwright 本地渲染依赖**: 不再支持 Playwright 作为渲染回退方案,统一使用 AstrBot 内置 t2i 服务渲染图片 diff --git a/CLAUDE.md b/CLAUDE.md index 3de0753..d2f1306 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,6 @@ # CLAUDE.md — astrbot_plugin_deerpipe -AstrBot plugin for daily check-ins, monthly calendars, group leaderboards, and yearly deer maps. +AstrBot plugin for daily check-ins, monthly calendars, yearly deer maps, and a static help image. ## Project overview @@ -30,8 +30,9 @@ src/ infrastructure/ # DB, config, cache, rendering, utilities shared/ # Constants, paths, shared helpers templates/ # HTML/CSS templates for rendered images -resources/images/ # Bundled image assets -assets/ # README screenshots and previews +resources/images/ # Bundled runtime image assets (characters, pipes, etc.) +assets/ # README previews + runtime static help.png +scripts/ # Dev-only helpers (e.g. gen_help_image.py) tests/ # pytest suites and mocks ``` @@ -40,6 +41,7 @@ tests/ # pytest suites and mocks - Keep `main.py` thin; put business logic in `src/application` or `src/domain`. - Store shared trigger patterns and constants in `src/shared/constants.py`. - Plain-text command triggers must stay narrow and anchored. Add regression tests whenever matching rules change. +- Help image is a fixed file at `assets/help.png` (no t2i). After command surface changes, regenerate with `python scripts/gen_help_image.py`. - Use `async def` for AstrBot handlers and public service methods that touch I/O. - Keep `metadata.yaml`, `CHANGELOG.md`, and release-visible behavior in sync. diff --git a/README.md b/README.md index d6cec02..4857c2f 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,6 @@ - 📅 **精美日历** - 可视化展示每月打卡记录,支持自定义样式 - 🗓️ **历史查询** - 支持查看任意年份和月份的打卡记录 -- 📊 **群排行榜** - 支持查看今日、昨日、本月的群打卡排行榜 - 🤝 **好友互助** - 支持帮好友打卡,批量操作一键完成 - 🎨 **批量报告** - 多人打卡时生成精美的批量结算报告 - 🔄 **补卡功能** - 支持补录遗漏的打卡记录 @@ -55,6 +54,7 @@ - 💾 **数据导入导出** - 支持 JSON 格式备份和恢复数据 - 🤖 **LLM 工具** - 提供 AI 工具函数,支持智能查询打卡数据 - 🔒 **隐私设置** - 可设置是否允许他人帮自己打卡 +- 🧾 **帮助图** - `/鹿帮助` 或 `鹿帮助` 发送固定命令说明图 --- @@ -106,21 +106,21 @@ - **附加模式** (`additive`):打卡2次显示为 打勾图标 + "+1" - **计数模式** (`count`):打卡2次显示为 打勾图标 + "x2" -**打勾图标说明:** -- 开启 `show_check_mark`:签到日期会显示打勾图标 ✓ -- 关闭 `show_check_mark`:签到日期只显示打卡次数,不显示打勾图标 - -### 渲染配置 (`rendering`) - -| 配置项 | 类型 | 说明 | 默认值 | -|--------|------|------|--------| -| `rendering.render_timeout` | 整数 | 单次 t2i 图片渲染的最大等待时间(秒) | `30` | - -图片统一通过 AstrBot 内置的网络 t2i 服务渲染,插件不依赖或启动本地 -Playwright。单次渲染失败或超时只影响当前请求,后续请求仍会重新调用 t2i, -无需人工重置渲染器。 - ---- +**打勾图标说明:** +- 开启 `show_check_mark`:签到日期会显示打勾图标 ✓ +- 关闭 `show_check_mark`:签到日期只显示打卡次数,不显示打勾图标 + +### 渲染配置 (`rendering`) + +| 配置项 | 类型 | 说明 | 默认值 | +|--------|------|------|--------| +| `rendering.render_timeout` | 整数 | 单次 t2i 图片渲染的最大等待时间(秒) | `30` | + +图片统一通过 AstrBot 内置的网络 t2i 服务渲染,插件不依赖或启动本地 +Playwright。单次渲染失败或超时只影响当前请求,后续请求仍会重新调用 t2i, +无需人工重置渲染器。 + +--- ## 📝 使用方法 @@ -128,8 +128,9 @@ Playwright。单次渲染失败或超时只影响当前请求,后续请求仍 | 命令 | 说明 | |------------------------|---------------------------| +| `/鹿帮助` 或 `鹿帮助` | 查看命令帮助图(固定图 `assets/help.png`,不走 t2i) | | `/deer` 或 `/🦌` 或 `🦌` | 自我打卡 | -| `/deer @用户` 或 `🦌 @用户` | 帮他人打卡 | +| `/deer @用户` 或 `🦌@用户` | 帮他人打卡(有无空格均可;也可 `帮🦌@用户`) | | `/允许被🦌` | 允许他人帮自己打卡 | | `/禁止被🦌` | 禁止他人帮自己打卡 | | `/设置被鹿 开 @用户` | 管理员:允许指定用户被帮打卡 | @@ -148,9 +149,21 @@ Playwright。单次渲染失败或超时只影响当前请求,后续请求仍 🦌 # 自我打卡 鹿 # 自我打卡 撸🦌 # 自我打卡 -帮🦌 @用户 # 帮他人打卡 +🦌@用户 # 帮他人打卡(无空格也可) +帮🦌@用户 # 帮他人打卡 +鹿帮助 # 查看帮助图 ``` +### 维护说明(开发) + +若增删命令或别名,请同步: + +1. `main.py` 中的 `@filter.command` / 纯文本正则 +2. `scripts/gen_help_image.py` 的 `SECTIONS` +3. 在本机执行 `python scripts/gen_help_image.py` 重生成 `assets/help.png` + +该脚本仅用于开发机截图,**不是**插件运行时依赖。 + ### 历史记录查询 支持查看任意年月的打卡记录: diff --git a/assets/help.png b/assets/help.png new file mode 100644 index 0000000..cfda2cf Binary files /dev/null and b/assets/help.png differ diff --git a/main.py b/main.py index 63b994e..8dd4307 100644 --- a/main.py +++ b/main.py @@ -32,6 +32,7 @@ DeerPipeHTMLRenderer, DeerPipeLLMTools, DeerPipeService, + HelpCommandHandler, ResourceLoader, TemplateRenderer, close_aiohttp_session, @@ -42,6 +43,7 @@ from .src.shared.constants import ( PLAIN_CALENDAR_TRIGGER_PATTERN, PLAIN_DEER_TRIGGER_PATTERN, + PLAIN_HELP_TRIGGER_PATTERN, ) logger = get_logger() @@ -253,6 +255,7 @@ def __init__(self, context: Context, config: AstrBotConfig) -> None: self.data_handler = DataCommandHandler(self.data_manager) self.base_dir = Path(__file__).parent self.deermap_handler = DeermapCommandHandler(self.db, self.base_dir) + self.help_handler = HelpCommandHandler(self.base_dir) # 初始化 HTML 渲染器 render_timeout = cfg.render_timeout @@ -598,6 +601,12 @@ async def tool_get_user_deer_data( # Command Handlers (使用命令处理器) # ================================================================== + @filter.command("鹿帮助", alias={"🦌帮助", "鹿菜单", "deer_help", "deerhelp"}) + async def help_cmd(self, event: AstrMessageEvent) -> AsyncGenerator[Any, None]: + """发送固定帮助图 (/鹿帮助).""" + async for result in self.help_handler.handle_help(event): + yield result + @filter.command("deer", alias={"鹿", "🦌", "撸", "撸🦌"}) async def deer_cmd(self, event: AstrMessageEvent) -> AsyncGenerator[Any, None]: """自我打卡或帮他人打卡 (/deer).""" @@ -748,10 +757,17 @@ async def deermap_cmd( # ================================================================== def _is_explicit_slash_command(self, event: AstrMessageEvent) -> bool: - """检查消息是否以 / 开头.""" + """检查消息是否显式带 / 命令前缀. + + 同时看 message_str(wake 可能已剥前缀)与原始 Plain 组件, + 任一处出现 / 前缀即视为 slash 命令,避免 plain 与 command 双跑。 + """ + message_str = (event.get_message_str() or "").strip() + if message_str.startswith("/"): + return True for comp in event.get_messages(): - if isinstance(comp, Plain): - return comp.text.strip().startswith("/") + if isinstance(comp, Plain) and comp.text.strip().startswith("/"): + return True return False def _parse_calendar_date(self, text: str) -> tuple[dt.date, str] | None: @@ -841,3 +857,14 @@ async def plain_calendar_merged_cmd( ): yield result return + + @filter.regex(PLAIN_HELP_TRIGGER_PATTERN) + async def plain_help_cmd( + self, event: AstrMessageEvent + ) -> AsyncGenerator[Any, None]: + """纯文本帮助命令(不带/前缀).""" + if self._is_explicit_slash_command(event): + return + + async for result in self.help_handler.handle_help(event): + yield result diff --git a/metadata.yaml b/metadata.yaml index 595189f..a438d87 100644 --- a/metadata.yaml +++ b/metadata.yaml @@ -1,6 +1,6 @@ name: astrbot_plugin_deerpipe display_name: 鹿乃子月历 -version: v2.1.1 +version: v2.1.2 author: FlanChanXwO desc: 一款可爱的每日打卡插件,记录你的健康生活每一天,生成精美的月度打卡日历,支持补签和好友互动。 support_platforms: diff --git a/scripts/gen_help_image.py b/scripts/gen_help_image.py new file mode 100644 index 0000000..3863075 --- /dev/null +++ b/scripts/gen_help_image.py @@ -0,0 +1,201 @@ +"""用 Playwright 截取帮助图到 assets/help.png。 + +依赖本机已安装 playwright chromium: + python -m playwright install chromium +""" + +from __future__ import annotations + +import asyncio +import base64 +from pathlib import Path + +from playwright.async_api import async_playwright + +ROOT = Path(__file__).resolve().parent.parent +OUT_PNG = ROOT / "assets" / "help.png" + +# 与 main.py 实际命令/别名保持一致 +SECTIONS = [ + ( + "日常打卡", + [ + ("/deer · 鹿 · 🦌 · 撸 · 撸🦌", "自己打卡;也可纯文本 鹿 / 🦌"), + ("/deer @用户 · 🦌@用户 · 帮🦌@用户", "帮他人打卡;可 @ 多人出批量报告"), + ("/补鹿 <日> · 补🦌 · 补撸", "补录当月指定日期,如 /补鹿 5"), + ], + ), + ( + "查询统计", + [ + ("/鹿历 · 🦌历 · 撸历", "本月打卡月历图"), + ("/上月鹿历 · 上月🦌历", "上月打卡月历图"), + ("/鹿历 2025 3 · 2025年3月鹿历", "指定年月月历(命令或纯文本)"), + ("/鹿力图 [年] · 鹿年历 · 🦌力图", "年度打卡热力图"), + ], + ), + ( + "隐私与管理", + [ + ("/允许被鹿 · 允许被🦌", "允许他人帮自己打卡"), + ("/禁止被鹿 · 禁止被🦌", "禁止他人帮自己打卡"), + ("/设置被鹿 开|关 @用户", "管理员设置他人是否可被帮打"), + ("/管理鹿管数据 导出|导入", "管理员 JSON 备份与恢复"), + ], + ), + ( + "帮助", + [ + ( + "/鹿帮助 · 🦌帮助 · 鹿菜单 · deer_help · deerhelp", + "查看本帮助图(也可纯文本 鹿帮助)", + ), + ], + ), +] + + +def _data_uri(path: Path) -> str: + data = path.read_bytes() + return "data:image/png;base64," + base64.b64encode(data).decode() + + +def build_html() -> str: + char_uri = _data_uri(ROOT / "resources" / "images" / "character_1.png") + pipe_uri = _data_uri(ROOT / "resources" / "images" / "deerpipe.png") + + rows: list[str] = [] + for title, items in SECTIONS: + rows.append(f'
{title}
') + for cmd, desc in items: + rows.append( + f'
{cmd}
' + f'
{desc}
' + ) + + return f""" + + + + + + + + +
+
+
🦌 鹿乃子 · 使用帮助
+
DEER PIPE HELP
+
character
+
+
+ {"".join(rows)} +
pipe
+
+ +
+ + +""" + + +async def render() -> None: + html_path = ROOT / "assets" / "_help_preview.html" + html_path.write_text(build_html(), encoding="utf-8") + try: + async with async_playwright() as p: + browser = await p.chromium.launch() + page = await browser.new_page( + viewport={"width": 1100, "height": 1600}, + device_scale_factor=2, + ) + await page.goto(html_path.as_uri(), wait_until="networkidle") + await page.evaluate( + """async () => { + if (document.fonts) { + try { await document.fonts.ready; } catch (e) {} + try { + await Promise.all([ + document.fonts.load('1em "ADLaM Display"'), + document.fonts.load('700 1em "Nunito"'), + document.fonts.load('800 1em "Nunito"'), + ]); + } catch (e) {} + } + }""" + ) + await page.wait_for_timeout(400) + await page.locator("#capture").screenshot(path=str(OUT_PNG), type="png") + await browser.close() + finally: + html_path.unlink(missing_ok=True) + print(f"saved {OUT_PNG} ({OUT_PNG.stat().st_size} bytes)") + + +if __name__ == "__main__": + asyncio.run(render()) diff --git a/src/__init__.py b/src/__init__.py index 92ac7bf..82016e8 100644 --- a/src/__init__.py +++ b/src/__init__.py @@ -105,6 +105,7 @@ DeerPipeService, DeermapCommandHandler, DeermapPresenter, + HelpCommandHandler, MessageTemplates, ) @@ -170,6 +171,7 @@ "DeerPipeService", "DeermapCommandHandler", "DeermapPresenter", + "HelpCommandHandler", "MessageTemplates", # Infrastructure - Config "AIBehaviorConfig", diff --git a/src/application/__init__.py b/src/application/__init__.py index f1091b6..65a340f 100644 --- a/src/application/__init__.py +++ b/src/application/__init__.py @@ -10,6 +10,7 @@ DataCommandHandler, DeerCommandHandler, DeermapCommandHandler, + HelpCommandHandler, ) from .presenters import ( CalendarPresenter, @@ -30,6 +31,7 @@ "AdminCommandHandler", "DataCommandHandler", "DeermapCommandHandler", + "HelpCommandHandler", # Presenters "CalendarPresenter", "DeermapPresenter", diff --git a/src/application/commands/__init__.py b/src/application/commands/__init__.py index c3a5eba..190e66a 100644 --- a/src/application/commands/__init__.py +++ b/src/application/commands/__init__.py @@ -9,6 +9,7 @@ from .data_cmd import DataCommandHandler from .deer_cmd import DeerCommandHandler from .deermap_cmd import DeermapCommandHandler +from .help_cmd import HelpCommandHandler __all__ = [ "CommandHandler", @@ -17,4 +18,5 @@ "AdminCommandHandler", "DataCommandHandler", "DeermapCommandHandler", + "HelpCommandHandler", ] diff --git a/src/application/commands/calendar_cmd.py b/src/application/commands/calendar_cmd.py index af0d31e..bb2621d 100644 --- a/src/application/commands/calendar_cmd.py +++ b/src/application/commands/calendar_cmd.py @@ -12,6 +12,7 @@ from astrbot.core.message.components import At from ...infrastructure import extract_mention_user_ids, get_logger +from ...shared.constants import EVENT_DEDUP_CALENDAR if TYPE_CHECKING: from astrbot.api.event import AstrMessageEvent @@ -141,6 +142,10 @@ async def _run_calendar_query( Yields: 发送给用户的响应 """ + if event.get_extra(EVENT_DEDUP_CALENDAR): + return + event.set_extra(EVENT_DEDUP_CALENDAR, True) + messages = event.message_obj.message at_list = [m for m in messages if isinstance(m, At)] at_ids = extract_mention_user_ids(at_list) @@ -163,6 +168,7 @@ async def _run_calendar_query( except Exception: logger.error(f"查询 {target_name} 日历渲染异常") yield event.plain_result(f"{target_name} 的日历数据加载失败。") + event.stop_event() return try: @@ -178,3 +184,4 @@ async def _run_calendar_query( except Exception: logger.error("查询日历渲染异常") yield event.plain_result("日历数据加载失败。") + event.stop_event() diff --git a/src/application/commands/deer_cmd.py b/src/application/commands/deer_cmd.py index a1ebd38..7247e23 100644 --- a/src/application/commands/deer_cmd.py +++ b/src/application/commands/deer_cmd.py @@ -17,6 +17,7 @@ from ...domain import TEMPLATE_GROUP_ONLY from ...infrastructure import extract_mention_user_ids, get_logger from ...shared import ResourcePaths +from ...shared.constants import EVENT_DEDUP_DEER if TYPE_CHECKING: from astrbot.api.event import AstrMessageEvent @@ -128,6 +129,10 @@ async def run_deer_checkin( Yields: 发送给用户的响应 """ + if event.get_extra(EVENT_DEDUP_DEER): + return + event.set_extra(EVENT_DEDUP_DEER, True) + messages = event.message_obj.message at_list = [m for m in messages if isinstance(m, At)] at_ids = extract_mention_user_ids(at_list) @@ -135,11 +140,13 @@ async def run_deer_checkin( if at_ids: if event.get_message_type() != MessageType.GROUP_MESSAGE: yield event.plain_result(TEMPLATE_GROUP_ONLY) + event.stop_event() return self_id = event.get_self_id() if self_id and self_id in at_ids: yield event.plain_result("不可以帮 Bot🦌哦~") + event.stop_event() return try: @@ -147,6 +154,7 @@ async def run_deer_checkin( except (OSError, RuntimeError, ValueError) as exc: self.logger.error(f"deer_cmd help_other failed: {exc}") yield event.plain_result("操作失败,请稍后重试。") + event.stop_event() return if len(at_ids) == 1: @@ -158,6 +166,7 @@ async def run_deer_checkin( if not result_data["success"]: reason = result_data.get("reason", "无法帮🦌") yield event.plain_result(f"❌ 无法帮 {target_name} 🦌:{reason}") + event.stop_event() return try: @@ -180,6 +189,7 @@ async def run_deer_checkin( except Exception: logger.error("帮🦌日历渲染异常") yield event.plain_result(f"成功帮{target_name}🦌了") + event.stop_event() return # 批量帮🦌 @@ -198,6 +208,7 @@ async def run_deer_checkin( status = "✅" if r["success"] else "❌" lines.append(f"{status} {r['nickname']} - 第 {r['count']} 次") yield event.plain_result("\n".join(lines)) + event.stop_event() return # 自我打卡 @@ -215,6 +226,7 @@ async def run_deer_checkin( except Exception: logger.error("自我打卡日历渲染异常") yield event.plain_result(result) + event.stop_event() async def _render_batch_report( self, results: list[dict], success_count: int, html_render diff --git a/src/application/commands/help_cmd.py b/src/application/commands/help_cmd.py new file mode 100644 index 0000000..db1adc3 --- /dev/null +++ b/src/application/commands/help_cmd.py @@ -0,0 +1,40 @@ +"""Help command handler. + +发送固定帮助图,不依赖 t2i 渲染。 +""" + +from __future__ import annotations + +from collections.abc import AsyncGenerator +from pathlib import Path +from typing import TYPE_CHECKING, Any + +from ...infrastructure.utils.logger import get_logger +from ...shared.constants import EVENT_DEDUP_HELP + +if TYPE_CHECKING: + from astrbot.api.event import AstrMessageEvent + +logger = get_logger() + + +class HelpCommandHandler: + """帮助命令处理器:直接发送打包的静态帮助图.""" + + def __init__(self, base_dir: Path) -> None: + self.help_image = (base_dir / "assets" / "help.png").resolve() + self.logger = logger + + async def handle_help(self, event: AstrMessageEvent) -> AsyncGenerator[Any, None]: + if event.get_extra(EVENT_DEDUP_HELP): + return + event.set_extra(EVENT_DEDUP_HELP, True) + + if not self.help_image.is_file(): + self.logger.error("帮助图缺失: %s", self.help_image) + yield event.plain_result("帮助图资源缺失,请重新安装或更新插件。") + event.stop_event() + return + + yield event.image_result(str(self.help_image)) + event.stop_event() diff --git a/src/shared/constants.py b/src/shared/constants.py index 0dee9b3..ffa9042 100644 --- a/src/shared/constants.py +++ b/src/shared/constants.py @@ -12,15 +12,29 @@ # ============================================================================= PLAIN_DEER_TRIGGER_PATTERN: Final[str] = ( - r"^(?!/)(?:[🦌鹿撸]|撸🦌|帮\s*(?:[🦌鹿撸]|撸🦌))(?:\s+.*)?$" + r"^(?!/)" + r"(?:" + r"帮\s*(?:[🦌鹿撸]|撸🦌)(?:\s+.*|@.*)?|" + r"(?:撸🦌|[🦌鹿撸])(?!\s*(?:帮助|菜单))(?:\s+.*|@.*)?" + r")$" ) -"""纯文本打卡触发:仅完整短命令或“帮鹿/帮🦌 ...”格式,避免普通文本误触发.""" +"""纯文本打卡触发:短命令或“帮鹿/帮🦌 ...”;允许 🦌@用户 无空格;排除“鹿帮助/鹿菜单”.""" PLAIN_CALENDAR_TRIGGER_PATTERN: Final[str] = ( r"^(?!/)(上月)?(\d{4}年\d{1,2}月)?[🦌鹿撸](历|🦌历)$" ) """纯文本鹿历查询触发.""" +PLAIN_HELP_TRIGGER_PATTERN: Final[str] = ( + r"(?i)^(?!/)(?:鹿\s*帮助|🦌\s*帮助|鹿\s*菜单|deer[_\s]?help)$" +) +"""纯文本帮助触发:整句锚定,允许中文空格与英文大小写;与 slash 别名对齐.""" + +# 同一事件内 slash + plain 双 handler 幂等键(AstrBot event.extra) +EVENT_DEDUP_HELP: Final[str] = "deerpipe:handled:help" +EVENT_DEDUP_DEER: Final[str] = "deerpipe:handled:deer" +EVENT_DEDUP_CALENDAR: Final[str] = "deerpipe:handled:calendar" + # ============================================================================= # HTTP 和网络相关常量 # ============================================================================= diff --git a/tests/run_tests.py b/tests/run_tests.py index b4f2087..7659f86 100644 --- a/tests/run_tests.py +++ b/tests/run_tests.py @@ -22,6 +22,7 @@ # 测试文件列表 TEST_FILES = [ "test_plain_message_patterns.py", + "test_help_handler.py", "test_html_renderer.py", "test_standalone.py", "test_extended.py", diff --git a/tests/test_help_handler.py b/tests/test_help_handler.py new file mode 100644 index 0000000..690f23c --- /dev/null +++ b/tests/test_help_handler.py @@ -0,0 +1,149 @@ +"""Tests for HelpCommandHandler static image delivery and dedup.""" + +from __future__ import annotations + +import asyncio +import importlib.util +import sys +import types +from pathlib import Path +from types import SimpleNamespace + + +ROOT = Path(__file__).parent.parent +CMD_PATH = ROOT / "src" / "application" / "commands" / "help_cmd.py" +CONST_PATH = ROOT / "src" / "shared" / "constants.py" + + +def _load_help_command_handler(): + """Load HelpCommandHandler without importing the full application package.""" + pkg = "deerpipe_help_test" + modules = { + pkg: types.ModuleType(pkg), + f"{pkg}.application": types.ModuleType(f"{pkg}.application"), + f"{pkg}.application.commands": types.ModuleType(f"{pkg}.application.commands"), + f"{pkg}.infrastructure": types.ModuleType(f"{pkg}.infrastructure"), + f"{pkg}.infrastructure.utils": types.ModuleType(f"{pkg}.infrastructure.utils"), + f"{pkg}.infrastructure.utils.logger": types.ModuleType( + f"{pkg}.infrastructure.utils.logger" + ), + f"{pkg}.shared": types.ModuleType(f"{pkg}.shared"), + f"{pkg}.shared.constants": types.ModuleType(f"{pkg}.shared.constants"), + } + modules[pkg].__path__ = [] + modules[f"{pkg}.application"].__path__ = [] + modules[f"{pkg}.application.commands"].__path__ = [str(CMD_PATH.parent)] + modules[f"{pkg}.infrastructure"].__path__ = [] + modules[f"{pkg}.infrastructure.utils"].__path__ = [] + modules[f"{pkg}.shared"].__path__ = [] + + modules[f"{pkg}.infrastructure.utils.logger"].get_logger = lambda: SimpleNamespace( + error=lambda *args, **kwargs: None + ) + + # Minimal constants needed by help_cmd + const_src = CONST_PATH.read_text(encoding="utf-8") + # exec just EVENT_DEDUP_HELP via reading the file symbols + namespace: dict = {"__name__": f"{pkg}.shared.constants", "Final": str} + # Provide typing.Final for the constants module + import typing + + namespace["Final"] = typing.Final + namespace["Literal"] = typing.Literal + exec(compile(const_src, str(CONST_PATH), "exec"), namespace) + modules[f"{pkg}.shared.constants"].EVENT_DEDUP_HELP = namespace["EVENT_DEDUP_HELP"] + + for name, mod in modules.items(): + sys.modules[name] = mod + + spec = importlib.util.spec_from_file_location( + f"{pkg}.application.commands.help_cmd", + CMD_PATH, + ) + assert spec is not None and spec.loader is not None + mod = importlib.util.module_from_spec(spec) + mod.__package__ = f"{pkg}.application.commands" + sys.modules[spec.name] = mod + spec.loader.exec_module(mod) + return mod.HelpCommandHandler, namespace["EVENT_DEDUP_HELP"] + + +HelpCommandHandler, EVENT_DEDUP_HELP = _load_help_command_handler() + + +class _FakeEvent: + def __init__(self) -> None: + self.image_calls: list[str] = [] + self.plain_calls: list[str] = [] + self.extras: dict = {} + self.stopped = False + + def image_result(self, path: str): + self.image_calls.append(path) + return SimpleNamespace(kind="image", path=path) + + def plain_result(self, text: str): + self.plain_calls.append(text) + return SimpleNamespace(kind="plain", text=text) + + def get_extra(self, key: str | None = None, default=None): + if key is None: + return self.extras + return self.extras.get(key, default) + + def set_extra(self, key, value) -> None: + self.extras[key] = value + + def stop_event(self) -> None: + self.stopped = True + + +def test_handle_help_sends_image_when_file_exists(tmp_path: Path) -> None: + assets = tmp_path / "assets" + assets.mkdir() + help_png = assets / "help.png" + help_png.write_bytes(b"fake-png") + + handler = HelpCommandHandler(tmp_path) + event = _FakeEvent() + + results = asyncio.run(_collect(handler.handle_help(event))) + + assert len(results) == 1 + assert event.image_calls == [str(help_png.resolve())] + assert event.plain_calls == [] + assert event.stopped is True + assert event.extras.get(EVENT_DEDUP_HELP) is True + + +def test_handle_help_falls_back_to_text_when_missing(tmp_path: Path) -> None: + handler = HelpCommandHandler(tmp_path) + event = _FakeEvent() + + results = asyncio.run(_collect(handler.handle_help(event))) + + assert len(results) == 1 + assert event.image_calls == [] + assert event.plain_calls + assert "缺失" in event.plain_calls[0] + assert event.stopped is True + + +def test_handle_help_is_idempotent_on_same_event(tmp_path: Path) -> None: + assets = tmp_path / "assets" + assets.mkdir() + (assets / "help.png").write_bytes(b"fake-png") + + handler = HelpCommandHandler(tmp_path) + event = _FakeEvent() + + first = asyncio.run(_collect(handler.handle_help(event))) + second = asyncio.run(_collect(handler.handle_help(event))) + + assert len(first) == 1 + assert second == [] + assert len(event.image_calls) == 1 + + +async def _collect(agen): + return [item async for item in agen] diff --git a/tests/test_plain_message_patterns.py b/tests/test_plain_message_patterns.py index 94ef063..1e12c46 100644 --- a/tests/test_plain_message_patterns.py +++ b/tests/test_plain_message_patterns.py @@ -11,20 +11,43 @@ from shared.constants import ( # noqa: E402 PLAIN_CALENDAR_TRIGGER_PATTERN, PLAIN_DEER_TRIGGER_PATTERN, + PLAIN_HELP_TRIGGER_PATTERN, ) def test_plain_deer_trigger_accepts_short_commands() -> None: pattern = re.compile(PLAIN_DEER_TRIGGER_PATTERN) - for text in ("鹿", "🦌", "撸", "撸🦌", "🦌 @用户", "帮🦌 @用户", "帮鹿 @用户"): + for text in ( + "鹿", + "🦌", + "撸", + "撸🦌", + "🦌 @用户", + "🦌@用户", + "鹿@用户", + "帮🦌 @用户", + "帮🦌@用户", + "帮鹿 @用户", + "撸🦌@用户", + ): assert pattern.match(text), text def test_plain_deer_trigger_rejects_normal_text() -> None: pattern = re.compile(PLAIN_DEER_TRIGGER_PATTERN) - for text in ("鹿乃子月历", "鹿历", "今天鹿一下", "我想看鹿乃子", "/鹿"): + for text in ( + "鹿乃子月历", + "鹿历", + "今天鹿一下", + "我想看鹿乃子", + "/鹿", + "鹿帮助", + "鹿 帮助", + "鹿菜单", + "鹿 菜单", + ): assert not pattern.match(text), text @@ -33,3 +56,30 @@ def test_plain_calendar_trigger_accepts_calendar_queries() -> None: for text in ("鹿历", "🦌历", "上月鹿历", "2025年3月鹿历"): assert pattern.match(text), text + + +def test_plain_help_trigger_accepts_help_commands() -> None: + pattern = re.compile(PLAIN_HELP_TRIGGER_PATTERN) + + for text in ( + "鹿帮助", + "🦌帮助", + "鹿菜单", + "鹿 帮助", + "🦌 帮助", + "鹿 菜单", + "deer help", + "deerhelp", + "deer_help", + "Deer_help", + "DEERHELP", + "Deer help", + ): + assert pattern.match(text), text + + +def test_plain_help_trigger_rejects_other_text() -> None: + pattern = re.compile(PLAIN_HELP_TRIGGER_PATTERN) + + for text in ("/鹿帮助", "鹿", "帮鹿", "查看鹿帮助一下", "deer-help"): + assert not pattern.match(text), text