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

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,300 @@
# -*- coding: utf-8 -*-
"""趣味指令规则服务层。
服务层职责:
1. 聚合 MySQL + Redis + 应用内存三层缓存读写。
2. 提供规则匹配能力给插件层。
3. 提供后台 CRUD 的统一入口,确保变更后缓存立刻刷新。
"""
import json
import re
import threading
import time
from typing import Any, Dict, List, Optional, Tuple
from loguru import logger
from db.fun_command_rule_db import FunCommandRuleDBOperator
class FunCommandRuleService:
"""趣味指令规则服务。"""
# Redis 键定义:统一集中,便于后续迁移命名。
REDIS_RULES_KEY = "fun:command:rules:all"
def __init__(self, db_operator: FunCommandRuleDBOperator, redis_client, local_ttl_seconds: int = 30):
self.db = db_operator
self.redis = redis_client
self.local_ttl_seconds = max(int(local_ttl_seconds or 30), 5)
# 进程内缓存:热路径优先命中,避免每条消息都打 Redis。
self._local_lock = threading.RLock()
self._local_rules: List[Dict[str, Any]] = []
self._local_expire_at: float = 0.0
# 命中冷却缓存:用于每条规则的简单限频。
self._cooldown_lock = threading.RLock()
self._cooldown_map: Dict[str, float] = {}
def init_tables(self) -> bool:
"""初始化底层数据表。"""
return self.db.init_tables()
# --------------------------- 缓存层 ---------------------------
def _load_rules_from_db(self) -> List[Dict[str, Any]]:
"""从 MySQL 回源全量启用规则。"""
return self.db.list_rules(enabled=True)
def _write_redis_rules(self, rules: List[Dict[str, Any]]) -> None:
"""写入 Redis 持久缓存。
注意这里不设置过期时间Redis 作为跨进程共享缓存常驻。
"""
try:
self.redis.set(self.REDIS_RULES_KEY, json.dumps(rules or [], ensure_ascii=False))
except Exception as e:
logger.warning(f"写入 Redis 规则缓存失败: {e}")
def _read_redis_rules(self) -> Optional[List[Dict[str, Any]]]:
"""从 Redis 读取规则缓存。"""
try:
text = self.redis.get(self.REDIS_RULES_KEY)
if not text:
return None
data = json.loads(text)
if not isinstance(data, list):
return None
return data
except Exception as e:
logger.warning(f"读取 Redis 规则缓存失败: {e}")
return None
def _set_local_cache(self, rules: List[Dict[str, Any]]) -> None:
"""更新应用内缓存。"""
with self._local_lock:
self._local_rules = list(rules or [])
self._local_expire_at = time.time() + self.local_ttl_seconds
def _get_local_cache(self) -> Optional[List[Dict[str, Any]]]:
"""读取应用内缓存。
仅当未过期才返回,确保后台更新后最长 local_ttl_seconds 可见。
"""
with self._local_lock:
if time.time() < self._local_expire_at:
return list(self._local_rules)
return None
def refresh_cache(self) -> List[Dict[str, Any]]:
"""强制刷新缓存DB -> Redis -> Local"""
rules = self._load_rules_from_db()
self._write_redis_rules(rules)
self._set_local_cache(rules)
return rules
def get_enabled_rules(self) -> List[Dict[str, Any]]:
"""获取启用规则。
读取顺序:
1. 本地缓存命中直接返回(最高性能)。
2. Redis 命中则回填本地缓存。
3. MySQL 回源并回填 Redis + 本地缓存。
"""
local_rules = self._get_local_cache()
if local_rules is not None:
return local_rules
redis_rules = self._read_redis_rules()
if redis_rules is not None:
self._set_local_cache(redis_rules)
return redis_rules
return self.refresh_cache()
# --------------------------- 管理端 CRUD ---------------------------
def list_rules(self, scope_type: str = "", scope_id: str = "", enabled: Optional[bool] = None) -> List[Dict[str, Any]]:
"""后台使用:按条件列规则。"""
return self.db.list_rules(scope_type=scope_type, scope_id=scope_id, enabled=enabled)
def create_rule(self, payload: Dict[str, Any]) -> bool:
"""创建规则并刷新缓存。"""
ok = self.db.create_rule(payload)
if ok:
self.refresh_cache()
return ok
def update_rule(self, rule_id: int, payload: Dict[str, Any]) -> bool:
"""更新规则并刷新缓存。"""
ok = self.db.update_rule(rule_id, payload)
if ok:
self.refresh_cache()
return ok
def delete_rule(self, rule_id: int) -> bool:
"""删除规则并刷新缓存。"""
ok = self.db.delete_rule(rule_id)
if ok:
self.refresh_cache()
return ok
def toggle_rule(self, rule_id: int, enabled: bool, updated_by: str = "system") -> bool:
"""启停规则并刷新缓存。"""
ok = self.db.toggle_rule(rule_id=rule_id, enabled=enabled, updated_by=updated_by)
if ok:
self.refresh_cache()
return ok
# --------------------------- 规则匹配 ---------------------------
@staticmethod
def _scope_match(rule: Dict[str, Any], scope_type: str, scope_id: str) -> bool:
"""判断规则作用域是否命中当前会话。"""
rule_scope_type = str(rule.get("scope_type", "global") or "global").strip().lower()
rule_scope_id = str(rule.get("scope_id", "") or "").strip()
# global全局可用。
if rule_scope_type == "global":
return True
# group/private必须同时匹配 scope_id。
if rule_scope_type == scope_type:
return not rule_scope_id or rule_scope_id == scope_id
return False
@staticmethod
def _trigger_match(rule: Dict[str, Any], event_key: str, content: str) -> bool:
"""判断规则触发条件是否命中。
支持:
- event事件触发如 PAT
- exact/prefix/contains/regex文本触发
"""
trigger_type = str(rule.get("trigger_type", "exact") or "exact").strip().lower()
trigger_text = str(rule.get("trigger_text", "") or "")
target_event_key = str(rule.get("event_key", "") or "").strip().upper()
normalized_content = str(content or "").strip()
if trigger_type == "event":
return bool(target_event_key) and target_event_key == str(event_key or "").strip().upper()
if not normalized_content:
return False
if trigger_type == "exact":
return normalized_content == trigger_text
if trigger_type == "prefix":
return normalized_content.startswith(trigger_text)
if trigger_type == "contains":
return trigger_text in normalized_content
if trigger_type == "regex":
try:
return re.search(trigger_text, normalized_content) is not None
except re.error:
# 正则配置非法时直接视为不匹配,避免打断主流程。
return False
return False
def _cooldown_key(self, rule_id: int, session_key: str) -> str:
"""构造冷却键。
session_key 采用群ID/私聊ID确保不同会话互不干扰。
"""
return f"{int(rule_id)}::{session_key}"
def _check_and_mark_cooldown(self, rule: Dict[str, Any], session_key: str) -> bool:
"""检查并写入冷却窗口。
返回 True 表示允许触发False 表示仍在冷却中。
"""
cooldown_seconds = int(rule.get("cooldown_seconds", 0) or 0)
if cooldown_seconds <= 0:
return True
now = time.time()
key = self._cooldown_key(int(rule.get("id", 0) or 0), session_key)
with self._cooldown_lock:
expired_at = self._cooldown_map.get(key, 0)
if now < expired_at:
return False
self._cooldown_map[key] = now + cooldown_seconds
# 轻量清理,防止 map 长期膨胀。
if len(self._cooldown_map) > 5000:
stale_keys = [k for k, v in self._cooldown_map.items() if v < now]
for stale_key in stale_keys[:1000]:
self._cooldown_map.pop(stale_key, None)
return True
def match_rule(
self,
scope_type: str,
scope_id: str,
content: str,
event_key: str,
session_key: str,
) -> Optional[Dict[str, Any]]:
"""匹配首条可执行规则。
设计为“首条命中即返回”,通过 priority 实现可控顺序。
"""
rules = self.get_enabled_rules()
if not rules:
return None
normalized_scope_type = str(scope_type or "global").strip().lower()
normalized_scope_id = str(scope_id or "").strip()
for rule in rules:
if not self._scope_match(rule, normalized_scope_type, normalized_scope_id):
continue
if not self._trigger_match(rule, event_key=event_key, content=content):
continue
if not self._check_and_mark_cooldown(rule, session_key=session_key):
continue
return rule
return None
def validate_responses(self, responses_json: Any) -> Tuple[bool, str, List[Dict[str, Any]]]:
"""校验响应动作数组。
返回:
- ok: 是否通过
- message: 错误说明
- normalized: 归一化后的响应列表
"""
if isinstance(responses_json, str):
try:
responses_json = json.loads(responses_json)
except Exception:
return False, "responses_json 不是合法 JSON", []
if not isinstance(responses_json, list) or not responses_json:
return False, "responses_json 必须是非空数组", []
normalized: List[Dict[str, Any]] = []
allowed_types = {"text", "image", "voice", "video", "link", "app"}
for idx, item in enumerate(responses_json):
if not isinstance(item, dict):
return False, f"{idx + 1} 条响应必须是对象", []
action_type = str(item.get("type", "") or "").strip().lower()
if action_type not in allowed_types:
return False, f"{idx + 1} 条响应 type 非法,仅支持 {sorted(allowed_types)}", []
delay_ms = int(item.get("delay_ms", 0) or 0)
if delay_ms < 0:
delay_ms = 0
normalized_item = dict(item)
normalized_item["type"] = action_type
normalized_item["delay_ms"] = delay_ms
normalized.append(normalized_item)
return True, "ok", normalized