# -*- coding: utf-8 -*- """趣味指令剧本插件。 核心目标: 1. 让机器人支持“文案/事件 -> 多媒体回应”的可配置玩法。 2. 把玩法规则彻底数据化,便于后续持续收集、扩展梗库。 3. 将“拍一拍”作为内置事件纳入统一触发体系。 """ import asyncio import os from pathlib import Path from typing import Any, Dict, List, Optional, Tuple from loguru import logger from base.plugin_common.message_plugin_interface import MessagePluginInterface from base.plugin_common.plugin_interface import PluginStatus from db.fun_command_rule_db import FunCommandRuleDBOperator from utils.fun_command_rule_service import FunCommandRuleService from utils.robot_cmd.robot_command import GroupBotManager, PermissionStatus from wechat_ipad import WechatAPIClient from wechat_ipad.models.message import MessageType class FunCommandPlayPlugin(MessagePluginInterface): """趣味指令剧本插件。""" FEATURE_KEY = "FUN_COMMAND_PLAY" FEATURE_DESCRIPTION = "🎭 趣味指令剧本 [配置文案/事件触发多媒体玩法回复]" @property def name(self) -> str: return "趣味指令剧本" @property def version(self) -> str: return "1.0.0" @property def description(self) -> str: return "支持文案与事件触发的趣味玩法回复(文本、图片、语音、视频、卡片、App消息)" @property def author(self) -> str: return "codex" @property def commands(self) -> List[str]: # 该插件是“被动触发型”,不依赖固定命令词。 return [] @property def feature_key(self) -> Optional[str]: return self.FEATURE_KEY @property def feature_description(self) -> Optional[str]: return self.FEATURE_DESCRIPTION def __init__(self): super().__init__() # 注册群级权限开关,方便你按群控制玩法是否启用。 self.feature = self.register_feature() self.rule_service: Optional[FunCommandRuleService] = None self.enable = True def initialize(self, context: Dict[str, Any]) -> bool: """初始化插件。""" self.LOG = logger self.LOG.debug(f"正在初始化 {self.name} 插件...") db_manager = context.get("db_manager") if db_manager is None: self.LOG.error("未拿到 db_manager,趣味指令剧本插件初始化失败") return False # 读取开关配置:保留插件级总开关,便于快速停用。 plugin_cfg = self._config.get("FunCommandPlay", {}) self.enable = bool(plugin_cfg.get("enable", True)) # 初始化规则服务,确保首次启动就有表结构。 redis_client = db_manager.get_redis_connection() db_operator = FunCommandRuleDBOperator(db_manager) self.rule_service = FunCommandRuleService(db_operator=db_operator, redis_client=redis_client, local_ttl_seconds=30) if not self.rule_service.init_tables(): self.LOG.error("趣味指令规则表初始化失败") return False # 启动时预热一次缓存,减少第一条消息延迟。 self.rule_service.refresh_cache() self.LOG.debug(f"[{self.name}] 插件初始化完成") return True def start(self) -> bool: self.status = PluginStatus.RUNNING self.LOG.info(f"[{self.name}] 插件已启动") return True def stop(self) -> bool: self.status = PluginStatus.STOPPED self.LOG.info(f"[{self.name}] 插件已停止") return True @staticmethod def _normalize_scope(message: Dict[str, Any]) -> Tuple[str, str, str]: """标准化作用域信息。 返回: - scope_type: global/group/private - scope_id: 群ID或用户ID - target_id: 发送目标(群ID优先,否则私聊用户ID) """ room_id = str(message.get("roomid", "") or "").strip() sender = str(message.get("sender", "") or "").strip() if room_id: return "group", room_id, room_id return "private", sender, sender @staticmethod def _extract_event_key(message: Dict[str, Any]) -> str: """提取事件触发键。 当前内置: - PAT:拍一拍事件 检测策略: 1. 系统消息文案包含“拍了拍”。 2. XML 内容包含 patMsg 结构。 """ content = str(message.get("content", "") or "") full_msg = message.get("full_wx_msg") # 通过消息枚举类型识别系统消息,再结合关键词更稳妥。 msg_type = getattr(full_msg, "msg_type", None) msg_type_value = getattr(msg_type, "value", msg_type) is_system = str(msg_type_value) in {str(MessageType.SYSTEM.value), str(MessageType.SYSTEM_NOTIFY.value), "10000", "10002"} lowered_content = content.lower() if " Dict[str, str]: """构建模板变量上下文。""" room_id = str(message.get("roomid", "") or "") sender = str(message.get("sender", "") or "") return { "sender": sender, "roomid": room_id, "event": event_key or "", } @staticmethod def _render_template(text: str, context: Dict[str, str]) -> str: """轻量模板替换。 使用 {sender}/{roomid}/{event} 占位符, 保持简单可控,避免引入模板引擎复杂性。 """ output = str(text or "") for key, value in (context or {}).items(): output = output.replace(f"{{{key}}}", str(value or "")) return output def _find_match_rule(self, message: Dict[str, Any]) -> Optional[Dict[str, Any]]: """查找命中规则。""" if not self.rule_service: return None scope_type, scope_id, _ = self._normalize_scope(message) content = str(message.get("content", "") or "").strip() event_key = self._extract_event_key(message) session_key = scope_id or str(message.get("sender", "") or "") return self.rule_service.match_rule( scope_type=scope_type, scope_id=scope_id, content=content, event_key=event_key, session_key=session_key, ) def can_process(self, message: Dict[str, Any]) -> bool: """判断是否可处理。 说明: 1. 只在插件总开关开启时参与匹配。 2. 只处理群聊与私聊文本/系统类消息,不处理空内容。 3. 群聊下会遵循群权限开关。 """ if not self.enable or not self.rule_service: return False content = str(message.get("content", "") or "").strip() if not content: return False sender = str(message.get("sender", "") or "").strip() room_id = str(message.get("roomid", "") or "").strip() gbm: GroupBotManager = message.get("gbm") # 防止机器人自回复导致循环。 if self.bot and sender and sender == getattr(self.bot, "wxid", ""): return False # 群聊场景遵循群级权限。 if room_id and gbm and gbm.get_group_permission(room_id, self.feature) == PermissionStatus.DISABLED: return False # 先做一次匹配并塞入 message,process_message 阶段直接复用,减少重复计算。 matched_rule = self._find_match_rule(message) if matched_rule: message["_fun_rule_match"] = matched_rule message["_fun_event_key"] = self._extract_event_key(message) return True return False async def _send_action(self, bot: WechatAPIClient, target_id: str, action: Dict[str, Any], context: Dict[str, str]) -> None: """发送单条响应动作。""" action_type = str(action.get("type", "") or "").strip().lower() # 支持配置 delay_ms,模拟“连发节奏感”。 delay_ms = int(action.get("delay_ms", 0) or 0) if delay_ms > 0: await asyncio.sleep(delay_ms / 1000.0) if action_type == "text": text = self._render_template(str(action.get("text", "") or ""), context) if text: await bot.send_text_message(target_id, text) return if action_type == "image": image_path = self._render_template(str(action.get("path", "") or ""), context) if image_path and os.path.exists(image_path): await bot.send_image_message(target_id, Path(image_path)) return if action_type == "voice": voice_path = self._render_template(str(action.get("path", "") or ""), context) voice_format = str(action.get("format", "") or "").strip().lower() if voice_path and os.path.exists(voice_path): if not voice_format: suffix = Path(voice_path).suffix.lower() voice_format = "wav" if suffix == ".wav" else "mp3" await bot.send_voice_message(target_id, Path(voice_path), voice_format) return if action_type == "video": video_path = self._render_template(str(action.get("path", "") or ""), context) cover_path = self._render_template(str(action.get("cover_path", "") or ""), context) if video_path and os.path.exists(video_path): if cover_path and os.path.exists(cover_path): await bot.send_video_message(target_id, Path(video_path), Path(cover_path)) else: await bot.send_video_message(target_id, Path(video_path)) return if action_type == "link": title = self._render_template(str(action.get("title", "") or ""), context) desc = self._render_template(str(action.get("desc", "") or ""), context) url = self._render_template(str(action.get("url", "") or ""), context) thumb_url = self._render_template(str(action.get("thumb_url", "") or ""), context) if url: await bot.send_link_message(target_id, url=url, title=title, description=desc, thumb_url=thumb_url) return if action_type == "app": xml = self._render_template(str(action.get("xml", "") or ""), context) app_type = int(action.get("app_type", 0) or 0) if xml: await bot.send_app_message(target_id, xml=xml, type=app_type) return async def process_message(self, message: Dict[str, Any]) -> Tuple[bool, Optional[str]]: """处理趣味规则消息。""" bot: WechatAPIClient = message.get("bot") if not bot: return False, "bot 不可用" # 优先复用 can_process 阶段缓存的命中规则,避免重复匹配。 matched_rule = message.get("_fun_rule_match") or self._find_match_rule(message) if not matched_rule: return False, "未命中规则" _, _, target_id = self._normalize_scope(message) if not target_id: return False, "无可用目标" responses = matched_rule.get("responses_json") or [] if not isinstance(responses, list) or not responses: return False, "规则无响应动作" event_key = str(message.get("_fun_event_key", "") or "") context = self._build_message_context(message, event_key=event_key) try: for action in responses: if not isinstance(action, dict): continue await self._send_action(bot, target_id, action, context) return True, f"命中趣味规则 #{matched_rule.get('id')}" except Exception as e: self.LOG.error(f"趣味指令剧本执行失败: rule={matched_rule.get('id')}, error={e}") return False, f"执行失败: {e}"