diff --git a/Lib/common.py b/Lib/common.py index cb42e4d..b72c0c8 100644 --- a/Lib/common.py +++ b/Lib/common.py @@ -1,7 +1,7 @@ """ 工具 """ - +import os.path import shutil import sys import time @@ -241,9 +241,12 @@ def save_exc_dump(description: str = None, path: str = None): break i += 1 + for _ in ['?', '*', '"', '<', '>']: + path_ = path_.replace(_, "") + kwargs = { "frame": frame, - "path": path_ + "path": os.path.normpath(path_) } if description: kwargs["description"] = description diff --git a/Lib/core/OnebotAPI.py b/Lib/core/OnebotAPI.py index 710be8e..9b635c4 100644 --- a/Lib/core/OnebotAPI.py +++ b/Lib/core/OnebotAPI.py @@ -199,14 +199,14 @@ def get_msg(self, message_id: int): } return self.get("/get_msg", data) - def get_forward_msg(self, message_id: int): + def get_forward_msg(self, id: int): """ 获取合并转发消息 Args: - message_id: 消息id + id: 合并转发id """ data = { - "message_id": message_id + "id": id } return self.get("/get_forward_msg", data) diff --git a/Lib/core/PluginManager.py b/Lib/core/PluginManager.py index 9ae4042..a1f30cb 100644 --- a/Lib/core/PluginManager.py +++ b/Lib/core/PluginManager.py @@ -4,6 +4,7 @@ import dataclasses import importlib +import inspect import sys import traceback @@ -221,3 +222,31 @@ def run_plugin_main_wrapper(event): event: 事件 """ run_plugin_main(event.event_data) + + +def get_caller_plugin_data(): + """ + 获取调用者的插件数据 + :return: + plugin_data: dict | None + """ + + stack = inspect.stack()[1:] + for frame_info in stack: + filename = frame_info.filename + + normalized_filename = os.path.normpath(filename) + normalized_plugins_path = os.path.normpath(PLUGINS_PATH) + + if normalized_filename.startswith(normalized_plugins_path): + for plugin in found_plugins: + normalized_plugin_file_path = os.path.normpath(plugin["file_path"]) + plugin_dir, plugin_file = os.path.split(normalized_plugin_file_path) + + if plugin_dir == normalized_plugins_path: + if normalized_plugin_file_path == normalized_filename: + return plugin + else: + if normalized_filename.startswith(plugin_dir): + return plugin + return None diff --git a/Lib/utils/Actions.py b/Lib/utils/Actions.py index 938de94..47cba1a 100644 --- a/Lib/utils/Actions.py +++ b/Lib/utils/Actions.py @@ -335,16 +335,16 @@ class GetForwardMsg(Action): call_func = api.get_forward_msg - def __init__(self, message_id: int, callback: Callable[[Result], ...] = None): + def __init__(self, id: int, callback: Callable[[Result], ...] = None): """ Args: - message_id (int): 合并转发 ID + id (int): 合并转发 ID callback (Callable[[Result], ...], optional): 回调函数. Defaults to None. """ - super().__init__(message_id=message_id, callback=callback) + super().__init__(id=id, callback=callback) - def logger(self, result, message_id: int): - logger.debug(f"获取合并转发消息 {message_id}") + def logger(self, result, id: int): + logger.debug(f"获取合并转发消息 {id}") class SendLike(Action): diff --git a/Lib/utils/EventHandlers.py b/Lib/utils/EventHandlers.py index 897a3d2..f37bbb3 100644 --- a/Lib/utils/EventHandlers.py +++ b/Lib/utils/EventHandlers.py @@ -5,8 +5,8 @@ import traceback from Lib.common import save_exc_dump -from Lib.core import EventManager, ConfigManager -from Lib.utils import EventClassifier, Logger, QQRichText +from Lib.core import EventManager, ConfigManager, PluginManager +from Lib.utils import EventClassifier, Logger, QQRichText, StateManager import inspect from typing import Literal, Callable, Any, Type @@ -313,18 +313,51 @@ def wrapper(func): return wrapper - def match(self, event_data: EventClassifier.Event): + def match(self, event_data: EventClassifier.Event, plugin_data: dict): """ 匹配事件处理器 Args: event_data: 事件数据 + plugin_data: 插件数据 """ for priority, rules, handler, args, kwargs in sorted(self.handlers, key=lambda x: x[0], reverse=True): try: - if all(rule.match(event_data) for rule in rules): - if handler(event_data, *args, **kwargs) is True: - logger.debug(f"处理器 {handler.__name__} 阻断了事件 {event_data} 的传播") - return + if not all(rule(event_data) for rule in rules): + continue + + # 检测依赖注入 + handler_kwargs = kwargs.copy() # 复制静态 kwargs + + sig = inspect.signature(handler) + + for name, param in sig.parameters.items(): + if name == "state": + if (isinstance(event_data, EventClassifier.MessageEvent) or + isinstance(event_data, EventClassifier.PrivateMessageEvent)): + state_id = f"u{event_data.user_id}" + elif isinstance(event_data, EventClassifier.GroupMessageEvent): + state_id = f"g{event_data.group_id}_u{event_data.user_id}" + else: + raise TypeError("event_data must be a MessageEvent") + handler_kwargs[name] = StateManager.get_state(state_id, plugin_data) + elif name == "user_state": + if isinstance(event_data, EventClassifier.MessageEvent): + state_id = f"u{event_data.user_id}" + else: + raise TypeError("event_data must be a MessageEvent") + handler_kwargs[name] = StateManager.get_state(state_id, plugin_data) + elif name == "group_state": + if isinstance(event_data, EventClassifier.GroupMessageEvent): + state_id = f"g{event_data.group_id}" + else: + raise TypeError("event_data must be a MessageEvent") + handler_kwargs[name] = StateManager.get_state(state_id, plugin_data) + + result = handler(event_data, *args, **handler_kwargs) + + if result is True: + logger.debug(f"处理器 {handler.__name__} 阻断了事件 {event_data} 的传播") + return # 阻断同一 Matcher 内的传播 except Exception as e: if ConfigManager.GlobalConfig().debug.save_dump: dump_path = save_exc_dump(f"执行匹配事件或执行处理器时出错 {event_data}") @@ -340,12 +373,12 @@ def match(self, event_data: EventClassifier.Event): events_matchers: dict[str, dict[Type[EventClassifier.Event], list[tuple[int, list[Rule], Matcher]]]] = {} -def _on_event(event_data, path, event_type): +def _on_event(event_data, path, event_type, plugin_data): matchers = events_matchers[path][event_type] for priority, rules, matcher in sorted(matchers, key=lambda x: x[0], reverse=True): matcher_event_data = event_data.__class__(event_data.event_data) if all(rule.match(matcher_event_data) for rule in rules): - matcher.match(matcher_event_data) + matcher.match(matcher_event_data, plugin_data) def on_event(event: Type[EventClassifier.Event], priority: int = 0, rules: list[Rule] = None): @@ -364,12 +397,13 @@ def on_event(event: Type[EventClassifier.Event], priority: int = 0, rules: list[ raise TypeError("rules must be a list of Rule") if not issubclass(event, EventClassifier.Event): raise TypeError("event must be an instance of EventClassifier.Event") - path = inspect.stack()[1].filename + plugin_data = PluginManager.get_caller_plugin_data() + path = plugin_data["path"] if path not in events_matchers: events_matchers[path] = {} if event not in events_matchers[path]: events_matchers[path][event] = [] - EventManager.event_listener(event, path=path, event_type=event)(_on_event) + EventManager.event_listener(event, path=path, event_type=event, plugin_data=plugin_data)(_on_event) events_matcher = Matcher() events_matchers[path][event].append((priority, rules, events_matcher)) return events_matcher diff --git a/Lib/utils/QQRichText.py b/Lib/utils/QQRichText.py index ccb79b1..4c18810 100644 --- a/Lib/utils/QQRichText.py +++ b/Lib/utils/QQRichText.py @@ -117,7 +117,7 @@ def cq_2_array(cq: str) -> list[dict[str, dict[str, Any]]]: segment_data[now_key] += c # 继续拼接参数值 if "text" in segment_data and len(segment_data["text"]): # 处理末尾可能存在的文本内容 - cq_array.append({"type": "text", "data": {"text": segment_data["text"]}}) + cq_array.append({"type": "text", "data": {"text": cq_decode(segment_data["text"])}}) return cq_array @@ -961,8 +961,8 @@ class XML(Segment): segment_type = "xml" def __init__(self, data): - self.data = data - super().__init__({"type": "xml", "data": {"data": str(self.data)}}) + self.xml_data = data + super().__init__({"type": "xml", "data": {"data": str(self.xml_data)}}) def set_xml_data(self, data): """ @@ -970,8 +970,8 @@ def set_xml_data(self, data): Args: data: xml数据 """ - self.data = data - self.array["data"]["data"] = str(self.data) + self.xml_data = data + self.array["data"]["data"] = str(self.xml_data) class JSON(Segment): @@ -985,8 +985,8 @@ def __init__(self, data): Args: data: JSON 内容 """ - self.data = data - super().__init__({"type": "json", "data": {"data": str(self.data)}}) + self.json_data = data + super().__init__({"type": "json", "data": {"data": str(self.json_data)}}) def set_json(self, data): """ @@ -994,8 +994,8 @@ def set_json(self, data): Args: data: json 内容 """ - self.data = data - self.array["data"]["data"] = str(self.data) + self.json_data = data + self.array["data"]["data"] = str(self.json_data) def get_json(self): """ @@ -1003,7 +1003,7 @@ def get_json(self): Returns: json: json数据 """ - return json.loads(self.data) + return json.loads(self.json_data) class QQRichText: diff --git a/Lib/utils/StateManager.py b/Lib/utils/StateManager.py new file mode 100644 index 0000000..bd1b6b2 --- /dev/null +++ b/Lib/utils/StateManager.py @@ -0,0 +1,68 @@ +""" +状态管理器 +注意,数据存储于内存中,重启丢失,需要持久化保存的数据请勿放在里面 +""" + +from Lib.core import PluginManager + +states = {} + + +def get_state(state_id: str, plugin_data: dict = None): + """ + 获取状态数据 + Args: + state_id: 状态ID + plugin_data: 插件数据 + + Returns: + 状态数据,结构类似 + { + "state_id": state_id, + "data": { + k1: v1, + k2: v2 + }, + "other_plugin_data": { + plugin1_path: { + "data": { + k1: v1, + k2: v2 + }, + "meta": { + "plugin_data": plugin_data + } + }, + plugin2_path: { + "data": { + k1: v1, + k2: v2 + }, + "meta": { + "plugin_data": plugin_data + } + } + } + } + """ + if plugin_data is None: + plugin_data = PluginManager.get_caller_plugin_data() + + if state_id not in states: + states[state_id] = {} + + if plugin_data["path"] not in states[state_id]: + states[state_id][plugin_data["path"]] = { + "data": {}, + "meta": { + "plugin_data": plugin_data + } + } + + return { + "state_id": state_id, + "data": states[state_id][plugin_data["path"]]["data"], + "other_plugin_data": { + k: v for k, v in states[state_id].items() if k != plugin_data["path"] + } + } diff --git a/Lib/utils/__init__.py b/Lib/utils/__init__.py index a700ff0..ddc8a6e 100644 --- a/Lib/utils/__init__.py +++ b/Lib/utils/__init__.py @@ -3,6 +3,7 @@ """ from . import Logger from . import EventClassifier +from . import StateManager from . import QQRichText from . import QQDataCacher from . import Actions diff --git a/README.md b/README.md index 590b999..8f234ff 100644 --- a/README.md +++ b/README.md @@ -2,74 +2,72 @@ ![MuRainBot2](https://socialify.git.ci/MuRainBot/MuRainBot2/image?custom_description=%E5%9F%BA%E4%BA%8Epython%E9%80%82%E9%85%8Donebot11%E5%8D%8F%E8%AE%AE%E7%9A%84%E8%BD%BB%E9%87%8F%E7%BA%A7QQBot%E6%A1%86%E6%9E%B6&description=1&forks=1&issues=1&logo=https%3A%2F%2Fgithub.com%2FMuRainBot%2FMuRainBot2Doc%2Fblob%2Fmaster%2Fdocs%2Fpublic%2Ficon.png%3Fraw%3Dtrue&name=1&pattern=Overlapping+Hexagons&pulls=1&stargazers=1&theme=Auto) - GitHub license + GitHub license python - - Badge + + OneBot v11
- + visitor counter +## 🤔 概述 -## 🤔概述 +MuRainBot2 (MRB2) 是一个基于 Python、适配 OneBot v11 协议的**轻量级** QQ 机器人框架。 -### 这是什么? +它专注于提供稳定高效的核心事件处理与 API 调用能力,所有具体功能(如关键词回复、群管理等)均通过插件实现,赋予开发者高度的灵活性。 -#### 这是一个基于python适配onebot11协议的轻量级QQBot框架 - -切记! -MRB2本身**不具备任何**实际功能 - -具体的功能(如通过关键词回复特定消息等)都需要插件来实现,有任何功能的需要可以自行阅读文档编写。 - -同时,对于具体的操作(如监听消息、发送消息、批准加群请求之类的)请先明确: +对于具体的操作(如监听消息、发送消息、批准加群请求等)请先明确: - 什么是[OneBot11协议](https://11.onebot.dev); - - 什么是Onebot实现端,什么是Onebot框架; - - Onebot实现端具有哪些features - -~~作者自己写着用的,有一些写的不好的地方还请见谅~~ - -~~*什么?你问我为什么要叫MRB2,因为这个框架最初是给我的一个叫做沐雨的qqbot写的,然后之前还有[一个写的很垃圾](https://github.com/xiaosuyyds/PyQQbot)的版本,所以就叫做MRB2*~~ + - 什么是Onebot实现端,什么是Onebot开发框架; + - Onebot实现端具有哪些功能 +## ✨ 核心特性 -### 关于本Readme以及MRB2文档的一些术语 -#### 首先推荐在阅读本Readme以及MRB2文档中先了解Onebot的一些基本属于以方便理解[我是链接](https://12.onebot.dev/glossary) -(上述的术语表中是Onebot12的定义,对于Onebot11可能略有偏差但是大体相通) -* MRB2:MuRainBot2,缩写MRB2 -* onebot11协议:[OneBot v11](https://11.onebot.dev/),一个用于QQ机器人的协议,本项目就是基于此协议开发的框架 -* 框架:根据[onebot12的定义](https://12.onebot.dev/glossary/#onebot-sdk)(~~我也不知道为什么,但是onebot11就是没有定义什么是框架和sdk之类乱七八糟的术语表~~) -MRB2就是这样一个SDK(也可称为框架),可以让插件不需要自行实现http通讯和事件、操作、消息等内容的事件分发与解析,同时将实际的功能分为一个一个插件,方便管理。 -* 插件:MRB2本身不具备任何实际上的功能,一切都需要编写插件来实现功能; -MRB2的插件,统一放在`plugins`文件夹中,每个插件都是一个python文件,也可以是一个文件夹,文件夹中必须包含一个`__init__.py`文件用于初始化插件 +* **🪶 轻量高效:** 没有太多冗杂的功能,使用简单,内存占用较低。 +* **🧩 轻松扩展:** 灵活的插件系统,让您能够轻松、快速地添加、移除或定制所需功能。 +* **🔁 基于线程池:** 基于内置线程池实现并发处理,没有异步的较为复杂的语法,直接编写同步代码。 -### 关于一些提醒 +## 🚨 重要提醒:关于重构与兼容性 -本项目在2024年12月4日在dev分支对框架进行了重构,主要重构了目录结构与一些Lib的实现,不支持过去的插件,如果你有旧版本的插件,可以尝试使用新的框架的文档来进行适配(放心,差别不会很大)。 +> [!CAUTION] +> **请注意:** 本项目在 2024 年底至 2025 年初进行了一次 **彻底的框架重构**(主要涉及 `dev` 分支并在 2025年1月29日 合并至 `master`)。 +> +> **当前的 MRB2 版本与重构前的旧版本插件完全不兼容。** 如果您是旧版本用户或拥有旧插件,请参考 **[最新文档](https://mrb2.xiaosu.icu)** 进行适配迁移。 -并在2025年1月29日将重构后的dev分支合并到了master分支。 +## 📖 背景与术语 ---- +* **MRB2:** MuRainBot2 的缩写。 +* **OneBot v11 协议:** 一个广泛应用于即时通讯软件中的聊天机器人的应用层协议标准,本项目基于此标准开发。详情请见 [OneBot v11](https://11.onebot.dev/)。 +* **框架 (SDK):** MRB2 作为一个 OneBot SDK(或称开发框架),负责处理与 OneBot 实现端的通信、事件分发、API 调用封装等底层工作,以及提供插件系统,让开发者可以专注于插件功能的实现。更多通用术语可参考 [OneBot v12 术语表](https://12.onebot.dev/glossary/) (v11 与 v12 大体相通)。 +* **插件:** MRB2 的所有功能都由插件提供。插件通常是放置在 `plugins` 目录下的 Python 文件或包含 `__init__.py` 的 Python 包。 -如果使用时遇到问题,请将 `config.yml` 的`debug.enable`设置为`true`,然后复现 bug, -并检查该问题是否是你使用的 Onebot 实现端的问题(可查看实现端的日志检查是否有异常) +~~*什么?你问我为什么要叫MRB2,因为这个框架最初是给我的一个叫做沐雨的qqbot写的,然后之前还有[一个写的很垃圾](https://github.com/xiaosuyyds/PyQQbot)的版本,所以就叫做MRB2*~~ -如果是,请自行在你使用的 Onebot 实现端进行反馈。 +## 🐛 问题反馈 -如果不是,将完整 完整 完整的将日志信息(部分对于问题排查不重要的敏感信息(如 QQ 群号、 QQ 号等)可自行遮挡) 和错误描述发到 [issues](https://github.com/MuRainBot/MuRainBot2/issues/new/choose)。 -如果你开启了 `config.yml` 的`debug.save_dump` 也可以把出错的 dump 文件也一起上传以辅助排查问题。 +如果使用时遇到问题,请按以下步骤操作: -### 目录结构 +1. 将框架版本更新到 [`dev`](https://github.com/MuRainBot/MuRainBot2/tree/dev) 分支 +2. 将 `config.yml` 中的 `debug.enable` 设置为 `true`。 +3. 复现您遇到的 Bug。 +4. **检查 Onebot 实现端的日志**,确认问题是否源于实现端本身。如果是,请向您使用的实现端反馈。 +5. 如果问题确认在 MRB2 框架: + * 请准备**完整**的 MRB2 日志文件 (`logs` 目录下)。您可以自行遮挡日志中的 QQ 号、群号等敏感信息。 + * 提供清晰的错误描述、复现步骤。 + * 如果开启了 `save_dump` 且生成了 dump 文件,可以一并提供。(不强制,但是推荐提供,不过需要注意可以检查一下是否包含apikey等敏感信息) + * 将以上信息提交到项目的 [**Issues**](https://github.com/MuRainBot/MuRainBot2/issues/new/choose) 页面。 +## 📁 目录结构
-查看基本看目录结构 +查看基本目录结构 ``` ├─ data MRB2及插件的临时/缓存文件 @@ -87,7 +85,7 @@ MRB2的插件,统一放在`plugins`文件夹中,每个插件都是一个pyth │ ... ├─ plugins │ ├─ xxx.py xxx插件代码 -│ ├─ yyy.py yyy插件代码 +│ ├─ yyy.py yyy插件代码 │ ... ├─ plugin_configs │ ├─ xxx.yml xxx插件的配置文件 @@ -100,33 +98,41 @@ MRB2的插件,统一放在`plugins`文件夹中,每个插件都是一个pyth
+## 💻 如何部署? + +**本项目使用 Python 3.12+ 开发,并利用了其部分新特性 (如 [PEP 701](https://docs.python.org/zh-cn/3/whatsnew/3.12.html#whatsnew312-pep701))。推荐使用 Python 3.12 或更高版本运行,如果使用 Python 3.12 以下版本,由于未经测试,可能会导致部分代码出现问题。** -## 💻如何部署? -**作者在python3.12编写,由于使用了一些高版本python添加的特性 -(例如[PEP701中取消部分f-string语法限制(这真的超方便的好不好()](https://docs.python.org/zh-cn/3.13/whatsnew/3.12.html#whatsnew312-pep701)), -推荐在3.12.X或以上版本部署和运行** +详细的部署步骤、配置说明和插件开发指南,请查阅: -### 具体可查看本项目的[`文档`](https://mrb2.xiaosu.icu) +### ➡️ [**MRB2 官方文档**](https://mrb2.xiaosu.icu) -## 📕关于版本 -* 目前MRB2版本为1.0.0-dev +## 📕 关于版本 -* 关于版本号的说明: - * 版本号格式为`<主版本>.<次版本>.<修订版本>-<特殊提醒/版本(如果有)>` 例如`1.0.0` - * 开发版版本号后统一添加`-dev`后缀 例如`1.0.0-dev` +* 当前版本:`1.0.0-dev` +* 版本号格式:`<主版本>.<次版本>.<修订版本>[-<特殊标识>]` (例如 `1.0.0`, `1.0.1-beta`, `1.1.0-dev`)。 -## ❤️鸣谢❤️ +## ❤️ 鸣谢 ❤️ -### 请勿直接提交到[`master`](https://github.com/MuRainBot/MuRainBot2)分支,请先提交到[`dev`](https://github.com/MuRainBot/MuRainBot2/tree/dev)分支,每隔一段时间我们会合并到[`master`](https://github.com/MuRainBot/MuRainBot2)分支 +**贡献指南:** 我们欢迎各种形式的贡献!请将您的 Pull Request 提交到 `dev` 分支。我们会定期将 `dev` 分支的稳定更新合并到 `master` 分支。 + +**感谢所有为 MRB2 付出努力的贡献者!** -### 感谢所有为此项目做出贡献的大大,你们的存在,让社区变得更加美好~! - 感谢他们(鼓掌)! + Contributors -### 以及特别鸣谢[HarcicYang](https://github.com/HarcicYang)和[kaokao221](https://github.com/kaokao221)为此项目提供了许多的帮助~ - +**特别感谢 [HarcicYang](https://github.com/HarcicYang)、[kaokao221](https://github.com/kaokao221) 和 [BigCookie233](https://github.com/BigCookie233) 在项目开发过程中提供的宝贵帮助!** -## ⭐StarHistory⭐ +## ⭐ Star History ⭐ [![](https://api.star-history.com/svg?repos=MuRainBot/MuRainBot2&type=Date)](https://github.com/MuRainBot/MuRainBot2/stargazers) + + +## 🚀 关于性能 + +本项目在正常使用,默认配置,多群聊,6-8个中等复杂度(如签到、图片绘制(如视频信息展示等)、AI聊天(基于API接口调用的))的插件情况下内存占用稳定在 100-160MB 左右 +(具体取决于插件和群聊数量以及配置文件,也可能超过这个范围) + +仅安装默认插件,默认配置,情况下内存占用稳定在 40MB-60MB 左右 + +如果实在内存不够用可调小缓存(配置文件中的 `qq_data_cache.max_cache_size`)(尽管这个也占不了多少内存)