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''
+ )
+
+ return f"""
+
+
+
+
+
+
+
+
+
+
+
+"""
+
+
+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