重构:移除过时的napcat_adapter_plugin组件

- 从napcat_adapter_plugin中删除了stream_router.py、utils.py、video_handler.py、websocket_manager.py和todo.md文件。
- 在napcat_cache.json中为组和成员信息引入了一种新的缓存结构。
- 通过移除未使用的模块和整合功能,简化了插件的架构。
This commit is contained in:
Windpicker-owo
2025-11-26 16:40:31 +08:00
parent 8a6b141017
commit 8fc4cd4c3b
35 changed files with 162 additions and 9624 deletions

View File

@@ -45,7 +45,6 @@ class BotInterestManager:
"""初始化兴趣标签系统"""
try:
logger.info("机器人兴趣系统开始初始化...")
logger.info(f"人设ID: {personality_id}, 描述长度: {len(personality_description)}")
# 初始化embedding模型
await self._initialize_embedding_model()
@@ -74,19 +73,14 @@ class BotInterestManager:
async def _initialize_embedding_model(self):
"""初始化embedding模型"""
logger.info("🔧 正在配置embedding客户端...")
# 使用项目配置的embedding模型
from src.config.config import model_config
from src.llm_models.utils_model import LLMRequest
logger.debug("✅ 成功导入embedding相关模块")
# 检查embedding配置是否存在
if not hasattr(model_config.model_task_config, "embedding"):
raise RuntimeError("❌ 未找到embedding模型配置")
logger.info("📋 找到embedding模型配置")
self.embedding_config = model_config.model_task_config.embedding
if self.embedding_dimension:
@@ -96,36 +90,20 @@ class BotInterestManager:
# 创建LLMRequest实例用于embedding
self.embedding_request = LLMRequest(model_set=self.embedding_config, request_type="interest_embedding")
logger.info("✅ Embedding请求客户端初始化成功")
logger.info(f"🔗 客户端类型: {type(self.embedding_request).__name__}")
# 获取第一个embedding模型的ModelInfo
if hasattr(self.embedding_config, "model_list") and self.embedding_config.model_list:
first_model_name = self.embedding_config.model_list[0]
logger.info(f"🎯 使用embedding模型: {first_model_name}")
else:
logger.warning("⚠️ 未找到embedding模型列表")
logger.info("✅ Embedding模型初始化完成")
async def _load_or_generate_interests(self, personality_description: str, personality_id: str):
"""加载或生成兴趣标签"""
logger.info(f"📚 正在为 '{personality_id}' 加载或生成兴趣标签...")
# 首先尝试从数据库加载
logger.info("尝试从数据库加载兴趣标签...")
loaded_interests = await self._load_interests_from_database(personality_id)
if loaded_interests:
self.current_interests = loaded_interests
active_count = len(loaded_interests.get_active_tags())
logger.info(f"成功从数据库加载 {active_count} 个兴趣标签 (版本: {loaded_interests.version})")
tags_info = [f" - '{tag.tag_name}' (权重: {tag.weight:.2f})" for tag in loaded_interests.get_active_tags()]
tags_str = "\n".join(tags_info)
logger.info(f"当前兴趣标签:\n{tags_str}")
# 为加载的标签生成embedding数据库不存储embedding启动时动态生成
logger.info("🧠 为加载的标签生成embedding向量...")
await self._generate_embeddings_for_tags(loaded_interests)
else:
# 生成新的兴趣标签
@@ -163,7 +141,6 @@ class BotInterestManager:
raise RuntimeError("❌ Embedding客户端未初始化无法生成兴趣标签")
# 构建提示词
logger.info("📝 构建LLM提示词...")
prompt = f"""
基于以下机器人人设描述,生成一套合适的兴趣标签:
@@ -218,13 +195,11 @@ class BotInterestManager:
"""
# 调用LLM生成兴趣标签
logger.info("🤖 正在调用LLM生成兴趣标签...")
response = await self._call_llm_for_interest_generation(prompt)
if not response:
raise RuntimeError("❌ LLM未返回有效响应")
logger.info("✅ LLM响应成功开始解析兴趣标签...")
# 使用统一的 JSON 解析工具
interests_data = extract_and_parse_json(response, strict=False)
if not interests_data or not isinstance(interests_data, dict):
@@ -290,7 +265,6 @@ class BotInterestManager:
replyer_config = model_config.model_task_config.replyer
# 调用LLM API
logger.info("🚀 正在通过LLM API发送请求...")
success, response, reasoning_content, model_name = await llm_api.generate_with_model(
prompt=full_prompt,
model_config=replyer_config,
@@ -300,13 +274,6 @@ class BotInterestManager:
)
if success and response:
logger.info(f"✅ LLM响应成功模型: {model_name}, 响应长度: {len(response)} 字符")
logger.debug(
f"📄 LLM响应内容: {response[:200]}..." if len(response) > 200 else f"📄 LLM响应内容: {response}"
)
if reasoning_content:
logger.debug(f"🧠 推理内容: {reasoning_content[:100]}...")
# 直接返回原始响应,后续使用统一的 JSON 解析工具
return response
else:
@@ -329,11 +296,8 @@ class BotInterestManager:
# 尝试从文件加载缓存
file_cache = await self._load_embedding_cache_from_file(interests.personality_id)
if file_cache:
logger.info(f"📂 从文件加载 {len(file_cache)} 个embedding缓存")
self.embedding_cache.update(file_cache)
logger.info(f"🧠 开始为 {total_tags} 个兴趣标签生成embedding向量...")
memory_cached_count = 0
file_cached_count = 0
generated_count = 0
@@ -352,8 +316,6 @@ class BotInterestManager:
else:
# 动态生成新的embedding
embedding_text = tag.tag_name
logger.debug(f" [{i}/{total_tags}] 🔄 正在为 '{tag.tag_name}' 动态生成embedding...")
embedding = await self._get_embedding(embedding_text)
if embedding:
@@ -371,18 +333,8 @@ class BotInterestManager:
# 如果有新生成的embedding保存到文件
if generated_count > 0:
await self._save_embedding_cache_to_file(interests.personality_id)
logger.info(f"💾 已将 {generated_count} 个新生成的embedding保存到缓存文件")
interests.last_updated = datetime.now()
logger.info("=" * 50)
logger.info("✅ Embedding生成完成!")
logger.info(f"📊 总标签数: {total_tags}")
logger.info(f"<EFBFBD> 文件缓存命中: {file_cached_count}")
logger.info(f"<EFBFBD>💾 内存缓存命中: {memory_cached_count}")
logger.info(f"🆕 新生成: {generated_count}")
logger.info(f"❌ 失败: {failed_count}")
logger.info(f"🗃️ 总缓存大小: {len(self.embedding_cache)}")
logger.info("=" * 50)
async def _get_embedding(self, text: str) -> list[float]:
"""获取文本的embedding向量"""
@@ -391,11 +343,9 @@ class BotInterestManager:
# 检查缓存
if text in self.embedding_cache:
logger.debug(f"💾 使用缓存的embedding: '{text[:30]}...'")
return self.embedding_cache[text]
# 使用LLMRequest获取embedding
logger.debug(f"🔄 正在获取embedding: '{text[:30]}...'")
if not self.embedding_request:
raise RuntimeError("❌ Embedding客户端未初始化")
embedding, model_name = await self.embedding_request.get_embedding(text)
@@ -414,16 +364,12 @@ class BotInterestManager:
)
else:
self.embedding_dimension = current_dim
logger.info(f"📏 检测到embedding维度: {current_dim}")
elif current_dim != self.embedding_dimension:
logger.warning(
"⚠️ 收到的embedding维度发生变化: 之前=%d, 当前=%d。请确认模型配置是否正确。",
self.embedding_dimension,
current_dim,
)
logger.debug(f"✅ Embedding获取成功维度: {current_dim}, 模型: {model_name}")
return embedding
else:
raise RuntimeError(f"❌ 返回的embedding为空: {embedding}")
@@ -435,11 +381,8 @@ class BotInterestManager:
else:
combined_text = message_text
logger.debug(f"🔄 正在为消息生成embedding输入长度: {len(combined_text)}")
# 生成embedding
embedding = await self._get_embedding(combined_text)
logger.debug(f"✅ 消息embedding生成成功维度: {len(embedding)}")
return embedding
async def generate_embeddings_for_texts(

View File

@@ -258,6 +258,10 @@ class MessageHandler:
group_info = message_info.get("group_info")
user_info = message_info.get("user_info")
if not user_info and not group_info:
logger.debug("消息缺少用户信息,已跳过处理")
return None
# 获取或创建聊天流
platform = message_info.get("platform", "unknown")

View File

@@ -41,6 +41,7 @@ from .base import (
EventHandlerInfo,
EventType,
PluginInfo,
AdapterInfo,
# 新增的增强命令系统
PlusCommand,
BaseRouterComponent,

View File

@@ -15,7 +15,7 @@ from typing import TYPE_CHECKING, Any, Dict, Optional
from mofox_wire import AdapterBase as MoFoxAdapterBase, CoreSink, MessageEnvelope, ProcessCoreSink
if TYPE_CHECKING:
from src.plugin_system.base.base_plugin import BasePlugin
from src.plugin_system import BasePlugin, AdapterInfo
from src.common.logger import get_logger

View File

@@ -3,6 +3,7 @@ from abc import abstractmethod
from src.common.logger import get_logger
from src.plugin_system.base.component_types import (
ActionInfo,
AdapterInfo,
CommandInfo,
ComponentType,
EventHandlerInfo,
@@ -13,6 +14,7 @@ from src.plugin_system.base.component_types import (
)
from .base_action import BaseAction
from .base_adapter import BaseAdapter
from .base_command import BaseCommand
from .base_events_handler import BaseEventHandler
from .base_interest_calculator import BaseInterestCalculator
@@ -35,27 +37,26 @@ class BasePlugin(PluginBase):
@classmethod
def _get_component_info_from_class(cls, component_class: type, component_type: ComponentType):
"""组件类自动生成组件信息
"""类获取组件信息
Args:
component_class: 组件类
component_type: 组件类型
Returns:
对应类型的ComponentInfo对象
对应的ComponentInfo对象
"""
if component_type == ComponentType.COMMAND:
if hasattr(component_class, "get_command_info"):
return component_class.get_command_info()
else:
logger.warning(f"Command {component_class.__name__} 缺少 get_command_info 方法")
logger.warning(f"Command组件 {component_class.__name__} 缺少 get_command_info 方法")
return None
elif component_type == ComponentType.ACTION:
if hasattr(component_class, "get_action_info"):
return component_class.get_action_info()
else:
logger.warning(f"Action {component_class.__name__} 缺少 get_action_info 方法")
logger.warning(f"Action组件 {component_class.__name__} 缺少 get_action_info 方法")
return None
elif component_type == ComponentType.INTEREST_CALCULATOR:
@@ -63,30 +64,37 @@ class BasePlugin(PluginBase):
return component_class.get_interest_calculator_info()
else:
logger.warning(
f"InterestCalculator {component_class.__name__} 缺少 get_interest_calculator_info 方法"
f"InterestCalculator组件 {component_class.__name__} 缺少 get_interest_calculator_info 方法"
)
return None
elif component_type == ComponentType.PLUS_COMMAND:
# PlusCommand的get_info逻辑可以在这里实现
logger.warning("PlusCommand的get_info逻辑尚未实现")
# PlusCommand组件的get_info方法尚未实现
logger.warning("PlusCommand组件的get_info方法尚未实现")
return None
elif component_type == ComponentType.TOOL:
# Tool的get_info逻辑可以在这里实现
logger.warning("Tool的get_info逻辑尚未实现")
# Tool组件的get_info方法尚未实现
logger.warning("Tool组件的get_info方法尚未实现")
return None
elif component_type == ComponentType.EVENT_HANDLER:
# EventHandler的get_info逻辑可以在这里实现
logger.warning("EventHandler的get_info逻辑尚未实现")
# EventHandler组件的get_info方法尚未实现
logger.warning("EventHandler组件的get_info方法尚未实现")
return None
elif component_type == ComponentType.PROMPT:
if hasattr(component_class, "get_prompt_info"):
return component_class.get_prompt_info()
else:
logger.warning(f"Prompt {component_class.__name__} 缺少 get_prompt_info 方法")
logger.warning(f"Prompt组件 {component_class.__name__} 缺少 get_prompt_info 方法")
return None
elif component_type == ComponentType.ADAPTER:
if hasattr(component_class, "get_adapter_info"):
return component_class.get_adapter_info()
else:
logger.warning(f"Adapter<EFBFBD><EFBFBD> {component_class.__name__} ȱ<><C8B1> get_adapter_info <20><><EFBFBD><EFBFBD>")
return None
else:
@@ -95,16 +103,13 @@ class BasePlugin(PluginBase):
@classmethod
def get_component_info(cls, component_class: type, component_type: ComponentType):
"""获取组件信息的通用方法
这是一个便捷方法内部调用_get_component_info_from_class
"""获取组件信息
Args:
component_class: 组件类
component_type: 组件类型
Returns:
对应类型的ComponentInfo对象
对应的ComponentInfo对象
"""
return cls._get_component_info_from_class(component_class, component_type)
@@ -113,6 +118,7 @@ class BasePlugin(PluginBase):
self,
) -> list[
tuple[ActionInfo, type[BaseAction]]
| tuple[AdapterInfo, type[BaseAdapter]]
| tuple[CommandInfo, type[BaseCommand]]
| tuple[PlusCommandInfo, type[PlusCommand]]
| tuple[EventHandlerInfo, type[BaseEventHandler]]

View File

@@ -152,6 +152,7 @@ class AdapterInfo:
name: str # 适配器名称
component_type: ComponentType = field(default=ComponentType.ADAPTER, init=False)
plugin_name: str = "" # <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>
version: str = "1.0.0" # 适配器版本
platform: str = "unknown" # 平台名称
description: str = "" # 适配器描述

View File

@@ -10,6 +10,7 @@ from fastapi import Depends
from src.common.logger import get_logger
from src.config.config import global_config as bot_config
from src.plugin_system.base.base_action import BaseAction
from src.plugin_system.base.base_adapter import BaseAdapter
from src.plugin_system.base.base_chatter import BaseChatter
from src.plugin_system.base.base_command import BaseCommand
from src.plugin_system.base.base_events_handler import BaseEventHandler
@@ -19,6 +20,7 @@ from src.plugin_system.base.base_prompt import BasePrompt
from src.plugin_system.base.base_tool import BaseTool
from src.plugin_system.base.component_types import (
ActionInfo,
AdapterInfo,
ChatterInfo,
CommandInfo,
ComponentInfo,
@@ -46,6 +48,7 @@ ComponentClassType = (
| type[BaseInterestCalculator]
| type[BasePrompt]
| type[BaseRouterComponent]
| type[BaseAdapter]
)
@@ -109,6 +112,21 @@ class ComponentRegistry:
"""启用的chatter名 -> chatter类"""
logger.info("组件注册中心初始化完成")
self._interest_calculator_registry: dict[str, type["BaseInterestCalculator"]] = {}
"""兴趣计算器名 -> 兴趣计算器类"""
self._enabled_interest_calculator_registry: dict[str, type["BaseInterestCalculator"]] = {}
"""启用的兴趣计算器名 -> 兴趣计算器类"""
self._prompt_registry: dict[str, type["BasePrompt"]] = {}
"""提示词组件名 -> 提示词组件类"""
self._enabled_prompt_registry: dict[str, type["BasePrompt"]] = {}
"""启用的提示词组件名 -> 提示词组件类"""
self._adapter_registry: dict[str, type["BaseAdapter"]] = {}
"""适配器组件名 -> 适配器组件类"""
self._enabled_adapter_registry: dict[str, type["BaseAdapter"]] = {}
"""启用的适配器组件名 -> 适配器组件类"""
# == 注册方法 ==
def register_plugin(self, plugin_info: PluginInfo) -> bool:
@@ -204,6 +222,10 @@ class ComponentRegistry:
assert isinstance(component_info, RouterInfo)
assert issubclass(component_class, BaseRouterComponent)
ret = self._register_router_component(component_info, component_class)
case ComponentType.ADAPTER:
assert isinstance(component_info, AdapterInfo)
assert issubclass(component_class, BaseAdapter)
ret = self._register_adapter_component(component_info, component_class)
case _:
logger.warning(f"未知组件类型: {component_type}")
ret = False
@@ -335,12 +357,6 @@ class ComponentRegistry:
logger.error(f"注册失败: {calculator_name} 不是有效的InterestCalculator")
return False
# 创建专门的InterestCalculator注册表如果还没有
if not hasattr(self, "_interest_calculator_registry"):
self._interest_calculator_registry: dict[str, type["BaseInterestCalculator"]] = {}
if not hasattr(self, "_enabled_interest_calculator_registry"):
self._enabled_interest_calculator_registry: dict[str, type["BaseInterestCalculator"]] = {}
_assign_plugin_attrs(
interest_calculator_class,
interest_calculator_info.plugin_name,
@@ -365,11 +381,6 @@ class ComponentRegistry:
logger.error(f"Prompt组件 {prompt_class.__name__} 必须指定名称")
return False
if not hasattr(self, "_prompt_registry"):
self._prompt_registry: dict[str, type[BasePrompt]] = {}
if not hasattr(self, "_enabled_prompt_registry"):
self._enabled_prompt_registry: dict[str, type[BasePrompt]] = {}
_assign_plugin_attrs(
prompt_class, prompt_info.plugin_name, self.get_plugin_config(prompt_info.plugin_name) or {}
)
@@ -420,6 +431,29 @@ class ComponentRegistry:
logger.error(f"注册路由组件 '{router_info.name}' 时出错: {e}", exc_info=True)
return False
def _register_adapter_component(self, adapter_info: AdapterInfo, adapter_class: type[BaseAdapter]) -> bool:
"""将Adapter组件注册到Adapter特定注册表"""
adapter_name = adapter_info.name
if not adapter_name:
logger.error(f"Adapter组件 {adapter_class.__name__} 必须指定名称")
return False
if not isinstance(adapter_info, AdapterInfo) or not issubclass(adapter_class, BaseAdapter):
logger.error(f"注册失败: {adapter_name} 不是有效的Adapter")
return False
_assign_plugin_attrs(
adapter_class, adapter_info.plugin_name, self.get_plugin_config(adapter_info.plugin_name) or {}
)
if not hasattr(self, "_adapter_registry"):
self._adapter_registry: dict[str, type[BaseAdapter]] = {}
self._adapter_registry[adapter_name] = adapter_class
if not adapter_info.enabled:
logger.warning(f"Adapter {adapter_name} 未启用")
return True
# === 组件移除相关 ===
async def remove_component(self, component_name: str, component_type: ComponentType, plugin_name: str) -> bool:
@@ -664,6 +698,7 @@ class ComponentRegistry:
| BaseInterestCalculator
| BasePrompt
| BaseRouterComponent
| BaseAdapter
]
| None
):
@@ -693,6 +728,7 @@ class ComponentRegistry:
| type[BaseInterestCalculator]
| type[BasePrompt]
| type[BaseRouterComponent]
| type[BaseAdapter]
| None,
self._components_classes.get(namespaced_name),
)
@@ -919,6 +955,7 @@ class ComponentRegistry:
chatter_components: int = 0
prompt_components: int = 0
router_components: int = 0
adapter_components: int = 0
for component in self._components.values():
if component.component_type == ComponentType.ACTION:
action_components += 1
@@ -936,6 +973,8 @@ class ComponentRegistry:
prompt_components += 1
elif component.component_type == ComponentType.ROUTER:
router_components += 1
elif component.component_type == ComponentType.ADAPTER:
adapter_components += 1
return {
"action_components": action_components,
"command_components": command_components,
@@ -946,6 +985,7 @@ class ComponentRegistry:
"chatter_components": chatter_components,
"prompt_components": prompt_components,
"router_components": router_components,
"adapter_components": adapter_components,
"total_components": len(self._components),
"total_plugins": len(self._plugins),
"components_by_type": {

View File

@@ -481,13 +481,14 @@ class PluginManager:
chatter_count = stats.get("chatter_components", 0)
prompt_count = stats.get("prompt_components", 0)
router_count = stats.get("router_components", 0)
adapter_count = stats.get("adapter_components", 0)
total_components = stats.get("total_components", 0)
# 📋 显示插件加载总览
if total_registered > 0:
logger.info("🎉 插件系统加载完成!")
logger.info(
f"📊 总览: {total_registered}个插件, {total_components}个组件 (Action: {action_count}, Command: {command_count}, Tool: {tool_count}, PlusCommand: {plus_command_count}, EventHandler: {event_handler_count}, Chatter: {chatter_count}, Prompt: {prompt_count}, Router: {router_count})"
f"📊 总览: {total_registered}个插件, {total_components}个组件 (Action: {action_count}, Command: {command_count}, Tool: {tool_count}, PlusCommand: {plus_command_count}, EventHandler: {event_handler_count}, Chatter: {chatter_count}, Prompt: {prompt_count}, Router: {router_count}, Adapter: {adapter_count})"
)
# 显示详细的插件列表
@@ -531,6 +532,9 @@ class PluginManager:
router_components = [
c for c in plugin_info.components if c.component_type == ComponentType.ROUTER
]
adapter_components = [
c for c in plugin_info.components if c.component_type == ComponentType.ADAPTER
]
if action_components:
action_details = [format_component(c) for c in action_components]
@@ -560,6 +564,9 @@ class PluginManager:
if router_components:
router_details = [format_component(c) for c in router_components]
logger.info(f" 🌐 Router组件: {', '.join(router_details)}")
if adapter_components:
adapter_details = [format_component(c) for c in adapter_components]
logger.info(f" 🔌 Adapter组件: {', '.join(adapter_details)}")
# 权限节点信息
if plugin_instance := self.loaded_plugins.get(plugin_name):

View File

@@ -19,7 +19,7 @@ import websockets
from mofox_wire import CoreSink, MessageEnvelope, WebSocketAdapterOptions
from src.common.logger import get_logger
from src.plugin_system import register_plugin
from src.plugin_system import ConfigField, register_plugin
from src.plugin_system.base import BaseAdapter, BasePlugin
from src.plugin_system.apis import config_api
@@ -43,7 +43,7 @@ class NapcatAdapter(BaseAdapter):
run_in_subprocess = False
def __init__(self, core_sink: CoreSink, plugin: Optional[BasePlugin] = None):
def __init__(self, core_sink: CoreSink, plugin: Optional[BasePlugin] = None, **kwargs):
"""初始化 Napcat 适配器"""
# 从插件配置读取 WebSocket URL
if plugin:
@@ -52,7 +52,6 @@ class NapcatAdapter(BaseAdapter):
access_token = config_api.get_plugin_config(plugin.config, "napcat_server.access_token", "")
ws_url = f"ws://{host}:{port}"
headers = {}
if access_token:
headers["Authorization"] = f"Bearer {access_token}"
@@ -62,11 +61,12 @@ class NapcatAdapter(BaseAdapter):
# 配置 WebSocket 传输
transport = WebSocketAdapterOptions(
mode="server",
url=ws_url,
headers=headers if headers else None,
)
super().__init__(core_sink, plugin=plugin, transport=transport)
super().__init__(core_sink, plugin=plugin, transport=transport, **kwargs)
# 初始化处理器
self.message_handler = MessageHandler(self)
@@ -178,11 +178,14 @@ class NapcatAdapter(BaseAdapter):
self._response_pool[echo] = future
# 构造请求
request = orjson.dumps({
# Napcat expects JSON text frames; orjson.dumps returns bytes so decode to str
request = orjson.dumps(
{
"action": action,
"params": params,
"echo": echo,
})
}
).decode()
try:
# 发送请求
@@ -214,57 +217,53 @@ class NapcatAdapterPlugin(BasePlugin):
"""Napcat 适配器插件"""
plugin_name = "napcat_adapter_plugin"
config_file_name = "config.toml"
enable_plugin = True
plugin_version = "2.0.0"
plugin_author = "MoFox Team"
plugin_description = "Napcat/OneBot 11 适配器(基于 MoFox-Bus 重写)"
# 配置 Schema
config_schema: ClassVar[dict] = {
"plugin": {
"name": {"type": str, "default": "napcat_adapter_plugin"},
"version": {"type": str, "default": "1.0.0"},
"enabled": {"type": bool, "default": True},
},
"napcat_server": {
"host": {"type": str, "default": "localhost"},
"port": {"type": int, "default": 8095},
"access_token": {"type": str, "default": ""},
},
"features": {
"group_list_type": {"type": str, "default": "blacklist"},
"group_list": {"type": list, "default": []},
"private_list_type": {"type": str, "default": "blacklist"},
"private_list": {"type": list, "default": []},
"ban_user_id": {"type": list, "default": []},
"ban_qq_bot": {"type": bool, "default": False},
},
config_section_descriptions: ClassVar = {
"plugin": "插件开关",
"napcat_server": "Napcat WebSocket 连接设置",
"features": "过滤和名单配置",
}
def __init__(self):
self._adapter: Optional[NapcatAdapter] = None
async def on_plugin_loaded(self):
"""插件加载时启动适配器"""
logger.info("Napcat 适配器插件正在加载...")
# 从 CoreSinkManager 获取 InProcessCoreSink
from src.common.core_sink_manager import get_core_sink_manager
core_sink_manager = get_core_sink_manager()
core_sink = core_sink_manager.get_in_process_sink()
# 创建并启动适配器
self._adapter = NapcatAdapter(core_sink, plugin=self)
await self._adapter.start()
logger.info("Napcat 适配器插件已加载")
async def on_plugin_unloaded(self):
"""插件卸载时停止适配器"""
if self._adapter:
await self._adapter.stop()
logger.info("Napcat 适配器插件已卸载")
config_schema: ClassVar[dict] = {
"plugin": {
"enabled": ConfigField(type=bool, default=True, description="是否启用 Napcat 适配器"),
"config_version": ConfigField(type=str, default="2.0.0", description="配置文件版本"),
},
"napcat_server": {
"mode": ConfigField(
type=str,
default="reverse",
description="ws 连接模式: reverse/direct",
choices=["reverse", "direct"],
),
"host": ConfigField(type=str, default="localhost", description="Napcat WebSocket 服务地址"),
"port": ConfigField(type=int, default=8095, description="Napcat WebSocket 服务端口"),
"access_token": ConfigField(type=str, default="", description="Napcat API 访问令牌(可选)"),
},
"features": {
"group_list_type": ConfigField(
type=str,
default="blacklist",
description="群聊名单模式: blacklist/whitelist",
choices=["blacklist", "whitelist"],
),
"group_list": ConfigField(type=list, default=[], description="群聊名单;根据名单模式过滤"),
"private_list_type": ConfigField(
type=str,
default="blacklist",
description="私聊名单模式: blacklist/whitelist",
choices=["blacklist", "whitelist"],
),
"private_list": ConfigField(type=list, default=[], description="私聊名单;根据名单模式过滤"),
"ban_user_id": ConfigField(type=list, default=[], description="全局封禁的用户 ID 列表"),
"ban_qq_bot": ConfigField(type=bool, default=False, description="是否屏蔽其他 QQ 机器人消息"),
},
}
def get_plugin_components(self) -> list:
"""返回适配器组件"""

View File

@@ -0,0 +1 @@
{"group_info":{"169850076":{"data":{"group_all_shut":0,"group_remark":"","group_id":169850076,"group_name":"墨狐狐🌟起源之地","member_count":439,"max_member_count":500},"ts":1764145931.0869653},"329577252":{"data":{"group_all_shut":0,"group_remark":"","group_id":329577252,"group_name":"真言的小窝『小可爱聚集地』","member_count":1748,"max_member_count":2000},"ts":1764145933.5440657},"160791652":{"data":{"group_all_shut":0,"group_remark":"","group_id":160791652,"group_name":"一群「往世乐土」","member_count":476,"max_member_count":500},"ts":1764146234.340923}},"group_detail_info":{},"member_info":{"329577252:3307339367":{"data":{"group_id":329577252,"user_id":3307339367,"nickname":"爱丽丝","card":"爱丽丝","sex":"unknown","age":0,"area":"","level":"70","qq_level":11,"join_time":1751672499,"last_sent_time":1764145599,"title_expire_time":0,"unfriendly":false,"card_changeable":true,"is_robot":false,"shut_up_timestamp":0,"role":"admin","title":"不智能ai"},"ts":1764145619.5129182},"169850076:2359337932":{"data":{"group_id":169850076,"user_id":2359337932,"nickname":"不到人","card":"","sex":"unknown","age":0,"area":"","level":"43","qq_level":56,"join_time":1758019556,"last_sent_time":1764145519,"title_expire_time":0,"unfriendly":false,"card_changeable":true,"is_robot":false,"shut_up_timestamp":0,"role":"admin","title":""},"ts":1764145931.3597307}},"stranger_info":{},"self_info":{}}

View File

@@ -106,11 +106,13 @@ class MessageHandler:
accept_format=ACCEPT_FORMAT,
)
msg_builder.seg_list(seg_list)
return msg_builder.build()
async def handle_single_segment(
self, segment: dict, raw_message: dict, in_reply: bool = False
) -> SegPayload | List[SegPayload] | None:
) -> SegPayload | None:
"""
处理单一消息段并转换为 MessageEnvelope
@@ -203,31 +205,25 @@ class MessageHandler:
sender_info: dict = message_detail.get("sender", {})
sender_nickname: str = sender_info.get("nickname", "")
sender_id = sender_info.get("user_id")
seg_message: List[SegPayload] = []
if not sender_nickname:
logger.warning("无法获取被引用的人的昵称,返回默认值")
seg_message.append(
{
return {
"type": "text",
"data": f"[回复<未知用户>{reply_message}],说:",
}
)
else:
if sender_id:
seg_message.append(
{
return {
"type": "text",
"data": f"[回复<{sender_nickname}({sender_id})>{reply_message}],说:",
}
)
else:
seg_message.append(
{
return {
"type": "text",
"data": f"[回复<{sender_nickname}>{reply_message}],说:",
}
)
return seg_message
case "voice":
seg_data = segment.get("url", "")
case _:

View File

@@ -1,279 +0,0 @@
log/
logs/
out/
.env
.env.*
.cursor
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
uv.lock
llm_statistics.txt
mongodb
napcat
run_dev.bat
elua.confirmed
# C extensions
*.so
/results
config_backup/
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# UV
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
#uv.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
#pdm.lock
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
# in version control.
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
.pdm.toml
.pdm-python
.pdm-build/
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
# PyPI configuration file
.pypirc
# rjieba
rjieba.cache
# .vscode
!.vscode/settings.json
# direnv
/.direnv
# JetBrains
.idea
*.iml
*.ipr
# PyEnv
# If using PyEnv and configured to use a specific Python version locally
# a .local-version file will be created in the root of the project to specify the version.
.python-version
OtherRes.txt
/eula.confirmed
/privacy.confirmed
logs
.ruff_cache
.vscode
/config/*
config/old/bot_config_20250405_212257.toml
temp/
# General
.DS_Store
.AppleDouble
.LSOverride
# Icon must end with two \r
Icon
# Thumbnails
._*
# Files that might appear in the root of a volume
.DocumentRevisions-V100
.fseventsd
.Spotlight-V100
.TemporaryItems
.Trashes
.VolumeIcon.icns
.com.apple.timemachine.donotpresent
# Directories potentially created on remote AFP share
.AppleDB
.AppleDesktop
Network Trash Folder
Temporary Items
.apdisk
# Windows thumbnail cache files
Thumbs.db
Thumbs.db:encryptable
ehthumbs.db
ehthumbs_vista.db
# Dump file
*.stackdump
# Folder config file
[Dd]esktop.ini
# Recycle Bin used on file shares
$RECYCLE.BIN/
# Windows Installer files
*.cab
*.msi
*.msix
*.msm
*.msp
# Windows shortcuts
*.lnk
config.toml
feature.toml
config.toml.back
test
data/NapcatAdapter.db
data/NapcatAdapter.db-shm
data/NapcatAdapter.db-wal

View File

@@ -1,254 +0,0 @@
PLUGIN_NAME = "napcat_adapter"
QQ_FACE: dict = {
"0": "[表情:惊讶]",
"1": "[表情:撇嘴]",
"2": "[表情:色]",
"3": "[表情:发呆]",
"4": "[表情:得意]",
"5": "[表情:流泪]",
"6": "[表情:害羞]",
"7": "[表情:闭嘴]",
"8": "[表情:睡]",
"9": "[表情:大哭]",
"10": "[表情:尴尬]",
"11": "[表情:发怒]",
"12": "[表情:调皮]",
"13": "[表情:呲牙]",
"14": "[表情:微笑]",
"15": "[表情:难过]",
"16": "[表情:酷]",
"18": "[表情:抓狂]",
"19": "[表情:吐]",
"20": "[表情:偷笑]",
"21": "[表情:可爱]",
"22": "[表情:白眼]",
"23": "[表情:傲慢]",
"24": "[表情:饥饿]",
"25": "[表情:困]",
"26": "[表情:惊恐]",
"27": "[表情:流汗]",
"28": "[表情:憨笑]",
"29": "[表情:悠闲]",
"30": "[表情:奋斗]",
"31": "[表情:咒骂]",
"32": "[表情:疑问]",
"33": "[表情: 嘘]",
"34": "[表情:晕]",
"35": "[表情:折磨]",
"36": "[表情:衰]",
"37": "[表情:骷髅]",
"38": "[表情:敲打]",
"39": "[表情:再见]",
"41": "[表情:发抖]",
"42": "[表情:爱情]",
"43": "[表情:跳跳]",
"46": "[表情:猪头]",
"49": "[表情:拥抱]",
"53": "[表情:蛋糕]",
"56": "[表情:刀]",
"59": "[表情:便便]",
"60": "[表情:咖啡]",
"63": "[表情:玫瑰]",
"64": "[表情:凋谢]",
"66": "[表情:爱心]",
"67": "[表情:心碎]",
"74": "[表情:太阳]",
"75": "[表情:月亮]",
"76": "[表情:赞]",
"77": "[表情:踩]",
"78": "[表情:握手]",
"79": "[表情:胜利]",
"85": "[表情:飞吻]",
"86": "[表情:怄火]",
"89": "[表情:西瓜]",
"96": "[表情:冷汗]",
"97": "[表情:擦汗]",
"98": "[表情:抠鼻]",
"99": "[表情:鼓掌]",
"100": "[表情:糗大了]",
"101": "[表情:坏笑]",
"102": "[表情:左哼哼]",
"103": "[表情:右哼哼]",
"104": "[表情:哈欠]",
"105": "[表情:鄙视]",
"106": "[表情:委屈]",
"107": "[表情:快哭了]",
"108": "[表情:阴险]",
"109": "[表情:左亲亲]",
"110": "[表情:吓]",
"111": "[表情:可怜]",
"112": "[表情:菜刀]",
"114": "[表情:篮球]",
"116": "[表情:示爱]",
"118": "[表情:抱拳]",
"119": "[表情:勾引]",
"120": "[表情:拳头]",
"121": "[表情:差劲]",
"123": "[表情NO]",
"124": "[表情OK]",
"125": "[表情:转圈]",
"129": "[表情:挥手]",
"137": "[表情:鞭炮]",
"144": "[表情:喝彩]",
"146": "[表情:爆筋]",
"147": "[表情:棒棒糖]",
"169": "[表情:手枪]",
"171": "[表情:茶]",
"172": "[表情:眨眼睛]",
"173": "[表情:泪奔]",
"174": "[表情:无奈]",
"175": "[表情:卖萌]",
"176": "[表情:小纠结]",
"177": "[表情:喷血]",
"178": "[表情:斜眼笑]",
"179": "[表情doge]",
"181": "[表情:戳一戳]",
"182": "[表情:笑哭]",
"183": "[表情:我最美]",
"185": "[表情:羊驼]",
"187": "[表情:幽灵]",
"201": "[表情:点赞]",
"212": "[表情:托腮]",
"262": "[表情:脑阔疼]",
"263": "[表情:沧桑]",
"264": "[表情:捂脸]",
"265": "[表情:辣眼睛]",
"266": "[表情:哦哟]",
"267": "[表情:头秃]",
"268": "[表情:问号脸]",
"269": "[表情:暗中观察]",
"270": "[表情emm]",
"271": "[表情:吃 瓜]",
"272": "[表情:呵呵哒]",
"273": "[表情:我酸了]",
"277": "[表情:滑稽狗头]",
"281": "[表情:翻白眼]",
"282": "[表情:敬礼]",
"283": "[表情:狂笑]",
"284": "[表情:面无表情]",
"285": "[表情:摸鱼]",
"286": "[表情:魔鬼笑]",
"287": "[表情:哦]",
"289": "[表情:睁眼]",
"293": "[表情:摸锦鲤]",
"294": "[表情:期待]",
"295": "[表情:拿到红包]",
"297": "[表情:拜谢]",
"298": "[表情:元宝]",
"299": "[表情:牛啊]",
"300": "[表情:胖三斤]",
"302": "[表情:左拜年]",
"303": "[表情:右拜年]",
"305": "[表情:右亲亲]",
"306": "[表情:牛气冲天]",
"307": "[表情:喵喵]",
"311": "[表情打call]",
"312": "[表情:变形]",
"314": "[表情:仔细分析]",
"317": "[表情:菜汪]",
"318": "[表情:崇拜]",
"319": "[表情: 比心]",
"320": "[表情:庆祝]",
"323": "[表情:嫌弃]",
"324": "[表情:吃糖]",
"325": "[表情:惊吓]",
"326": "[表情:生气]",
"332": "[表情:举牌牌]",
"333": "[表情:烟花]",
"334": "[表情:虎虎生威]",
"336": "[表情:豹富]",
"337": "[表情:花朵脸]",
"338": "[表情:我想开了]",
"339": "[表情:舔屏]",
"341": "[表情:打招呼]",
"342": "[表情酸Q]",
"343": "[表情:我方了]",
"344": "[表情:大怨种]",
"345": "[表情:红包多多]",
"346": "[表情:你真棒棒]",
"347": "[表情:大展宏兔]",
"349": "[表情:坚强]",
"350": "[表情:贴贴]",
"351": "[表情:敲敲]",
"352": "[表情:咦]",
"353": "[表情:拜托]",
"354": "[表情:尊嘟假嘟]",
"355": "[表情:耶]",
"356": "[表情666]",
"357": "[表情:裂开]",
"392": "[表情:龙年 快乐]",
"393": "[表情:新年中龙]",
"394": "[表情:新年大龙]",
"395": "[表情:略略略]",
"396": "[表情:龙年快乐]",
"424": "[表情:按钮]",
"😊": "[表情:嘿嘿]",
"😌": "[表情:羞涩]",
"😚": "[ 表情:亲亲]",
"😓": "[表情:汗]",
"😰": "[表情:紧张]",
"😝": "[表情:吐舌]",
"😁": "[表情:呲牙]",
"😜": "[表情:淘气]",
"": "[表情:可爱]",
"😍": "[表情:花痴]",
"😔": "[表情:失落]",
"😄": "[表情:高兴]",
"😏": "[表情:哼哼]",
"😒": "[表情:不屑]",
"😳": "[表情:瞪眼]",
"😘": "[表情:飞吻]",
"😭": "[表情:大哭]",
"😱": "[表情:害怕]",
"😂": "[表情:激动]",
"💪": "[表情:肌肉]",
"👊": "[表情:拳头]",
"👍": "[表情 :厉害]",
"👏": "[表情:鼓掌]",
"👎": "[表情:鄙视]",
"🙏": "[表情:合十]",
"👌": "[表情:好的]",
"👆": "[表情:向上]",
"👀": "[表情:眼睛]",
"🍜": "[表情:拉面]",
"🍧": "[表情:刨冰]",
"🍞": "[表情:面包]",
"🍺": "[表情:啤酒]",
"🍻": "[表情:干杯]",
"": "[表情:咖啡]",
"🍎": "[表情:苹果]",
"🍓": "[表情:草莓]",
"🍉": "[表情:西瓜]",
"🚬": "[表情:吸烟]",
"🌹": "[表情:玫瑰]",
"🎉": "[表情:庆祝]",
"💝": "[表情:礼物]",
"💣": "[表情:炸弹]",
"": "[表情:闪光]",
"💨": "[表情:吹气]",
"💦": "[表情:水]",
"🔥": "[表情:火]",
"💤": "[表情:睡觉]",
"💩": "[表情:便便]",
"💉": "[表情:打针]",
"📫": "[表情:邮箱]",
"🐎": "[表情:骑马]",
"👧": "[表情:女孩]",
"👦": "[表情:男孩]",
"🐵": "[表情:猴]",
"🐷": "[表情:猪]",
"🐮": "[表情:牛]",
"🐔": "[表情:公鸡]",
"🐸": "[表情:青蛙]",
"👻": "[表情:幽灵]",
"🐛": "[表情:虫]",
"🐶": "[表情:狗]",
"🐳": "[表情:鲸鱼]",
"👢": "[表情:靴子]",
"": "[表情:晴天]",
"": "[表情:问号]",
"🔫": "[表情:手枪]",
"💓": "[表情:爱 心]",
"🏪": "[表情:便利店]",
}

View File

@@ -1,16 +0,0 @@
from src.plugin_system.base.plugin_metadata import PluginMetadata
__plugin_meta__ = PluginMetadata(
name="napcat_plugin",
description="基于OneBot 11协议的NapCat QQ协议插件提供完整的QQ机器人API接口使用现有adapter连接",
usage="该插件提供 `napcat_tool` tool。",
version="1.0.0",
author="Windpicker_owo",
license="GPL-v3.0-or-later",
repository_url="https://github.com/Windpicker-owo/InternetSearchPlugin",
keywords=["qq", "bot", "napcat", "onebot", "api", "websocket"],
categories=["protocol"],
extra={
"is_built_in": False,
},
)

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,430 +0,0 @@
import asyncio
import inspect
import orjson
from typing import ClassVar, List
import websockets as Server
from src.common.logger import get_logger
from src.plugin_system import BaseEventHandler, BasePlugin, ConfigField, EventType, register_plugin
from src.plugin_system.apis import config_api
from src.plugin_system.core.event_manager import event_manager
from . import CONSTS, event_handlers, event_types
from .src.message_chunker import chunker, reassembler
from .src.mmc_com_layer import mmc_start_com, mmc_stop_com, router
from .src.recv_handler.message_handler import message_handler
from .src.recv_handler.message_sending import message_send_instance
from .src.recv_handler.meta_event_handler import meta_event_handler
from .src.recv_handler.notice_handler import notice_handler
from .src.response_pool import check_timeout_response, put_response
from .src.send_handler import send_handler
from .src.stream_router import stream_router
from .src.websocket_manager import websocket_manager
logger = get_logger("napcat_adapter")
# 旧的全局消息队列已被流路由器替代
# message_queue = asyncio.Queue()
def get_classes_in_module(module):
classes = []
for _name, member in inspect.getmembers(module):
if inspect.isclass(member):
classes.append(member)
return classes
async def message_recv(server_connection: Server.ServerConnection):
await message_handler.set_server_connection(server_connection)
asyncio.create_task(notice_handler.set_server_connection(server_connection))
await send_handler.set_server_connection(server_connection)
async for raw_message in server_connection:
# 只在debug模式下记录原始消息
if logger.level <= 10: # DEBUG level
logger.debug(f"{raw_message[:1500]}..." if (len(raw_message) > 1500) else raw_message)
decoded_raw_message: dict = orjson.loads(raw_message)
try:
# 首先尝试解析原始消息
decoded_raw_message: dict = orjson.loads(raw_message)
# 检查是否是切片消息 (来自 MMC)
if chunker.is_chunk_message(decoded_raw_message):
logger.debug("接收到切片消息,尝试重组")
# 尝试重组消息
reassembled_message = await reassembler.add_chunk(decoded_raw_message)
if reassembled_message:
# 重组完成,处理完整消息
logger.debug("消息重组完成,处理完整消息")
decoded_raw_message = reassembled_message
else:
# 切片尚未完整,继续等待更多切片
logger.debug("等待更多切片...")
continue
# 处理完整消息(可能是重组后的,也可能是原本就完整的)
post_type = decoded_raw_message.get("post_type")
if post_type in ["meta_event", "message", "notice"]:
# 使用流路由器路由消息到对应的聊天流
await stream_router.route_message(decoded_raw_message)
elif post_type is None:
await put_response(decoded_raw_message)
except orjson.JSONDecodeError as e:
logger.error(f"消息解析失败: {e}")
logger.debug(f"原始消息: {raw_message[:500]}...")
except Exception as e:
logger.error(f"处理消息时出错: {e}")
logger.debug(f"原始消息: {raw_message[:500]}...")
# 旧的单消费者消息处理循环已被流路由器替代
# 现在每个聊天流都有自己的消费者协程
# async def message_process():
# """消息处理主循环"""
# ...
async def napcat_server(plugin_config: dict):
"""启动 Napcat WebSocket 连接(支持正向和反向连接)"""
# 使用插件系统配置API获取配置
mode = config_api.get_plugin_config(plugin_config, "napcat_server.mode")
logger.info(f"正在启动 adapter连接模式: {mode}")
try:
await websocket_manager.start_connection(message_recv, plugin_config)
except Exception as e:
logger.error(f"启动 WebSocket 连接失败: {e}")
raise
async def graceful_shutdown():
"""优雅关闭所有组件"""
try:
logger.info("正在关闭adapter...")
# 停止流路由器
try:
await stream_router.stop()
except Exception as e:
logger.warning(f"停止流路由器时出错: {e}")
# 停止消息重组器的清理任务
try:
await reassembler.stop_cleanup_task()
except Exception as e:
logger.warning(f"停止消息重组器清理任务时出错: {e}")
# 停止功能管理器文件监控(已迁移到插件系统配置,无需操作)
# 关闭消息处理器(包括消息缓冲器)
try:
await message_handler.shutdown()
except Exception as e:
logger.warning(f"关闭消息处理器时出错: {e}")
# 关闭 WebSocket 连接
try:
await websocket_manager.stop_connection()
except Exception as e:
logger.warning(f"关闭WebSocket连接时出错: {e}")
# 关闭 MoFox-Bot 连接
try:
await mmc_stop_com()
except Exception as e:
logger.warning(f"关闭MoFox-Bot连接时出错: {e}")
# 取消所有剩余任务
current_task = asyncio.current_task()
tasks = [t for t in asyncio.all_tasks() if t is not current_task and not t.done()]
if tasks:
logger.info(f"正在取消 {len(tasks)} 个剩余任务...")
for task in tasks:
task.cancel()
# 等待任务取消完成,忽略 CancelledError
try:
await asyncio.wait_for(asyncio.gather(*tasks, return_exceptions=True), timeout=10)
except asyncio.TimeoutError:
logger.warning("部分任务取消超时")
except Exception as e:
logger.debug(f"任务取消过程中的异常(可忽略): {e}")
logger.info("Adapter已成功关闭")
except Exception as e:
logger.error(f"Adapter关闭中出现错误: {e}")
class LauchNapcatAdapterHandler(BaseEventHandler):
"""自动启动Adapter"""
handler_name: str = "launch_napcat_adapter_handler"
handler_description: str = "自动启动napcat adapter"
weight: int = 100
intercept_message: bool = False
init_subscribe: ClassVar[list] = [EventType.ON_START]
async def execute(self, kwargs):
# 启动消息重组器的清理任务
logger.info("启动消息重组器...")
await reassembler.start_cleanup_task()
# 启动流路由器
logger.info("启动流路由器...")
await stream_router.start()
logger.info("开始启动Napcat Adapter")
# 创建单独的异步任务,防止阻塞主线程
asyncio.create_task(self._start_maibot_connection())
asyncio.create_task(napcat_server(self.plugin_config))
# 不再需要 message_process 任务,由流路由器管理消费者
asyncio.create_task(check_timeout_response())
async def _start_maibot_connection(self):
"""非阻塞方式启动MoFox-Bot连接等待主服务启动后再连接"""
# 等待一段时间让MoFox-Bot主服务完全启动
await asyncio.sleep(5)
max_attempts = 10
attempt = 0
while attempt < max_attempts:
try:
logger.info(f"尝试连接MoFox-Bot (第{attempt + 1}次)")
await mmc_start_com(self.plugin_config)
message_send_instance.maibot_router = router
logger.info("MoFox-Bot router连接已建立")
return
except Exception as e:
attempt += 1
if attempt >= max_attempts:
logger.error(f"MoFox-Bot连接失败已达到最大重试次数: {e}")
return
else:
delay = min(2 + attempt, 10) # 逐渐增加延迟最大10秒
logger.warning(f"MoFox-Bot连接失败: {e}{delay}秒后重试")
await asyncio.sleep(delay)
class StopNapcatAdapterHandler(BaseEventHandler):
"""关闭Adapter"""
handler_name: str = "stop_napcat_adapter_handler"
handler_description: str = "关闭napcat adapter"
weight: int = 100
intercept_message: bool = False
init_subscribe: ClassVar[list] = [EventType.ON_STOP]
async def execute(self, kwargs):
await graceful_shutdown()
return
@register_plugin
class NapcatAdapterPlugin(BasePlugin):
plugin_name = CONSTS.PLUGIN_NAME
dependencies: ClassVar[List[str]] = [] # 插件依赖列表
python_dependencies: ClassVar[List[str]] = [] # Python包依赖列表
config_file_name: str = "config.toml" # 配置文件名
@property
def enable_plugin(self) -> bool:
"""通过配置文件动态控制插件启用状态"""
# 如果已经通过配置加载了状态,使用配置中的值
# 否则使用默认值(禁用状态)
return False
# 配置节描述
config_section_descriptions: ClassVar[dict] = {"plugin": "插件基本信息"}
# 配置Schema定义
config_schema: ClassVar[dict] = {
"plugin": {
"name": ConfigField(type=str, default="napcat_adapter_plugin", description="插件名称"),
"version": ConfigField(type=str, default="1.1.0", description="插件版本"),
"config_version": ConfigField(type=str, default="1.3.1", description="配置文件版本"),
"enabled": ConfigField(type=bool, default=True, description="是否启用插件"),
},
"inner": {
"version": ConfigField(type=str, default="0.2.1", description="配置版本号,请勿修改"),
},
"nickname": {
"nickname": ConfigField(type=str, default="", description="昵称配置(目前未使用)"),
},
"napcat_server": {
"mode": ConfigField(
type=str,
default="reverse",
description="连接模式reverse=反向连接(作为服务器), forward=正向连接(作为客户端)",
choices=["reverse", "forward"],
),
"host": ConfigField(type=str, default="localhost", description="主机地址"),
"port": ConfigField(type=int, default=8095, description="端口号"),
"url": ConfigField(
type=str,
default="",
description="正向连接时的完整WebSocket URL如 ws://localhost:8080/ws (仅在forward模式下使用)",
),
"access_token": ConfigField(
type=str, default="", description="WebSocket 连接的访问令牌,用于身份验证(可选)"
),
"heartbeat_interval": ConfigField(type=int, default=30, description="心跳间隔时间(按秒计)"),
},
"maibot_server": {
"platform_name": ConfigField(type=str, default="qq", description="平台名称,用于消息路由"),
"host": ConfigField(type=str, default="", description="MoFox-Bot服务器地址留空则使用全局配置"),
"port": ConfigField(type=int, default=0, description="MoFox-Bot服务器端口设为0则使用全局配置"),
},
"voice": {
"use_tts": ConfigField(
type=bool, default=False, description="是否使用tts语音请确保你配置了tts并有对应的adapter"
),
},
"slicing": {
"max_frame_size": ConfigField(
type=int, default=64, description="WebSocket帧的最大大小单位为字节默认64KB"
),
"delay_ms": ConfigField(type=int, default=10, description="切片发送间隔时间,单位为毫秒"),
},
"debug": {
"level": ConfigField(
type=str,
default="INFO",
description="日志等级DEBUG, INFO, WARNING, ERROR, CRITICAL",
choices=["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"],
),
},
"stream_router": {
"max_streams": ConfigField(type=int, default=500, description="最大并发流数量"),
"stream_timeout": ConfigField(type=int, default=600, description="流不活跃超时时间(秒),超时后自动清理"),
"stream_queue_size": ConfigField(type=int, default=100, description="每个流的消息队列大小"),
"cleanup_interval": ConfigField(type=int, default=60, description="清理不活跃流的间隔时间(秒)"),
},
"features": {
# 权限设置
"group_list_type": ConfigField(
type=str,
default="blacklist",
description="群聊列表类型whitelist白名单或 blacklist黑名单",
choices=["whitelist", "blacklist"],
),
"group_list": ConfigField(type=list, default=[], description="群聊ID列表"),
"private_list_type": ConfigField(
type=str,
default="blacklist",
description="私聊列表类型whitelist白名单或 blacklist黑名单",
choices=["whitelist", "blacklist"],
),
"private_list": ConfigField(type=list, default=[], description="用户ID列表"),
"ban_user_id": ConfigField(
type=list, default=[], description="全局禁止用户ID列表这些用户无法在任何地方使用机器人"
),
"ban_qq_bot": ConfigField(type=bool, default=False, description="是否屏蔽QQ官方机器人消息"),
# 聊天功能设置
"enable_poke": ConfigField(type=bool, default=True, description="是否启用戳一戳功能"),
"ignore_non_self_poke": ConfigField(type=bool, default=False, description="是否无视不是针对自己的戳一戳"),
"poke_debounce_seconds": ConfigField(
type=int, default=3, description="戳一戳防抖时间(秒),在指定时间内第二次针对机器人的戳一戳将被忽略"
),
"enable_reply_at": ConfigField(type=bool, default=True, description="是否启用引用回复时艾特用户的功能"),
"reply_at_rate": ConfigField(type=float, default=0.5, description="引用回复时艾特用户的几率 (0.0 ~ 1.0)"),
"enable_emoji_like": ConfigField(type=bool, default=True, description="是否启用群聊表情回复功能"),
# 视频处理设置
"enable_video_analysis": ConfigField(type=bool, default=True, description="是否启用视频识别功能"),
"max_video_size_mb": ConfigField(type=int, default=100, description="视频文件最大大小限制MB"),
"download_timeout": ConfigField(type=int, default=60, description="视频下载超时时间(秒)"),
"supported_formats": ConfigField(
type=list, default=["mp4", "avi", "mov", "mkv", "flv", "wmv", "webm"], description="支持的视频格式"
),
},
}
# 配置节描述
config_section_descriptions: ClassVar[dict] = {
"plugin": "插件基本信息",
"inner": "内部配置信息(请勿修改)",
"nickname": "昵称配置(目前未使用)",
"napcat_server": "Napcat连接的ws服务设置",
"maibot_server": "连接麦麦的ws服务设置",
"voice": "发送语音设置",
"slicing": "WebSocket消息切片设置",
"debug": "调试设置",
"stream_router": "流路由器设置(按聊天流分配消费者,提升高并发性能)",
"features": "功能设置(权限控制、聊天功能、视频处理等)",
}
def register_events(self):
# 注册事件
for e in event_types.NapcatEvent.ON_RECEIVED:
event_manager.register_event(e, allowed_triggers=[self.plugin_name])
for e in event_types.NapcatEvent.ACCOUNT:
event_manager.register_event(e, allowed_subscribers=[f"{e.value}_handler"])
for e in event_types.NapcatEvent.GROUP:
event_manager.register_event(e, allowed_subscribers=[f"{e.value}_handler"])
for e in event_types.NapcatEvent.MESSAGE:
event_manager.register_event(e, allowed_subscribers=[f"{e.value}_handler"])
def get_plugin_components(self):
self.register_events()
components = []
components.append((LauchNapcatAdapterHandler.get_handler_info(), LauchNapcatAdapterHandler))
components.append((StopNapcatAdapterHandler.get_handler_info(), StopNapcatAdapterHandler))
components.extend(
(handler.get_handler_info(), handler)
for handler in get_classes_in_module(event_handlers)
if issubclass(handler, BaseEventHandler)
)
return components
async def on_plugin_loaded(self):
# 初始化数据库表
await self._init_database_tables()
# 设置插件配置
message_send_instance.set_plugin_config(self.config)
# 设置chunker的插件配置
chunker.set_plugin_config(self.config)
# 设置response_pool的插件配置
from .src.response_pool import set_plugin_config as set_response_pool_config
set_response_pool_config(self.config)
# 设置send_handler的插件配置
send_handler.set_plugin_config(self.config)
# 设置message_handler的插件配置
message_handler.set_plugin_config(self.config)
# 设置notice_handler的插件配置
notice_handler.set_plugin_config(self.config)
# 设置meta_event_handler的插件配置
meta_event_handler.set_plugin_config(self.config)
# 设置流路由器的配置
stream_router.max_streams = config_api.get_plugin_config(self.config, "stream_router.max_streams", 500)
stream_router.stream_timeout = config_api.get_plugin_config(self.config, "stream_router.stream_timeout", 600)
stream_router.stream_queue_size = config_api.get_plugin_config(self.config, "stream_router.stream_queue_size", 100)
stream_router.cleanup_interval = config_api.get_plugin_config(self.config, "stream_router.cleanup_interval", 60)
# 设置其他handler的插件配置现在由component_registry在注册时自动设置
async def _init_database_tables(self):
"""初始化插件所需的数据库表"""
try:
from src.common.database.core.engine import get_engine
from .src.database import NapcatBanRecord
engine = await get_engine()
async with engine.begin() as conn:
# 创建 napcat_ban_records 表
await conn.run_sync(NapcatBanRecord.metadata.create_all)
logger.info("Napcat 插件数据库表初始化成功")
except Exception as e:
logger.error(f"Napcat 插件数据库表初始化失败: {e}", exc_info=True)

View File

@@ -1,47 +0,0 @@
[project]
name = "MoFoxBotNapcatAdapter"
version = "0.4.8"
description = "A MoFox-Bot adapter for Napcat"
dependencies = [
"ruff>=0.12.9",
]
[tool.ruff]
include = ["*.py"]
# 行长度设置
line-length = 120
[tool.ruff.lint]
fixable = ["ALL"]
unfixable = []
# 启用的规则
select = [
"E", # pycodestyle 错误
"F", # pyflakes
"B", # flake8-bugbear
]
ignore = ["E711","E501"]
[tool.ruff.format]
docstring-code-format = true
indent-style = "space"
# 使用双引号表示字符串
quote-style = "double"
# 尊重魔法尾随逗号
# 例如:
# items = [
# "apple",
# "banana",
# "cherry",
# ]
skip-magic-trailing-comma = false
# 自动检测合适的换行符
line-ending = "auto"

View File

@@ -1,32 +0,0 @@
import os
from enum import Enum
import tomlkit
from src.common.logger import get_logger
logger = get_logger("napcat_adapter")
class CommandType(Enum):
"""命令类型"""
GROUP_BAN = "set_group_ban" # 禁言用户
GROUP_WHOLE_BAN = "set_group_whole_ban" # 群全体禁言
GROUP_KICK = "set_group_kick" # 踢出群聊
SEND_POKE = "send_poke" # 戳一戳
DELETE_MSG = "delete_msg" # 撤回消息
AI_VOICE_SEND = "send_group_ai_record" # 发送群AI语音
SET_EMOJI_LIKE = "set_msg_emoji_like" # 设置表情回应
SEND_AT_MESSAGE = "send_at_message" # 艾特用户并发送消息
SEND_LIKE = "send_like" # 点赞
def __str__(self) -> str:
return self.value
pyproject_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), "pyproject.toml")
toml_data = tomlkit.parse(open(pyproject_path, "r", encoding="utf-8").read())
project_data = toml_data.get("project", {})
version = project_data.get("version", "unknown")
logger.info(f"版本\n\nMoFox-Bot-Napcat-Adapter 版本: {version}\n喜欢的话点个star喵~\n")

View File

@@ -1,157 +0,0 @@
"""Napcat Adapter 插件数据库层 (基于主程序异步SQLAlchemy API)
本模块替换原先的 sqlmodel + 同步Session 实现:
1. 复用主项目的异步数据库连接与迁移体系
2. 提供与旧接口名兼容的方法(update_ban_record/create_ban_record/delete_ban_record)
3. 新增首选异步方法: update_ban_records / create_or_update / delete_record / get_ban_records
数据语义:
user_id == 0 表示群全体禁言
注意: 所有方法均为异步, 需要在 async 上下文中调用。
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import List, Optional, Sequence
from sqlalchemy import BigInteger, Column, Index, Integer, UniqueConstraint, select
from sqlalchemy.ext.asyncio import AsyncSession
from src.common.database.core.models import Base
from src.common.database.core import get_db_session
from src.common.logger import get_logger
logger = get_logger("napcat_adapter")
class NapcatBanRecord(Base):
__tablename__ = "napcat_ban_records"
id = Column(Integer, primary_key=True, autoincrement=True)
group_id = Column(BigInteger, nullable=False, index=True)
user_id = Column(BigInteger, nullable=False, index=True) # 0 == 全体禁言
lift_time = Column(BigInteger, nullable=True) # -1 / None 表示未知/永久
__table_args__ = (
UniqueConstraint("group_id", "user_id", name="uq_napcat_group_user"),
Index("idx_napcat_ban_group", "group_id"),
Index("idx_napcat_ban_user", "user_id"),
)
@dataclass
class BanUser:
user_id: int
group_id: int
lift_time: Optional[int] = -1
def identity(self) -> tuple[int, int]:
return self.group_id, self.user_id
class NapcatDatabase:
async def _fetch_all(self, session: AsyncSession) -> Sequence[NapcatBanRecord]:
result = await session.execute(select(NapcatBanRecord))
return result.scalars().all()
async def get_ban_records(self) -> List[BanUser]:
async with get_db_session() as session:
rows = await self._fetch_all(session)
return [BanUser(group_id=r.group_id, user_id=r.user_id, lift_time=r.lift_time) for r in rows]
async def update_ban_records(self, ban_list: List[BanUser]) -> None:
target_map = {b.identity(): b for b in ban_list}
async with get_db_session() as session:
rows = await self._fetch_all(session)
existing_map = {(r.group_id, r.user_id): r for r in rows}
changed = 0
for ident, ban in target_map.items():
if ident in existing_map:
row = existing_map[ident]
if row.lift_time != ban.lift_time:
row.lift_time = ban.lift_time
changed += 1
else:
session.add(
NapcatBanRecord(group_id=ban.group_id, user_id=ban.user_id, lift_time=ban.lift_time)
)
changed += 1
removed = 0
for ident, row in existing_map.items():
if ident not in target_map:
await session.delete(row)
removed += 1
logger.debug(
f"Napcat ban list sync => total_incoming={len(ban_list)} created_or_updated={changed} removed={removed}"
)
async def create_or_update(self, ban_record: BanUser) -> None:
async with get_db_session() as session:
stmt = select(NapcatBanRecord).where(
NapcatBanRecord.group_id == ban_record.group_id,
NapcatBanRecord.user_id == ban_record.user_id,
)
result = await session.execute(stmt)
row = result.scalars().first()
if row:
if row.lift_time != ban_record.lift_time:
row.lift_time = ban_record.lift_time
logger.debug(
f"更新禁言记录 group={ban_record.group_id} user={ban_record.user_id} lift={ban_record.lift_time}"
)
else:
session.add(
NapcatBanRecord(
group_id=ban_record.group_id, user_id=ban_record.user_id, lift_time=ban_record.lift_time
)
)
logger.debug(
f"创建禁言记录 group={ban_record.group_id} user={ban_record.user_id} lift={ban_record.lift_time}"
)
async def delete_record(self, ban_record: BanUser) -> None:
async with get_db_session() as session:
stmt = select(NapcatBanRecord).where(
NapcatBanRecord.group_id == ban_record.group_id,
NapcatBanRecord.user_id == ban_record.user_id,
)
result = await session.execute(stmt)
row = result.scalars().first()
if row:
await session.delete(row)
logger.debug(
f"删除禁言记录 group={ban_record.group_id} user={ban_record.user_id} lift={row.lift_time}"
)
else:
logger.info(
f"未找到禁言记录 group={ban_record.group_id} user={ban_record.user_id}"
)
# 兼容旧命名
async def update_ban_record(self, ban_list: List[BanUser]) -> None: # old name
await self.update_ban_records(ban_list)
async def create_ban_record(self, ban_record: BanUser) -> None: # old name
await self.create_or_update(ban_record)
async def delete_ban_record(self, ban_record: BanUser) -> None: # old name
await self.delete_record(ban_record)
napcat_db = NapcatDatabase()
def is_identical(a: BanUser, b: BanUser) -> bool:
return a.group_id == b.group_id and a.user_id == b.user_id
__all__ = [
"BanUser",
"NapcatBanRecord",
"napcat_db",
"is_identical",
]

View File

@@ -1,280 +0,0 @@
"""
消息切片处理模块
用于在 Ada 发送给 MMC 时进行消息切片,利用 WebSocket 协议的自动重组特性
仅在 Ada -> MMC 方向进行切片其他方向MMC -> AdaAda <-> Napcat不切片
"""
import asyncio
import orjson
import time
import uuid
from typing import Any, Dict, List, Optional, Union
from src.common.logger import get_logger
from src.plugin_system.apis import config_api
logger = get_logger("napcat_adapter")
class MessageChunker:
"""消息切片器,用于处理大消息的分片发送"""
def __init__(self):
self.max_chunk_size = 64 * 1024 # 默认值,将在设置配置时更新
self.plugin_config = None
def set_plugin_config(self, plugin_config: dict):
"""设置插件配置"""
self.plugin_config = plugin_config
if plugin_config:
max_frame_size = config_api.get_plugin_config(plugin_config, "slicing.max_frame_size", 64)
self.max_chunk_size = max_frame_size * 1024
def should_chunk_message(self, message: Union[str, Dict[str, Any]]) -> bool:
"""判断消息是否需要切片"""
try:
if isinstance(message, dict):
message_str = orjson.dumps(message, option=orjson.OPT_NON_STR_KEYS).decode('utf-8')
else:
message_str = message
return len(message_str.encode("utf-8")) > self.max_chunk_size
except Exception as e:
logger.error(f"检查消息大小时出错: {e}")
return False
def chunk_message(
self, message: Union[str, Dict[str, Any]], chunk_id: Optional[str] = None
) -> List[Dict[str, Any]]:
"""
将消息切片
Args:
message: 要切片的消息(字符串或字典)
chunk_id: 切片组ID如果不提供则自动生成
Returns:
切片后的消息字典列表
"""
try:
# 统一转换为字符串
if isinstance(message, dict):
message_str = orjson.dumps(message, option=orjson.OPT_NON_STR_KEYS).decode('utf-8')
else:
message_str = message
if not self.should_chunk_message(message_str):
# 不需要切片的情况,如果输入是字典则返回字典,如果是字符串则包装成非切片标记的字典
if isinstance(message, dict):
return [message]
else:
return [{"_original_message": message_str}]
if chunk_id is None:
chunk_id = str(uuid.uuid4())
message_bytes = message_str.encode("utf-8")
total_size = len(message_bytes)
# 计算需要多少个切片
num_chunks = (total_size + self.max_chunk_size - 1) // self.max_chunk_size
chunks = []
for i in range(num_chunks):
start_pos = i * self.max_chunk_size
end_pos = min(start_pos + self.max_chunk_size, total_size)
chunk_data = message_bytes[start_pos:end_pos]
# 构建切片消息
chunk_message = {
"__mmc_chunk_info__": {
"chunk_id": chunk_id,
"chunk_index": i,
"total_chunks": num_chunks,
"chunk_size": len(chunk_data),
"total_size": total_size,
"timestamp": time.time(),
},
"__mmc_chunk_data__": chunk_data.decode("utf-8", errors="ignore"),
"__mmc_is_chunked__": True,
}
chunks.append(chunk_message)
logger.debug(f"消息切片完成: {total_size} bytes -> {num_chunks} chunks (ID: {chunk_id})")
return chunks
except Exception as e:
logger.error(f"消息切片时出错: {e}")
# 出错时返回原消息
if isinstance(message, dict):
return [message]
else:
return [{"_original_message": message}]
def is_chunk_message(self, message: Union[str, Dict[str, Any]]) -> bool:
"""判断是否是切片消息"""
try:
if isinstance(message, str):
data = orjson.loads(message)
else:
data = message
return (
isinstance(data, dict)
and "__mmc_chunk_info__" in data
and "__mmc_chunk_data__" in data
and "__mmc_is_chunked__" in data
)
except (orjson.JSONDecodeError, TypeError):
return False
class MessageReassembler:
"""消息重组器,用于重组接收到的切片消息"""
def __init__(self, timeout: int = 30):
self.timeout = timeout
self.chunk_buffers: Dict[str, Dict[str, Any]] = {}
self._cleanup_task = None
async def start_cleanup_task(self):
"""启动清理任务"""
if self._cleanup_task is None:
self._cleanup_task = asyncio.create_task(self._cleanup_expired_chunks())
async def stop_cleanup_task(self):
"""停止清理任务"""
if self._cleanup_task:
self._cleanup_task.cancel()
try:
await self._cleanup_task
except asyncio.CancelledError:
pass
self._cleanup_task = None
async def _cleanup_expired_chunks(self):
"""清理过期的切片缓冲区"""
while True:
try:
await asyncio.sleep(10) # 每10秒检查一次
current_time = time.time()
expired_chunks = []
for chunk_id, buffer_info in self.chunk_buffers.items():
if current_time - buffer_info["timestamp"] > self.timeout:
expired_chunks.append(chunk_id)
for chunk_id in expired_chunks:
logger.warning(f"清理过期的切片缓冲区: {chunk_id}")
del self.chunk_buffers[chunk_id]
except asyncio.CancelledError:
break
except Exception as e:
logger.error(f"清理过期切片时出错: {e}")
async def add_chunk(self, message: Union[str, Dict[str, Any]]) -> Optional[Dict[str, Any]]:
"""
添加切片,如果切片完整则返回重组后的消息
Args:
message: 切片消息(字符串或字典)
Returns:
如果切片完整则返回重组后的原始消息字典否则返回None
"""
try:
# 统一转换为字典
if isinstance(message, str):
chunk_data = orjson.loads(message)
else:
chunk_data = message
# 检查是否是切片消息
if not chunker.is_chunk_message(chunk_data):
# 不是切片消息,直接返回
if "_original_message" in chunk_data:
# 这是一个被包装的非切片消息,解包返回
try:
return orjson.loads(chunk_data["_original_message"])
except orjson.JSONDecodeError:
return {"text_message": chunk_data["_original_message"]}
else:
return chunk_data
chunk_info = chunk_data["__mmc_chunk_info__"]
chunk_content = chunk_data["__mmc_chunk_data__"]
chunk_id = chunk_info["chunk_id"]
chunk_index = chunk_info["chunk_index"]
total_chunks = chunk_info["total_chunks"]
chunk_timestamp = chunk_info.get("timestamp", time.time())
# 初始化缓冲区
if chunk_id not in self.chunk_buffers:
self.chunk_buffers[chunk_id] = {
"chunks": {},
"total_chunks": total_chunks,
"received_chunks": 0,
"timestamp": chunk_timestamp,
}
buffer = self.chunk_buffers[chunk_id]
# 检查切片是否已经接收过
if chunk_index in buffer["chunks"]:
logger.warning(f"重复接收切片: {chunk_id}#{chunk_index}")
return None
# 添加切片
buffer["chunks"][chunk_index] = chunk_content
buffer["received_chunks"] += 1
buffer["timestamp"] = time.time() # 更新时间戳
logger.debug(f"接收切片: {chunk_id}#{chunk_index} ({buffer['received_chunks']}/{total_chunks})")
# 检查是否接收完整
if buffer["received_chunks"] == total_chunks:
# 重组消息
reassembled_message = ""
for i in range(total_chunks):
if i not in buffer["chunks"]:
logger.error(f"切片 {chunk_id}#{i} 缺失,无法重组")
return None
reassembled_message += buffer["chunks"][i]
# 清理缓冲区
del self.chunk_buffers[chunk_id]
logger.debug(f"消息重组完成: {chunk_id} ({len(reassembled_message)} chars)")
# 尝试反序列化重组后的消息
try:
return orjson.loads(reassembled_message)
except orjson.JSONDecodeError:
# 如果不能反序列化为JSON则作为文本消息返回
return {"text_message": reassembled_message}
return None
except (orjson.JSONDecodeError, KeyError, TypeError) as e:
logger.error(f"处理切片消息时出错: {e}")
return None
def get_pending_chunks_info(self) -> Dict[str, Any]:
"""获取待处理切片信息"""
info = {}
for chunk_id, buffer in self.chunk_buffers.items():
info[chunk_id] = {
"received": buffer["received_chunks"],
"total": buffer["total_chunks"],
"progress": f"{buffer['received_chunks']}/{buffer['total_chunks']}",
"age_seconds": time.time() - buffer["timestamp"],
}
return info
# 全局实例
chunker = MessageChunker()
reassembler = MessageReassembler()

View File

@@ -1,61 +0,0 @@
from mofox_wire import RouteConfig, Router, TargetConfig
from src.common.logger import get_logger
from src.common.server import get_global_server
from src.plugin_system.apis import config_api
from .send_handler import send_handler
logger = get_logger("napcat_adapter")
router = None
def create_router(plugin_config: dict):
"""创建路由器实例"""
global router
platform_name = config_api.get_plugin_config(plugin_config, "maibot_server.platform_name", "qq")
# 优先从插件配置读取 host 和 port如果不存在则回退到全局配置
config_host = config_api.get_plugin_config(plugin_config, "maibot_server.host", "")
config_port = config_api.get_plugin_config(plugin_config, "maibot_server.port", 0)
if config_host and config_port > 0:
# 使用插件配置
host = config_host
port = config_port
logger.debug(f"初始化MoFox-Bot连接使用插件配置地址{host}:{port}")
else:
# 回退到全局配置
server = get_global_server()
host = server.host
port = server.port
logger.debug(f"初始化MoFox-Bot连接使用全局配置地址{host}:{port}")
route_config = RouteConfig(
route_config={
platform_name: TargetConfig(
url=f"ws://{host}:{port}/ws",
token=None,
)
}
)
router = Router(route_config)
return router
async def mmc_start_com(plugin_config: dict | None = None):
"""启动MoFox-Bot连接"""
logger.debug("正在连接MoFox-Bot")
if plugin_config:
create_router(plugin_config)
if router:
router.register_class_handler(send_handler.handle_message)
await router.run()
async def mmc_stop_com():
"""停止MoFox-Bot连接"""
if router:
await router.stop()

View File

@@ -1,92 +0,0 @@
from enum import Enum
class MetaEventType:
lifecycle = "lifecycle" # 生命周期
class Lifecycle:
connect = "connect" # 生命周期 - WebSocket 连接成功
heartbeat = "heartbeat" # 心跳
class MessageType: # 接受消息大类
private = "private" # 私聊消息
class Private:
friend = "friend" # 私聊消息 - 好友
group = "group" # 私聊消息 - 群临时
group_self = "group_self" # 私聊消息 - 群中自身发送
other = "other" # 私聊消息 - 其他
group = "group" # 群聊消息
class Group:
normal = "normal" # 群聊消息 - 普通
anonymous = "anonymous" # 群聊消息 - 匿名消息
notice = "notice" # 群聊消息 - 系统提示
class NoticeType: # 通知事件
friend_recall = "friend_recall" # 私聊消息撤回
group_recall = "group_recall" # 群聊消息撤回
notify = "notify"
group_ban = "group_ban" # 群禁言
group_msg_emoji_like = "group_msg_emoji_like" # 群聊表情回复
group_upload = "group_upload" # 群文件上传
class Notify:
poke = "poke" # 戳一戳
input_status = "input_status" # 正在输入
class GroupBan:
ban = "ban" # 禁言
lift_ban = "lift_ban" # 解除禁言
class RealMessageType: # 实际消息分类
text = "text" # 纯文本
face = "face" # qq表情
image = "image" # 图片
record = "record" # 语音
video = "video" # 视频
at = "at" # @某人
rps = "rps" # 猜拳魔法表情
dice = "dice" # 骰子
shake = "shake" # 私聊窗口抖动(只收)
poke = "poke" # 群聊戳一戳
share = "share" # 链接分享json形式
reply = "reply" # 回复消息
forward = "forward" # 转发消息
node = "node" # 转发消息节点
json = "json" # json消息
file = "file" # 文件
class MessageSentType:
private = "private"
class Private:
friend = "friend"
group = "group"
group = "group"
class Group:
normal = "normal"
class CommandType(Enum):
"""命令类型"""
GROUP_BAN = "set_group_ban" # 禁言用户
GROUP_WHOLE_BAN = "set_group_whole_ban" # 群全体禁言
GROUP_KICK = "set_group_kick" # 踢出群聊
SEND_POKE = "send_poke" # 戳一戳
DELETE_MSG = "delete_msg" # 撤回消息
def __str__(self) -> str:
return self.value
ACCEPT_FORMAT = ["text", "image", "emoji", "reply", "voice", "command", "voiceurl", "music", "videourl", "file"]

View File

@@ -1,120 +0,0 @@
import asyncio
from maim_message import MessageBase, Router
from src.common.logger import get_logger
from src.plugin_system.apis import config_api
from ..message_chunker import chunker
logger = get_logger("napcat_adapter")
class MessageSending:
"""
负责把消息发送到麦麦
"""
maibot_router: Router = None
plugin_config = None
_connection_retries = 0
_max_retries = 3
def __init__(self):
pass
def set_plugin_config(self, plugin_config: dict):
"""设置插件配置"""
self.plugin_config = plugin_config
async def _attempt_reconnect(self):
"""尝试重新连接MoFox-Bot router"""
if self._connection_retries < self._max_retries:
self._connection_retries += 1
logger.warning(f"尝试重新连接MoFox-Bot router (第{self._connection_retries}次)")
try:
# 重新导入router
from ..mmc_com_layer import router
self.maibot_router = router
if self.maibot_router is not None:
logger.info("MoFox-Bot router重连成功")
self._connection_retries = 0 # 重置重试计数
return True
except Exception as e:
logger.error(f"重连失败: {e}")
else:
logger.error(f"已达到最大重连次数({self._max_retries}),停止重试")
return False
async def message_send(self, message_base: MessageBase) -> bool:
"""
发送消息Ada -> MMC 方向,需要实现切片)
Parameters:
message_base: MessageBase: 消息基类,包含发送目标和消息内容等信息
"""
try:
# 检查maibot_router是否已初始化
if self.maibot_router is None:
logger.warning("MoFox-Bot router未初始化尝试重新连接")
if not await self._attempt_reconnect():
logger.error("MoFox-Bot router重连失败无法发送消息")
logger.error("请检查与MoFox-Bot之间的连接")
return False
# 检查是否需要切片发送
message_dict = message_base.to_dict()
if chunker.should_chunk_message(message_dict):
logger.info("消息过大,进行切片发送到 MoFox-Bot")
# 切片消息
chunks = chunker.chunk_message(message_dict)
# 逐个发送切片
for i, chunk in enumerate(chunks):
logger.debug(f"发送切片 {i + 1}/{len(chunks)} 到 MoFox-Bot")
# 获取对应的客户端并发送切片
platform = message_base.message_info.platform
# 再次检查router状态防止运行时被重置
if self.maibot_router is None or not hasattr(self.maibot_router, "clients"):
logger.warning("MoFox-Bot router连接已断开尝试重新连接")
if not await self._attempt_reconnect():
logger.error("MoFox-Bot router重连失败切片发送中止")
return False
if platform not in self.maibot_router.clients:
logger.error(f"平台 {platform} 未连接")
return False
client = self.maibot_router.clients[platform]
send_status = await client.send_message(chunk)
if not send_status:
logger.error(f"发送切片 {i + 1}/{len(chunks)} 失败")
return False
# 使用配置中的延迟时间
if i < len(chunks) - 1 and self.plugin_config:
delay_ms = config_api.get_plugin_config(self.plugin_config, "slicing.delay_ms", 10)
delay_seconds = delay_ms / 1000.0
logger.debug(f"切片发送延迟: {delay_ms}毫秒")
await asyncio.sleep(delay_seconds)
logger.debug("所有切片发送完成")
return True
else:
# 直接发送小消息
send_status = await self.maibot_router.send_message(message_base)
if not send_status:
raise RuntimeError("可能是路由未正确配置或连接异常")
return send_status
except Exception as e:
logger.error(f"发送消息失败: {str(e)}")
logger.error("请检查与MoFox-Bot之间的连接")
return False
message_send_instance = MessageSending()

View File

@@ -1,65 +0,0 @@
import asyncio
import time
from src.common.logger import get_logger
from src.plugin_system.apis import config_api
from . import MetaEventType
logger = get_logger("napcat_adapter")
class MetaEventHandler:
"""
处理Meta事件
"""
def __init__(self):
self.interval = 5.0 # 默认值稍后通过set_plugin_config设置
self._interval_checking = False
self.plugin_config = None
def set_plugin_config(self, plugin_config: dict):
"""设置插件配置"""
self.plugin_config = plugin_config
# 更新interval值
self.interval = (
config_api.get_plugin_config(self.plugin_config, "napcat_server.heartbeat_interval", 5000) / 1000
)
async def handle_meta_event(self, message: dict) -> None:
event_type = message.get("meta_event_type")
if event_type == MetaEventType.lifecycle:
sub_type = message.get("sub_type")
if sub_type == MetaEventType.Lifecycle.connect:
self_id = message.get("self_id")
self.last_heart_beat = time.time()
logger.info(f"Bot {self_id} 连接成功")
# 不在连接时立即启动心跳检查,等第一个心跳包到达后再启动
elif event_type == MetaEventType.heartbeat:
if message["status"].get("online") and message["status"].get("good"):
self_id = message.get("self_id")
if not self._interval_checking and self_id:
# 第一次收到心跳包时才启动心跳检查
asyncio.create_task(self.check_heartbeat(self_id))
self.last_heart_beat = time.time()
interval = message.get("interval")
if interval:
self.interval = interval / 1000
else:
self_id = message.get("self_id")
logger.warning(f"Bot {self_id} Napcat 端异常!")
async def check_heartbeat(self, id: int) -> None:
self._interval_checking = True
while True:
now_time = time.time()
if now_time - self.last_heart_beat > self.interval * 2:
logger.error(f"Bot {id} 可能发生了连接断开被下线或者Napcat卡死")
break
else:
logger.debug("心跳正常")
await asyncio.sleep(self.interval)
meta_event_handler = MetaEventHandler()

View File

@@ -1,705 +0,0 @@
import asyncio
import orjson
import time
from typing import ClassVar, Optional, Tuple
import websockets as Server
from maim_message import BaseMessageInfo, FormatInfo, GroupInfo, MessageBase, Seg, UserInfo
from src.common.logger import get_logger
from src.plugin_system.apis import config_api
from ...CONSTS import PLUGIN_NAME, QQ_FACE
from ..database import BanUser, is_identical, napcat_db
from ..utils import (
get_group_info,
get_member_info,
get_self_info,
get_stranger_info,
read_ban_list,
)
from ..websocket_manager import websocket_manager
from . import ACCEPT_FORMAT, NoticeType
from .message_handler import message_handler
from .message_sending import message_send_instance
logger = get_logger("napcat_adapter")
notice_queue: asyncio.Queue[MessageBase] = asyncio.Queue(maxsize=100)
unsuccessful_notice_queue: asyncio.Queue[MessageBase] = asyncio.Queue(maxsize=3)
class NoticeHandler:
banned_list: ClassVar[list[BanUser]] = [] # 当前仍在禁言中的用户列表
lifted_list: ClassVar[list[BanUser]] = [] # 已经自然解除禁言
def __init__(self):
self.server_connection: Server.ServerConnection | None = None
self.last_poke_time: float = 0.0 # 记录最后一次针对机器人的戳一戳时间
self.plugin_config = None
def set_plugin_config(self, plugin_config: dict):
"""设置插件配置"""
self.plugin_config = plugin_config
async def set_server_connection(self, server_connection: Server.ServerConnection) -> None:
"""设置Napcat连接"""
self.server_connection = server_connection
while self.server_connection.state != Server.State.OPEN:
await asyncio.sleep(0.5)
self.banned_list, self.lifted_list = await read_ban_list(self.server_connection)
asyncio.create_task(self.auto_lift_detect())
asyncio.create_task(self.send_notice())
asyncio.create_task(self.handle_natural_lift())
def get_server_connection(self) -> Server.ServerConnection:
"""获取当前的服务器连接"""
# 优先使用直接设置的连接,否则从 websocket_manager 获取
if self.server_connection:
return self.server_connection
return websocket_manager.get_connection()
async def _ban_operation(self, group_id: int, user_id: Optional[int] = None, lift_time: Optional[int] = None) -> None:
"""
将用户禁言记录添加到self.banned_list中
如果是全体禁言则user_id为0
"""
if user_id is None:
user_id = 0 # 使用0表示全体禁言
lift_time = -1
ban_record = BanUser(user_id=user_id, group_id=group_id, lift_time=lift_time)
for record in list(self.banned_list):
if is_identical(record, ban_record):
self.banned_list.remove(record)
self.banned_list.append(ban_record)
await napcat_db.create_ban_record(ban_record) # 更新
return
self.banned_list.append(ban_record)
await napcat_db.create_ban_record(ban_record) # 新建
async def _lift_operation(self, group_id: int, user_id: Optional[int] = None) -> None:
"""
从self.lifted_group_list中移除已经解除全体禁言的群
"""
if user_id is None:
user_id = 0 # 使用0表示全体禁言
ban_record = BanUser(user_id=user_id, group_id=group_id, lift_time=-1)
self.lifted_list.append(ban_record)
# 从被禁言列表里移除对应记录
for record in list(self.banned_list):
if is_identical(record, ban_record):
self.banned_list.remove(record)
break
await napcat_db.delete_ban_record(ban_record)
async def handle_notice(self, raw_message: dict) -> None:
notice_type = raw_message.get("notice_type")
# message_time: int = raw_message.get("time")
message_time: float = time.time() # 应可乐要求现在是float了
self_id = raw_message.get("self_id")
group_id = raw_message.get("group_id")
user_id = raw_message.get("user_id")
target_id = raw_message.get("target_id")
handled_message: Seg = None
user_info: UserInfo = None
system_notice: bool = False
match notice_type:
case NoticeType.friend_recall:
logger.info("好友撤回一条消息")
logger.info(f"撤回消息ID{raw_message.get('message_id')}, 撤回时间:{raw_message.get('time')}")
logger.warning("暂时不支持撤回消息处理")
case NoticeType.group_recall:
logger.info("群内用户撤回一条消息")
logger.info(f"撤回消息ID{raw_message.get('message_id')}, 撤回时间:{raw_message.get('time')}")
logger.warning("暂时不支持撤回消息处理")
case NoticeType.notify:
sub_type = raw_message.get("sub_type")
match sub_type:
case NoticeType.Notify.poke:
if config_api.get_plugin_config(self.plugin_config, "features.enable_poke", True) and await message_handler.check_allow_to_chat(
user_id, group_id, False, False
):
logger.debug("处理戳一戳消息")
handled_message, user_info = await self.handle_poke_notify(raw_message, group_id, user_id)
else:
logger.warning("戳一戳消息被禁用,取消戳一戳处理")
case NoticeType.Notify.input_status:
from src.plugin_system.core.event_manager import event_manager
from ...event_types import NapcatEvent
event_manager.emit_event(NapcatEvent.ON_RECEIVED.FRIEND_INPUT, permission_group=PLUGIN_NAME)
case _:
logger.warning(f"不支持的notify类型: {notice_type}.{sub_type}")
case NoticeType.group_msg_emoji_like:
# 该事件转移到 handle_group_emoji_like_notify函数内触发
if config_api.get_plugin_config(self.plugin_config, "features.enable_emoji_like", True):
logger.debug("处理群聊表情回复")
handled_message, user_info = await self.handle_group_emoji_like_notify(raw_message,group_id,user_id)
else:
logger.warning("群聊表情回复被禁用,取消群聊表情回复处理")
case NoticeType.group_ban:
sub_type = raw_message.get("sub_type")
match sub_type:
case NoticeType.GroupBan.ban:
if not await message_handler.check_allow_to_chat(user_id, group_id, True, False):
return None
logger.info("处理群禁言")
handled_message, user_info = await self.handle_ban_notify(raw_message, group_id)
system_notice = True
case NoticeType.GroupBan.lift_ban:
if not await message_handler.check_allow_to_chat(user_id, group_id, True, False):
return None
logger.info("处理解除群禁言")
handled_message, user_info = await self.handle_lift_ban_notify(raw_message, group_id)
system_notice = True
case _:
logger.warning(f"不支持的group_ban类型: {notice_type}.{sub_type}")
case NoticeType.group_upload:
logger.info("群文件上传")
if user_id == self_id:
logger.info("检测到机器人自己上传文件,忽略此通知")
return None
if not await message_handler.check_allow_to_chat(user_id, group_id, False, False):
return None
handled_message, user_info = await self.handle_group_upload_notify(raw_message, group_id, user_id, self_id)
case _:
logger.warning(f"不支持的notice类型: {notice_type}")
return None
if not handled_message or not user_info:
logger.warning("notice处理失败或不支持")
return None
group_info: GroupInfo | None = None
if group_id:
fetched_group_info = await get_group_info(self.get_server_connection(), group_id)
group_name: str | None = None
if fetched_group_info:
group_name = fetched_group_info.get("group_name")
else:
logger.warning("无法获取notice消息所在群的名称")
group_info = GroupInfo(
platform=config_api.get_plugin_config(self.plugin_config, "maibot_server.platform_name", "qq"),
group_id=group_id,
group_name=group_name,
)
# 准备additional_config包含notice标志
notice_config = {
"is_notice": system_notice, # 禁言/解禁是系统通知
"is_public_notice": False, # 群内notice非公共
"target_id": target_id, # 在这里塞了一个target_id方便mmc那边知道被戳的人是谁
}
# 根据notice_type设置notice_type字段
if system_notice:
sub_type = raw_message.get("sub_type")
if notice_type == NoticeType.group_ban:
if sub_type == NoticeType.GroupBan.ban:
user_id_in_ban = raw_message.get("user_id")
if user_id_in_ban == 0:
notice_config["notice_type"] = "group_whole_ban"
else:
notice_config["notice_type"] = "group_ban"
elif sub_type == NoticeType.GroupBan.lift_ban:
user_id_in_ban = raw_message.get("user_id")
if user_id_in_ban == 0:
notice_config["notice_type"] = "group_whole_lift_ban"
else:
notice_config["notice_type"] = "group_lift_ban"
elif notice_type == NoticeType.notify:
sub_type = raw_message.get("sub_type")
if sub_type == NoticeType.Notify.poke:
notice_config["notice_type"] = "poke"
notice_config["is_notice"] = True # 戳一戳也是notice
elif notice_type == NoticeType.group_msg_emoji_like:
notice_config["notice_type"] = "emoji_like"
notice_config["is_notice"] = True # 表情回复也是notice
elif notice_type == NoticeType.group_upload:
notice_config["notice_type"] = "group_upload"
notice_config["is_notice"] = True # 文件上传也是notice
message_info: BaseMessageInfo = BaseMessageInfo(
platform=config_api.get_plugin_config(self.plugin_config, "maibot_server.platform_name", "qq"),
message_id="notice",
time=message_time,
user_info=user_info,
group_info=group_info,
template_info=None,
format_info=FormatInfo(
content_format=["text", "notify"],
accept_format=ACCEPT_FORMAT,
),
additional_config=notice_config, # 字典而不是JSON字符串
)
message_base: MessageBase = MessageBase(
message_info=message_info,
message_segment=handled_message,
raw_message=orjson.dumps(raw_message).decode('utf-8'),
)
if system_notice:
await self.put_notice(message_base)
return None
else:
logger.debug("发送到Maibot处理通知信息")
await message_send_instance.message_send(message_base)
return None
async def handle_poke_notify(
self, raw_message: dict, group_id: int, user_id: int
) -> Tuple[Seg | None, UserInfo | None]:
# sourcery skip: merge-comparisons, merge-duplicate-blocks, remove-redundant-if, remove-unnecessary-else, swap-if-else-branches
self_info: dict = await get_self_info(self.get_server_connection())
if not self_info:
logger.error("自身信息获取失败")
return None, None
self_id = raw_message.get("self_id")
target_id = raw_message.get("target_id")
# 防抖检查:如果是针对机器人的戳一戳,检查防抖时间
if self_id == target_id:
current_time = time.time()
debounce_seconds = config_api.get_plugin_config(self.plugin_config, "features.poke_debounce_seconds", 2.0)
if self.last_poke_time > 0:
time_diff = current_time - self.last_poke_time
if time_diff < debounce_seconds:
logger.debug(f"戳一戳防抖:用户 {user_id} 的戳一戳被忽略(距离上次戳一戳 {time_diff:.2f} 秒)")
return None, None
# 记录这次戳一戳的时间
self.last_poke_time = current_time
target_name: str = None
raw_info: list = raw_message.get("raw_info")
if group_id:
user_qq_info: dict = await get_member_info(self.get_server_connection(), group_id, user_id)
else:
user_qq_info: dict = await get_stranger_info(self.get_server_connection(), user_id)
if user_qq_info:
user_name = user_qq_info.get("nickname")
user_cardname = user_qq_info.get("card")
else:
user_name = "QQ用户"
user_cardname = "QQ用户"
logger.debug("无法获取戳一戳对方的用户昵称")
# 计算Seg
if self_id == target_id:
display_name = ""
target_name = self_info.get("nickname")
elif self_id == user_id:
# 让ada不发送麦麦戳别人的消息
return None, None
else:
# 如果配置为忽略不是针对自己的戳一戳则直接返回None
if config_api.get_plugin_config(self.plugin_config, "features.ignore_non_self_poke", False):
logger.debug("忽略不是针对自己的戳一戳消息")
return None, None
# 老实说这一步判定没啥意义,毕竟私聊是没有其他人之间的戳一戳,但是感觉可以有这个判定来强限制群聊环境
if group_id:
fetched_member_info: dict = await get_member_info(self.get_server_connection(), group_id, target_id)
if fetched_member_info:
target_name = fetched_member_info.get("nickname")
else:
target_name = "QQ用户"
logger.debug("无法获取被戳一戳方的用户昵称")
display_name = user_name
else:
return None, None
first_txt: str = "戳了戳"
second_txt: str = ""
try:
first_txt = raw_info[2].get("txt", "戳了戳")
second_txt = raw_info[4].get("txt", "")
except Exception as e:
logger.warning(f"解析戳一戳消息失败: {str(e)},将使用默认文本")
user_info: UserInfo = UserInfo(
platform=config_api.get_plugin_config(self.plugin_config, "maibot_server.platform_name", "qq"),
user_id=user_id,
user_nickname=user_name,
user_cardname=user_cardname,
)
seg_data: Seg = Seg(
type="text",
data=f"{display_name}{first_txt}{target_name}{second_txt}这是QQ的一个功能用于提及某人但没那么明显",
)
return seg_data, user_info
async def handle_group_emoji_like_notify(self, raw_message: dict, group_id: int, user_id: int):
if not group_id:
logger.error("群ID不能为空无法处理群聊表情回复通知")
return None, None
user_qq_info: dict = await get_member_info(self.get_server_connection(), group_id, user_id)
if user_qq_info:
user_name = user_qq_info.get("nickname")
user_cardname = user_qq_info.get("card")
else:
user_name = "QQ用户"
user_cardname = "QQ用户"
logger.debug("无法获取表情回复对方的用户昵称")
from src.plugin_system.core.event_manager import event_manager
from ...event_types import NapcatEvent
target_message = await event_manager.trigger_event(NapcatEvent.MESSAGE.GET_MSG,message_id=raw_message.get("message_id",""))
target_message_text = target_message.get_message_result().get("data",{}).get("raw_message","")
if not target_message:
logger.error("未找到对应消息")
return None, None
if len(target_message_text) > 15:
target_message_text = target_message_text[:15] + "..."
user_info: UserInfo = UserInfo(
platform=config_api.get_plugin_config(self.plugin_config, "maibot_server.platform_name", "qq"),
user_id=user_id,
user_nickname=user_name,
user_cardname=user_cardname,
)
like_emoji_id = raw_message.get("likes")[0].get("emoji_id")
event_manager.emit_event(
NapcatEvent.ON_RECEIVED.EMOJI_LIEK,
permission_group=PLUGIN_NAME,
group_id=group_id,
user_id=user_id,
message_id=raw_message.get("message_id",""),
emoji_id=like_emoji_id
)
seg_data = Seg(type="text",data=f"{user_name}使用Emoji表情{QQ_FACE.get(like_emoji_id,"")}回复了你的消息[{target_message_text}]")
return seg_data, user_info
async def handle_group_upload_notify(self, raw_message: dict, group_id: int, user_id: int, self_id: int):
if not group_id:
logger.error("群ID不能为空无法处理群文件上传通知")
return None, None
user_qq_info: dict = await get_member_info(self.get_server_connection(), group_id, user_id)
if user_qq_info:
user_name = user_qq_info.get("nickname")
user_cardname = user_qq_info.get("card")
else:
user_name = "QQ用户"
user_cardname = "QQ用户"
logger.debug("无法获取上传文件的用户昵称")
file_info = raw_message.get("file")
if not file_info:
logger.error("群文件上传通知中缺少文件信息")
return None, None
user_info: UserInfo = UserInfo(
platform=config_api.get_plugin_config(self.plugin_config, "maibot_server.platform_name", "qq"),
user_id=user_id,
user_nickname=user_name,
user_cardname=user_cardname,
)
file_name = file_info.get("name", "未知文件")
file_size = file_info.get("size", 0)
seg_data = Seg(type="text", data=f"{user_name} 上传了文件: {file_name} (大小: {file_size} 字节)")
return seg_data, user_info
async def handle_ban_notify(self, raw_message: dict, group_id: int) -> Tuple[Seg, UserInfo] | Tuple[None, None]:
if not group_id:
logger.error("群ID不能为空无法处理禁言通知")
return None, None
# 计算user_info
operator_id = raw_message.get("operator_id")
operator_nickname: str = None
operator_cardname: str = None
member_info: dict = await get_member_info(self.get_server_connection(), group_id, operator_id)
if member_info:
operator_nickname = member_info.get("nickname")
operator_cardname = member_info.get("card")
else:
logger.warning("无法获取禁言执行者的昵称,消息可能会无效")
operator_nickname = "QQ用户"
operator_info: UserInfo = UserInfo(
platform=config_api.get_plugin_config(self.plugin_config, "maibot_server.platform_name", "qq"),
user_id=operator_id,
user_nickname=operator_nickname,
user_cardname=operator_cardname,
)
# 计算Seg
user_id = raw_message.get("user_id")
banned_user_info: UserInfo = None
user_nickname: str = "QQ用户"
user_cardname: str = None
sub_type: str = None
duration = raw_message.get("duration")
if duration is None:
logger.error("禁言时长不能为空,无法处理禁言通知")
return None, None
if user_id == 0: # 为全体禁言
sub_type: str = "whole_ban"
await self._ban_operation(group_id)
else: # 为单人禁言
# 获取被禁言人的信息
sub_type: str = "ban"
fetched_member_info: dict = await get_member_info(self.get_server_connection(), group_id, user_id)
if fetched_member_info:
user_nickname = fetched_member_info.get("nickname")
user_cardname = fetched_member_info.get("card")
banned_user_info: UserInfo = UserInfo(
platform=config_api.get_plugin_config(self.plugin_config, "maibot_server.platform_name", "qq"),
user_id=user_id,
user_nickname=user_nickname,
user_cardname=user_cardname,
)
await self._ban_operation(group_id, user_id, int(time.time() + duration))
seg_data: Seg = Seg(
type="notify",
data={
"sub_type": sub_type,
"duration": duration,
"banned_user_info": banned_user_info.to_dict() if banned_user_info else None,
},
)
return seg_data, operator_info
async def handle_lift_ban_notify(
self, raw_message: dict, group_id: int
) -> Tuple[Seg, UserInfo] | Tuple[None, None]:
if not group_id:
logger.error("群ID不能为空无法处理解除禁言通知")
return None, None
# 计算user_info
operator_id = raw_message.get("operator_id")
operator_nickname: str = None
operator_cardname: str = None
member_info: dict = await get_member_info(self.get_server_connection(), group_id, operator_id)
if member_info:
operator_nickname = member_info.get("nickname")
operator_cardname = member_info.get("card")
else:
logger.warning("无法获取解除禁言执行者的昵称,消息可能会无效")
operator_nickname = "QQ用户"
operator_info: UserInfo = UserInfo(
platform=config_api.get_plugin_config(self.plugin_config, "maibot_server.platform_name", "qq"),
user_id=operator_id,
user_nickname=operator_nickname,
user_cardname=operator_cardname,
)
# 计算Seg
sub_type: str = None
user_nickname: str = "QQ用户"
user_cardname: str = None
lifted_user_info: UserInfo = None
user_id = raw_message.get("user_id")
if user_id == 0: # 全体禁言解除
sub_type = "whole_lift_ban"
await self._lift_operation(group_id)
else: # 单人禁言解除
sub_type = "lift_ban"
# 获取被解除禁言人的信息
fetched_member_info: dict = await get_member_info(self.get_server_connection(), group_id, user_id)
if fetched_member_info:
user_nickname = fetched_member_info.get("nickname")
user_cardname = fetched_member_info.get("card")
else:
logger.warning("无法获取解除禁言消息发送者的昵称,消息可能会无效")
lifted_user_info: UserInfo = UserInfo(
platform=config_api.get_plugin_config(self.plugin_config, "maibot_server.platform_name", "qq"),
user_id=user_id,
user_nickname=user_nickname,
user_cardname=user_cardname,
)
await self._lift_operation(group_id, user_id)
seg_data: Seg = Seg(
type="notify",
data={
"sub_type": sub_type,
"lifted_user_info": lifted_user_info.to_dict() if lifted_user_info else None,
},
)
return seg_data, operator_info
@staticmethod
async def put_notice(message_base: MessageBase) -> None:
"""
将处理后的通知消息放入通知队列
"""
if notice_queue.full() or unsuccessful_notice_queue.full():
logger.warning("通知队列已满,可能是多次发送失败,消息丢弃")
else:
await notice_queue.put(message_base)
async def handle_natural_lift(self) -> None:
while True:
if len(self.lifted_list) != 0:
lift_record = self.lifted_list.pop()
group_id = lift_record.group_id
user_id = lift_record.user_id
asyncio.create_task(napcat_db.delete_ban_record(lift_record)) # 从数据库中删除禁言记录
seg_message: Seg = await self.natural_lift(group_id, user_id)
fetched_group_info = await get_group_info(self.get_server_connection(), group_id)
group_name: str = None
if fetched_group_info:
group_name = fetched_group_info.get("group_name")
else:
logger.warning("无法获取notice消息所在群的名称")
group_info = GroupInfo(
platform=config_api.get_plugin_config(self.plugin_config, "maibot_server.platform_name", "qq"),
group_id=group_id,
group_name=group_name,
)
# 准备notice标志
notice_config = {
"is_notice": True,
"is_public_notice": False,
"notice_type": "group_lift_ban" if user_id != 0 else "group_whole_lift_ban",
}
message_info: BaseMessageInfo = BaseMessageInfo(
platform=config_api.get_plugin_config(self.plugin_config, "maibot_server.platform_name", "qq"),
message_id="notice",
time=time.time(),
user_info=None, # 自然解除禁言没有操作者
group_info=group_info,
template_info=None,
format_info=None,
additional_config=notice_config, # 字典而不是JSON字符串
)
message_base: MessageBase = MessageBase(
message_info=message_info,
message_segment=seg_message,
raw_message=orjson.dumps(
{
"post_type": "notice",
"notice_type": "group_ban",
"sub_type": "lift_ban",
"group_id": group_id,
"user_id": user_id,
"operator_id": None, # 自然解除禁言没有操作者
}
).decode('utf-8'),
)
await self.put_notice(message_base)
await asyncio.sleep(0.5) # 确保队列处理间隔
else:
await asyncio.sleep(5) # 每5秒检查一次
async def natural_lift(self, group_id: int, user_id: int) -> Seg | None:
if not group_id:
logger.error("群ID不能为空无法处理解除禁言通知")
return None
if user_id == 0: # 理论上永远不会触发
return Seg(
type="notify",
data={
"sub_type": "whole_lift_ban",
"lifted_user_info": None,
},
)
user_nickname: str = "QQ用户"
user_cardname: str = None
fetched_member_info: dict = await get_member_info(self.get_server_connection(), group_id, user_id)
if fetched_member_info:
user_nickname = fetched_member_info.get("nickname")
user_cardname = fetched_member_info.get("card")
lifted_user_info: UserInfo = UserInfo(
platform=config_api.get_plugin_config(self.plugin_config, "maibot_server.platform_name", "qq"),
user_id=user_id,
user_nickname=user_nickname,
user_cardname=user_cardname,
)
return Seg(
type="notify",
data={
"sub_type": "lift_ban",
"lifted_user_info": lifted_user_info.to_dict(),
},
)
async def auto_lift_detect(self) -> None:
while True:
if len(self.banned_list) == 0:
await asyncio.sleep(5)
continue
for ban_record in self.banned_list:
if ban_record.user_id == 0 or ban_record.lift_time == -1:
continue
if ban_record.lift_time <= int(time.time()):
# 触发自然解除禁言
logger.debug(f"检测到用户 {ban_record.user_id} 在群 {ban_record.group_id} 的禁言已解除")
self.lifted_list.append(ban_record)
self.banned_list.remove(ban_record)
await asyncio.sleep(5)
@staticmethod
async def send_notice() -> None:
"""
发送通知消息到Napcat
"""
while True:
if not unsuccessful_notice_queue.empty():
to_be_send: MessageBase = await unsuccessful_notice_queue.get()
try:
send_status = await message_send_instance.message_send(to_be_send)
if send_status:
unsuccessful_notice_queue.task_done()
else:
await unsuccessful_notice_queue.put(to_be_send)
except Exception as e:
logger.error(f"发送通知消息失败: {str(e)}")
await unsuccessful_notice_queue.put(to_be_send)
await asyncio.sleep(1)
continue
to_be_send: MessageBase = await notice_queue.get()
try:
send_status = await message_send_instance.message_send(to_be_send)
if send_status:
notice_queue.task_done()
else:
await unsuccessful_notice_queue.put(to_be_send)
except Exception as e:
logger.error(f"发送通知消息失败: {str(e)}")
await unsuccessful_notice_queue.put(to_be_send)
await asyncio.sleep(1)
notice_handler = NoticeHandler()

View File

@@ -1,252 +0,0 @@
qq_face: dict = {
"0": "[表情:惊讶]",
"1": "[表情:撇嘴]",
"2": "[表情:色]",
"3": "[表情:发呆]",
"4": "[表情:得意]",
"5": "[表情:流泪]",
"6": "[表情:害羞]",
"7": "[表情:闭嘴]",
"8": "[表情:睡]",
"9": "[表情:大哭]",
"10": "[表情:尴尬]",
"11": "[表情:发怒]",
"12": "[表情:调皮]",
"13": "[表情:呲牙]",
"14": "[表情:微笑]",
"15": "[表情:难过]",
"16": "[表情:酷]",
"18": "[表情:抓狂]",
"19": "[表情:吐]",
"20": "[表情:偷笑]",
"21": "[表情:可爱]",
"22": "[表情:白眼]",
"23": "[表情:傲慢]",
"24": "[表情:饥饿]",
"25": "[表情:困]",
"26": "[表情:惊恐]",
"27": "[表情:流汗]",
"28": "[表情:憨笑]",
"29": "[表情:悠闲]",
"30": "[表情:奋斗]",
"31": "[表情:咒骂]",
"32": "[表情:疑问]",
"33": "[表情: 嘘]",
"34": "[表情:晕]",
"35": "[表情:折磨]",
"36": "[表情:衰]",
"37": "[表情:骷髅]",
"38": "[表情:敲打]",
"39": "[表情:再见]",
"41": "[表情:发抖]",
"42": "[表情:爱情]",
"43": "[表情:跳跳]",
"46": "[表情:猪头]",
"49": "[表情:拥抱]",
"53": "[表情:蛋糕]",
"56": "[表情:刀]",
"59": "[表情:便便]",
"60": "[表情:咖啡]",
"63": "[表情:玫瑰]",
"64": "[表情:凋谢]",
"66": "[表情:爱心]",
"67": "[表情:心碎]",
"74": "[表情:太阳]",
"75": "[表情:月亮]",
"76": "[表情:赞]",
"77": "[表情:踩]",
"78": "[表情:握手]",
"79": "[表情:胜利]",
"85": "[表情:飞吻]",
"86": "[表情:怄火]",
"89": "[表情:西瓜]",
"96": "[表情:冷汗]",
"97": "[表情:擦汗]",
"98": "[表情:抠鼻]",
"99": "[表情:鼓掌]",
"100": "[表情:糗大了]",
"101": "[表情:坏笑]",
"102": "[表情:左哼哼]",
"103": "[表情:右哼哼]",
"104": "[表情:哈欠]",
"105": "[表情:鄙视]",
"106": "[表情:委屈]",
"107": "[表情:快哭了]",
"108": "[表情:阴险]",
"109": "[表情:左亲亲]",
"110": "[表情:吓]",
"111": "[表情:可怜]",
"112": "[表情:菜刀]",
"114": "[表情:篮球]",
"116": "[表情:示爱]",
"118": "[表情:抱拳]",
"119": "[表情:勾引]",
"120": "[表情:拳头]",
"121": "[表情:差劲]",
"123": "[表情NO]",
"124": "[表情OK]",
"125": "[表情:转圈]",
"129": "[表情:挥手]",
"137": "[表情:鞭炮]",
"144": "[表情:喝彩]",
"146": "[表情:爆筋]",
"147": "[表情:棒棒糖]",
"169": "[表情:手枪]",
"171": "[表情:茶]",
"172": "[表情:眨眼睛]",
"173": "[表情:泪奔]",
"174": "[表情:无奈]",
"175": "[表情:卖萌]",
"176": "[表情:小纠结]",
"177": "[表情:喷血]",
"178": "[表情:斜眼笑]",
"179": "[表情doge]",
"181": "[表情:戳一戳]",
"182": "[表情:笑哭]",
"183": "[表情:我最美]",
"185": "[表情:羊驼]",
"187": "[表情:幽灵]",
"201": "[表情:点赞]",
"212": "[表情:托腮]",
"262": "[表情:脑阔疼]",
"263": "[表情:沧桑]",
"264": "[表情:捂脸]",
"265": "[表情:辣眼睛]",
"266": "[表情:哦哟]",
"267": "[表情:头秃]",
"268": "[表情:问号脸]",
"269": "[表情:暗中观察]",
"270": "[表情emm]",
"271": "[表情:吃 瓜]",
"272": "[表情:呵呵哒]",
"273": "[表情:我酸了]",
"277": "[表情:滑稽狗头]",
"281": "[表情:翻白眼]",
"282": "[表情:敬礼]",
"283": "[表情:狂笑]",
"284": "[表情:面无表情]",
"285": "[表情:摸鱼]",
"286": "[表情:魔鬼笑]",
"287": "[表情:哦]",
"289": "[表情:睁眼]",
"293": "[表情:摸锦鲤]",
"294": "[表情:期待]",
"295": "[表情:拿到红包]",
"297": "[表情:拜谢]",
"298": "[表情:元宝]",
"299": "[表情:牛啊]",
"300": "[表情:胖三斤]",
"302": "[表情:左拜年]",
"303": "[表情:右拜年]",
"305": "[表情:右亲亲]",
"306": "[表情:牛气冲天]",
"307": "[表情:喵喵]",
"311": "[表情打call]",
"312": "[表情:变形]",
"314": "[表情:仔细分析]",
"317": "[表情:菜汪]",
"318": "[表情:崇拜]",
"319": "[表情: 比心]",
"320": "[表情:庆祝]",
"323": "[表情:嫌弃]",
"324": "[表情:吃糖]",
"325": "[表情:惊吓]",
"326": "[表情:生气]",
"332": "[表情:举牌牌]",
"333": "[表情:烟花]",
"334": "[表情:虎虎生威]",
"336": "[表情:豹富]",
"337": "[表情:花朵脸]",
"338": "[表情:我想开了]",
"339": "[表情:舔屏]",
"341": "[表情:打招呼]",
"342": "[表情酸Q]",
"343": "[表情:我方了]",
"344": "[表情:大怨种]",
"345": "[表情:红包多多]",
"346": "[表情:你真棒棒]",
"347": "[表情:大展宏兔]",
"349": "[表情:坚强]",
"350": "[表情:贴贴]",
"351": "[表情:敲敲]",
"352": "[表情:咦]",
"353": "[表情:拜托]",
"354": "[表情:尊嘟假嘟]",
"355": "[表情:耶]",
"356": "[表情666]",
"357": "[表情:裂开]",
"392": "[表情:龙年 快乐]",
"393": "[表情:新年中龙]",
"394": "[表情:新年大龙]",
"395": "[表情:略略略]",
"396": "[表情:龙年快乐]",
"424": "[表情:按钮]",
"😊": "[表情:嘿嘿]",
"😌": "[表情:羞涩]",
"😚": "[ 表情:亲亲]",
"😓": "[表情:汗]",
"😰": "[表情:紧张]",
"😝": "[表情:吐舌]",
"😁": "[表情:呲牙]",
"😜": "[表情:淘气]",
"": "[表情:可爱]",
"😍": "[表情:花痴]",
"😔": "[表情:失落]",
"😄": "[表情:高兴]",
"😏": "[表情:哼哼]",
"😒": "[表情:不屑]",
"😳": "[表情:瞪眼]",
"😘": "[表情:飞吻]",
"😭": "[表情:大哭]",
"😱": "[表情:害怕]",
"😂": "[表情:激动]",
"💪": "[表情:肌肉]",
"👊": "[表情:拳头]",
"👍": "[表情 :厉害]",
"👏": "[表情:鼓掌]",
"👎": "[表情:鄙视]",
"🙏": "[表情:合十]",
"👌": "[表情:好的]",
"👆": "[表情:向上]",
"👀": "[表情:眼睛]",
"🍜": "[表情:拉面]",
"🍧": "[表情:刨冰]",
"🍞": "[表情:面包]",
"🍺": "[表情:啤酒]",
"🍻": "[表情:干杯]",
"": "[表情:咖啡]",
"🍎": "[表情:苹果]",
"🍓": "[表情:草莓]",
"🍉": "[表情:西瓜]",
"🚬": "[表情:吸烟]",
"🌹": "[表情:玫瑰]",
"🎉": "[表情:庆祝]",
"💝": "[表情:礼物]",
"💣": "[表情:炸弹]",
"": "[表情:闪光]",
"💨": "[表情:吹气]",
"💦": "[表情:水]",
"🔥": "[表情:火]",
"💤": "[表情:睡觉]",
"💩": "[表情:便便]",
"💉": "[表情:打针]",
"📫": "[表情:邮箱]",
"🐎": "[表情:骑马]",
"👧": "[表情:女孩]",
"👦": "[表情:男孩]",
"🐵": "[表情:猴]",
"🐷": "[表情:猪]",
"🐮": "[表情:牛]",
"🐔": "[表情:公鸡]",
"🐸": "[表情:青蛙]",
"👻": "[表情:幽灵]",
"🐛": "[表情:虫]",
"🐶": "[表情:狗]",
"🐳": "[表情:鲸鱼]",
"👢": "[表情:靴子]",
"": "[表情:晴天]",
"": "[表情:问号]",
"🔫": "[表情:手枪]",
"💓": "[表情:爱 心]",
"🏪": "[表情:便利店]",
}

View File

@@ -1,63 +0,0 @@
import asyncio
import time
from typing import Dict
from src.common.logger import get_logger
from src.plugin_system.apis import config_api
logger = get_logger("napcat_adapter")
response_dict: Dict = {}
response_time_dict: Dict = {}
plugin_config = None
def set_plugin_config(config: dict):
"""设置插件配置"""
global plugin_config
plugin_config = config
async def get_response(request_id: str, timeout: int = 10) -> dict:
response = await asyncio.wait_for(_get_response(request_id), timeout)
_ = response_time_dict.pop(request_id)
logger.debug(f"响应信息id: {request_id} 已从响应字典中取出")
return response
async def _get_response(request_id: str) -> dict:
"""
内部使用的获取响应函数,主要用于在需要时获取响应
"""
while request_id not in response_dict:
await asyncio.sleep(0.2)
return response_dict.pop(request_id)
async def put_response(response: dict):
echo_id = response.get("echo")
now_time = time.time()
response_dict[echo_id] = response
response_time_dict[echo_id] = now_time
logger.debug(f"响应信息id: {echo_id} 已存入响应字典")
async def check_timeout_response() -> None:
while True:
cleaned_message_count: int = 0
now_time = time.time()
# 获取心跳间隔配置
heartbeat_interval = 30 # 默认值
if plugin_config:
heartbeat_interval = config_api.get_plugin_config(plugin_config, "napcat_server.heartbeat_interval", 30)
for echo_id, response_time in list(response_time_dict.items()):
if now_time - response_time > heartbeat_interval:
cleaned_message_count += 1
response_dict.pop(echo_id)
response_time_dict.pop(echo_id)
logger.warning(f"响应消息 {echo_id} 超时,已删除")
if cleaned_message_count > 0:
logger.info(f"已删除 {cleaned_message_count} 条超时响应消息")
await asyncio.sleep(heartbeat_interval)

View File

@@ -1,705 +0,0 @@
import orjson
import random
import time
import websockets as Server
import uuid
from maim_message import (
UserInfo,
GroupInfo,
Seg,
BaseMessageInfo,
MessageBase,
)
from typing import Dict, Any, Tuple, Optional
from src.plugin_system.apis import config_api
from . import CommandType
from .response_pool import get_response
from src.common.logger import get_logger
logger = get_logger("napcat_adapter")
from .utils import get_image_format, convert_image_to_gif
from .recv_handler.message_sending import message_send_instance
from .websocket_manager import websocket_manager
class SendHandler:
def __init__(self):
self.server_connection: Optional[Server.ServerConnection] = None
self.plugin_config = None
def set_plugin_config(self, plugin_config: dict):
"""设置插件配置"""
self.plugin_config = plugin_config
async def set_server_connection(self, server_connection: Server.ServerConnection) -> None:
"""设置Napcat连接"""
self.server_connection = server_connection
def get_server_connection(self) -> Optional[Server.ServerConnection]:
"""获取当前的服务器连接"""
# 优先使用直接设置的连接,否则从 websocket_manager 获取
if self.server_connection:
return self.server_connection
return websocket_manager.get_connection()
async def handle_message(self, raw_message_base_dict: dict) -> None:
raw_message_base: MessageBase = MessageBase.from_dict(raw_message_base_dict)
message_segment: Seg = raw_message_base.message_segment
logger.info("接收到来自MoFox-Bot的消息处理中")
if message_segment.type == "command":
logger.info("处理命令")
return await self.send_command(raw_message_base)
elif message_segment.type == "adapter_command":
logger.info("处理适配器命令")
return await self.handle_adapter_command(raw_message_base)
elif message_segment.type == "adapter_response":
# adapter_response消息是Napcat发送给Bot的不应该在这里处理
# 这个handler只处理Bot发送给Napcat的消息
logger.info("收到adapter_response消息此消息应该由Bot端处理跳过")
return None
else:
logger.info("处理普通消息")
return await self.send_normal_message(raw_message_base)
async def send_normal_message(self, raw_message_base: MessageBase) -> None:
"""
处理普通消息发送
"""
logger.info("处理普通信息中")
message_info: BaseMessageInfo = raw_message_base.message_info
message_segment: Seg = raw_message_base.message_segment
group_info: Optional[GroupInfo] = message_info.group_info
user_info: Optional[UserInfo] = message_info.user_info
target_id: Optional[int] = None
action: Optional[str] = None
id_name: Optional[str] = None
processed_message: list = []
try:
if user_info:
processed_message = await self.handle_seg_recursive(message_segment, user_info)
except Exception as e:
logger.error(f"处理消息时发生错误: {e}")
return
if not processed_message:
logger.critical("现在暂时不支持解析此回复!")
return None
if group_info and user_info:
logger.debug("发送群聊消息")
target_id = int(group_info.group_id) if group_info.group_id else None
action = "send_group_msg"
id_name = "group_id"
elif user_info:
logger.debug("发送私聊消息")
target_id = int(user_info.user_id) if user_info.user_id else None
action = "send_private_msg"
id_name = "user_id"
else:
logger.error("无法识别的消息类型")
return
logger.info("尝试发送到napcat")
logger.debug(
f"准备发送到napcat的消息体: action='{action}', {id_name}='{target_id}', message='{processed_message}'"
)
response = await self.send_message_to_napcat(
action,
{
id_name: target_id,
"message": processed_message,
},
)
if response.get("status") == "ok":
logger.info("消息发送成功")
qq_message_id = response.get("data", {}).get("message_id")
await self.message_sent_back(raw_message_base, qq_message_id)
else:
logger.warning(f"消息发送失败napcat返回{str(response)}")
async def send_command(self, raw_message_base: MessageBase) -> None:
"""
处理命令类
"""
logger.info("处理命令中")
message_info: BaseMessageInfo = raw_message_base.message_info
message_segment: Seg = raw_message_base.message_segment
group_info: Optional[GroupInfo] = message_info.group_info
seg_data: Dict[str, Any] = message_segment.data if isinstance(message_segment.data, dict) else {}
command_name: Optional[str] = seg_data.get("name")
try:
args = seg_data.get("args", {})
if not isinstance(args, dict):
args = {}
match command_name:
case CommandType.GROUP_BAN.name:
command, args_dict = self.handle_ban_command(args, group_info)
case CommandType.GROUP_WHOLE_BAN.name:
command, args_dict = self.handle_whole_ban_command(args, group_info)
case CommandType.GROUP_KICK.name:
command, args_dict = self.handle_kick_command(args, group_info)
case CommandType.SEND_POKE.name:
command, args_dict = self.handle_poke_command(args, group_info)
case CommandType.DELETE_MSG.name:
command, args_dict = self.delete_msg_command(args)
case CommandType.AI_VOICE_SEND.name:
command, args_dict = self.handle_ai_voice_send_command(args, group_info)
case CommandType.SET_EMOJI_LIKE.name:
command, args_dict = self.handle_set_emoji_like_command(args)
case CommandType.SEND_AT_MESSAGE.name:
command, args_dict = self.handle_at_message_command(args, group_info)
case CommandType.SEND_LIKE.name:
command, args_dict = self.handle_send_like_command(args)
case _:
logger.error(f"未知命令: {command_name}")
return
except Exception as e:
logger.error(f"处理命令时发生错误: {e}")
return None
if not command or not args_dict:
logger.error("命令或参数缺失")
return None
logger.info(f"准备向 Napcat 发送命令: command='{command}', args_dict='{args_dict}'")
response = await self.send_message_to_napcat(command, args_dict)
logger.info(f"收到 Napcat 的命令响应: {response}")
if response.get("status") == "ok":
logger.info(f"命令 {command_name} 执行成功")
else:
logger.warning(f"命令 {command_name} 执行失败napcat返回{str(response)}")
async def handle_adapter_command(self, raw_message_base: MessageBase) -> None:
"""
处理适配器命令类 - 用于直接向Napcat发送命令并返回结果
"""
logger.info("处理适配器命令中")
message_segment: Seg = raw_message_base.message_segment
seg_data: Dict[str, Any] = message_segment.data if isinstance(message_segment.data, dict) else {}
try:
action = seg_data.get("action")
params = seg_data.get("params", {})
request_id = seg_data.get("request_id")
if not action:
logger.error("适配器命令缺少action参数")
await self.send_adapter_command_response(
raw_message_base, {"status": "error", "message": "缺少action参数"}, request_id
)
return
logger.info(f"执行适配器命令: {action}")
# 根据action决定处理方式
if action == "get_cookies":
# 对于get_cookies我们需要一个更长的超时时间
response = await self.send_message_to_napcat(action, params, timeout=40.0)
else:
# 对于其他命令,使用默认超时
response = await self.send_message_to_napcat(action, params)
# 发送响应回MoFox-Bot
logger.debug(f"[DEBUG handle_adapter_command] 即将调用send_adapter_command_response, request_id={request_id}")
await self.send_adapter_command_response(raw_message_base, response, request_id)
logger.debug("[DEBUG handle_adapter_command] send_adapter_command_response调用完成")
if response.get("status") == "ok":
logger.info(f"适配器命令 {action} 执行成功")
else:
logger.warning(f"适配器命令 {action} 执行失败napcat返回{str(response)}")
# 无论成功失败,都记录下完整的响应内容以供调试
logger.debug(f"适配器命令 {action} 的完整响应: {response}")
except Exception as e:
logger.error(f"处理适配器命令时发生错误: {e}")
error_response = {"status": "error", "message": str(e)}
await self.send_adapter_command_response(raw_message_base, error_response, seg_data.get("request_id"))
def get_level(self, seg_data: Seg) -> int:
if seg_data.type == "seglist":
return 1 + max(self.get_level(seg) for seg in seg_data.data)
else:
return 1
async def handle_seg_recursive(self, seg_data: Seg, user_info: UserInfo) -> list:
payload: list = []
if seg_data.type == "seglist":
# level = self.get_level(seg_data) # 给以后可能的多层嵌套做准备,此处不使用
if not seg_data.data:
return []
for seg in seg_data.data:
payload = await self.process_message_by_type(seg, payload, user_info)
else:
payload = await self.process_message_by_type(seg_data, payload, user_info)
return payload
async def process_message_by_type(self, seg: Seg, payload: list, user_info: UserInfo) -> list:
# sourcery skip: reintroduce-else, swap-if-else-branches, use-named-expression
new_payload = payload
if seg.type == "reply":
target_id = seg.data
target_id = str(target_id)
if target_id == "notice":
return payload
logger.info(target_id if isinstance(target_id, str) else "")
new_payload = self.build_payload(
payload,
await self.handle_reply_message(target_id if isinstance(target_id, str) else "", user_info),
True,
)
elif seg.type == "text":
text = seg.data
if not text:
return payload
new_payload = self.build_payload(
payload,
self.handle_text_message(text if isinstance(text, str) else ""),
False,
)
elif seg.type == "face":
logger.warning("MoFox-Bot 发送了qq原生表情暂时不支持")
elif seg.type == "image":
image = seg.data
new_payload = self.build_payload(payload, self.handle_image_message(image), False)
elif seg.type == "emoji":
emoji = seg.data
new_payload = self.build_payload(payload, self.handle_emoji_message(emoji), False)
elif seg.type == "voice":
voice = seg.data
new_payload = self.build_payload(payload, self.handle_voice_message(voice), False)
elif seg.type == "voiceurl":
voice_url = seg.data
new_payload = self.build_payload(payload, self.handle_voiceurl_message(voice_url), False)
elif seg.type == "music":
song_id = seg.data
new_payload = self.build_payload(payload, self.handle_music_message(song_id), False)
elif seg.type == "videourl":
video_url = seg.data
new_payload = self.build_payload(payload, self.handle_videourl_message(video_url), False)
elif seg.type == "file":
file_path = seg.data
new_payload = self.build_payload(payload, self.handle_file_message(file_path), False)
return new_payload
def build_payload(self, payload: list, addon: dict | list, is_reply: bool = False) -> list:
# sourcery skip: for-append-to-extend, merge-list-append, simplify-generator
"""构建发送的消息体"""
if is_reply:
temp_list = []
if isinstance(addon, list):
temp_list.extend(addon)
else:
temp_list.append(addon)
for i in payload:
if i.get("type") == "reply":
logger.debug("检测到多个回复,使用最新的回复")
continue
temp_list.append(i)
return temp_list
else:
if isinstance(addon, list):
payload.extend(addon)
else:
payload.append(addon)
return payload
async def handle_reply_message(self, id: str, user_info: UserInfo) -> dict | list:
"""处理回复消息"""
logger.debug(f"开始处理回复消息消息ID: {id}")
reply_seg = {"type": "reply", "data": {"id": id}}
# 检查是否启用引用艾特功能
if not config_api.get_plugin_config(self.plugin_config, "features.enable_reply_at", False):
logger.info("引用艾特功能未启用,仅发送普通回复")
return reply_seg
try:
msg_info_response = await self.send_message_to_napcat("get_msg", {"message_id": id})
logger.debug(f"获取消息 {id} 的详情响应: {msg_info_response}")
replied_user_id = None
if msg_info_response and msg_info_response.get("status") == "ok":
sender_info = msg_info_response.get("data", {}).get("sender")
if sender_info:
replied_user_id = sender_info.get("user_id")
# 如果没有获取到被回复者的ID则直接返回不进行@
if not replied_user_id:
logger.warning(f"无法获取消息 {id} 的发送者信息,跳过 @")
logger.info(f"最终返回的回复段: {reply_seg}")
return reply_seg
# 根据概率决定是否艾特用户
if random.random() < config_api.get_plugin_config(self.plugin_config, "features.reply_at_rate", 0.5):
at_seg = {"type": "at", "data": {"qq": str(replied_user_id)}}
# 在艾特后面添加一个空格
text_seg = {"type": "text", "data": {"text": " "}}
result_seg = [reply_seg, at_seg, text_seg]
logger.info(f"最终返回的回复段: {result_seg}")
return result_seg
except Exception as e:
logger.error(f"处理引用回复并尝试@时出错: {e}")
# 出现异常时,只发送普通的回复,避免程序崩溃
logger.info(f"最终返回的回复段: {reply_seg}")
return reply_seg
logger.info(f"最终返回的回复段: {reply_seg}")
return reply_seg
def handle_text_message(self, message: str) -> dict:
"""处理文本消息"""
return {"type": "text", "data": {"text": message}}
def handle_image_message(self, encoded_image: str) -> dict:
"""处理图片消息"""
return {
"type": "image",
"data": {
"file": f"base64://{encoded_image}",
"subtype": 0,
},
} # base64 编码的图片
def handle_emoji_message(self, encoded_emoji: str) -> dict:
"""处理表情消息"""
encoded_image = encoded_emoji
image_format = get_image_format(encoded_emoji)
if image_format != "gif":
encoded_image = convert_image_to_gif(encoded_emoji)
return {
"type": "image",
"data": {
"file": f"base64://{encoded_image}",
"subtype": 1,
"summary": "[动画表情]",
},
}
def handle_voice_message(self, encoded_voice: str) -> dict:
"""处理语音消息"""
use_tts = False
if self.plugin_config:
use_tts = config_api.get_plugin_config(self.plugin_config, "voice.use_tts", False)
if not use_tts:
logger.warning("未启用语音消息处理")
return {}
if not encoded_voice:
return {}
return {
"type": "record",
"data": {"file": f"base64://{encoded_voice}"},
}
def handle_voiceurl_message(self, voice_url: str) -> dict:
"""处理语音链接消息"""
return {
"type": "record",
"data": {"file": voice_url},
}
def handle_music_message(self, song_id: str) -> dict:
"""处理音乐消息"""
return {
"type": "music",
"data": {"type": "163", "id": song_id},
}
def handle_videourl_message(self, video_url: str) -> dict:
"""处理视频链接消息"""
return {
"type": "video",
"data": {"file": video_url},
}
def handle_file_message(self, file_path: str) -> dict:
"""处理文件消息"""
return {
"type": "file",
"data": {"file": f"file://{file_path}"},
}
def delete_msg_command(self, args: Dict[str, Any]) -> Tuple[str, Dict[str, Any]]:
"""处理删除消息命令"""
return "delete_msg", {"message_id": args["message_id"]}
def handle_ban_command(self, args: Dict[str, Any], group_info: GroupInfo) -> Tuple[str, Dict[str, Any]]:
"""处理封禁命令
Args:
args (Dict[str, Any]): 参数字典
group_info (GroupInfo): 群聊信息(对应目标群聊)
Returns:
Tuple[CommandType, Dict[str, Any]]
"""
duration: int = int(args["duration"])
user_id: int = int(args["qq_id"])
group_id: int = int(group_info.group_id)
if duration < 0:
raise ValueError("封禁时间必须大于等于0")
if not user_id or not group_id:
raise ValueError("封禁命令缺少必要参数")
if duration > 2592000:
raise ValueError("封禁时间不能超过30天")
return (
CommandType.GROUP_BAN.value,
{
"group_id": group_id,
"user_id": user_id,
"duration": duration,
},
)
def handle_whole_ban_command(self, args: Dict[str, Any], group_info: GroupInfo) -> Tuple[str, Dict[str, Any]]:
"""处理全体禁言命令
Args:
args (Dict[str, Any]): 参数字典
group_info (GroupInfo): 群聊信息(对应目标群聊)
Returns:
Tuple[CommandType, Dict[str, Any]]
"""
enable = args["enable"]
assert isinstance(enable, bool), "enable参数必须是布尔值"
group_id: int = int(group_info.group_id)
if group_id <= 0:
raise ValueError("群组ID无效")
return (
CommandType.GROUP_WHOLE_BAN.value,
{
"group_id": group_id,
"enable": enable,
},
)
def handle_kick_command(self, args: Dict[str, Any], group_info: GroupInfo) -> Tuple[str, Dict[str, Any]]:
"""处理群成员踢出命令
Args:
args (Dict[str, Any]): 参数字典
group_info (GroupInfo): 群聊信息(对应目标群聊)
Returns:
Tuple[CommandType, Dict[str, Any]]
"""
user_id: int = int(args["qq_id"])
group_id: int = int(group_info.group_id)
if group_id <= 0:
raise ValueError("群组ID无效")
if user_id <= 0:
raise ValueError("用户ID无效")
return (
CommandType.GROUP_KICK.value,
{
"group_id": group_id,
"user_id": user_id,
"reject_add_request": False, # 不拒绝加群请求
},
)
def handle_poke_command(self, args: Dict[str, Any], group_info: GroupInfo) -> Tuple[str, Dict[str, Any]]:
"""处理戳一戳命令
Args:
args (Dict[str, Any]): 参数字典
group_info (GroupInfo): 群聊信息(对应目标群聊)
Returns:
Tuple[CommandType, Dict[str, Any]]
"""
user_id: int = int(args["qq_id"])
if group_info is None:
group_id = None
else:
group_id: int = int(group_info.group_id)
if group_id <= 0:
raise ValueError("群组ID无效")
if user_id <= 0:
raise ValueError("用户ID无效")
return (
CommandType.SEND_POKE.value,
{
"group_id": group_id,
"user_id": user_id,
},
)
def handle_set_emoji_like_command(self, args: Dict[str, Any]) -> Tuple[str, Dict[str, Any]]:
"""处理设置表情回应命令
Args:
args (Dict[str, Any]): 参数字典
Returns:
Tuple[CommandType, Dict[str, Any]]
"""
logger.info(f"开始处理表情回应命令, 接收到参数: {args}")
try:
message_id = int(args["message_id"])
emoji_id = int(args["emoji_id"])
set_like = bool(args["set"])
except (KeyError, ValueError) as e:
logger.error(f"处理表情回应命令时发生错误: {e}, 原始参数: {args}")
raise ValueError(f"缺少必需参数或参数类型错误: {e}")
return (
CommandType.SET_EMOJI_LIKE.value,
{"message_id": message_id, "emoji_id": emoji_id, "set": set_like},
)
def handle_send_like_command(self, args: Dict[str, Any]) -> Tuple[str, Dict[str, Any]]:
"""
处理发送点赞命令的逻辑。
Args:
args (Dict[str, Any]): 参数字典
Returns:
Tuple[CommandType, Dict[str, Any]]
"""
try:
user_id: int = int(args["qq_id"])
times: int = int(args["times"])
except (KeyError, ValueError):
raise ValueError("缺少必需参数: qq_id 或 times")
return (
CommandType.SEND_LIKE.value,
{"user_id": user_id, "times": times},
)
def handle_ai_voice_send_command(self, args: Dict[str, Any], group_info: GroupInfo) -> Tuple[str, Dict[str, Any]]:
"""
处理AI语音发送命令的逻辑。
并返回 NapCat 兼容的 (action, params) 元组。
"""
if not group_info or not group_info.group_id:
raise ValueError("AI语音发送命令必须在群聊上下文中使用")
if not args:
raise ValueError("AI语音发送命令缺少参数")
group_id: int = int(group_info.group_id)
character_id = args.get("character")
text_content = args.get("text")
if not character_id or not text_content:
raise ValueError(f"AI语音发送命令参数不完整: character='{character_id}', text='{text_content}'")
return (
CommandType.AI_VOICE_SEND.value,
{
"group_id": group_id,
"text": text_content,
"character": character_id,
},
)
async def send_message_to_napcat(self, action: str, params: dict, timeout: float = 20.0) -> dict:
request_uuid = str(uuid.uuid4())
payload = orjson.dumps({"action": action, "params": params, "echo": request_uuid}).decode('utf-8')
# 获取当前连接
connection = self.get_server_connection()
if not connection:
logger.error("没有可用的 Napcat 连接")
return {"status": "error", "message": "no connection"}
try:
await connection.send(payload)
response = await get_response(request_uuid, timeout=timeout) # 使用传入的超时时间
except TimeoutError:
logger.error(f"发送消息超时({timeout}秒),未收到响应: action={action}, params={params}")
return {"status": "error", "message": "timeout"}
except Exception as e:
logger.error(f"发送消息失败: {e}")
return {"status": "error", "message": str(e)}
return response
async def message_sent_back(self, message_base: MessageBase, qq_message_id: str) -> None:
# 修改 additional_config添加 echo 字段
if message_base.message_info.additional_config is None:
message_base.message_info.additional_config = {}
message_base.message_info.additional_config["echo"] = True
# 获取原始的 mmc_message_id
mmc_message_id = message_base.message_info.message_id
# 修改 message_segment 为 notify 类型
message_base.message_segment = Seg(
type="notify", data={"sub_type": "echo", "echo": mmc_message_id, "actual_id": qq_message_id}
)
await message_send_instance.message_send(message_base)
logger.debug("已回送消息ID")
return
async def send_adapter_command_response(
self, original_message: MessageBase, response_data: dict, request_id: str
) -> None:
"""
发送适配器命令响应回MoFox-Bot
Args:
original_message: 原始消息
response_data: 响应数据
request_id: 请求ID
"""
try:
# 修改 additional_config添加 echo 字段
if original_message.message_info.additional_config is None:
original_message.message_info.additional_config = {}
original_message.message_info.additional_config["echo"] = True
# 修改 message_segment 为 adapter_response 类型
original_message.message_segment = Seg(
type="adapter_response",
data={"request_id": request_id, "response": response_data, "timestamp": int(time.time() * 1000)},
)
await message_send_instance.message_send(original_message)
except Exception as e:
logger.error(f"发送适配器命令响应时出错: {e}")
def handle_at_message_command(self, args: Dict[str, Any], group_info: GroupInfo) -> Tuple[str, Dict[str, Any]]:
"""处理艾特并发送消息命令
Args:
args (Dict[str, Any]): 参数字典, 包含 qq_id 和 text
group_info (GroupInfo): 群聊信息
Returns:
Tuple[str, Dict[str, Any]]: (action, params)
"""
at_user_id = args.get("qq_id")
text = args.get("text")
if not at_user_id or not text:
raise ValueError("艾特消息命令缺少 qq_id 或 text 参数")
if not group_info:
raise ValueError("艾特消息命令必须在群聊上下文中使用")
message_payload = [
{"type": "at", "data": {"qq": str(at_user_id)}},
{"type": "text", "data": {"text": " " + str(text)}},
]
return (
"send_group_msg",
{
"group_id": group_info.group_id,
"message": message_payload,
},
)
send_handler = SendHandler()

View File

@@ -1,351 +0,0 @@
"""
按聊天流分配消费者的消息路由系统
核心思想:
- 为每个活跃的聊天流stream_id创建独立的消息队列和消费者协程
- 同一聊天流的消息由同一个 worker 处理,保证顺序性
- 不同聊天流的消息并发处理,提高吞吐量
- 动态管理流的生命周期,自动清理不活跃的流
"""
import asyncio
import time
from typing import Dict, Optional
from src.common.logger import get_logger
logger = get_logger("stream_router")
class StreamConsumer:
"""单个聊天流的消息消费者
维护独立的消息队列和处理协程
"""
def __init__(self, stream_id: str, queue_maxsize: int = 100):
self.stream_id = stream_id
self.queue: asyncio.Queue = asyncio.Queue(maxsize=queue_maxsize)
self.worker_task: Optional[asyncio.Task] = None
self.last_active_time = time.time()
self.is_running = False
# 性能统计
self.stats = {
"total_messages": 0,
"total_processing_time": 0.0,
"queue_overflow_count": 0,
}
async def start(self) -> None:
"""启动消费者"""
if not self.is_running:
self.is_running = True
self.worker_task = asyncio.create_task(self._process_loop())
logger.debug(f"Stream Consumer 启动: {self.stream_id}")
async def stop(self) -> None:
"""停止消费者"""
self.is_running = False
if self.worker_task:
self.worker_task.cancel()
try:
await self.worker_task
except asyncio.CancelledError:
pass
logger.debug(f"Stream Consumer 停止: {self.stream_id}")
async def enqueue(self, message: dict) -> None:
"""将消息加入队列"""
self.last_active_time = time.time()
try:
# 使用 put_nowait 避免阻塞路由器
self.queue.put_nowait(message)
except asyncio.QueueFull:
self.stats["queue_overflow_count"] += 1
logger.warning(
f"Stream {self.stream_id} 队列已满 "
f"({self.queue.qsize()}/{self.queue.maxsize})"
f"消息被丢弃!溢出次数: {self.stats['queue_overflow_count']}"
)
# 可选策略:丢弃最旧的消息
# try:
# self.queue.get_nowait()
# self.queue.put_nowait(message)
# logger.debug(f"Stream {self.stream_id} 丢弃最旧消息,添加新消息")
# except asyncio.QueueEmpty:
# pass
async def _process_loop(self) -> None:
"""消息处理循环"""
# 延迟导入,避免循环依赖
from .recv_handler.message_handler import message_handler
from .recv_handler.meta_event_handler import meta_event_handler
from .recv_handler.notice_handler import notice_handler
logger.info(f"Stream {self.stream_id} 处理循环启动")
try:
while self.is_running:
try:
# 等待消息1秒超时
message = await asyncio.wait_for(
self.queue.get(),
timeout=1.0
)
start_time = time.time()
# 处理消息
post_type = message.get("post_type")
if post_type == "message":
await message_handler.handle_raw_message(message)
elif post_type == "meta_event":
await meta_event_handler.handle_meta_event(message)
elif post_type == "notice":
await notice_handler.handle_notice(message)
else:
logger.warning(f"未知的 post_type: {post_type}")
processing_time = time.time() - start_time
# 更新统计
self.stats["total_messages"] += 1
self.stats["total_processing_time"] += processing_time
self.last_active_time = time.time()
self.queue.task_done()
# 性能监控每100条消息输出一次
if self.stats["total_messages"] % 100 == 0:
avg_time = self.stats["total_processing_time"] / self.stats["total_messages"]
logger.info(
f"Stream {self.stream_id[:30]}... 统计: "
f"消息数={self.stats['total_messages']}, "
f"平均耗时={avg_time:.3f}秒, "
f"队列长度={self.queue.qsize()}"
)
# 动态延迟:队列空时短暂休眠
if self.queue.qsize() == 0:
await asyncio.sleep(0.01)
except asyncio.TimeoutError:
# 超时是正常的,继续循环
continue
except asyncio.CancelledError:
logger.info(f"Stream {self.stream_id} 处理循环被取消")
break
except Exception as e:
logger.error(f"Stream {self.stream_id} 处理消息时出错: {e}", exc_info=True)
# 继续处理下一条消息
await asyncio.sleep(0.1)
finally:
logger.info(f"Stream {self.stream_id} 处理循环结束")
def get_stats(self) -> dict:
"""获取性能统计"""
avg_time = (
self.stats["total_processing_time"] / self.stats["total_messages"]
if self.stats["total_messages"] > 0
else 0
)
return {
"stream_id": self.stream_id,
"queue_size": self.queue.qsize(),
"total_messages": self.stats["total_messages"],
"avg_processing_time": avg_time,
"queue_overflow_count": self.stats["queue_overflow_count"],
"last_active_time": self.last_active_time,
}
class StreamRouter:
"""流路由器
负责将消息路由到对应的聊天流队列
动态管理聊天流的生命周期
"""
def __init__(
self,
max_streams: int = 500,
stream_timeout: int = 600,
stream_queue_size: int = 100,
cleanup_interval: int = 60,
):
self.streams: Dict[str, StreamConsumer] = {}
self.lock = asyncio.Lock()
self.max_streams = max_streams
self.stream_timeout = stream_timeout
self.stream_queue_size = stream_queue_size
self.cleanup_interval = cleanup_interval
self.cleanup_task: Optional[asyncio.Task] = None
self.is_running = False
async def start(self) -> None:
"""启动路由器"""
if not self.is_running:
self.is_running = True
self.cleanup_task = asyncio.create_task(self._cleanup_loop())
logger.info(
f"StreamRouter 已启动 - "
f"最大流数: {self.max_streams}, "
f"超时: {self.stream_timeout}秒, "
f"队列大小: {self.stream_queue_size}"
)
async def stop(self) -> None:
"""停止路由器"""
self.is_running = False
if self.cleanup_task:
self.cleanup_task.cancel()
try:
await self.cleanup_task
except asyncio.CancelledError:
pass
# 停止所有流消费者
logger.info(f"正在停止 {len(self.streams)} 个流消费者...")
for consumer in self.streams.values():
await consumer.stop()
self.streams.clear()
logger.info("StreamRouter 已停止")
async def route_message(self, message: dict) -> None:
"""路由消息到对应的流"""
stream_id = self._extract_stream_id(message)
# 快速路径:流已存在
if stream_id in self.streams:
await self.streams[stream_id].enqueue(message)
return
# 慢路径:需要创建新流
async with self.lock:
# 双重检查
if stream_id not in self.streams:
# 检查流数量限制
if len(self.streams) >= self.max_streams:
logger.warning(
f"达到最大流数量限制 ({self.max_streams})"
f"尝试清理不活跃的流..."
)
await self._cleanup_inactive_streams()
# 清理后仍然超限,记录警告但继续创建
if len(self.streams) >= self.max_streams:
logger.error(
f"清理后仍达到最大流数量 ({len(self.streams)}/{self.max_streams})"
)
# 创建新流
consumer = StreamConsumer(stream_id, self.stream_queue_size)
self.streams[stream_id] = consumer
await consumer.start()
logger.info(f"创建新的 Stream Consumer: {stream_id} (总流数: {len(self.streams)})")
await self.streams[stream_id].enqueue(message)
def _extract_stream_id(self, message: dict) -> str:
"""从消息中提取 stream_id
返回格式: platform:id:type
例如: qq:123456:group 或 qq:789012:private
"""
post_type = message.get("post_type")
# 非消息类型,使用默认流(避免创建过多流)
if post_type not in ["message", "notice"]:
return "system:meta_event"
# 消息类型
if post_type == "message":
message_type = message.get("message_type")
if message_type == "group":
group_id = message.get("group_id")
return f"qq:{group_id}:group"
elif message_type == "private":
user_id = message.get("user_id")
return f"qq:{user_id}:private"
# notice 类型
elif post_type == "notice":
group_id = message.get("group_id")
if group_id:
return f"qq:{group_id}:group"
user_id = message.get("user_id")
if user_id:
return f"qq:{user_id}:private"
# 未知类型,使用通用流
return "unknown:unknown"
async def _cleanup_inactive_streams(self) -> None:
"""清理不活跃的流"""
current_time = time.time()
to_remove = []
for stream_id, consumer in self.streams.items():
if current_time - consumer.last_active_time > self.stream_timeout:
to_remove.append(stream_id)
for stream_id in to_remove:
await self.streams[stream_id].stop()
del self.streams[stream_id]
logger.debug(f"清理不活跃的流: {stream_id}")
if to_remove:
logger.info(
f"清理了 {len(to_remove)} 个不活跃的流 "
f"(当前活跃流: {len(self.streams)}/{self.max_streams})"
)
async def _cleanup_loop(self) -> None:
"""定期清理循环"""
logger.info(f"清理循环已启动,间隔: {self.cleanup_interval}")
try:
while self.is_running:
await asyncio.sleep(self.cleanup_interval)
await self._cleanup_inactive_streams()
except asyncio.CancelledError:
logger.info("清理循环已停止")
def get_all_stats(self) -> list[dict]:
"""获取所有流的统计信息"""
return [consumer.get_stats() for consumer in self.streams.values()]
def get_summary(self) -> dict:
"""获取路由器摘要"""
total_messages = sum(c.stats["total_messages"] for c in self.streams.values())
total_queue_size = sum(c.queue.qsize() for c in self.streams.values())
total_overflows = sum(c.stats["queue_overflow_count"] for c in self.streams.values())
# 计算平均队列长度
avg_queue_size = total_queue_size / len(self.streams) if self.streams else 0
# 找出最繁忙的流
busiest_stream = None
if self.streams:
busiest_stream = max(
self.streams.values(),
key=lambda c: c.stats["total_messages"]
).stream_id
return {
"total_streams": len(self.streams),
"max_streams": self.max_streams,
"total_messages_processed": total_messages,
"total_queue_size": total_queue_size,
"avg_queue_size": avg_queue_size,
"total_queue_overflows": total_overflows,
"busiest_stream": busiest_stream,
}
# 全局路由器实例
stream_router = StreamRouter()

View File

@@ -1,314 +0,0 @@
import base64
import io
import orjson
import ssl
import uuid
from typing import List, Optional, Tuple, Union
import urllib3
import websockets as Server
from PIL import Image
from src.common.logger import get_logger
from .database import BanUser, napcat_db
from .response_pool import get_response
logger = get_logger("napcat_adapter")
class SSLAdapter(urllib3.PoolManager):
def __init__(self, *args, **kwargs):
context = ssl.create_default_context()
context.set_ciphers("DEFAULT@SECLEVEL=1")
context.minimum_version = ssl.TLSVersion.TLSv1_2
kwargs["ssl_context"] = context
super().__init__(*args, **kwargs)
async def get_group_info(websocket: Server.ServerConnection, group_id: int) -> dict | None:
"""
获取群相关信息
返回值需要处理可能为空的情况
"""
logger.debug("获取群聊信息中")
request_uuid = str(uuid.uuid4())
payload = orjson.dumps({"action": "get_group_info", "params": {"group_id": group_id}, "echo": request_uuid}).decode('utf-8')
try:
await websocket.send(payload)
socket_response: dict = await get_response(request_uuid)
except TimeoutError:
logger.error(f"获取群信息超时,群号: {group_id}")
return None
except Exception as e:
logger.error(f"获取群信息失败: {e}")
return None
logger.debug(socket_response)
return socket_response.get("data")
async def get_group_detail_info(websocket: Server.ServerConnection, group_id: int) -> dict | None:
"""
获取群详细信息
返回值需要处理可能为空的情况
"""
logger.debug("获取群详细信息中")
request_uuid = str(uuid.uuid4())
payload = orjson.dumps({"action": "get_group_detail_info", "params": {"group_id": group_id}, "echo": request_uuid}).decode('utf-8')
try:
await websocket.send(payload)
socket_response: dict = await get_response(request_uuid)
except TimeoutError:
logger.error(f"获取群详细信息超时,群号: {group_id}")
return None
except Exception as e:
logger.error(f"获取群详细信息失败: {e}")
return None
logger.debug(socket_response)
return socket_response.get("data")
async def get_member_info(websocket: Server.ServerConnection, group_id: int, user_id: int) -> dict | None:
"""
获取群成员信息
返回值需要处理可能为空的情况
"""
logger.debug("获取群成员信息中")
request_uuid = str(uuid.uuid4())
payload = orjson.dumps(
{
"action": "get_group_member_info",
"params": {"group_id": group_id, "user_id": user_id, "no_cache": True},
"echo": request_uuid,
}
).decode('utf-8')
try:
await websocket.send(payload)
socket_response: dict = await get_response(request_uuid)
except TimeoutError:
logger.error(f"获取成员信息超时,群号: {group_id}, 用户ID: {user_id}")
return None
except Exception as e:
logger.error(f"获取成员信息失败: {e}")
return None
logger.debug(socket_response)
return socket_response.get("data")
async def get_image_base64(url: str) -> str:
# sourcery skip: raise-specific-error
"""获取图片/表情包的Base64"""
logger.debug(f"下载图片: {url}")
http = SSLAdapter()
try:
response = http.request("GET", url, timeout=10)
if response.status != 200:
raise Exception(f"HTTP Error: {response.status}")
image_bytes = response.data
return base64.b64encode(image_bytes).decode("utf-8")
except Exception as e:
logger.error(f"图片下载失败: {str(e)}")
raise
def convert_image_to_gif(image_base64: str) -> str:
# sourcery skip: extract-method
"""
将Base64编码的图片转换为GIF格式
Parameters:
image_base64: str: Base64编码的图片数据
Returns:
str: Base64编码的GIF图片数据
"""
logger.debug("转换图片为GIF格式")
try:
image_bytes = base64.b64decode(image_base64)
image = Image.open(io.BytesIO(image_bytes))
output_buffer = io.BytesIO()
image.save(output_buffer, format="GIF")
output_buffer.seek(0)
return base64.b64encode(output_buffer.read()).decode("utf-8")
except Exception as e:
logger.error(f"图片转换为GIF失败: {str(e)}")
return image_base64
async def get_self_info(websocket: Server.ServerConnection) -> dict | None:
"""
获取自身信息
Parameters:
websocket: WebSocket连接对象
Returns:
data: dict: 返回的自身信息
"""
logger.debug("获取自身信息中")
request_uuid = str(uuid.uuid4())
payload = orjson.dumps({"action": "get_login_info", "params": {}, "echo": request_uuid}).decode('utf-8')
try:
await websocket.send(payload)
response: dict = await get_response(request_uuid)
except TimeoutError:
logger.error("获取自身信息超时")
return None
except Exception as e:
logger.error(f"获取自身信息失败: {e}")
return None
logger.debug(response)
return response.get("data")
def get_image_format(raw_data: str) -> str:
"""
从Base64编码的数据中确定图片的格式。
Parameters:
raw_data: str: Base64编码的图片数据。
Returns:
format: str: 图片的格式(例如 'jpeg', 'png', 'gif')。
"""
image_bytes = base64.b64decode(raw_data)
return Image.open(io.BytesIO(image_bytes)).format.lower()
async def get_stranger_info(websocket: Server.ServerConnection, user_id: int) -> dict | None:
"""
获取陌生人信息
Parameters:
websocket: WebSocket连接对象
user_id: 用户ID
Returns:
dict: 返回的陌生人信息
"""
logger.debug("获取陌生人信息中")
request_uuid = str(uuid.uuid4())
payload = orjson.dumps({"action": "get_stranger_info", "params": {"user_id": user_id}, "echo": request_uuid}).decode('utf-8')
try:
await websocket.send(payload)
response: dict = await get_response(request_uuid)
except TimeoutError:
logger.error(f"获取陌生人信息超时用户ID: {user_id}")
return None
except Exception as e:
logger.error(f"获取陌生人信息失败: {e}")
return None
logger.debug(response)
return response.get("data")
async def get_message_detail(websocket: Server.ServerConnection, message_id: Union[str, int]) -> dict | None:
"""
获取消息详情,可能为空
Parameters:
websocket: WebSocket连接对象
message_id: 消息ID
Returns:
dict: 返回的消息详情
"""
logger.debug("获取消息详情中")
request_uuid = str(uuid.uuid4())
payload = orjson.dumps({"action": "get_msg", "params": {"message_id": message_id}, "echo": request_uuid}).decode('utf-8')
try:
await websocket.send(payload)
response: dict = await get_response(request_uuid, 30) # 增加超时时间到30秒
except TimeoutError:
logger.error(f"获取消息详情超时消息ID: {message_id}")
return None
except Exception as e:
logger.error(f"获取消息详情失败: {e}")
return None
logger.debug(response)
return response.get("data")
async def get_record_detail(
websocket: Server.ServerConnection, file: str, file_id: Optional[str] = None
) -> dict | None:
"""
获取语音消息内容
Parameters:
websocket: WebSocket连接对象
file: 文件名
file_id: 文件ID
Returns:
dict: 返回的语音消息详情
"""
logger.debug("获取语音消息详情中")
request_uuid = str(uuid.uuid4())
payload = orjson.dumps(
{
"action": "get_record",
"params": {"file": file, "file_id": file_id, "out_format": "wav"},
"echo": request_uuid,
}
).decode('utf-8')
try:
await websocket.send(payload)
response: dict = await get_response(request_uuid, 30) # 增加超时时间到30秒
except TimeoutError:
logger.error(f"获取语音消息详情超时,文件: {file}, 文件ID: {file_id}")
return None
except Exception as e:
logger.error(f"获取语音消息详情失败: {e}")
return None
logger.debug(f"{str(response)[:200]}...") # 防止语音的超长base64编码导致日志过长
return response.get("data")
async def read_ban_list(
websocket: Server.ServerConnection,
) -> Tuple[List[BanUser], List[BanUser]]:
"""
从根目录下的data文件夹中的文件读取禁言列表。
同时自动更新已经失效禁言
Returns:
Tuple[
一个仍在禁言中的用户的BanUser列表,
一个已经自然解除禁言的用户的BanUser列表,
一个仍在全体禁言中的群的BanUser列表,
一个已经自然解除全体禁言的群的BanUser列表,
]
"""
try:
ban_list = await napcat_db.get_ban_records()
lifted_list: List[BanUser] = []
logger.info("已经读取禁言列表")
# 复制列表以避免迭代中修改原列表问题
for ban_record in list(ban_list):
if ban_record.user_id == 0:
fetched_group_info = await get_group_info(websocket, ban_record.group_id)
if fetched_group_info is None:
logger.warning(f"无法获取群信息,群号: {ban_record.group_id},默认禁言解除")
lifted_list.append(ban_record)
ban_list.remove(ban_record)
continue
group_all_shut: int = fetched_group_info.get("group_all_shut")
if group_all_shut == 0:
lifted_list.append(ban_record)
ban_list.remove(ban_record)
continue
else:
fetched_member_info = await get_member_info(websocket, ban_record.group_id, ban_record.user_id)
if fetched_member_info is None:
logger.warning(
f"无法获取群成员信息用户ID: {ban_record.user_id}, 群号: {ban_record.group_id},默认禁言解除"
)
lifted_list.append(ban_record)
ban_list.remove(ban_record)
continue
lift_ban_time: int = fetched_member_info.get("shut_up_timestamp")
if lift_ban_time == 0:
lifted_list.append(ban_record)
ban_list.remove(ban_record)
else:
ban_record.lift_time = lift_ban_time
await napcat_db.update_ban_record(ban_list)
return ban_list, lifted_list
except Exception as e:
logger.error(f"读取禁言列表失败: {e}")
return [], []
async def save_ban_record(list: List[BanUser]):
return await napcat_db.update_ban_record(list)

View File

@@ -1,179 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
视频下载和处理模块
用于从QQ消息中下载视频并转发给Bot进行分析
"""
import asyncio
from pathlib import Path
from typing import Any, Dict, Optional
import aiohttp
from src.common.logger import get_logger
logger = get_logger("video_handler")
class VideoDownloader:
def __init__(self, max_size_mb: int = 100, download_timeout: int = 60):
self.max_size_mb = max_size_mb
self.download_timeout = download_timeout
self.supported_formats = {".mp4", ".avi", ".mov", ".mkv", ".flv", ".wmv", ".webm", ".m4v"}
def is_video_url(self, url: str) -> bool:
"""检查URL是否为视频文件"""
try:
# QQ视频URL可能没有扩展名所以先检查Content-Type
# 对于QQ视频我们先假设是视频稍后通过Content-Type验证
# 检查URL中是否包含视频相关的关键字
video_keywords = ["video", "mp4", "avi", "mov", "mkv", "flv", "wmv", "webm", "m4v"]
url_lower = url.lower()
# 如果URL包含视频关键字认为是视频
if any(keyword in url_lower for keyword in video_keywords):
return True
# 检查文件扩展名(传统方法)
path = Path(url.split("?")[0]) # 移除查询参数
if path.suffix.lower() in self.supported_formats:
return True
# 对于QQ等特殊平台,URL可能没有扩展名
# 我们允许这些URL通过,稍后通过HTTP头Content-Type验证
qq_domains = ["qpic.cn", "gtimg.cn", "qq.com", "tencent.com"]
if any(domain in url_lower for domain in qq_domains):
return True
return False
except Exception:
# 如果解析失败,默认允许尝试下载(稍后验证)
return True
def check_file_size(self, content_length: Optional[str]) -> bool:
"""检查文件大小是否在允许范围内"""
if content_length is None:
return True # 无法获取大小时允许下载
try:
size_bytes = int(content_length)
size_mb = size_bytes / (1024 * 1024)
return size_mb <= self.max_size_mb
except Exception:
return True
async def download_video(self, url: str, filename: Optional[str] = None) -> Dict[str, Any]:
"""
下载视频文件
Args:
url: 视频URL
filename: 可选的文件名
Returns:
dict: 下载结果包含success、data、filename、error等字段
"""
try:
logger.info(f"开始下载视频: {url}")
# 检查URL格式
if not self.is_video_url(url):
logger.warning(f"URL格式检查失败: {url}")
return {"success": False, "error": "不支持的视频格式", "url": url}
async with aiohttp.ClientSession() as session:
# 先发送HEAD请求检查文件大小
try:
async with session.head(url, timeout=aiohttp.ClientTimeout(total=10)) as response:
if response.status != 200:
logger.warning(f"HEAD请求失败状态码: {response.status}")
else:
content_length = response.headers.get("Content-Length")
if not self.check_file_size(content_length):
return {
"success": False,
"error": f"视频文件过大,超过{self.max_size_mb}MB限制",
"url": url,
}
except Exception as e:
logger.warning(f"HEAD请求失败: {e},继续尝试下载")
# 下载文件
async with session.get(url, timeout=aiohttp.ClientTimeout(total=self.download_timeout)) as response:
if response.status != 200:
return {"success": False, "error": f"下载失败HTTP状态码: {response.status}", "url": url}
# 检查Content-Type是否为视频
content_type = response.headers.get("Content-Type", "").lower()
if content_type:
# 检查是否为视频类型
video_mime_types = [
"video/",
"application/octet-stream",
"application/x-msvideo",
"video/x-msvideo",
]
is_video_content = any(mime in content_type for mime in video_mime_types)
if not is_video_content:
logger.warning(f"Content-Type不是视频格式: {content_type}")
# 如果不是明确的视频类型但可能是QQ的特殊格式继续尝试
if "text/" in content_type or "application/json" in content_type:
return {
"success": False,
"error": f"URL返回的不是视频内容Content-Type: {content_type}",
"url": url,
}
# 再次检查Content-Length
content_length = response.headers.get("Content-Length")
if not self.check_file_size(content_length):
return {"success": False, "error": f"视频文件过大,超过{self.max_size_mb}MB限制", "url": url}
# 读取文件内容
video_data = await response.read()
# 检查实际文件大小
actual_size_mb = len(video_data) / (1024 * 1024)
if actual_size_mb > self.max_size_mb:
return {
"success": False,
"error": f"视频文件过大,实际大小: {actual_size_mb:.2f}MB",
"url": url,
}
# 确定文件名
if filename is None:
filename = Path(url.split("?")[0]).name
if not filename or "." not in filename:
filename = "video.mp4"
logger.info(f"视频下载成功: {filename}, 大小: {actual_size_mb:.2f}MB")
return {
"success": True,
"data": video_data,
"filename": filename,
"size_mb": actual_size_mb,
"url": url,
}
except asyncio.TimeoutError:
return {"success": False, "error": "下载超时", "url": url}
except Exception as e:
logger.error(f"下载视频时出错: {e}")
return {"success": False, "error": str(e), "url": url}
# 全局实例
_video_downloader = None
def get_video_downloader(max_size_mb: int = 100, download_timeout: int = 60) -> VideoDownloader:
"""获取视频下载器实例"""
global _video_downloader
if _video_downloader is None:
_video_downloader = VideoDownloader(max_size_mb, download_timeout)
return _video_downloader

View File

@@ -1,163 +0,0 @@
import asyncio
from typing import Any, Callable, Optional
import websockets as Server
from src.common.logger import get_logger
from src.plugin_system.apis import config_api
logger = get_logger("napcat_adapter")
class WebSocketManager:
"""WebSocket 连接管理器,支持正向和反向连接"""
def __init__(self):
self.connection: Optional[Server.ServerConnection] = None
self.server: Optional[Server.WebSocketServer] = None
self.is_running = False
self.reconnect_interval = 5 # 重连间隔(秒)
self.max_reconnect_attempts = 10 # 最大重连次数
self.plugin_config = None
async def start_connection(
self, message_handler: Callable[[Server.ServerConnection], Any], plugin_config: dict
) -> None:
"""根据配置启动 WebSocket 连接"""
self.plugin_config = plugin_config
mode = config_api.get_plugin_config(plugin_config, "napcat_server.mode")
if mode == "reverse":
await self._start_reverse_connection(message_handler)
elif mode == "forward":
await self._start_forward_connection(message_handler)
else:
raise ValueError(f"不支持的连接模式: {mode}")
async def _start_reverse_connection(self, message_handler: Callable[[Server.ServerConnection], Any]) -> None:
"""启动反向连接(作为服务器)"""
host = config_api.get_plugin_config(self.plugin_config, "napcat_server.host")
port = config_api.get_plugin_config(self.plugin_config, "napcat_server.port")
logger.info(f"正在启动反向连接模式,监听地址: ws://{host}:{port}")
async def handle_client(websocket, path=None):
self.connection = websocket
logger.info(f"Napcat 客户端已连接: {websocket.remote_address}")
try:
await message_handler(websocket)
except Exception as e:
logger.error(f"处理客户端连接时出错: {e}")
finally:
self.connection = None
logger.info("Napcat 客户端已断开连接")
self.server = await Server.serve(handle_client, host, port, max_size=2**26)
self.is_running = True
logger.info(f"反向连接服务器已启动,监听地址: ws://{host}:{port}")
# 保持服务器运行
await self.server.serve_forever()
async def _start_forward_connection(self, message_handler: Callable[[Server.ServerConnection], Any]) -> None:
"""启动正向连接(作为客户端)"""
url = self._get_forward_url()
logger.info(f"正在启动正向连接模式,目标地址: {url}")
reconnect_count = 0
while reconnect_count < self.max_reconnect_attempts:
try:
logger.info(f"尝试连接到 Napcat 服务器: {url}")
# 准备连接参数
connect_kwargs = {"max_size": 2**26}
# 如果配置了访问令牌,添加到请求头
access_token = config_api.get_plugin_config(self.plugin_config, "napcat_server.access_token")
if access_token:
connect_kwargs["additional_headers"] = {"Authorization": f"Bearer {access_token}"}
logger.info("已添加访问令牌到连接请求头")
async with Server.connect(url, **connect_kwargs) as websocket:
self.connection = websocket
self.is_running = True
reconnect_count = 0 # 重置重连计数
logger.info(f"成功连接到 Napcat 服务器: {url}")
try:
await message_handler(websocket)
except Server.exceptions.ConnectionClosed:
logger.warning("与 Napcat 服务器的连接已断开")
except Exception as e:
logger.error(f"处理正向连接时出错: {e}")
finally:
self.connection = None
self.is_running = False
except (
Server.exceptions.ConnectionClosed,
Server.exceptions.InvalidMessage,
OSError,
ConnectionRefusedError,
) as e:
reconnect_count += 1
logger.warning(f"连接失败 ({reconnect_count}/{self.max_reconnect_attempts}): {e}")
if reconnect_count < self.max_reconnect_attempts:
logger.info(f"将在 {self.reconnect_interval} 秒后重试连接...")
await asyncio.sleep(self.reconnect_interval)
else:
logger.error("已达到最大重连次数,停止重连")
raise
except Exception as e:
logger.error(f"正向连接时发生未知错误: {e}")
raise
def _get_forward_url(self) -> str:
"""获取正向连接的 URL"""
# 如果配置了完整的 URL直接使用
url = config_api.get_plugin_config(self.plugin_config, "napcat_server.url")
if url:
return url
# 否则根据 host 和 port 构建 URL
host = config_api.get_plugin_config(self.plugin_config, "napcat_server.host")
port = config_api.get_plugin_config(self.plugin_config, "napcat_server.port")
return f"ws://{host}:{port}"
async def stop_connection(self) -> None:
"""停止 WebSocket 连接"""
self.is_running = False
if self.connection:
try:
await self.connection.close()
logger.info("WebSocket 连接已关闭")
except Exception as e:
logger.error(f"关闭 WebSocket 连接时出错: {e}")
finally:
self.connection = None
if self.server:
try:
self.server.close()
await self.server.wait_closed()
logger.info("WebSocket 服务器已关闭")
except Exception as e:
logger.error(f"关闭 WebSocket 服务器时出错: {e}")
finally:
self.server = None
def get_connection(self) -> Optional[Server.ServerConnection]:
"""获取当前的 WebSocket 连接"""
return self.connection
def is_connected(self) -> bool:
"""检查是否已连接"""
return self.connection is not None and self.is_running
# 全局 WebSocket 管理器实例
websocket_manager = WebSocketManager()

View File

@@ -1,89 +0,0 @@
# TODO List:
- [x] logger使用主程序的
- [ ] 使用插件系统的config系统
- [x] 接收从napcat传递的所有信息
- [ ] <del>优化架构,各模块解耦,暴露关键方法用于提供接口</del>
- [ ] <del>单独一个模块负责与主程序通信</del>
- [ ] 使用event系统完善接口api
---
Event分为两种一种是对外输出的event由napcat插件自主触发并传递参数另一种是接收外界输入的event由外部插件触发并向napcat传递参数
## 例如,
### 对外输出的event
napcat_on_received_text -> (message_seg: Seg) 接受到qq的文字消息,会向handler传递一个Seg
napcat_on_received_face -> (message_seg: Seg) 接受到qq的表情消息,会向handler传递一个Seg
napcat_on_received_reply -> (message_seg: Seg) 接受到qq的回复消息,会向handler传递一个Seg
napcat_on_received_image -> (message_seg: Seg) 接受到qq的图片消息,会向handler传递一个Seg
napcat_on_received_image -> (message_seg: Seg) 接受到qq的图片消息,会向handler传递一个Seg
napcat_on_received_record -> (message_seg: Seg) 接受到qq的语音消息,会向handler传递一个Seg
napcat_on_received_rps -> (message_seg: Seg) 接受到qq的猜拳魔法表情,会向handler传递一个Seg
napcat_on_received_friend_invitation -> (user_id: str) 接受到qq的好友请求,会向handler传递一个user_id
...
此类event不接受外部插件的触发只能由napcat插件统一触发。
外部插件需要编写handler并订阅此类事件。
```python
from src.plugin_system.core.event_manager import event_manager
from src.plugin_system.base.base_event import HandlerResult
class MyEventHandler(BaseEventHandler):
handler_name = "my_handler"
handler_description = "我的自定义事件处理器"
weight = 10 # 权重,越大越先执行
intercept_message = False # 是否拦截消息
init_subscribe = ["napcat_on_received_text"] # 初始订阅的事件
async def execute(self, params: dict) -> HandlerResult:
"""处理事件"""
try:
message = params.get("message_seg")
print(f"收到消息: {message.data}")
# 业务逻辑处理
# ...
return HandlerResult(
success=True,
continue_process=True, # 是否继续让其他处理器处理
message="处理成功",
handler_name=self.handler_name
)
except Exception as e:
return HandlerResult(
success=False,
continue_process=True,
message=f"处理失败: {str(e)}",
handler_name=self.handler_name
)
```
### 接收外界输入的event
napcat_kick_group <- (user_id, group_id) 踢出某个群组中的某个用户
napcat_mute_user <- (user_id, group_id, time) 禁言某个群组中的某个用户
napcat_unmute_user <- (user_id, group_id) 取消禁言某个群组中的某个用户
napcat_mute_group <- (user_id, group_id) 禁言某个群组
napcat_unmute_group <- (user_id, group_id) 取消禁言某个群组
napcat_add_friend <- (user_id) 向某个用户发出好友请求
napcat_accept_friend <- (user_id) 接收某个用户的好友请求
napcat_reject_friend <- (user_id) 拒绝某个用户的好友请求
...
此类事件只由外部插件触发并传递参数由napcat完成请求任务
外部插件需要触发此类的event并传递正确的参数
```python
from src.plugin_system.core.event_manager import event_manager
# 触发事件
await event_manager.trigger_event("napcat_accept_friend", user_id = 1234123)
```