Files
abot/plugins/fun_command_play/main.py
liuwei d61fb8bc8a 新增趣味指令剧本功能并接入拍一拍事件触发
1. 新增趣味指令规则数据层与服务层,支持应用级缓存+Redis+MySQL三级读取与缓存刷新。

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

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

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

5. 将趣味指令剧本接入 Dashboard 菜单与蓝图注册,并补充数据库迁移脚本。
2026-04-23 12:31:52 +08:00

313 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- 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}"