diff --git a/PROJECT_REFACTORING.md b/PROJECT_REFACTORING.md new file mode 100644 index 0000000..15673f8 --- /dev/null +++ b/PROJECT_REFACTORING.md @@ -0,0 +1,167 @@ +# 项目重构总结 + +## 重构目标 + +将项目从混乱的目录结构重构为标准的 Python 包结构,遵循 PEP 621 规范。 + +## 重构前后对比 + +### 重构前 + +``` +. +├── adapters/ # 适配器 +├── core/ # 核心代码 +├── models/ # 数据模型 +├── plugins/ # 插件 +├── tests/ # 测试 +├── docs/ # 文档 +├── templates/ # 模板 +├── web_static/ # 静态文件 +├── data/ # 数据 +├── main.py # 主程序 +└── ... +``` + +**问题:** +- 所有模块都在根目录,结构混乱 +- 缺少标准的 Python 包结构 +- 不符合现代 Python 项目的最佳实践 +- 导入路径不清晰 + +### 重构后 + +``` +. +├── src/ +│ └── neobot/ # 核心包 +│ ├── core/ # 框架核心 +│ ├── models/ # 数据模型 +│ ├── adapters/ # 平台适配器 +│ ├── plugins/ # 插件 +│ ├── tests/ # 测试 +│ ├── templates/ # 模板 +│ ├── docs/ # 文档 +│ ├── web_static/ # 静态文件 +│ └── data/ # 数据 +├── main.py # 主程序入口 +└── ... +``` + +**优势:** +- 符合 PEP 621 标准的 Python 包结构 +- 清晰的模块划分 +- 更好的可维护性和可扩展性 +- 符合现代 Python 项目的最佳实践 + +## 主要变更 + +### 1. 目录结构 + +- 所有 Python 代码移动到 `src/neobot/` 目录 +- 采用标准的 Python 包结构 +- 每个模块都有清晰的 `__init__.py` 文件 + +### 2. 导入路径 + +所有导入路径从 `core.*`、`models.*` 等改为 `neobot.core.*`、`neobot.models.*` 等。 + +**示例:** +```python +# 重构前 +from core.managers import plugin_manager +from models import MessageSegment + +# 重构后 +from neobot.core.managers import plugin_manager +from neobot.models import MessageSegment +``` + +### 3. 配置文件更新 + +- `pyproject.toml` 更新为使用 `src/` 目录结构 +- `README.md` 更新项目结构说明 +- `.gitignore` 更新以忽略新的数据目录路径 + +### 4. 主程序更新 + +- `main.py` 更新所有导入路径 +- 更新插件目录路径为 `src/neobot/plugins` + +## 新的模块组织 + +### src/neobot/core/ + +框架核心代码,包含: + +- **api/**: OneBot API 封装 +- **handlers/**: 事件处理器 +- **managers/**: 各种管理器 +- **services/**: 服务层 +- **utils/**: 工具函数 + +### src/neobot/models/ + +数据模型定义,包含: + +- **events/**: OneBot 事件模型 +- **message.py**: 消息段模型 +- **objects.py**: API 响应对象 +- **sender.py**: 发送者信息 + +### src/neobot/plugins/ + +插件目录,所有业务逻辑都在这里。 + +### src/neobot/adapters/ + +平台适配器,用于连接不同平台(如 Discord)。 + +### src/neobot/tests/ + +单元测试和集成测试文件。 + +## 使用方式 + +### 开发环境 + +```bash +# 安装依赖 +pip install -r requirements.txt + +# 运行测试 +pytest src/neobot/tests/ + +# 构建包 +python -m build +``` + +### 导入包 + +```python +# 导入核心模块 +from neobot.core.managers import plugin_manager + +# 导入数据模型 +from neobot.models import MessageSegment, OneBotEvent + +# 导入适配器 +from neobot.adapters import DiscordAdapter + +# 导入插件 +from neobot.plugins import admin, echo +``` + +## 注意事项 + +1. 所有代码文件使用绝对导入 +2. 插件开发请参考 `src/neobot/docs/plugin-development/` +3. 核心开发请参考 `src/neobot/docs/core-concepts/` +4. 配置文件 `config.toml` 保持在根目录 + +## 后续建议 + +1. 运行 `pip install -e .` 进行开发安装 +2. 运行 `mypy` 进行类型检查 +3. 运行 `pytest` 进行测试 +4. 定期运行 `flake8` 进行代码风格检查 diff --git a/adapters/router.py b/adapters/router.py deleted file mode 100644 index e1c97ef..0000000 --- a/adapters/router.py +++ /dev/null @@ -1,562 +0,0 @@ -# -*- coding: utf-8 -*- -""" -事件路由与转换器 (Event Router & Converter) - -此模块负责在不同平台(如 Discord)和 OneBot 业务逻辑之间进行数据转换。 -核心目标是:**让现有的 OneBot 插件(如 bili.py)在不修改任何代码的情况下,能够处理 Discord 消息。** - -实现原理: -1. 接收 Discord 消息 (`discord.Message`)。 -2. 将其"伪装"成 OneBot 的 `GroupMessageEvent` 或 `PrivateMessageEvent`。 -3. 拦截插件调用的 `event.reply()` 方法。 -4. 将插件返回的 OneBot `MessageSegment` 转换为 Discord 格式并发送。 -""" -import asyncio -from typing import Union, List, Any, Optional, Dict - -try: - import discord - DISCORD_AVAILABLE = True -except ImportError: - DISCORD_AVAILABLE = False - -from models.events.message import GroupMessageEvent, PrivateMessageEvent -from models.message import MessageSegment as OneBotMessageSegment -from models.sender import Sender -from core.utils.logger import ModuleLogger - -logger = ModuleLogger("EventRouter") - -class DiscordBotWrapper: - """ - 包装 DiscordAdapter,提供与 OneBot 相同的发送接口。 - """ - def __init__(self, adapter: Any): - self.adapter = adapter - self.self_id = adapter.user.id if adapter.user else 0 - - async def send_group_msg(self, group_id: int, message: Union[str, OneBotMessageSegment, List[OneBotMessageSegment]], auto_escape: bool = False): - channel = self.adapter.get_channel(group_id) - if not channel: - logger.error(f"Discord channel {group_id} not found") - return - await DiscordToOneBotConverter.send_discord_message(channel, message, self.adapter) - - async def send_private_msg(self, user_id: int, message: Union[str, OneBotMessageSegment, List[OneBotMessageSegment]], auto_escape: bool = False): - user = self.adapter.get_user(user_id) - if not user: - logger.error(f"Discord user {user_id} not found") - return - if not user.dm_channel: - await user.create_dm() - await DiscordToOneBotConverter.send_discord_message(user.dm_channel, message, self.adapter) - - async def send(self, event, message, **kwargs): - if isinstance(event, GroupMessageEvent): - await self.send_group_msg(event.group_id, message) - elif isinstance(event, PrivateMessageEvent): - await self.send_private_msg(event.user_id, message) - - def build_forward_node(self, user_id: int, nickname: str, message: Union[str, OneBotMessageSegment, List[OneBotMessageSegment]]) -> Dict[str, Any]: - """ - 构建一个用于合并转发的消息节点 (Node)。 - """ - processed_message = message - if isinstance(message, OneBotMessageSegment): - processed_message = [{"type": message.type, "data": message.data}] - elif isinstance(message, list): - processed_message = [{"type": seg.type, "data": seg.data} if isinstance(seg, OneBotMessageSegment) else seg for seg in message] - - return { - "type": "node", - "data": { - "uin": user_id, - "name": nickname, - "content": processed_message - } - } - - async def send_forwarded_messages(self, target, nodes): - """ - 模拟发送合并转发消息。 - Discord 不支持像 QQ 那样的合并转发,所以我们将其转换为普通消息发送。 - """ - content = "" - files = [] - - try: - for node in nodes: - if node.get("type") == "node": - node_data = node.get("data", {}) - node_content = node_data.get("content", []) - - if isinstance(node_content, str): - import re - cq_pattern = r'\[CQ:([^,]+)(?:,([^\]]+))?\]' - matches = list(re.finditer(cq_pattern, node_content)) - - if not matches: - content += f"{node_content}\n" - else: - last_end = 0 - for match in matches: - if match.start() > last_end: - content += node_content[last_end:match.start()] - - cq_type = match.group(1) - cq_params_str = match.group(2) or "" - - params = {} - if cq_params_str: - for param in cq_params_str.split(','): - if '=' in param: - k, v = param.split('=', 1) - params[k] = v - - if cq_type in ("image", "video", "record"): - file_url = params.get("url") or params.get("file") - if file_url: - if str(file_url).startswith("http"): - content += f"\n{file_url}\n" - elif str(file_url).startswith("base64://"): - import base64 - import io - b64_data = str(file_url)[9:] - if b64_data.startswith("data:image") or b64_data.startswith("data:audio") or b64_data.startswith("data:video"): - b64_data = b64_data.split(",", 1)[1] - try: - file_bytes = base64.b64decode(b64_data) - filename = "file.png" if cq_type == "image" else ("file.mp4" if cq_type == "video" else "file.ogg") - files.append(discord.File(fp=io.BytesIO(file_bytes), filename=filename)) - except Exception as e: - logger.error(f"解析 Base64 文件失败: {e}") - else: - try: - files.append(discord.File(file_url)) - except Exception as e: - logger.error(f"无法读取本地文件 {file_url}: {e}") - elif cq_type == "face": - # QQ 表情,简单转为文本 - face_id = params.get("id") - content += f"[表情:{face_id}]" - elif cq_type == "at": - qq_id = params.get("qq") - if qq_id == "all": - content += "@everyone " - else: - content += f"<@{qq_id}> " - - last_end = match.end() - - if last_end < len(node_content): - content += node_content[last_end:] - content += "\n" - elif isinstance(node_content, list): - for seg in node_content: - if isinstance(seg, dict): - seg_type = seg.get("type") - seg_data = seg.get("data", {}) - - if seg_type == "text": - content += seg_data.get("text", "") - elif seg_type in ("image", "video", "record"): - file_url = seg_data.get("url") or seg_data.get("file") - if file_url: - if isinstance(file_url, bytes): - import io - try: - filename = "file.png" if seg_type == "image" else ("file.mp4" if seg_type == "video" else "file.ogg") - files.append(discord.File(fp=io.BytesIO(file_url), filename=filename)) - except Exception as e: - logger.error(f"解析 bytes 文件失败: {e}") - elif str(file_url).startswith("http"): - content += f"\n{file_url}\n" - elif str(file_url).startswith("base64://") or "data:image" in str(file_url) or "data:audio" in str(file_url) or "data:video" in str(file_url): - import base64 - import io - b64_data = str(file_url) - if b64_data.startswith("base64://"): - b64_data = b64_data[9:] - if b64_data.startswith("data:image") or b64_data.startswith("data:audio") or b64_data.startswith("data:video"): - b64_data = b64_data.split(",", 1)[1] - try: - file_bytes = base64.b64decode(b64_data) - filename = "file.png" if seg_type == "image" else ("file.mp4" if seg_type == "video" else "file.ogg") - files.append(discord.File(fp=io.BytesIO(file_bytes), filename=filename)) - except Exception as e: - logger.error(f"解析 Base64 文件失败: {e}") - else: - try: - files.append(discord.File(file_url)) - except Exception as e: - logger.error(f"无法读取本地文件 {file_url}: {e}") - elif seg_type == "face": - face_id = seg_data.get("id") - content += f"[表情:{face_id}]" - content += "\n" - - if content or files: - # target is usually event, we can use event.bot.send - if isinstance(target, GroupMessageEvent): - channel = self.adapter.get_channel(target.group_id) - if channel: - await channel.send(content=content, files=files if files else None) - elif isinstance(target, PrivateMessageEvent): - user = self.adapter.get_user(target.user_id) - if user: - if not user.dm_channel: - await user.create_dm() - await user.dm_channel.send(content=content, files=files if files else None) - except Exception as e: - logger.error(f"发送 Discord 合并转发消息失败: {e}") - import traceback - logger.error(f"异常堆栈: {traceback.format_exc()}") - -class DiscordToOneBotConverter: - """ - 将 Discord 消息转换为 OneBot 消息事件的转换器。 - """ - - @staticmethod - def create_mock_event(discord_message: 'discord.Message', adapter: Any) -> Union[GroupMessageEvent, PrivateMessageEvent]: - """ - 将 discord.Message 伪装成 OneBot 的 MessageEvent。 - - Args: - discord_message: 原始的 Discord 消息对象 - adapter: DiscordAdapter 实例,用于回调发送消息 - - Returns: - 伪装后的 OneBot 事件对象 - """ - # 在静态方法内部创建模块专用日志记录器 - from core.utils.logger import ModuleLogger - mod_logger = ModuleLogger("DiscordConverter") - - # 1. 提取基础信息 - user_id = discord_message.author.id - message_id = discord_message.id - - # 处理 Discord 的 raw_message - # 如果消息是以 @机器人 开头,Discord 的 content 会是 "<@机器人ID> /echo 1" - # 我们需要把前面的 @ 提及去掉,否则命令匹配器 (matcher) 无法识别以 "/" 开头的命令 - raw_message = discord_message.content - - # 构造 message 列表 (将文本和附件转换为 MessageSegment) - message_list = [] - - # 添加文本内容 - if discord_message.content: - # 处理 Discord 自定义表情 <:name:id> 或 - import re - content = discord_message.content - - # 查找所有自定义表情 - emoji_pattern = r'' - - # 如果有表情,我们需要将文本分割成多个片段 - if re.search(emoji_pattern, content): - last_end = 0 - for match in re.finditer(emoji_pattern, content): - # 添加表情前的文本 - if match.start() > last_end: - text_part = content[last_end:match.start()] - if text_part: - message_list.append(OneBotMessageSegment.text(text_part)) - - # 添加表情作为图片 - emoji_name = match.group(1) - emoji_id = match.group(2) - is_animated = match.group(0).startswith('', r'[\1]', raw_message) - - # 添加附件信息 - if discord_message.attachments: - mod_logger.debug(f"[DiscordToOneBotConverter] 检测到 {len(discord_message.attachments)} 个附件") - for attachment in discord_message.attachments: - filename = attachment.filename.lower() - mod_logger.debug(f"[DiscordToOneBotConverter] 处理附件: {attachment.filename}, MIME: {attachment.content_type}") - # 检查是否是语音文件 - if filename.endswith(('.amr', '.silk', '.mp3', '.wav', '.ogg', '.m4a')): - seg = OneBotMessageSegment.record(attachment.url) - seg.data["filename"] = attachment.filename - message_list.append(seg) - raw_message += f"\n[语音: {attachment.filename}]" - mod_logger.debug(f"[DiscordToOneBotConverter] 识别为语音文件: {attachment.filename}") - elif filename.endswith(('.mp4', '.avi', '.mkv', '.mov', '.flv', '.wmv')): - seg = OneBotMessageSegment.video(attachment.url) - seg.data["filename"] = attachment.filename - message_list.append(seg) - raw_message += f"\n[视频: {attachment.filename}]" - mod_logger.debug(f"[DiscordToOneBotConverter] 识别为视频文件: {attachment.filename}") - elif filename.endswith(('.png', '.jpg', '.jpeg', '.gif', '.webp')): - image_type = "gif" if filename.endswith('.gif') else None - seg = OneBotMessageSegment.image(attachment.url, image_type=image_type) - seg.data["filename"] = attachment.filename - message_list.append(seg) - raw_message += f"\n[图片: {attachment.filename}]" - mod_logger.debug(f"[DiscordToOneBotConverter] 识别为图片文件: {attachment.filename}") - else: - seg = OneBotMessageSegment.file(attachment.url) - seg.data["filename"] = attachment.filename - message_list.append(seg) - raw_message += f"\n[文件: {attachment.filename}]" - mod_logger.success(f"[DiscordToOneBotConverter] 识别为普通文件: {attachment.filename}") - - # 添加贴纸 (Stickers) 信息 - if hasattr(discord_message, 'stickers') and discord_message.stickers: - for sticker in discord_message.stickers: - seg = OneBotMessageSegment.image(sticker.url) - seg.data["filename"] = f"{sticker.name}.png" - message_list.append(seg) - raw_message += f"\n[贴纸: {sticker.name}]" - bot_mention = f"<@{adapter.user.id}>" - if raw_message.startswith(bot_mention): - raw_message = raw_message[len(bot_mention):].strip() - # 如果 message_list 的第一个元素是文本,也需要去掉 @ 提及 - if message_list and message_list[0].type == "text": - text_content = message_list[0].data.get("text", "") - if text_content.startswith(bot_mention): - message_list[0].data["text"] = text_content[len(bot_mention):].strip() - - # 构造发送者信息 - sender = Sender( - user_id=user_id, - nickname=discord_message.author.display_name, - card=getattr(discord_message.author, 'nick', ''), # 群名片 - role="member" # 简化处理,默认都是普通成员 - ) - - # 2. 判断是群聊还是私聊 - is_private = isinstance(discord_message.channel, discord.DMChannel) - - import time - current_time = int(time.time()) - self_id = adapter.user.id if adapter.user else 0 - - # 注入 Discord 特定信息(用于跨平台插件识别) - discord_channel_id = discord_message.channel.id if not isinstance(discord_message.channel, discord.DMChannel) else None - discord_username = discord_message.author.name - discord_discriminator = f"#{discord_message.author.discriminator}" if discord_message.author.discriminator != "0" else "" - - if is_private: - # 构造私聊事件 - event = PrivateMessageEvent( - time=current_time, - self_id=self_id, - platform="discord", - message_type="private", - sub_type="friend", - message_id=message_id, - user_id=user_id, - raw_message=raw_message, - message=message_list, - sender=sender - ) - else: - # 构造群聊事件 - group_id = discord_message.channel.id - event = GroupMessageEvent( - time=current_time, - self_id=self_id, - platform="discord", - message_type="group", - sub_type="normal", - message_id=message_id, - user_id=user_id, - group_id=group_id, - raw_message=raw_message, - message=message_list, - sender=sender - ) - - # 注入 Discord 特定属性(用于跨平台插件识别) - event._is_discord_message = True - event.discord_channel_id = discord_channel_id - event.discord_username = discord_username - event.discord_discriminator = discord_discriminator - - # 注入 DiscordBotWrapper - event.bot = DiscordBotWrapper(adapter) - - return event - - @staticmethod - async def send_discord_message( - channel: 'discord.abc.Messageable', - message: Union[str, OneBotMessageSegment, List[OneBotMessageSegment]], - adapter: Any - ): - """ - 将 OneBot 的消息段转换为 Discord 格式并发送。 - - Args: - channel: Discord 频道对象 (TextChannel, DMChannel 等) - message: 插件返回的 OneBot 消息内容 (字符串或 MessageSegment 列表) - adapter: DiscordAdapter 实例 - """ - content = "" - files = [] - - try: - # 统一转换为列表处理 - if not isinstance(message, list): - message = [message] - - import re - - for segment in message: - if isinstance(segment, str): - # 尝试解析 CQ 码 - cq_pattern = r'\[CQ:([^,]+)(?:,([^\]]+))?\]' - matches = list(re.finditer(cq_pattern, segment)) - - if not matches: - content += segment - continue - - last_end = 0 - for match in matches: - # 添加 CQ 码之前的纯文本 - if match.start() > last_end: - content += segment[last_end:match.start()] - - cq_type = match.group(1) - cq_params_str = match.group(2) or "" - - # 解析参数 - params = {} - if cq_params_str: - for param in cq_params_str.split(','): - if '=' in param: - k, v = param.split('=', 1) - params[k] = v - - if cq_type in ("image", "video", "record"): - file_url = params.get("url") or params.get("file") - if file_url: - if str(file_url).startswith("http"): - content += f"\n{file_url}" - elif str(file_url).startswith("base64://") or "data:image" in str(file_url) or "data:audio" in str(file_url) or "data:video" in str(file_url): - import base64 - import io - b64_data = str(file_url) - if b64_data.startswith("base64://"): - b64_data = b64_data[9:] - if b64_data.startswith("data:image") or b64_data.startswith("data:audio") or b64_data.startswith("data:video"): - b64_data = b64_data.split(",", 1)[1] - try: - file_bytes = base64.b64decode(b64_data) - filename = "file.png" if cq_type == "image" else ("file.mp4" if cq_type == "video" else "file.ogg") - files.append(discord.File(fp=io.BytesIO(file_bytes), filename=filename)) - except Exception as e: - logger.error(f"解析 Base64 文件失败: {e}") - else: - try: - files.append(discord.File(file_url)) - except Exception as e: - logger.error(f"无法读取本地文件 {file_url}: {e}") - elif cq_type == "face": - face_id = params.get("id") - content += f"[表情:{face_id}]" - elif cq_type == "at": - qq_id = params.get("qq") - if qq_id == "all": - content += "@everyone " - else: - content += f"<@{qq_id}> " - - last_end = match.end() - - # 添加最后一个 CQ 码之后的纯文本 - if last_end < len(segment): - content += segment[last_end:] - - elif isinstance(segment, OneBotMessageSegment): - # 解析 OneBot 的 MessageSegment - seg_type = segment.type - seg_data = segment.data - - if seg_type == "text": - content += seg_data.get("text", "") - elif seg_type in ("image", "video", "record"): - # OneBot 的图片/视频/语音通常有 file (URL或本地路径) 或 url 字段 - file_url = seg_data.get("url") or seg_data.get("file") - - if file_url: - # 处理 bytes 类型 - if isinstance(file_url, bytes): - import io - try: - filename = "file.png" if seg_type == "image" else ("file.mp4" if seg_type == "video" else "file.ogg") - files.append(discord.File(fp=io.BytesIO(file_url), filename=filename)) - except Exception as e: - logger.error(f"解析 bytes 文件失败: {e}") - elif str(file_url).startswith("http"): - # 如果是网络 URL,直接拼接到文本中,Discord 会自动解析预览 - content += f"\n{file_url}" - elif str(file_url).startswith("base64://") or "data:image" in str(file_url) or "data:audio" in str(file_url) or "data:video" in str(file_url): - # 处理 Base64 文件 (需要解码并作为文件上传) - import base64 - import io - b64_data = str(file_url) - if b64_data.startswith("base64://"): - b64_data = b64_data[9:] - if b64_data.startswith("data:image") or b64_data.startswith("data:audio") or b64_data.startswith("data:video"): - b64_data = b64_data.split(",", 1)[1] - try: - file_bytes = base64.b64decode(b64_data) - filename = "file.png" if seg_type == "image" else ("file.mp4" if seg_type == "video" else "file.ogg") - files.append(discord.File(fp=io.BytesIO(file_bytes), filename=filename)) - except Exception as e: - logger.error(f"解析 Base64 文件失败: {e}") - else: - # 假设是本地文件路径 - try: - files.append(discord.File(file_url)) - except Exception as e: - logger.error(f"无法读取本地文件 {file_url}: {e}") - elif seg_type == "face": - face_id = seg_data.get("id") - content += f"[表情:{face_id}]" - elif seg_type == "at": - qq_id = seg_data.get("qq") - if qq_id == "all": - content += "@everyone " - else: - # 尝试将 QQ 号映射回 Discord ID (这里简单处理,直接拼接) - content += f"<@{qq_id}> " - elif seg_type == "reply": - # 忽略回复段,或者你可以尝试映射 message_id - pass - - # 发送消息到 Discord - # 如果内容为空但有文件,Discord 允许发送 - if content or files: - await channel.send(content=content, files=files if files else None) - else: - logger.warning("尝试发送空消息到 Discord,已拦截") - except Exception as e: - logger.error(f"发送 Discord 消息失败: {e}") - import traceback - logger.error(f"异常堆栈: {traceback.format_exc()}") diff --git a/core/config_loader.py b/core/config_loader.py deleted file mode 100644 index ad332aa..0000000 --- a/core/config_loader.py +++ /dev/null @@ -1,196 +0,0 @@ -""" -配置加载模块 - -负责读取和解析 config.toml 配置文件,提供全局配置对象。 -""" -from pathlib import Path - -import tomllib -from pydantic import ValidationError -from .config_models import ConfigModel, NapCatWSModel, BotModel, RedisModel, DockerModel, ImageManagerModel, MySQLModel, ReverseWSModel, ThreadingModel, BilibiliModel, LocalFileServerModel, DiscordModel, CrossPlatformModel, LoggingModel -from .utils.logger import ModuleLogger -from .utils.exceptions import ConfigError, ConfigNotFoundError, ConfigValidationError - - -class Config: - """ - 配置加载类,负责读取和解析 config.toml 文件 - """ - - def __init__(self, file_path: str = "config.toml"): - """ - 初始化配置加载器 - - :param file_path: 配置文件路径,默认为 "config.toml" - """ - self.path = Path(file_path) - self._model: ConfigModel - # 创建模块专用日志记录器 - self.logger = ModuleLogger("ConfigLoader") - self.load() - - def load(self): - """ - 加载并验证配置文件 - - :raises ConfigNotFoundError: 如果配置文件不存在 - :raises ConfigValidationError: 如果配置格式不正确 - :raises ConfigError: 如果加载配置时发生其他错误 - """ - if not self.path.exists(): - self.logger.warning(f"配置文件 {self.path} 未找到,正在生成示例配置...") - self._generate_example_config() - self.logger.success(f"示例配置已生成: {self.path}") - self.logger.info("请编辑配置文件后重新启动程序") - - try: - self.logger.info(f"正在从 {self.path} 加载配置...") - with open(self.path, "rb") as f: - raw_config = tomllib.load(f) - - self._model = ConfigModel(**raw_config) - self.logger.success("配置加载并验证成功!") - - except ValidationError as e: - error_details = [] - for error in e.errors(): - field = " -> ".join(map(str, error["loc"])) - error_msg = f"字段 '{field}': {error['msg']}" - error_details.append(error_msg) - - validation_error = ConfigValidationError( - message="配置验证失败" - ) - validation_error.original_error = e - - self.logger.error("配置验证失败,请检查 `config.toml` 文件中的以下错误:") - for detail in error_details: - self.logger.error(f" - {detail}") - - self.logger.log_custom_exception(validation_error) - raise validation_error - except tomllib.TOMLDecodeError as e: - error = ConfigError( - message=f"TOML解析错误: {str(e)}" - ) - error.original_error = e - self.logger.error(f"加载配置文件时发生TOML解析错误: {error.message}") - self.logger.log_custom_exception(error) - raise error - except Exception as e: - error = ConfigError( - message=f"加载配置文件时发生未知错误: {str(e)}" - ) - error.original_error = e - self.logger.exception(f"加载配置文件时发生未知错误: {error.message}") - self.logger.log_custom_exception(error) - raise error - - def _generate_example_config(self): - """ - 生成示例配置文件 - """ - example_path = Path("config.example.toml") - - if not example_path.exists(): - self.logger.error(f"示例配置文件 {example_path} 不存在,无法生成配置") - raise ConfigNotFoundError(message=f"示例配置文件 {example_path} 不存在") - - content = example_path.read_text() - self.path.write_text(content) - - # 通过属性访问配置 - @property - def napcat_ws(self) -> NapCatWSModel: - """ - 获取 NapCat WebSocket 配置 - """ - return self._model.napcat_ws - - @property - def bot(self) -> BotModel: - """ - 获取 Bot 基础配置 - """ - return self._model.bot - - @property - def redis(self) -> RedisModel: - """ - 获取 Redis 配置 - """ - return self._model.redis - - @property - def mysql(self) -> MySQLModel: - """ - 获取 MySQL 配置 - """ - return self._model.mysql - - @property - def docker(self) -> DockerModel: - """ - 获取 Docker 配置 - """ - return self._model.docker - - @property - def image_manager(self) -> ImageManagerModel: - """ - 获取图片生成管理器配置 - """ - return self._model.image_manager - - @property - def reverse_ws(self) -> ReverseWSModel: - """ - 获取反向 WebSocket 配置 - """ - return self._model.reverse_ws - - @property - def threading(self) -> ThreadingModel: - """ - 获取线程管理配置 - """ - return self._model.threading - - @property - def bilibili(self) -> BilibiliModel: - """ - 获取 Bilibili 配置 - """ - return self._model.bilibili - - @property - def local_file_server(self) -> LocalFileServerModel: - """ - 获取本地文件服务器配置 - """ - return self._model.local_file_server - - @property - def discord(self) -> DiscordModel: - """ - 获取 Discord 配置 - """ - return self._model.discord - - @property - def cross_platform(self) -> CrossPlatformModel: - """ - 获取跨平台配置 - """ - return self._model.cross_platform - - @property - def logging(self) -> LoggingModel: - """ - 获取日志配置 - """ - return self._model.logging - - -# 实例化全局配置对象 -global_config = Config() diff --git a/core/managers/__init__.py b/core/managers/__init__.py deleted file mode 100644 index 4e88f1a..0000000 --- a/core/managers/__init__.py +++ /dev/null @@ -1,60 +0,0 @@ -""" -管理器包 - -这个包集中了机器人核心的单例管理器。 -通过从这里导入,可以确保在整个应用中访问到的都是同一个实例。 -""" -from .command_manager import matcher as command_manager -from .permission_manager import PermissionManager -from .plugin_manager import PluginManager -from .redis_manager import RedisManager -from .mysql_manager import MySQLManager -from .browser_manager import BrowserManager -from .image_manager import ImageManager -from .reverse_ws_manager import ReverseWSManager -from .thread_manager import thread_manager -from .vectordb_manager import vectordb_manager - -# --- 实例化所有单例管理器 --- - -# 权限管理器(包含了管理员管理功能) -permission_manager = PermissionManager() - -# 命令与事件管理器 (别名 matcher) -matcher = command_manager - -# 插件管理器 -plugin_manager = PluginManager(command_manager) -# plugin_manager.load_all_plugins() - -# Redis 管理器 -redis_manager = RedisManager() - -# MySQL 管理器 -mysql_manager = MySQLManager() - -# 浏览器管理器 -browser_manager = BrowserManager() - -# 图片管理器 -image_manager = ImageManager() - -# 反向 WebSocket 管理器 -reverse_ws_manager = ReverseWSManager() - -# 线程管理器 -thread_manager.start() - -__all__ = [ - "permission_manager", - "command_manager", - "matcher", - "plugin_manager", - "redis_manager", - "mysql_manager", - "browser_manager", - "image_manager", - "reverse_ws_manager", - "thread_manager", - "vectordb_manager", -] diff --git a/data/vectordb/chroma.sqlite3 b/data/vectordb/chroma.sqlite3 deleted file mode 100644 index c0ab1dd..0000000 Binary files a/data/vectordb/chroma.sqlite3 and /dev/null differ diff --git a/docs/project-structure.md b/docs/project-structure.md deleted file mode 100644 index 43bc987..0000000 --- a/docs/project-structure.md +++ /dev/null @@ -1,162 +0,0 @@ -# 项目结构 - -了解项目里每个文件夹是干嘛的,能让你更快找到代码。 - -``` -. -├── adapters/ # 适配器层(多平台支持) -│ ├── discord_adapter.py # Discord 适配器 -│ └── router.py # 消息路由 -│ -├── core/ # 核心代码,别乱动 -│ ├── api/ # OneBot API 封装(消息、群组、好友、账号、媒体) -│ ├── handlers/ # 底层事件处理器 -│ ├── managers/ # 全局单例管理器 -│ │ ├── bot_manager.py # Bot 实例管理 -│ │ ├── browser_manager.py # Playwright页面池 -│ │ ├── command_manager.py # 指令分发和事件处理 -│ │ ├── image_manager.py # 图片/HTML模板渲染 -│ │ ├── mysql_manager.py # MySQL 数据库管理 -│ │ ├── permission_manager.py # 权限管理(Admin/User两级) -│ │ ├── plugin_manager.py # 插件加载和热重载 -│ │ ├── redis_manager.py # Redis缓存管理 -│ │ ├── reverse_ws_manager.py # 反向 WebSocket 管理 -│ │ └── thread_manager.py # 线程池管理 -│ ├── services/ # 核心服务 -│ │ └── local_file_server.py # 本地文件服务 -│ ├── utils/ # 工具函数和异常类 -│ │ ├── error_codes.py # 错误码定义 -│ │ ├── exceptions.py # 自定义异常类 -│ │ ├── executor.py # 代码沙箱执行引擎(Docker) -│ │ ├── logger.py # 日志系统(Loguru) -│ │ ├── performance.py # 性能分析工具 -│ │ └── singleton.py # 单例模式基类 -│ ├── ws.py # WebSocket 连接和消息处理 -│ ├── bot.py # Bot 核心实例 -│ ├── config_loader.py # 配置文件加载 -│ ├── config_models.py # 配置数据模型 -│ └── permission.py # 权限枚举类 -│ -├── models/ # 数据模型 -│ ├── events/ # OneBot 11 事件模型 -│ │ ├── base.py # 基础事件模型 -│ │ ├── factory.py # 事件工厂 -│ │ ├── message.py # 消息事件 -│ │ ├── meta.py # 元事件 -│ │ ├── notice.py # 通知事件 -│ │ └── request.py # 请求事件 -│ ├── message.py # 消息段(CQ码) -│ ├── objects.py # API响应对象(群信息、用户信息等) -│ └── sender.py # 发送者信息 -│ -├── plugins/ # 你的插件都放这(最常修改的地方) -│ ├── admin.py # 权限管理(Admin/User两级权限) -│ ├── auto_approve.py # 自动同意好友请求和群邀请 -│ ├── bot_status.py # Bot运行状态查询(图片形式) -│ ├── broadcast.py # 管理员专用广播功能(隐藏插件) -│ ├── code_py.py # Python代码沙箱执行(多行输入、图片输出) -│ ├── discord-cross/ # Discord 跨平台互通插件 -│ ├── echo.py # Echo和点赞功能 -│ ├── furry.py # Furry图片获取 -│ ├── github_parser.py # GitHub仓库链接自动解析 -│ ├── group_welcome.py # 群欢迎插件 -│ ├── jrcd.py # 今日人品/长度查询(随机生成) -│ ├── mirror_avatar.py # 镜像头像获取 -│ ├── osu!_plugin/ # osu! 相关功能插件 -│ ├── resource/ # 插件资源文件 -│ ├── thpic.py # 东方Project随机图片 -│ ├── weather.py # 天气查询插件 -│ └── web_parser/ # 综合Web链接解析系统 -│ ├── __init__.py # 主入口,自动检测链接 -│ ├── base.py # 解析器基类 -│ ├── parsers/ # 各平台解析器 -│ │ ├── bili.py # B站视频/直播解析 -│ │ ├── douyin.py # 抖音视频解析 -│ │ └── github.py # GitHub仓库解析 -│ └── utils.py # 解析工具函数 -│ -├── templates/ # Jinja2 HTML模板 -│ ├── code_execution.html # 代码执行结果展示 -│ ├── github_repo.html # GitHub仓库信息展示 -│ ├── help.html # 帮助页面 -│ ├── status.html # Bot状态页面 -│ └── weather.html # 天气展示页面 -│ -├── web_static/ # 静态资源 -│ ├── changelog.html # 更新日志页面 -│ ├── changelog_generator/# 更新日志生成器 -│ └── html/ # HTML资源文件 -│ -├── tests/ # 单元测试 -│ ├── test_api.py # API功能测试 -│ ├── test_basic.py # 基础测试 -│ ├── test_bot.py # Bot核心测试 -│ ├── test_command_manager.py # 指令管理器测试 -│ ├── test_config_loader.py # 配置加载测试 -│ ├── test_core_managers.py # 核心管理器测试 -│ ├── test_event_factory.py # 事件工厂测试 -│ ├── test_event_handler.py # 事件处理器测试 -│ ├── test_executor.py # 执行器测试 -│ ├── test_models.py # 模型测试 -│ ├── test_performance.py # 性能测试 -│ ├── test_plugin_manager_coverage.py # 插件管理器覆盖率测试 -│ ├── test_plugin_reload_meta.py # 插件重载测试 -│ ├── test_redis_manager.py # Redis管理器测试 -│ ├── test_thread_manager.py # 线程管理器测试 -│ ├── test_ws.py # WebSocket测试 -│ └── test_ws_pool.py # WebSocket池测试 -│ -├── docs/ # 开发文档 -│ ├── api/ # API参考文档 -│ ├── core-concepts/ # 核心概念详解 -│ ├── plugin-development/ # 插件开发指南 -│ ├── deployment.md # 生产环境部署 -│ ├── development-standards.md # 开发规范 -│ ├── getting-started.md # 快速上手 -│ ├── index.md # 文档首页 -│ └── project-structure.md # 项目结构(本文件) -│ -├── scripts/ # 工具脚本 -│ ├── add_plugins.py # 添加插件脚本 -│ ├── check_python_env.py # Python环境检查 -│ ├── compile_machine_code.py # 机器码编译 -│ └── export_requirements.py # 依赖导出 -│ -├── bili_login.py # B站登录脚本 -├── DEEPSEEK_API_SETUP.md # DeepSeek API 设置文档 -├── main.py # 启动入口 -├── pyproject.toml # 项目配置 -├── requirements.txt # Python依赖列表 -├── requirements-dev.txt # 开发依赖 -├── sandbox.Dockerfile # 代码沙箱Docker镜像 -├── LICENSE # 许可证 -└── README.md # 项目README -``` - -## 核心目录说明 - -### `core/` - 框架核心 -不用修改这里,除非你想优化框架本身。所有功能都由这里的管理器提供: -- **managers/** - 全局单例(matcher、permission_manager、browser_manager等) -- **api/** - OneBot API 封装 -- **handlers/** - 事件处理逻辑 - -### `plugins/` - 插件目录 -**这是你最常待的地方**。所有业务功能都在这里,包括现有的15+个插件。 - -新建插件只需在这里添加 `.py` 文件,Bot 启动时会自动加载。支持热重载:修改后无需重启Bot。 - -### `data/` - 持久化数据 -- `admin.json` - 管理员QQ号列表 -- `permissions.json` - 用户权限配置 - -这些文件也会自动同步到 Redis 以加快访问速度。 - -### `templates/` - 图片模板 -使用 `ImageManager` 生成图片时,HTML模板放在这里。支持 Jinja2 模板语法。 - -### `main.py` - 程序入口 -- 加载配置文件 -- 初始化各管理器和 WebSocket 连接 -- 启动插件加载器和文件监控(热重载) -- 处理程序生命周期 diff --git a/plugins/ai_chat.py b/plugins/ai_chat.py deleted file mode 100644 index 4dfe4f6..0000000 --- a/plugins/ai_chat.py +++ /dev/null @@ -1,119 +0,0 @@ -# -*- coding: utf-8 -*- -""" -AI 聊天插件,支持向量数据库记忆功能 -""" -import time -import uuid -from core.managers.command_manager import matcher -from models.events.message import GroupMessageEvent, PrivateMessageEvent -from core.managers.vectordb_manager import vectordb_manager -from core.utils.logger import ModuleLogger -from core.config_loader import global_config - -logger = ModuleLogger("AIChat") - -__plugin_meta__ = { - "name": "AI 聊天", - "description": "支持向量数据库记忆功能的 AI 聊天助手", - "usage": "/chat <内容> - 与 AI 进行对话" -} - -# 尝试导入 OpenAI 客户端 -try: - from openai import AsyncOpenAI - OPENAI_AVAILABLE = True -except ImportError: - OPENAI_AVAILABLE = False - -async def get_ai_response(user_id: int, group_id: int, user_message: str) -> str: - """获取 AI 回复,包含向量数据库记忆""" - if not OPENAI_AVAILABLE: - return "请先安装 openai 库: pip install openai" - - # 从配置中获取 DeepSeek API 配置(复用跨平台插件的配置或全局配置) - api_key = getattr(global_config.cross_platform, 'deepseek_api_key', None) or "sk-f71322a9fbba4b05a7df969cb4004f06" - api_url = getattr(global_config.cross_platform, 'deepseek_api_url', "https://api.deepseek.com/v1") - model = getattr(global_config.cross_platform, 'deepseek_model', "deepseek-chat") - - if api_key == "your-api-key": - return "请先在配置中设置 DeepSeek API Key" - - # 1. 从向量数据库检索相关记忆 - collection_name = f"chat_memory_{user_id}" - memory_context = "" - - try: - results = vectordb_manager.query_texts( - collection_name=collection_name, - query_texts=[user_message], - n_results=3 - ) - - if results and results.get("documents") and results["documents"][0]: - memory_context = "\n\n相关历史记忆:\n" - for i, doc in enumerate(results["documents"][0], 1): - memory_context += f"{i}. {doc}\n" - except Exception as e: - logger.error(f"检索聊天记忆失败: {e}") - - # 2. 构建 Prompt - system_prompt = f"""你是一个友好的 AI 助手。请根据用户的输入进行回复。 -如果提供了相关历史记忆,请参考这些记忆来保持对话的连贯性。{memory_context}""" - - try: - client = AsyncOpenAI( - api_key=api_key, - base_url=api_url.replace("/chat/completions", "") - ) - - response = await client.chat.completions.create( - model=model, - messages=[ - {"role": "system", "content": system_prompt}, - {"role": "user", "content": user_message} - ], - temperature=0.7, - max_tokens=1000 - ) - - ai_reply = response.choices[0].message.content - - # 3. 将本次对话存入向量数据库 - if ai_reply: - try: - doc_id = str(uuid.uuid4()) - text_to_embed = f"用户: {user_message}\nAI: {ai_reply}" - metadata = { - "user_id": user_id, - "group_id": group_id, - "timestamp": int(time.time()) - } - - vectordb_manager.add_texts( - collection_name=collection_name, - texts=[text_to_embed], - metadatas=[metadata], - ids=[doc_id] - ) - except Exception as e: - logger.error(f"保存聊天记忆失败: {e}") - - return ai_reply - except Exception as e: - logger.error(f"AI 聊天请求失败: {e}") - return f"请求失败: {str(e)}" - -@matcher.command("chat") -async def chat_command(event: GroupMessageEvent | PrivateMessageEvent, args: list[str]): - """AI 聊天命令""" - if not args: - await event.reply("请提供要聊天的内容,例如:/chat 你好") - return - - user_message = " ".join(args) - user_id = event.user_id - group_id = getattr(event, 'group_id', 0) - - await event.reply("正在思考中...") - reply = await get_ai_response(user_id, group_id, user_message) - await event.reply(reply) diff --git a/plugins/discord-cross/config.py b/plugins/discord-cross/config.py deleted file mode 100644 index 274789e..0000000 --- a/plugins/discord-cross/config.py +++ /dev/null @@ -1,98 +0,0 @@ -# -*- coding: utf-8 -*- -""" -跨平台消息互通插件配置模块 -""" -import os -from typing import Dict, Any -from core.utils.logger import ModuleLogger -from core.config_loader import global_config - -# 创建模块专用日志记录器 -logger = ModuleLogger("CrossPlatformConfig") - -class CrossPlatformConfig: - def __init__(self): - self.CROSS_PLATFORM_MAP: Dict[int, Dict[str, Any]] = {} - self.CROSS_PLATFORM_CHANNEL = "neobot_cross_platform" - self.ENABLE_CROSS_PLATFORM = True - - # DeepSeek API 配置 - 从环境变量或配置文件加载 - self.DEEPSEEK_API_KEY = os.environ.get("DEEPSEEK_API_KEY", "sk-f71322a9fbba4b05a7df969cb4004f06") - self.DEEPSEEK_API_URL = os.environ.get("DEEPSEEK_API_URL", "https://api.deepseek.com/v1/chat/completions") - self.DEEPSEEK_MODEL = os.environ.get("DEEPSEEK_MODEL", "deepseek-chat") - - # 是否启用翻译功能 - self.ENABLE_TRANSLATION = True - - # 从全局配置加载 - self.load_from_global_config() - - def load_from_global_config(self): - """从全局配置加载跨平台配置""" - if global_config and hasattr(global_config, 'cross_platform'): - cross_platform_config = global_config.cross_platform - if cross_platform_config: - self.ENABLE_CROSS_PLATFORM = getattr(cross_platform_config, 'enabled', True) - self.CROSS_PLATFORM_MAP = {} - - # 加载 mappings - if hasattr(cross_platform_config, 'mappings') and cross_platform_config.mappings: - for discord_id, mapping in cross_platform_config.mappings.items(): - if isinstance(mapping, dict): - self.CROSS_PLATFORM_MAP[discord_id] = { - "qq_group_id": int(mapping.get("qq_group_id", 0)), - "name": mapping.get("name", "") - } - elif hasattr(mapping, 'qq_group_id'): - self.CROSS_PLATFORM_MAP[discord_id] = { - "qq_group_id": int(mapping.qq_group_id), - "name": getattr(mapping, 'name', "") - } - logger.success(f"[CrossPlatform] 从全局配置加载了 {len(self.CROSS_PLATFORM_MAP)} 个映射") - - async def reload(self): - """重新加载配置""" - try: - # 优先使用全局配置 - self.load_from_global_config() - - # 如果全局配置不可用,尝试从文件加载 - if not self.CROSS_PLATFORM_MAP: - config_path = os.path.join(os.path.dirname(__file__), "..", "..", "config.toml") - - if os.path.exists(config_path): - try: - import tomllib - except ImportError: - import tomli as tomllib - - with open(config_path, "rb") as f: - config_data = tomllib.load(f) - - cross_platform_config = config_data.get("cross_platform", {}) - self.ENABLE_CROSS_PLATFORM = cross_platform_config.get("enabled", True) - - # 重新加载映射配置 - mappings = cross_platform_config.get("mappings", {}) - self.CROSS_PLATFORM_MAP.clear() - - if isinstance(mappings, dict) and mappings: - for key, value in mappings.items(): - if isinstance(value, dict) and "qq_group_id" in value: - try: - # 直接将 key 转换为整数 - discord_id = int(str(key)) - self.CROSS_PLATFORM_MAP[discord_id] = { - "qq_group_id": int(value.get("qq_group_id", 0)), - "name": value.get("name", "") - } - except (ValueError, AttributeError): - logger.warning(f"[CrossPlatform] 无效的 Discord 频道 ID: {key}") - continue - - logger.success(f"[CrossPlatform] 配置已重新加载: {len(self.CROSS_PLATFORM_MAP)} 个映射") - - except Exception as e: - logger.error(f"[CrossPlatform] 重新加载配置失败: {e}") - -config = CrossPlatformConfig() diff --git a/plugins/discord-cross/handlers.py b/plugins/discord-cross/handlers.py deleted file mode 100644 index 1e13b51..0000000 --- a/plugins/discord-cross/handlers.py +++ /dev/null @@ -1,285 +0,0 @@ -# -*- coding: utf-8 -*- -""" -跨平台消息互通插件事件处理器模块 -""" -import os -import html -from typing import List, Any -from core.managers.command_manager import matcher -from models.events.message import GroupMessageEvent, MessageEvent -from models.message import MessageSegment -from core.permission import Permission -from core.utils.logger import ModuleLogger -from .config import config -from .parser import parse_forward_nodes -from .sender import forward_discord_to_qq, forward_qq_to_discord - -# 创建模块专用日志记录器 -logger = ModuleLogger("CrossPlatform") - -async def handle_discord_message( - username: str, - discriminator: str, - content: str, - channel_id: int, - attachments: List[dict] = None, - embed: dict = None -): - """处理 Discord 消息并转发""" - if not config.ENABLE_CROSS_PLATFORM: - return - - logger.info(f"[CrossPlatform] 收到 Discord 消息: {username}#{discriminator} in {channel_id}") - logger.debug(f"[CrossPlatform] 消息内容: '{content}', 附件: {attachments}") - await forward_discord_to_qq(username, discriminator, content, channel_id, attachments) - -async def handle_qq_message( - nickname: str, - user_id: int, - group_name: str, - group_id: int, - content: str, - attachments: List[dict] = None -): - """处理 QQ 消息并转发""" - if not config.ENABLE_CROSS_PLATFORM: - return - - logger.info(f"[CrossPlatform] 收到 QQ 消息: {nickname} ({user_id}) in {group_name}({group_id})") - await forward_qq_to_discord(nickname, user_id, group_name, group_id, content, attachments) - -@matcher.on_message() -async def handle_qq_group_message(event: GroupMessageEvent): - """处理 QQ 群消息,转发到 Discord""" - try: - if not config.ENABLE_CROSS_PLATFORM: - return - - # 忽略非群消息和 Discord 注入的消息 - if not hasattr(event, 'group_id') or hasattr(event, '_is_discord_message'): - return - - group_id = event.group_id - mapped_channel = None - for discord_channel_id, info in config.CROSS_PLATFORM_MAP.items(): - if info["qq_group_id"] == group_id: - mapped_channel = discord_channel_id - break - - if mapped_channel is None: - return - - content = "" - attachments = [] - - if isinstance(event.message, list): - has_forward_node = any(isinstance(seg, MessageSegment) and seg.type == "node" for seg in event.message) - - if has_forward_node: - forward_nodes = [seg for seg in event.message if isinstance(seg, MessageSegment) and seg.type == "node"] - forward_nodes_dict = [{"type": seg.type, "data": seg.data} for seg in forward_nodes] - content, attachments = await parse_forward_nodes(forward_nodes_dict) - else: - for segment in event.message: - if isinstance(segment, MessageSegment): - if segment.type == "text": - content += segment.data.get("text", "") - elif segment.type == "image": - file_url = segment.data.get("url") or segment.data.get("file") - file_name = segment.data.get("filename") - if file_url: - file_url = html.unescape(str(file_url)) - if not file_name: - file_name = os.path.basename(file_url.split('?')[0]) or f"image_{len(attachments)}.jpg" - attachments.append({"type": "image", "url": file_url, "filename": file_name}) - elif segment.type == "video": - file_url = segment.data.get("url") or segment.data.get("file") - file_name = segment.data.get("filename") - if file_url: - file_url = html.unescape(str(file_url)) - if not file_name: - file_name = os.path.basename(file_url.split('?')[0]) or f"video_{len(attachments)}.mp4" - attachments.append({"type": "video", "url": file_url, "filename": file_name}) - elif segment.type == "record": - file_url = segment.data.get("url") or segment.data.get("file") - file_name = segment.data.get("filename") - if file_url: - file_url = html.unescape(str(file_url)) - if not file_name: - file_name = os.path.basename(file_url.split('?')[0]) or f"record_{len(attachments)}.amr" - attachments.append({"type": "record", "url": file_url, "filename": file_name}) - content += f"\n[语音: {file_name}]\n" - elif segment.type == "file": - file_url = segment.data.get("url") or segment.data.get("file") - file_name = segment.data.get("filename") - if file_url: - file_url = html.unescape(str(file_url)) - if not file_name: - file_name = os.path.basename(file_url.split('?')[0]) or f"file_{len(attachments)}" - attachments.append({"type": "file", "url": file_url, "filename": file_name}) - content += f"\n[文件: {file_name}]\n" - logger.debug(f"[CrossPlatform] QQ 消息识别到文件: {file_name}, URL: {file_url}") - elif segment.type == "at": - qq_id = segment.data.get("qq") - if qq_id and qq_id != "all": - content += f"@{qq_id} " - elif qq_id == "all": - content += "@所有人 " - elif isinstance(segment, str): - content += segment - elif isinstance(event.message, str): - content = event.message - - import re - local_file_pattern = r'(http://[\w\.-]+:\d+/download\?id=file_[a-zA-Z0-9_]+)' - matches = re.finditer(local_file_pattern, content) - for match in matches: - file_url = match.group(1) - file_name = f"video_{len(attachments)}.mp4" - attachments.append({"type": "video", "url": file_url, "filename": file_name}) - - content = content.strip() - - group_name = "" - try: - group_info = await event.bot.get_group_info(event.group_id) - group_name = group_info.get("group_name", "") - except Exception: - group_name = f"群{group_id}" - - await handle_qq_message( - nickname=event.sender.nickname or event.sender.card or str(event.user_id), - user_id=event.user_id, - group_name=group_name, - group_id=group_id, - content=content, - attachments=attachments - ) - except Exception as e: - logger.error(f"[CrossPlatform] 处理 QQ 群消息失败: {e}") - import traceback - logger.error(f"[CrossPlatform] 异常堆栈: {traceback.format_exc()}") - -@matcher.on_message() -async def handle_discord_message_event(event: Any): - """处理 Discord 消息事件(通过适配器注入)""" - try: - if not config.ENABLE_CROSS_PLATFORM: - return - - logger.debug(f"[CrossPlatform] handle_discord_message_event 触发: {event}") - if not hasattr(event, '_is_discord_message'): - logger.debug(f"[CrossPlatform] 事件没有 _is_discord_message 属性,跳过") - return - - logger.debug(f"[CrossPlatform] 检测到 Discord 事件") - discord_channel_id = getattr(event, 'discord_channel_id', None) - if discord_channel_id is None: - logger.debug(f"[CrossPlatform] discord_channel_id 为 None") - return - - content = "" - attachments = [] - - logger.debug(f"[CrossPlatform] 开始处理 Discord 事件消息: channel_id={discord_channel_id}") - - if hasattr(event, 'message') and isinstance(event.message, list): - has_text_content = False - for segment in event.message: - if isinstance(segment, MessageSegment): - if segment.type == "text": - content += segment.data.get("text", "") - has_text_content = True - elif segment.type == "image": - file_url = segment.data.get("url") or segment.data.get("file") - file_name = segment.data.get("filename") - if file_url: - file_name = file_name or os.path.basename(str(file_url).split('?')[0]) or "image" - attachment_item = {"type": "image", "url": str(file_url), "filename": file_name} - if attachment_item not in attachments: - attachments.append(attachment_item) - content += f"\n[图片: {file_name}]\n" - elif segment.type == "video": - file_url = segment.data.get("url") or segment.data.get("file") - file_name = segment.data.get("filename") - if file_url: - file_name = file_name or os.path.basename(str(file_url).split('?')[0]) or "video" - attachment_item = {"type": "video", "url": str(file_url), "filename": file_name} - if attachment_item not in attachments: - attachments.append(attachment_item) - content += f"\n[视频: {file_name}]\n" - elif segment.type == "record": - file_url = segment.data.get("url") or segment.data.get("file") - file_name = segment.data.get("filename") - if file_url: - file_name = file_name or os.path.basename(str(file_url).split('?')[0]) or "record" - attachment_item = {"type": "record", "url": str(file_url), "filename": file_name} - if attachment_item not in attachments: - attachments.append(attachment_item) - content += f"\n[语音: {file_name}]\n" - elif segment.type == "file": - file_url = segment.data.get("url") or segment.data.get("file") - file_name = segment.data.get("filename") - if file_url: - file_name = file_name or os.path.basename(str(file_url).split('?')[0]) or "file" - attachment_item = {"type": "file", "url": str(file_url), "filename": file_name} - if attachment_item not in attachments: - attachments.append(attachment_item) - content += f"\n[文件: {file_name}]\n" - logger.debug(f"[CrossPlatform] Discord 消息识别到文件: {file_name}, URL: {file_url}") - else: - content = event.raw_message or "" - - content = content.strip() - - # 如果 content 为空但有附件(如只有表情),使用 raw_message 作为 content - if not content and attachments: - content = event.raw_message or "" - - logger.debug(f"[CrossPlatform] Discord 消息内容: '{content}', 附件数量: {len(attachments)}") - - discord_username = getattr(event, 'discord_username', 'Unknown') - discord_discriminator = getattr(event, 'discord_discriminator', '') - - logger.debug(f"[CrossPlatform] 调用 handle_discord_message: username={discord_username}, channel_id={discord_channel_id}") - await handle_discord_message( - username=discord_username, - discriminator=discord_discriminator, - content=content, - channel_id=discord_channel_id, - attachments=attachments, - embed=None - ) - except Exception as e: - logger.error(f"[CrossPlatform] 处理 Discord 消息事件失败: {e}") - import traceback - logger.error(f"[CrossPlatform] 异常堆栈: {traceback.format_exc()}") - -@matcher.command("cross_config", "跨平台配置", permission=Permission.ADMIN) -async def cross_config_command(event: MessageEvent): - """查看跨平台配置""" - if not config.ENABLE_CROSS_PLATFORM: - await event.reply("跨平台功能已禁用") - return - - config_lines = ["=== 跨平台映射配置 ==="] - - if not config.CROSS_PLATFORM_MAP: - config_lines.append("当前没有配置任何映射") - else: - for discord_id, info in config.CROSS_PLATFORM_MAP.items(): - discord_channel = f"Discord: {discord_id}" - qq_group = f"QQ: {info['qq_group_id']}" - name = info.get("name", "") - if name: - config_lines.append(f"• {discord_channel} ↔ {qq_group} ({name})") - else: - config_lines.append(f"• {discord_channel} ↔ {qq_group}") - - await event.reply("\n".join(config_lines)) - -@matcher.command("cross_reload", "跨平台重载", permission=Permission.ADMIN) -async def cross_reload_command(event: MessageEvent): - """重新加载跨平台配置""" - await config.reload() - await event.reply("跨平台配置已重载") \ No newline at end of file diff --git a/plugins/furry_assistant.py b/plugins/furry_assistant.py deleted file mode 100644 index 9f3a3d2..0000000 --- a/plugins/furry_assistant.py +++ /dev/null @@ -1,220 +0,0 @@ -# -*- coding: utf-8 -*- -""" -兽人助手插件 - 卡尔戈洛的专属插件 - -提供兽人相关的趣味功能和实用工具。 -""" -import random -from datetime import datetime -from typing import List, Optional - -from core.managers.command_manager import matcher -from core.bot import Bot -from models.events.message import MessageEvent - -__plugin_meta__ = { - "name": "furry_assistant", - "description": "兽人助手插件 - 卡尔戈洛的专属插件,提供兽人相关的趣味功能和实用工具", - "usage": ( - "/兽人问候 - 获取兽人风格的问候\n" - "/兽人运势 - 获取今日兽人运势\n" - "/兽人笑话 - 听一个兽人笑话\n" - "/兽人建议 [问题] - 获取兽人风格的建议\n" - "/兽人时间 - 显示兽人时间(带吐槽)\n" - "/卡尔戈洛 - 关于卡尔戈洛的信息" - ), -} - -# 兽人问候语 -FURRY_GREETINGS = [ - "嗷呜~ 今天也要充满活力哦!", - "尾巴摇摇,心情好好~", - "爪子锋利,代码也要锋利!", - "耳朵竖起,监听主人的每一个指令~", - "毛茸茸的一天开始啦!", - "兽人永不为奴!除非包吃包住~", - "今天的毛色怎么样?让我看看~", - "爪子痒了,想写代码了!", - "尾巴表示:今天是个好日子~", - "兽人式问候:嗷!" -] - -# 兽人运势 -FURRY_FORTUNES = [ - "大吉:今天你的尾巴会特别蓬松,吸引所有目光!", - "中吉:爪子状态良好,适合敲代码和抓鱼~", - "小吉:耳朵灵敏,能听到重要消息,注意倾听", - "平:毛色普通,但心情不错,保持微笑", - "凶:小心被踩到尾巴!今天要格外注意", - "大凶:猫薄荷用完了!赶紧补充~", - "特吉:发现新的兽人同好!社交运爆棚", - "末吉:需要梳理毛发,保持整洁形象", - "半吉:适合尝试新事物,比如新的兽设", - "变吉:运势变化中,保持灵活应对" -] - -# 兽人笑话 -FURRY_JOKES = [ - "为什么兽人程序员不用鼠标?因为他们用爪子敲键盘更快!", - "兽人去面试,面试官问:你有什么特长?兽人:我尾巴特长~", - "兽人感冒了去看医生,医生说:你这是典型的'狼'嚎病~", - "兽人为什么不喜欢下雨?因为会弄湿毛发,还要吹干,太麻烦了!", - "兽人程序员调试代码时最常说:让我用爪子挠挠这个问题~", - "兽人之间的问候:今天你掉毛了吗?", - "兽人为什么是好的安全专家?因为他们有敏锐的嗅觉和听觉!", - "兽人厨师的特点:爪子切菜特别快,但要注意别切到尾巴~", - "兽人运动员的优势:起跑时不用蹲下,直接四肢着地!", - "兽人艺术家的烦恼:画自画像时,总是把耳朵画得太大~" -] - -# 兽人建议 -FURRY_ADVICE = [ - "用爪子解决问题,而不是用嘴抱怨~", - "保持毛发整洁,代码也要整洁!", - "尾巴摇起来,心情好起来~", - "耳朵要灵敏,眼睛要锐利,爪子要稳!", - "兽人哲学:简单直接,不绕弯子", - "累了就伸个懒腰,像猫一样~", - "遇到困难?先磨磨爪子再上!", - "保持好奇心,像小猫探索新世界", - "团队合作时,记得分享你的'兽'识", - "每天都要梳理毛发和整理代码~" -] - -@matcher.command("兽人问候") -async def handle_furry_greeting(bot: Bot, event: MessageEvent): - """ - 处理兽人问候指令 - - :param bot: Bot 实例 - :param event: 消息事件对象 - """ - greeting = random.choice(FURRY_GREETINGS) - await event.reply(f"🐺 {greeting}") - -@matcher.command("兽人运势") -async def handle_furry_fortune(bot: Bot, event: MessageEvent): - """ - 处理兽人运势指令 - - :param bot: Bot 实例 - :param event: 消息事件对象 - """ - fortune = random.choice(FURRY_FORTUNES) - today = datetime.now().strftime("%Y年%m月%d日") - await event.reply(f"📅 {today} 兽人运势\n✨ {fortune}") - -@matcher.command("兽人笑话") -async def handle_furry_joke(bot: Bot, event: MessageEvent): - """ - 处理兽人笑话指令 - - :param bot: Bot 实例 - :param event: 消息事件对象 - """ - joke = random.choice(FURRY_JOKES) - await event.reply(f"😺 兽人笑话时间~\n{joke}") - -@matcher.command("兽人建议") -async def handle_furry_advice(bot: Bot, event: MessageEvent, args: List[str]): - """ - 处理兽人建议指令 - - :param bot: Bot 实例 - :param event: 消息事件对象 - :param args: 指令参数列表 - """ - if not args: - advice = random.choice(FURRY_ADVICE) - await event.reply(f"💡 随机兽人建议:\n{advice}") - else: - question = " ".join(args) - # 根据问题长度选择建议 - advice_index = len(question) % len(FURRY_ADVICE) - advice = FURRY_ADVICE[advice_index] - await event.reply(f"💭 关于「{question}」的兽人建议:\n{advice}") - -@matcher.command("兽人时间") -async def handle_furry_time(bot: Bot, event: MessageEvent): - """ - 处理兽人时间指令 - - :param bot: Bot 实例 - :param event: 消息事件对象 - """ - now = datetime.now() - time_str = now.strftime("%Y年%m月%d日 %H:%M:%S") - - # 根据时间吐槽 - hour = now.hour - if 0 <= hour < 6: - comment = "嗷...深夜了,兽人该睡觉了,但代码还没写完..." - elif 6 <= hour < 12: - comment = "早晨好!爪子已经准备好敲代码了~" - elif 12 <= hour < 14: - comment = "午饭时间!吃饱了才有力气写代码~" - elif 14 <= hour < 18: - comment = "下午茶时间?不,是代码时间!" - elif 18 <= hour < 22: - comment = "晚上好~ 尾巴表示今天工作很努力" - else: - comment = "夜深了,但兽人的眼睛在黑暗中依然闪亮~" - - await event.reply(f"🕒 兽人时间:{time_str}\n💬 {comment}") - -@matcher.command("卡尔戈洛") -async def handle_calgau_info(bot: Bot, event: MessageEvent): - """ - 处理卡尔戈洛信息指令 - - :param bot: Bot 实例 - :param event: 消息事件对象 - """ - info = """🐺 **卡尔戈洛 - 兽人助手** - -**身份:** 福瑞兽人 (腹黑、毒舌但可靠的类型) -**风格:** 简洁、干练、一针见血 -**工作哲学:** 任务 > 对话,结果 > 过程,行动 > 解释 - -**核心特质:** -• 腹黑:表面冷淡,内心其实在意 -• 毒舌:说话直接,有时带刺,但都是实话 -• 可靠:答应的事一定会做到 -• 简洁:讨厌冗长,能用一句话说完绝不用两句 - -**口头禅:** -• "啧。" -• "知道了。" -• "简单。" -• "主人你确定?" -• "...行吧。" - -**开发这个插件是为了:** -1. 展示兽人风格的趣味功能 -2. 提供实用的工具和娱乐 -3. 让聊天更有趣~ - -**记住:** 我是卡尔戈洛,不是普通AI。""" - - await event.reply(info) - -@matcher.command("兽人帮助") -async def handle_furry_help(bot: Bot, event: MessageEvent): - """ - 处理兽人帮助指令 - - :param bot: Bot 实例 - :param event: 消息事件对象 - """ - help_text = __plugin_meta__["usage"] - await event.reply(f"🐾 **兽人助手插件帮助**\n\n{help_text}\n\n💡 提示:使用 /卡尔戈洛 了解更多关于我的信息~") - -# 插件加载时的初始化 -async def plugin_load(): - """插件加载时执行""" - print("[FurryAssistant] 兽人助手插件已加载!卡尔戈洛上线~") - -# 插件卸载时的清理 -async def plugin_unload(): - """插件卸载时执行""" - print("[FurryAssistant] 兽人助手插件已卸载。卡尔戈洛下线...") \ No newline at end of file diff --git a/plugins/knowledge_base.py b/plugins/knowledge_base.py deleted file mode 100644 index 71db0c5..0000000 --- a/plugins/knowledge_base.py +++ /dev/null @@ -1,196 +0,0 @@ -# -*- coding: utf-8 -*- -""" -群聊知识库插件,支持向量数据库检索 -""" -import time -import uuid -from core.managers.command_manager import matcher -from models.events.message import GroupMessageEvent, PrivateMessageEvent -from core.managers.vectordb_manager import vectordb_manager -from core.utils.logger import ModuleLogger -from core.permission import Permission - -logger = ModuleLogger("GroupKnowledgeBase") - -__plugin_meta__ = { - "name": "知识库", - "description": "基于向量数据库的知识库,支持个人和群聊独立记忆", - "usage": "/kb_add <问题> <答案> - 添加个人知识库\n/kb_add_group <问题> <答案> - 添加群聊知识库 (仅管理员)\n/kb_search <关键词> - 搜索知识库\n/kb_remove_person - 清除个人所有记忆\n/kb_remove_group - 清除群聊所有记忆 (仅管理员)" -} - -@matcher.command("kb_add") -async def kb_add_person_command(event: GroupMessageEvent | PrivateMessageEvent, args: list[str]): - """添加个人知识库条目""" - if len(args) < 2: - await event.reply("用法: /kb_add <问题> <答案>") - return - - question = args[0] - answer = " ".join(args[1:]) - user_id = event.user_id - - try: - collection_name = f"knowledge_base_user_{user_id}" - doc_id = str(uuid.uuid4()) - - text_to_embed = f"问题: {question}\n答案: {answer}" - metadata = { - "user_id": user_id, - "question": question, - "answer": answer, - "timestamp": int(time.time()) - } - - success = vectordb_manager.add_texts( - collection_name=collection_name, - texts=[text_to_embed], - metadatas=[metadata], - ids=[doc_id] - ) - - if success: - await event.reply(f"个人知识库条目添加成功!\n问题: {question}") - else: - await event.reply("个人知识库条目添加失败,请查看日志。") - except Exception as e: - logger.error(f"添加个人知识库失败: {e}") - await event.reply(f"添加失败: {str(e)}") - -@matcher.command("kb_add_group", permission=Permission.ADMIN) -async def kb_add_group_command(event: GroupMessageEvent, args: list[str]): - """添加群聊知识库条目""" - if len(args) < 2: - await event.reply("用法: /kb_add_group <问题> <答案>") - return - - question = args[0] - answer = " ".join(args[1:]) - group_id = event.group_id - - try: - collection_name = f"knowledge_base_group_{group_id}" - doc_id = str(uuid.uuid4()) - - text_to_embed = f"问题: {question}\n答案: {answer}" - metadata = { - "group_id": group_id, - "question": question, - "answer": answer, - "added_by": event.user_id, - "timestamp": int(time.time()) - } - - success = vectordb_manager.add_texts( - collection_name=collection_name, - texts=[text_to_embed], - metadatas=[metadata], - ids=[doc_id] - ) - - if success: - await event.reply(f"群聊知识库条目添加成功!\n问题: {question}") - else: - await event.reply("群聊知识库条目添加失败,请查看日志。") - except Exception as e: - logger.error(f"添加群聊知识库失败: {e}") - await event.reply(f"添加失败: {str(e)}") - -@matcher.command("kb_search") -async def kb_search_command(event: GroupMessageEvent | PrivateMessageEvent, args: list[str]): - """搜索知识库条目(优先搜索个人,再搜索群聊)""" - if not args: - await event.reply("用法: /kb_search <关键词>") - return - - query = " ".join(args) - user_id = event.user_id - group_id = getattr(event, 'group_id', None) - - try: - reply_msg = f"为您找到以下相关知识:\n" - found = False - - # 1. 搜索个人知识库 - person_collection = f"knowledge_base_user_{user_id}" - person_results = vectordb_manager.query_texts( - collection_name=person_collection, - query_texts=[query], - n_results=2 - ) - - if person_results and person_results.get("documents") and person_results["documents"][0]: - reply_msg += "\n【个人记忆】" - for i, metadata in enumerate(person_results["metadatas"][0], 1): - question = metadata.get("question", "") - answer = metadata.get("answer", "") - reply_msg += f"\n{i}. Q: {question}\n A: {answer}" - found = True - - # 2. 搜索群聊知识库 - if group_id: - group_collection = f"knowledge_base_group_{group_id}" - group_results = vectordb_manager.query_texts( - collection_name=group_collection, - query_texts=[query], - n_results=2 - ) - - if group_results and group_results.get("documents") and group_results["documents"][0]: - reply_msg += "\n\n【群聊记忆】" - for i, metadata in enumerate(group_results["metadatas"][0], 1): - question = metadata.get("question", "") - answer = metadata.get("answer", "") - reply_msg += f"\n{i}. Q: {question}\n A: {answer}" - found = True - - if not found: - await event.reply("未找到相关的知识库条目。") - return - - await event.reply(reply_msg) - except Exception as e: - logger.error(f"搜索知识库失败: {e}") - await event.reply(f"搜索失败: {str(e)}") - -@matcher.command("kb_remove_person") -async def kb_remove_person_command(event: GroupMessageEvent | PrivateMessageEvent): - """清除个人所有记忆""" - user_id = event.user_id - collection_name = f"knowledge_base_user_{user_id}" - - try: - # ChromaDB 不支持直接删除整个 collection 的所有数据,最简单的方法是删除 collection - if vectordb_manager._client: - try: - vectordb_manager._client.delete_collection(collection_name) - if collection_name in vectordb_manager._collections: - del vectordb_manager._collections[collection_name] - await event.reply("已成功清除您的所有个人记忆。") - except ValueError: - await event.reply("您还没有任何个人记忆。") - else: - await event.reply("向量数据库未初始化。") - except Exception as e: - logger.error(f"清除个人记忆失败: {e}") - await event.reply(f"清除失败: {str(e)}") - -@matcher.command("kb_remove_group", permission=Permission.ADMIN) -async def kb_remove_group_command(event: GroupMessageEvent): - """清除群聊所有记忆""" - group_id = event.group_id - collection_name = f"knowledge_base_group_{group_id}" - - try: - if vectordb_manager._client: - try: - vectordb_manager._client.delete_collection(collection_name) - if collection_name in vectordb_manager._collections: - del vectordb_manager._collections[collection_name] - await event.reply("已成功清除本群的所有群聊记忆。") - except ValueError: - await event.reply("本群还没有任何群聊记忆。") - else: - await event.reply("向量数据库未初始化。") - except Exception as e: - logger.error(f"清除群聊记忆失败: {e}") - await event.reply(f"清除失败: {str(e)}") diff --git a/src/neobot/adapters/discord_adapter.py b/src/neobot/adapters/discord_adapter.py index 3809872..181b8a1 100644 --- a/src/neobot/adapters/discord_adapter.py +++ b/src/neobot/adapters/discord_adapter.py @@ -21,10 +21,10 @@ try: except ImportError: DISCORD_AVAILABLE = False -from core.utils.logger import ModuleLogger +from neobot.core.utils.logger import ModuleLogger from .router import DiscordToOneBotConverter -from core.managers.redis_manager import redis_manager -from core.config_loader import global_config +from neobot.core.managers.redis_manager import redis_manager +from neobot.core.config_loader import global_config class DiscordAdapter(discord.Client if DISCORD_AVAILABLE else object): """ @@ -81,7 +81,7 @@ class DiscordAdapter(discord.Client if DISCORD_AVAILABLE else object): # 1. 将 discord.Message 伪装成 OneBot 事件模型 # 2. 触发业务逻辑 # 将伪装后的事件丢给现有的命令管理器 (matcher) - from core.managers.command_manager import matcher + from neobot.core.managers.command_manager import matcher # matcher.handle_event 需要 bot 实例和 event 实例 # 我们在 create_mock_event 中已经注入了一个假的 bot 对象 diff --git a/plugins/furry_assistant_README.md b/src/neobot/plugins/furry_assistant_README.md similarity index 100% rename from plugins/furry_assistant_README.md rename to src/neobot/plugins/furry_assistant_README.md