diff --git a/CHANGELOG.md b/CHANGELOG.md index f0a24eb..048aa6e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,21 @@ # Changelog +## [2.0.1] - 2026-05-18 + +### Added +- **提示消息全面可配置**:将 Setu 与 Fortune 的提示文案统一接入 `messages` 配置,支持 `enabled + text` 控制 +- **占位符渲染能力**:新增统一消息解析逻辑,支持 `{count}`、`{max_count}`、`{user_id}`、`{error}`、`{tags_info}` 等占位符 + +### Changed +- **Setu 无结果提示改由命令层处理**:移除 use case 内硬编码提示,统一通过配置解析生成 +- **Fortune 提示链路统一**:运势相关的配置未加载、仅群聊、刷新成功/失败、黑白名单操作反馈全部走统一消息配置 + +### Fixed +- **修复提示开关不生效**:补齐并生效 `send_failed` 开关,避免关闭后仍发送固定失败文案 +- **修复部分提示无法关闭**:`fetching`、`found`、`send_failed` 及新增 fortune 提示项均可独立关闭 +- **恢复今日运势卡片样式**:重新接回旧版模板、字体资源与渲染管线,避免截图卡片退化为简化样式 +- **修复私聊运势重复触发**:`jrys/今日运势` 在多命令前缀场景下改为基于命令唤醒状态去重,避免 regex 与 command 同时响应 + ## [2.0.0] - 2026-05-17 ### Changed diff --git a/_conf_schema.json b/_conf_schema.json index e4f228c..8d65d57 100644 --- a/_conf_schema.json +++ b/_conf_schema.json @@ -395,6 +395,12 @@ "type": "object", "hint": "图片最终发送失败时的提示消息。", "items": { + "enabled": { + "type": "bool", + "description": "启用该提示", + "hint": "是否发送此提示消息。", + "default": true + }, "text": { "type": "string", "description": "提示文本", @@ -402,7 +408,101 @@ "default": "图片发送失败,请稍后再试。" } } - } + }, + "rate_limited": { + "description": "并发限流提示", + "type": "object", + "items": { + "enabled": {"type": "bool", "description": "启用该提示", "default": true}, + "text": {"type": "string", "description": "提示文本", "default": "你有一个请求正在处理中,请稍后再试~"} + } + }, + "config_not_loaded": { + "description": "配置未加载提示", + "type": "object", + "items": { + "enabled": {"type": "bool", "description": "启用该提示", "default": true}, + "text": {"type": "string", "description": "提示文本", "default": "配置未加载"} + } + }, + "invalid_count": { + "description": "数量解析失败提示", + "type": "object", + "items": { + "enabled": {"type": "bool", "description": "启用该提示", "default": true}, + "text": {"type": "string", "description": "提示文本", "hint": "{min_count}/{max_count} 占位符可用。", "default": "数量解析失败,图片数量必须在{min_count}-{max_count}之间"} + } + }, + "max_count_exceeded": { + "description": "超出上限提示", + "type": "object", + "items": { + "enabled": {"type": "bool", "description": "启用该提示", "default": true}, + "text": {"type": "string", "description": "提示文本", "hint": "{max_count} 占位符可用。", "default": "一次最多只能获取{max_count}张哦~"} + } + }, + "count_out_of_range": { + "description": "数量越界提示", + "type": "object", + "items": { + "enabled": {"type": "bool", "description": "启用该提示", "default": true}, + "text": {"type": "string", "description": "提示文本", "hint": "{min_count}/{max_count} 占位符可用。", "default": "图片数量必须在{min_count}-{max_count}之间哦~"} + } + }, + "fetch_timeout": { + "description": "获取超时提示", + "type": "object", + "items": { + "enabled": {"type": "bool", "description": "启用该提示", "default": true}, + "text": {"type": "string", "description": "提示文本", "default": "获取图片超时,网络可能不稳定,请稍后再试。"} + } + }, + "fetch_failed": { + "description": "获取失败提示", + "type": "object", + "items": { + "enabled": {"type": "bool", "description": "启用该提示", "default": true}, + "text": {"type": "string", "description": "提示文本", "default": "获取图片失败,请稍后再试"} + } + }, + "no_result": { + "description": "无结果提示", + "type": "object", + "items": { + "enabled": {"type": "bool", "description": "启用该提示", "default": true}, + "text": {"type": "string", "description": "提示文本", "hint": "{tags_info} 占位符可用。", "default": "未找到{tags_info}符合要求的图片~"} + } + }, + "empty_payload": { + "description": "空负载提示", + "type": "object", + "items": { + "enabled": {"type": "bool", "description": "启用该提示", "default": true}, + "text": {"type": "string", "description": "提示文本", "default": "运气不好,一张图都没拿到..."} + } + }, + "r18_docx_failed": { + "description": "R18 Docx 封装失败提示", + "type": "object", + "items": { + "enabled": {"type": "bool", "description": "启用该提示", "default": true}, + "text": {"type": "string", "description": "提示文本", "default": "R18 Docx 封装失败,请稍后再试或联系管理员。"} + } + }, + "fortune_group_only": {"description": "运势群聊限制提示", "type": "object", "items": {"enabled": {"type": "bool", "description": "启用该提示", "default": true}, "text": {"type": "string", "description": "提示文本", "default": "此命令仅支持群聊"}}}, + "fortune_missing_user_id": {"description": "运势用户ID缺失提示", "type": "object", "items": {"enabled": {"type": "bool", "description": "启用该提示", "default": true}, "text": {"type": "string", "description": "提示文本", "default": "请指定用户ID"}}}, + "fortune_get_failed": {"description": "运势获取失败提示", "type": "object", "items": {"enabled": {"type": "bool", "description": "启用该提示", "default": true}, "text": {"type": "string", "description": "提示文本", "hint": "{error} 占位符可用。", "default": "获取运势失败: {error}"}}}, + "fortune_refresh_failed": {"description": "运势刷新失败提示", "type": "object", "items": {"enabled": {"type": "bool", "description": "启用该提示", "default": true}, "text": {"type": "string", "description": "提示文本", "hint": "{error} 占位符可用。", "default": "刷新运势失败: {error}"}}}, + "fortune_refresh_group_failed": {"description": "群运势刷新失败提示", "type": "object", "items": {"enabled": {"type": "bool", "description": "启用该提示", "default": true}, "text": {"type": "string", "description": "提示文本", "hint": "{error} 占位符可用。", "default": "刷新群运势失败: {error}"}}}, + "fortune_refresh_all_failed": {"description": "全局运势刷新失败提示", "type": "object", "items": {"enabled": {"type": "bool", "description": "启用该提示", "default": true}, "text": {"type": "string", "description": "提示文本", "hint": "{error} 占位符可用。", "default": "刷新全局运势失败: {error}"}}}, + "fortune_refresh_group_done": {"description": "群运势刷新成功提示", "type": "object", "items": {"enabled": {"type": "bool", "description": "启用该提示", "default": true}, "text": {"type": "string", "description": "提示文本", "hint": "{count} 占位符可用。", "default": "已刷新本群 {count} 位用户的今日运势"}}}, + "fortune_refresh_all_done": {"description": "全局运势刷新成功提示", "type": "object", "items": {"enabled": {"type": "bool", "description": "启用该提示", "default": true}, "text": {"type": "string", "description": "提示文本", "hint": "{count} 占位符可用。", "default": "已刷新全局 {count} 位用户的今日运势"}}}, + "fortune_enabled_group_done": {"description": "开启运势成功提示", "type": "object", "items": {"enabled": {"type": "bool", "description": "启用该提示", "default": true}, "text": {"type": "string", "description": "提示文本", "default": "运势功能已开启"}}}, + "fortune_disabled_group_done": {"description": "关闭运势成功提示", "type": "object", "items": {"enabled": {"type": "bool", "description": "启用该提示", "default": true}, "text": {"type": "string", "description": "提示文本", "default": "运势功能已关闭"}}}, + "fortune_block_user_done": {"description": "运势黑名单添加成功提示", "type": "object", "items": {"enabled": {"type": "bool", "description": "启用该提示", "default": true}, "text": {"type": "string", "description": "提示文本", "hint": "{user_id} 占位符可用。", "default": "用户 {user_id} 已添加到运势黑名单"}}}, + "fortune_unblock_user_done": {"description": "运势黑名单移除成功提示", "type": "object", "items": {"enabled": {"type": "bool", "description": "启用该提示", "default": true}, "text": {"type": "string", "description": "提示文本", "hint": "{user_id} 占位符可用。", "default": "用户 {user_id} 已从运势黑名单移除"}}}, + "fortune_trust_user_done": {"description": "运势白名单添加成功提示", "type": "object", "items": {"enabled": {"type": "bool", "description": "启用该提示", "default": true}, "text": {"type": "string", "description": "提示文本", "hint": "{user_id} 占位符可用。", "default": "用户 {user_id} 已添加到运势白名单"}}}, + "fortune_untrust_user_done": {"description": "运势白名单移除成功提示", "type": "object", "items": {"enabled": {"type": "bool", "description": "启用该提示", "default": true}, "text": {"type": "string", "description": "提示文本", "hint": "{user_id} 占位符可用。", "default": "用户 {user_id} 已从运势白名单移除"}}} } }, "safety": { diff --git a/main.py b/main.py index b2a39ed..7481400 100644 --- a/main.py +++ b/main.py @@ -7,6 +7,7 @@ from __future__ import annotations from collections.abc import AsyncGenerator +import re from typing import Any from astrbot.api import logger @@ -39,6 +40,7 @@ # Regex patterns for command triggers SETU_REGEX_PATTERN = r"^/?(来\s*(.*?)(份|个|张|点))(.*?)(?:福利|色|瑟|涩|塞)?图$" +FORTUNE_REGEX_PATTERN = r"^(?!/)(今日运势|jrys)$" # Module-level handler singletons _setu_handler: SetuCommandHandler | None = None @@ -103,15 +105,25 @@ "untrust": "untrust", } +_LEADING_COMMAND_PREFIX_PATTERN = re.compile(r"^[^\w\u4e00-\u9fff]+") + def _get_invoked_command(event: AstrMessageEvent) -> str: raw_message = getattr(event, "message_str", None) if not raw_message and hasattr(event, "get_message_str"): raw_message = event.get_message_str() text = str(raw_message or "").strip() - if text.startswith("/"): - text = text[1:].strip() - return text.split(maxsplit=1)[0] if text else "" + if not text: + return "" + first_token = text.split(maxsplit=1)[0] + return _LEADING_COMMAND_PREFIX_PATTERN.sub("", first_token).strip() + + +def _is_fortune_command_invocation(event: AstrMessageEvent) -> bool: + """Return True when the message is already handled by fortune command routing.""" + if not getattr(event, "is_at_or_wake_command", False): + return False + return _get_invoked_command(event) in {"今日运势", "jrys"} def _resolve_fortune_refresh_target(event: AstrMessageEvent, args: str) -> str: @@ -287,6 +299,19 @@ async def fortune_command( async for result in _fortune_handler.fortune_command(event): yield result + @filter.regex(FORTUNE_REGEX_PATTERN) + async def fortune_regex_command( + self, event: AstrMessageEvent + ) -> AsyncGenerator[Any, None]: + """纯文本今日运势/jrys入口(不带命令前缀)。""" + if _is_fortune_command_invocation(event): + return + if _fortune_handler is None: + yield event.plain_result("插件未初始化") + return + async for result in _fortune_handler.fortune_command(event): + yield result + @filter.command( "运势刷新", alias={ diff --git a/metadata.yaml b/metadata.yaml index efcbcb0..675eab1 100644 --- a/metadata.yaml +++ b/metadata.yaml @@ -1,6 +1,6 @@ name: astrbot_plugin_setu display_name: 瑟瑟! -version: v2.0.0 +version: v2.0.1 author: FlanChanXwO desc: 随机福利图插件,支持标签与数量,以及图片分级控制,可以针对于特定平台配置概率绕过审核的发送方式。 repo: https://github.com/FlanChanXwO/astrbot_plugin_setu diff --git a/src/application/setu/get_images.py b/src/application/setu/get_images.py index 0a22ad4..a065d32 100644 --- a/src/application/setu/get_images.py +++ b/src/application/setu/get_images.py @@ -32,8 +32,5 @@ async def execute(self, count: int, tags: list[str], r18: bool) -> SetuImagesRes ) if payload.is_empty: - tags_info = f"标签: {', '.join(tags)}" if tags else "" - return SetuImagesResult( - payload=None, notice=f"未找到{tags_info}符合要求的图片~" - ) + return SetuImagesResult(payload=None) return SetuImagesResult(payload=payload) diff --git a/src/infrastructure/astrbot/commands/fortune.py b/src/infrastructure/astrbot/commands/fortune.py index 81dea93..8c4c273 100644 --- a/src/infrastructure/astrbot/commands/fortune.py +++ b/src/infrastructure/astrbot/commands/fortune.py @@ -2,9 +2,12 @@ from __future__ import annotations +import base64 +import random from collections.abc import AsyncGenerator from typing import Any +import astrbot.api.message_components as Comp from astrbot.api.event import AstrMessageEvent from astrbot.core.provider.register import llm_tools @@ -13,11 +16,14 @@ FortuneGenerationRequest, FortuneRecord, ) +from ....domain.setu import SetuRequest from ....domain.fortune.service import FortuneService from ....shared import get_logger -from ... import get_access_control_repo +from ... import get_access_control_repo, get_provider from ...permission_service import PermissionService from ...persistence import get_fortune_repo +from ...providers import init_provider_from_config +from ..fortune_renderer import FortuneRenderer from ..config import get_config logger = get_logger() @@ -34,7 +40,7 @@ class FortuneCommandHandler: """ def __init__(self) -> None: - pass + self._renderer = FortuneRenderer() # ==================== Command Handlers ==================== @@ -44,23 +50,27 @@ async def fortune_command( """Handle /今日运势 command (/今日运势, /jrys).""" config = get_config() if not config: - yield event.result("配置未加载") + yield event.plain_result(self._message("config_not_loaded")) return has_perm, msg = await self._check_access(event, config) if not has_perm: - yield event.result(msg) + yield event.plain_result(msg) return request = self._build_fortune_request(event) try: repo = get_fortune_repo() - service = FortuneService(repo=repo) + service = FortuneService(repository=repo) result = await service.get_or_create_fortune(request) - yield event.result(self._format_fortune(result)) + fortune_image = await self._render_fortune_image(event, result, service) + if fortune_image: + yield event.chain_result([Comp.Image.fromBytes(fortune_image)]) + else: + yield event.plain_result(self._format_fortune(result)) except Exception as e: - yield event.result(f"获取运势失败: {e}") + yield event.plain_result(self._message("fortune_get_failed", error=e)) async def refresh_fortune_command( self, event: AstrMessageEvent @@ -68,23 +78,23 @@ async def refresh_fortune_command( """Handle /刷新今日运势 command.""" has_perm, msg = PermissionService.require_admin(event) if not has_perm: - yield event.result(msg) + yield event.plain_result(msg) return config = get_config() if not config: - yield event.result("配置未加载") + yield event.plain_result(self._message("config_not_loaded")) return request = self._build_fortune_request(event) try: repo = get_fortune_repo() - service = FortuneService(repo=repo) + service = FortuneService(repository=repo) result = await service.refresh_fortune(request) - yield event.result(self._format_fortune(result)) + yield event.plain_result(self._format_fortune(result)) except Exception as e: - yield event.result(f"刷新运势失败: {e}") + yield event.plain_result(self._message("fortune_refresh_failed", error=e)) async def refresh_group_fortune_command( self, event: AstrMessageEvent @@ -92,26 +102,30 @@ async def refresh_group_fortune_command( """Handle /刷新本群今日运势 command.""" has_perm, msg = PermissionService.require_admin(event) if not has_perm: - yield event.result(msg) + yield event.plain_result(msg) return group_id = event.get_group_id() if not group_id: - yield event.result("此命令仅支持群聊") + yield event.plain_result(self._message("fortune_group_only")) return config = get_config() if not config: - yield event.result("配置未加载") + yield event.plain_result(self._message("config_not_loaded")) return try: repo = get_fortune_repo() - service = FortuneService(repo=repo) + service = FortuneService(repository=repo) refreshed_count = await service.pregenerate_active_users() - yield event.result(f"已刷新本群 {refreshed_count} 位用户的今日运势") + yield event.plain_result( + self._message("fortune_refresh_group_done", count=refreshed_count) + ) except Exception as e: - yield event.result(f"刷新群运势失败: {e}") + yield event.plain_result( + self._message("fortune_refresh_group_failed", error=e) + ) async def refresh_all_fortune_command( self, event: AstrMessageEvent @@ -119,21 +133,25 @@ async def refresh_all_fortune_command( """Handle /刷新全局今日运势 command.""" has_perm, msg = PermissionService.require_super_user(event) if not has_perm: - yield event.result(msg) + yield event.plain_result(msg) return config = get_config() if not config: - yield event.result("配置未加载") + yield event.plain_result(self._message("config_not_loaded")) return try: repo = get_fortune_repo() - service = FortuneService(repo=repo) + service = FortuneService(repository=repo) refreshed_count = await service.pregenerate_active_users() - yield event.result(f"已刷新全局 {refreshed_count} 位用户的今日运势") + yield event.plain_result( + self._message("fortune_refresh_all_done", count=refreshed_count) + ) except Exception as e: - yield event.result(f"刷新全局运势失败: {e}") + yield event.plain_result( + self._message("fortune_refresh_all_failed", error=e) + ) async def enable_fortune_group_command( self, event: AstrMessageEvent, args: str = "" @@ -141,17 +159,17 @@ async def enable_fortune_group_command( """Handle /开启运势 command (enable Fortune for current group).""" has_perm, msg = PermissionService.require_admin(event) if not has_perm: - yield event.result(msg) + yield event.plain_result(msg) return group_id = event.get_group_id() if not group_id: - yield event.result("此命令仅支持群聊") + yield event.plain_result(self._message("fortune_group_only")) return repo = get_access_control_repo() await repo.remove_fortune_blocked_group(str(group_id)) - yield event.result("运势功能已开启") + yield event.plain_result(self._message("fortune_enabled_group_done")) async def disable_fortune_group_command( self, event: AstrMessageEvent, args: str = "" @@ -159,17 +177,17 @@ async def disable_fortune_group_command( """Handle /关闭运势 command (disable Fortune for current group).""" has_perm, msg = PermissionService.require_admin(event) if not has_perm: - yield event.result(msg) + yield event.plain_result(msg) return group_id = event.get_group_id() if not group_id: - yield event.result("此命令仅支持群聊") + yield event.plain_result(self._message("fortune_group_only")) return repo = get_access_control_repo() await repo.add_fortune_blocked_group(str(group_id)) - yield event.result("运势功能已关闭") + yield event.plain_result(self._message("fortune_disabled_group_done")) async def block_fortune_user_command( self, event: AstrMessageEvent, args: str = "" @@ -177,17 +195,19 @@ async def block_fortune_user_command( """Handle /拉黑运势用户 command (add user to Fortune blacklist).""" has_perm, msg = PermissionService.require_admin(event) if not has_perm: - yield event.result(msg) + yield event.plain_result(msg) return target_id = args.strip() or event.get_sender_id() if not target_id: - yield event.result("请指定用户ID") + yield event.plain_result(self._message("fortune_missing_user_id")) return repo = get_access_control_repo() await repo.add_fortune_blocked_user(str(target_id)) - yield event.result(f"用户 {target_id} 已添加到运势黑名单") + yield event.plain_result( + self._message("fortune_block_user_done", user_id=target_id) + ) async def unblock_fortune_user_command( self, event: AstrMessageEvent, args: str = "" @@ -195,17 +215,19 @@ async def unblock_fortune_user_command( """Handle /解除运势拉黑 command (remove user from Fortune blacklist).""" has_perm, msg = PermissionService.require_admin(event) if not has_perm: - yield event.result(msg) + yield event.plain_result(msg) return target_id = args.strip() if not target_id: - yield event.result("请指定用户ID") + yield event.plain_result(self._message("fortune_missing_user_id")) return repo = get_access_control_repo() await repo.remove_fortune_blocked_user(str(target_id)) - yield event.result(f"用户 {target_id} 已从运势黑名单移除") + yield event.plain_result( + self._message("fortune_unblock_user_done", user_id=target_id) + ) async def trust_fortune_user_command( self, event: AstrMessageEvent, args: str = "" @@ -213,17 +235,19 @@ async def trust_fortune_user_command( """Handle /信任运势用户 command (add user to Fortune whitelist).""" has_perm, msg = PermissionService.require_admin(event) if not has_perm: - yield event.result(msg) + yield event.plain_result(msg) return target_id = args.strip() or event.get_sender_id() if not target_id: - yield event.result("请指定用户ID") + yield event.plain_result(self._message("fortune_missing_user_id")) return repo = get_access_control_repo() await repo.add_fortune_whitelist_user(str(target_id)) - yield event.result(f"用户 {target_id} 已添加到运势白名单") + yield event.plain_result( + self._message("fortune_trust_user_done", user_id=target_id) + ) async def untrust_fortune_user_command( self, event: AstrMessageEvent, args: str = "" @@ -231,17 +255,19 @@ async def untrust_fortune_user_command( """Handle /取消运势信任 command (remove user from Fortune whitelist).""" has_perm, msg = PermissionService.require_admin(event) if not has_perm: - yield event.result(msg) + yield event.plain_result(msg) return target_id = args.strip() if not target_id: - yield event.result("请指定用户ID") + yield event.plain_result(self._message("fortune_missing_user_id")) return repo = get_access_control_repo() await repo.remove_fortune_whitelist_user(str(target_id)) - yield event.result(f"用户 {target_id} 已从运势白名单移除") + yield event.plain_result( + self._message("fortune_untrust_user_done", user_id=target_id) + ) # ==================== LLM Tool Handlers ==================== @@ -249,7 +275,7 @@ async def _llm_get_fortune(self, event: AstrMessageEvent) -> str: """LLM tool handler for getting today's fortune.""" config = get_config() if not config: - return "配置未加载" + return self._message("config_not_loaded") has_perm, msg = await self._check_access(event, config) if not has_perm: @@ -259,11 +285,11 @@ async def _llm_get_fortune(self, event: AstrMessageEvent) -> str: try: repo = get_fortune_repo() - service = FortuneService(repo=repo) + service = FortuneService(repository=repo) result = await service.get_or_create_fortune(request) return f"今日运势: {result.title}, 星级: {result.star_count}/{result.max_stars}" except Exception as e: - return f"获取运势失败: {e}" + return self._message("fortune_get_failed", error=e) async def _llm_refresh_fortune(self, event: AstrMessageEvent) -> str: """LLM tool handler for refreshing today's fortune.""" @@ -273,17 +299,17 @@ async def _llm_refresh_fortune(self, event: AstrMessageEvent) -> str: config = get_config() if not config: - return "配置未加载" + return self._message("config_not_loaded") request = self._build_fortune_request(event) try: repo = get_fortune_repo() - service = FortuneService(repo=repo) + service = FortuneService(repository=repo) result = await service.refresh_fortune(request) return f"今日运势已刷新: {result.title}" except Exception as e: - return f"刷新运势失败: {e}" + return self._message("fortune_refresh_failed", error=e) async def _llm_refresh_group_fortune(self, event: AstrMessageEvent) -> str: """LLM tool handler for refreshing group fortunes.""" @@ -293,19 +319,19 @@ async def _llm_refresh_group_fortune(self, event: AstrMessageEvent) -> str: group_id = event.get_group_id() if not group_id: - return "此命令仅支持群聊" + return self._message("fortune_group_only") config = get_config() if not config: - return "配置未加载" + return self._message("config_not_loaded") try: repo = get_fortune_repo() - service = FortuneService(repo=repo) + service = FortuneService(repository=repo) refreshed_count = await service.pregenerate_active_users() - return f"已刷新本群 {refreshed_count} 位用户的今日运势" + return self._message("fortune_refresh_group_done", count=refreshed_count) except Exception as e: - return f"刷新群运势失败: {e}" + return self._message("fortune_refresh_group_failed", error=e) async def _llm_refresh_all_fortune(self, event: AstrMessageEvent) -> str: """LLM tool handler for refreshing all fortunes.""" @@ -315,15 +341,15 @@ async def _llm_refresh_all_fortune(self, event: AstrMessageEvent) -> str: config = get_config() if not config: - return "配置未加载" + return self._message("config_not_loaded") try: repo = get_fortune_repo() - service = FortuneService(repo=repo) + service = FortuneService(repository=repo) refreshed_count = await service.pregenerate_active_users() - return f"已刷新全局 {refreshed_count} 位用户的今日运势" + return self._message("fortune_refresh_all_done", count=refreshed_count) except Exception as e: - return f"刷新全局运势失败: {e}" + return self._message("fortune_refresh_all_failed", error=e) # ==================== Helper Methods ==================== @@ -374,6 +400,88 @@ def _format_fortune(self, result: FortuneRecord) -> str: f"💬 {result.description}" ) + async def _render_fortune_image( + self, event: AstrMessageEvent, record: FortuneRecord, service: FortuneService + ) -> bytes | None: + """Render fortune to image, fallback to None when unavailable.""" + cached = await service.get_cached_image(record.user_id, record.date_str) + if cached: + return cached + + bg_bytes, img_url = await self._get_fortune_background_image() + if not bg_bytes: + return None + + image_base64 = base64.b64encode(bg_bytes).decode("ascii") + fortune_dict = { + "username": record.username, + "date_str": record.date_str, + "title": record.title, + "star_count": record.star_count, + "max_stars": record.max_stars, + "description": record.description, + "extra_message": record.extra_message, + "theme_color": record.theme_color, + } + try: + image_bytes = await self._renderer.render_to_image( + fortune_dict, image_base64=image_base64 + ) + if image_bytes: + await service.update_image_cache(record, image_bytes, img_url) + return image_bytes + except Exception as exc: + logger.warning("[fortune] failed to render fortune image: %s", exc) + return None + + async def _get_fortune_background_image(self) -> tuple[bytes | None, str | None]: + config = get_config() + if not config: + return None, None + try: + init_provider_from_config(config) + provider = get_provider() + + tags = [t.strip() for t in config.fortune.tags.split(",") if t.strip()] + mode = config.fortune.content_mode.value + if mode == "r18": + is_r18 = True + elif mode == "mix": + is_r18 = random.random() > 0.5 + else: + is_r18 = False + + request = SetuRequest.from_user_input( + count=1, + tags=tags, + r18=is_r18, + exclude_ai=config.exclude_ai, + ) + payload = await provider.fetch_and_download(request) + if payload.is_empty: + return None, None + + if payload.raw_bytes: + return payload.raw_bytes[0], payload.urls[0] if payload.urls else None + if payload.items and isinstance(payload.items[0], bytes): + return payload.items[0], payload.urls[0] if payload.urls else None + if payload.file_paths: + return payload.file_paths[0].read_bytes(), ( + payload.urls[0] if payload.urls else None + ) + return None, None + except Exception as exc: + logger.warning("[fortune] failed to fetch background image: %s", exc) + return None, None + + def _message(self, key: str, **kwargs: Any) -> str: + config = get_config() + if config and hasattr(config, "resolve_message"): + text = config.resolve_message(key, **kwargs) + if text is not None: + return text + return "" + # ==================== LLM Tools Registration ==================== diff --git a/src/infrastructure/astrbot/commands/setu.py b/src/infrastructure/astrbot/commands/setu.py index 50e63c9..0a0e474 100644 --- a/src/infrastructure/astrbot/commands/setu.py +++ b/src/infrastructure/astrbot/commands/setu.py @@ -91,7 +91,7 @@ async def get_random_picture( ) -> AsyncGenerator[Any, None]: """Handle natural language setu requests (regex trigger).""" if not await _rate_limiter.acquire(event): - yield event.plain_result("你有一个请求正在处理中,请稍后再试~") + yield event.plain_result(self._message("rate_limited")) return try: @@ -125,12 +125,18 @@ async def _handle_random_picture_internal( if num < 1 or num > max_count: if num == -1: yield event.plain_result( - f"数量解析失败,图片数量必须在1-{max_count}之间" + self._message("invalid_count", min_count=1, max_count=max_count) ) elif num > max_count: - yield event.plain_result(f"一次最多只能获取{max_count}张哦~") + yield event.plain_result( + self._message("max_count_exceeded", max_count=max_count) + ) else: - yield event.plain_result(f"图片数量必须在1-{max_count}之间哦~") + yield event.plain_result( + self._message( + "count_out_of_range", min_count=1, max_count=max_count + ) + ) return tag_str = match.group(4).strip() @@ -146,10 +152,10 @@ async def _handle_random_picture_internal( yield result except asyncio.TimeoutError: logger.warning("get_random_picture timeout (>60s)") - yield event.plain_result("获取图片超时,网络可能不稳定,请稍后再试。") + yield event.plain_result(self._message("fetch_timeout")) except Exception: logger.exception("get_random_picture failed") - yield event.plain_result("获取图片失败,请稍后再试") + yield event.plain_result(self._message("fetch_failed")) async def setu_command( self, event: AstrMessageEvent, count: str = "1", *, tags: str = "" @@ -160,7 +166,7 @@ async def setu_command( Example: /setu 3 girl cute """ if not await _rate_limiter.acquire(event): - yield event.plain_result("你有一个请求正在处理中,请稍后再试~") + yield event.plain_result(self._message("rate_limited")) return try: @@ -196,7 +202,9 @@ async def _handle_setu_command_internal( all_tags = f"{extra_tag} {all_tags}".strip() if num > max_count: - yield event.plain_result(f"一次最多只能获取{max_count}张哦~") + yield event.plain_result( + self._message("max_count_exceeded", max_count=max_count) + ) return parsed_tags = [t.strip() for t in all_tags.split() if t.strip()] @@ -211,10 +219,10 @@ async def _handle_setu_command_internal( yield result except asyncio.TimeoutError: logger.warning("setu command timeout (>60s)") - yield event.plain_result("获取图片超时,网络可能不稳定,请稍后再试。") + yield event.plain_result(self._message("fetch_timeout")) except Exception: logger.exception("setu command failed") - yield event.plain_result("获取图片失败,网络或服务异常,请稍后再试。") + yield event.plain_result(self._message("fetch_failed")) # ==================== LLM Tool Handlers ==================== @@ -224,7 +232,7 @@ async def _llm_get_setu_handler( """LLM tool handler for getting Setu images.""" config = get_config() if not config: - return "配置未加载" + return self._message("config_not_loaded") has_perm, msg = await self._check_access(event, config) if not has_perm: @@ -248,7 +256,7 @@ async def _llm_get_setu_handler( pass return f"Successfully fetched {payload.count} images" except Exception: - return "获取图片失败,请稍后再试" + return self._message("fetch_failed") # ==================== Helper Methods ==================== @@ -269,6 +277,10 @@ async def _fetch_and_send_images( self, event: AstrMessageEvent, num: int, tags: list[str], is_r18: bool, config ) -> AsyncGenerator[Any, None]: """Fetch images and send to user.""" + fetching_message = self._message("fetching") + if fetching_message: + yield event.plain_result(fetching_message) + init_provider_from_config(config) provider = get_provider() use_case = GetSetuImagesUseCase(provider) @@ -277,16 +289,13 @@ async def _fetch_and_send_images( result = await use_case.execute(num, tags, is_r18) except asyncio.TimeoutError: logger.warning("image fetch timeout (>60s)") - yield event.plain_result("获取图片超时,网络可能不稳定,请稍后再试。") - return - - if result.notice: - yield event.plain_result(result.notice) + yield event.plain_result(self._message("fetch_timeout")) return payload = result.payload if payload is None: - yield event.plain_result("未找到符合要求的图片~") + tags_info = f"标签: {', '.join(tags)}" if tags else "" + yield event.plain_result(self._message("no_result", tags_info=tags_info)) return from ...sending import ImageSender @@ -357,6 +366,15 @@ def _parse_count(self, count_str: str) -> int: return -1 + def _message(self, key: str, **kwargs: Any) -> str: + """Resolve configured message text.""" + config = get_config() + if config and hasattr(config, "resolve_message"): + text = config.resolve_message(key, **kwargs) + if text is not None: + return text + return "" + # ==================== LLM Tools Registration ==================== diff --git a/src/infrastructure/astrbot/fortune_renderer.py b/src/infrastructure/astrbot/fortune_renderer.py new file mode 100644 index 0000000..27a320c --- /dev/null +++ b/src/infrastructure/astrbot/fortune_renderer.py @@ -0,0 +1,152 @@ +"""Today's fortune HTML renderer.""" + +from __future__ import annotations + +import base64 +from pathlib import Path +from typing import Any + +from astrbot.api import logger +from astrbot.core import html_renderer + + +class FortuneRenderer: + """Render fortune cards to images via AstrBot's HTML renderer.""" + + def __init__(self, template_path: Path | None = None) -> None: + if template_path is None: + template_path = ( + Path(__file__).resolve().parents[3] / "templates" / "fortune.html" + ) + self.template_path = template_path + self._fonts_dir = self.template_path.parent / "res" / "fonts" + self._embedded_fonts_css: str | None = None + + def _get_embedded_fonts_css(self) -> str: + if self._embedded_fonts_css is not None: + return self._embedded_fonts_css + + fonts_config = [ + ("NotoSansSC-Regular", "NotoSansSC-Regular.woff2"), + ("NotoSansSC-Bold", "NotoSansSC-Bold.woff2"), + ("SSFangTangTi", "SSFangTangTi.woff2"), + ] + css_parts = [" /* Embedded fonts - auto-generated */"] + + for font_name, file_name in fonts_config: + font_path = self._fonts_dir / file_name + try: + if not font_path.exists(): + logger.warning("[fortune] Font file not found: %s", font_path) + continue + font_data = font_path.read_bytes() + b64_data = base64.b64encode(font_data).decode("ascii") + css_parts.append( + f""" @font-face {{ + font-family: '{font_name}'; + src: url('data:font/woff2;base64,{b64_data}') format('woff2'); + font-weight: normal; + font-style: normal; + font-display: swap; + }}""" + ) + except OSError as exc: + logger.error("[fortune] Failed to read font %s: %s", file_name, exc) + + self._embedded_fonts_css = "\n".join(css_parts) + return self._embedded_fonts_css + + @staticmethod + def _truncate_username(username: str, max_length: int = 15) -> str: + return username[:max_length] + "..." if len(username) > max_length else username + + def _get_template(self) -> str: + try: + return self.template_path.read_text(encoding="utf-8") + except OSError as exc: + logger.error("[fortune] Failed to read template: %s", exc) + return """ +
+运势: {{ title }}
+星级: {{ stars_display }}
+{{ description }}
+