新增趣味指令剧本功能并接入拍一拍事件触发

1. 新增趣味指令规则数据层与服务层,支持应用级缓存+Redis+MySQL三级读取与缓存刷新。

2. 新增 fun_command_play 插件,支持文本/图片/语音/视频/卡片/App 多媒体响应,并接入群权限开关。

3. 新增拍一拍事件识别(PAT)并纳入统一触发模型。

4. 新增后台页面与API:规则增删改查、启停、命中测试。

5. 将趣味指令剧本接入 Dashboard 菜单与蓝图注册,并补充数据库迁移脚本。
This commit is contained in:
liuwei
2026-04-23 12:31:52 +08:00
parent b1f435c8ff
commit d61fb8bc8a
10 changed files with 1570 additions and 0 deletions

View File

@@ -0,0 +1,7 @@
# 从当前包导入插件主类
from .main import FunCommandPlayPlugin
def get_plugin():
"""返回插件实例。"""
return FunCommandPlayPlugin()

View File

@@ -0,0 +1,2 @@
[FunCommandPlay]
enable = true

View File

@@ -0,0 +1,312 @@
# -*- 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 "<patmsg" in lowered_content:
return "PAT"
if is_system and "拍了拍" in content:
return "PAT"
return ""
@staticmethod
def _build_message_context(message: Dict[str, Any], event_key: str) -> 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
# 先做一次匹配并塞入 messageprocess_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}"