Merge branch 'MoFox-Studio:dev' into dev
This commit is contained in:
@@ -58,6 +58,7 @@ from sqlalchemy import (
|
|||||||
Table,
|
Table,
|
||||||
inspect,
|
inspect,
|
||||||
text,
|
text,
|
||||||
|
types as sqltypes,
|
||||||
)
|
)
|
||||||
from sqlalchemy.engine import Engine, Connection
|
from sqlalchemy.engine import Engine, Connection
|
||||||
from sqlalchemy.exc import SQLAlchemyError
|
from sqlalchemy.exc import SQLAlchemyError
|
||||||
@@ -191,7 +192,7 @@ def get_database_config_from_toml(db_type: str) -> dict | None:
|
|||||||
|
|
||||||
|
|
||||||
def create_sqlite_engine(sqlite_path: str) -> Engine:
|
def create_sqlite_engine(sqlite_path: str) -> Engine:
|
||||||
"""创建 SQLite 引擎"""
|
"""<EFBFBD><EFBFBD><EFBFBD><EFBFBD> SQLite <EFBFBD><EFBFBD><EFBFBD><EFBFBD>"""
|
||||||
if not os.path.isabs(sqlite_path):
|
if not os.path.isabs(sqlite_path):
|
||||||
sqlite_path = os.path.join(PROJECT_ROOT, sqlite_path)
|
sqlite_path = os.path.join(PROJECT_ROOT, sqlite_path)
|
||||||
|
|
||||||
@@ -200,28 +201,18 @@ def create_sqlite_engine(sqlite_path: str) -> Engine:
|
|||||||
|
|
||||||
url = f"sqlite:///{sqlite_path}"
|
url = f"sqlite:///{sqlite_path}"
|
||||||
logger.info("使用 SQLite 数据库: %s", sqlite_path)
|
logger.info("使用 SQLite 数据库: %s", sqlite_path)
|
||||||
return create_engine(url, future=True)
|
engine = create_engine(
|
||||||
|
url,
|
||||||
|
future=True,
|
||||||
def create_mysql_engine(
|
connect_args={
|
||||||
host: str,
|
"timeout": 30, # wait a bit if the db is locked
|
||||||
port: int,
|
"check_same_thread": False,
|
||||||
database: str,
|
},
|
||||||
user: str,
|
)
|
||||||
password: str,
|
# Increase busy timeout to reduce "database is locked" errors on SQLite
|
||||||
charset: str = "utf8mb4",
|
with engine.connect() as conn:
|
||||||
) -> Engine:
|
conn.execute(text("PRAGMA busy_timeout=30000"))
|
||||||
"""创建 MySQL 引擎"""
|
return engine
|
||||||
# 延迟导入 pymysql,以便友好提示
|
|
||||||
try:
|
|
||||||
import pymysql # noqa: F401
|
|
||||||
except ImportError:
|
|
||||||
logger.error("需要安装 pymysql 才能连接 MySQL: pip install pymysql")
|
|
||||||
raise
|
|
||||||
|
|
||||||
url = f"mysql+pymysql://{user}:{password}@{host}:{port}/{database}?charset={charset}"
|
|
||||||
logger.info("使用 MySQL 数据库: %s@%s:%s/%s", user, host, port, database)
|
|
||||||
return create_engine(url, future=True)
|
|
||||||
|
|
||||||
|
|
||||||
def create_postgresql_engine(
|
def create_postgresql_engine(
|
||||||
@@ -324,22 +315,35 @@ def get_table_row_count(conn: Connection, table: Table) -> int:
|
|||||||
|
|
||||||
|
|
||||||
def copy_table_structure(source_table: Table, target_metadata: MetaData, target_engine: Engine) -> Table:
|
def copy_table_structure(source_table: Table, target_metadata: MetaData, target_engine: Engine) -> Table:
|
||||||
"""在目标数据库中创建与源表结构相同的表
|
"""复制表结构到目标数据库,使其结构保持一致"""
|
||||||
|
target_is_sqlite = target_engine.dialect.name == "sqlite"
|
||||||
|
target_is_pg = target_engine.dialect.name == "postgresql"
|
||||||
|
|
||||||
Args:
|
columns = []
|
||||||
source_table: 源表对象
|
for c in source_table.columns:
|
||||||
target_metadata: 目标元数据对象
|
new_col = c.copy()
|
||||||
target_engine: 目标数据库引擎
|
|
||||||
|
|
||||||
Returns:
|
# SQLite 不支持 nextval 等 server_default
|
||||||
Table: 目标表对象
|
if target_is_sqlite:
|
||||||
"""
|
new_col.server_default = None
|
||||||
# 复制表结构
|
|
||||||
|
# PostgreSQL 需要将部分 SQLite 特有类型转换
|
||||||
|
if target_is_pg:
|
||||||
|
col_type = new_col.type
|
||||||
|
# SQLite DATETIME -> 通用 DateTime
|
||||||
|
if isinstance(col_type, sqltypes.DateTime) or col_type.__class__.__name__ in {"DATETIME", "DateTime"}:
|
||||||
|
new_col.type = sqltypes.DateTime()
|
||||||
|
# TEXT(50) 等长度受限的 TEXT 在 PG 无效,改用 String(length)
|
||||||
|
elif isinstance(col_type, sqltypes.Text) and getattr(col_type, "length", None):
|
||||||
|
new_col.type = sqltypes.String(length=col_type.length)
|
||||||
|
|
||||||
|
columns.append(new_col)
|
||||||
|
|
||||||
|
# 为避免迭代约束集合时出现 “Set changed size during iteration”,这里不复制表级约束
|
||||||
target_table = Table(
|
target_table = Table(
|
||||||
source_table.name,
|
source_table.name,
|
||||||
target_metadata,
|
target_metadata,
|
||||||
*[c.copy() for c in source_table.columns],
|
*columns,
|
||||||
*[c.copy() for c in source_table.constraints],
|
|
||||||
)
|
)
|
||||||
target_metadata.create_all(target_engine, tables=[target_table])
|
target_metadata.create_all(target_engine, tables=[target_table])
|
||||||
return target_table
|
return target_table
|
||||||
@@ -383,8 +387,6 @@ def migrate_table_data(
|
|||||||
logger.error("查询表 %s 失败: %s", source_table.name, e)
|
logger.error("查询表 %s 失败: %s", source_table.name, e)
|
||||||
return 0, 1
|
return 0, 1
|
||||||
|
|
||||||
columns = source_table.columns.keys()
|
|
||||||
|
|
||||||
def insert_batch(rows: list[dict]):
|
def insert_batch(rows: list[dict]):
|
||||||
nonlocal migrated_rows, error_count
|
nonlocal migrated_rows, error_count
|
||||||
if not rows:
|
if not rows:
|
||||||
@@ -399,7 +401,8 @@ def migrate_table_data(
|
|||||||
|
|
||||||
batch: list[dict] = []
|
batch: list[dict] = []
|
||||||
for row in result:
|
for row in result:
|
||||||
row_dict = {col: row[col] for col in columns}
|
# Use column objects to access row mapping to avoid quoted_name keys
|
||||||
|
row_dict = {col.key: row._mapping[col] for col in source_table.columns}
|
||||||
batch.append(row_dict)
|
batch.append(row_dict)
|
||||||
if len(batch) >= batch_size:
|
if len(batch) >= batch_size:
|
||||||
insert_batch(batch)
|
insert_batch(batch)
|
||||||
@@ -535,6 +538,14 @@ class DatabaseMigrator:
|
|||||||
# 目标数据库配置
|
# 目标数据库配置
|
||||||
target_config = self._load_target_config()
|
target_config = self._load_target_config()
|
||||||
|
|
||||||
|
# 防止源/目标 SQLite 指向同一路径导致自我覆盖及锁
|
||||||
|
if (
|
||||||
|
self.source_type == "sqlite"
|
||||||
|
and self.target_type == "sqlite"
|
||||||
|
and os.path.abspath(source_config.get("path", "")) == os.path.abspath(target_config.get("path", ""))
|
||||||
|
):
|
||||||
|
raise ValueError("源数据库与目标数据库不能是同一个 SQLite 文件,请为目标指定不同的路径")
|
||||||
|
|
||||||
# 创建引擎
|
# 创建引擎
|
||||||
self.source_engine = create_engine_by_type(self.source_type, source_config)
|
self.source_engine = create_engine_by_type(self.source_type, source_config)
|
||||||
self.target_engine = create_engine_by_type(self.target_type, target_config)
|
self.target_engine = create_engine_by_type(self.target_type, target_config)
|
||||||
@@ -589,32 +600,36 @@ class DatabaseMigrator:
|
|||||||
|
|
||||||
return sorted_tables
|
return sorted_tables
|
||||||
|
|
||||||
def _drop_target_tables(self, conn: Connection):
|
def _drop_target_tables(self):
|
||||||
"""删除目标数据库中已经存在的表(谨慎操作)
|
"""删除目标数据库中已有的表(如果有)
|
||||||
|
|
||||||
这里为了避免冲突,迁移前会询问用户是否删除目标库中已经存在的同名表。
|
使用 Engine.begin() 进行连接以支持 autobegin 和 begin 兼容 SQLAlchemy 2.0 的写法
|
||||||
"""
|
"""
|
||||||
inspector = inspect(conn)
|
if self.target_engine is None:
|
||||||
existing_tables = inspector.get_table_names()
|
logger.warning("目标数据库引擎尚未初始化,无法删除表")
|
||||||
|
|
||||||
if not existing_tables:
|
|
||||||
logger.info("目标数据库中没有已存在的表,无需删除")
|
|
||||||
return
|
return
|
||||||
|
|
||||||
logger.info("目标数据库中当前存在的表: %s", ", ".join(existing_tables))
|
with self.target_engine.begin() as conn:
|
||||||
if confirm_action("是否删除目标数据库中已有的所有表?此操作不可恢复!", default=False):
|
inspector = inspect(conn)
|
||||||
with conn.begin():
|
existing_tables = inspector.get_table_names()
|
||||||
|
|
||||||
|
if not existing_tables:
|
||||||
|
logger.info("目标数据库中没有已存在的表,无需删除")
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.info("目标数据库中的当前表: %s", ", ".join(existing_tables))
|
||||||
|
if confirm_action("是否删除目标数据库中现有的表列表?此操作不可撤销", default=False):
|
||||||
for table_name in existing_tables:
|
for table_name in existing_tables:
|
||||||
try:
|
try:
|
||||||
logger.info("删除目标数据库中表: %s", table_name)
|
logger.info("删除目标数据库表: %s", table_name)
|
||||||
conn.execute(text(f"DROP TABLE IF EXISTS {table_name} CASCADE"))
|
conn.execute(text(f"DROP TABLE IF EXISTS {table_name} CASCADE"))
|
||||||
except SQLAlchemyError as e:
|
except SQLAlchemyError as e:
|
||||||
logger.error("删除表 %s 失败: %s", table_name, e)
|
logger.error("删除 %s 失败: %s", table_name, e)
|
||||||
self.stats["errors"].append(
|
self.stats["errors"].append(
|
||||||
f"删除表 {table_name} 失败: {e}"
|
f"删除 {table_name} 失败: {e}"
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
logger.info("用户选择保留目标数据库中已有的表,可能会与迁移数据发生冲突。")
|
logger.info("跳过删除目标数据库中的表,继续迁移过程")
|
||||||
|
|
||||||
def migrate(self):
|
def migrate(self):
|
||||||
"""执行迁移操作"""
|
"""执行迁移操作"""
|
||||||
@@ -630,8 +645,7 @@ class DatabaseMigrator:
|
|||||||
logger.info("按依赖顺序迁移表: %s", ", ".join(t.name for t in tables))
|
logger.info("按依赖顺序迁移表: %s", ", ".join(t.name for t in tables))
|
||||||
|
|
||||||
# 删除目标库中已有表(可选)
|
# 删除目标库中已有表(可选)
|
||||||
with self.target_engine.connect() as target_conn:
|
self._drop_target_tables()
|
||||||
self._drop_target_tables(target_conn)
|
|
||||||
|
|
||||||
# 开始迁移
|
# 开始迁移
|
||||||
with self.source_engine.connect() as source_conn, self.target_engine.connect() as target_conn:
|
with self.source_engine.connect() as source_conn, self.target_engine.connect() as target_conn:
|
||||||
@@ -937,7 +951,7 @@ def interactive_setup() -> dict:
|
|||||||
if target_type == "sqlite":
|
if target_type == "sqlite":
|
||||||
target_path = _ask_str(
|
target_path = _ask_str(
|
||||||
"目标 SQLite 文件路径(若不存在会自动创建)",
|
"目标 SQLite 文件路径(若不存在会自动创建)",
|
||||||
default="data/MaiBot_target.db",
|
default="data/MaiBot.db",
|
||||||
)
|
)
|
||||||
target_config = {"path": target_path}
|
target_config = {"path": target_path}
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -367,6 +367,7 @@ class BotInterestManager:
|
|||||||
self.embedding_dimension,
|
self.embedding_dimension,
|
||||||
current_dim,
|
current_dim,
|
||||||
)
|
)
|
||||||
|
return embedding
|
||||||
else:
|
else:
|
||||||
raise RuntimeError(f"❌ 返回的embedding为空: {embedding}")
|
raise RuntimeError(f"❌ 返回的embedding为空: {embedding}")
|
||||||
|
|
||||||
|
|||||||
@@ -1129,6 +1129,10 @@ class DefaultReplyer:
|
|||||||
if reply_to:
|
if reply_to:
|
||||||
# 兼容旧的reply_to
|
# 兼容旧的reply_to
|
||||||
sender, target = self._parse_reply_target(reply_to)
|
sender, target = self._parse_reply_target(reply_to)
|
||||||
|
# 回退逻辑:为 'reply_to' 路径提供 platform 和 user_id 的回退值,以修复 UnboundLocalError
|
||||||
|
# 这样就不再强制要求必须有 user_id,解决了QQ空间插件等场景下的崩溃问题
|
||||||
|
platform = chat_stream.platform
|
||||||
|
user_id = ""
|
||||||
else:
|
else:
|
||||||
# 对于 respond 动作,reply_message 可能为 None(统一回应未读消息)
|
# 对于 respond 动作,reply_message 可能为 None(统一回应未读消息)
|
||||||
# 对于 reply 动作,reply_message 必须存在(针对特定消息回复)
|
# 对于 reply 动作,reply_message 必须存在(针对特定消息回复)
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ TEMPLATE_DIR = os.path.join(PROJECT_ROOT, "template")
|
|||||||
|
|
||||||
# 考虑到,实际上配置文件中的mai_version是不会自动更新的,所以采用硬编码
|
# 考虑到,实际上配置文件中的mai_version是不会自动更新的,所以采用硬编码
|
||||||
# 对该字段的更新,请严格参照语义化版本规范:https://semver.org/lang/zh-CN/
|
# 对该字段的更新,请严格参照语义化版本规范:https://semver.org/lang/zh-CN/
|
||||||
MMC_VERSION = "0.13.0-alpha.3"
|
MMC_VERSION = "0.13.0-alpha.4"
|
||||||
|
|
||||||
# 全局配置变量
|
# 全局配置变量
|
||||||
_CONFIG_INITIALIZED = False
|
_CONFIG_INITIALIZED = False
|
||||||
|
|||||||
@@ -50,6 +50,8 @@ class NapcatAdapter(BaseAdapter):
|
|||||||
host = config_api.get_plugin_config(plugin.config, "napcat_server.host", "localhost")
|
host = config_api.get_plugin_config(plugin.config, "napcat_server.host", "localhost")
|
||||||
port = config_api.get_plugin_config(plugin.config, "napcat_server.port", 8095)
|
port = config_api.get_plugin_config(plugin.config, "napcat_server.port", 8095)
|
||||||
access_token = config_api.get_plugin_config(plugin.config, "napcat_server.access_token", "")
|
access_token = config_api.get_plugin_config(plugin.config, "napcat_server.access_token", "")
|
||||||
|
mode_str = config_api.get_plugin_config(plugin.config, "napcat_server.mode", "reverse")
|
||||||
|
ws_mode = "client" if mode_str == "direct" else "server"
|
||||||
|
|
||||||
ws_url = f"ws://{host}:{port}"
|
ws_url = f"ws://{host}:{port}"
|
||||||
headers = {}
|
headers = {}
|
||||||
@@ -58,10 +60,11 @@ class NapcatAdapter(BaseAdapter):
|
|||||||
else:
|
else:
|
||||||
ws_url = "ws://127.0.0.1:8095"
|
ws_url = "ws://127.0.0.1:8095"
|
||||||
headers = {}
|
headers = {}
|
||||||
|
ws_mode = "server"
|
||||||
|
|
||||||
# 配置 WebSocket 传输
|
# 配置 WebSocket 传输
|
||||||
transport = WebSocketAdapterOptions(
|
transport = WebSocketAdapterOptions(
|
||||||
mode="server",
|
mode=ws_mode,
|
||||||
url=ws_url,
|
url=ws_url,
|
||||||
headers=headers if headers else None,
|
headers=headers if headers else None,
|
||||||
)
|
)
|
||||||
|
|||||||
37
src/plugins/built_in/napcat_adapter/src/event_types.py
Normal file
37
src/plugins/built_in/napcat_adapter/src/event_types.py
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
"""Napcat 适配器事件类型定义"""
|
||||||
|
|
||||||
|
|
||||||
|
class NapcatEvent:
|
||||||
|
"""Napcat 适配器事件类型"""
|
||||||
|
|
||||||
|
class ON_RECEIVED:
|
||||||
|
"""接收事件"""
|
||||||
|
|
||||||
|
FRIEND_INPUT = "napcat.on_received.friend_input" # 好友正在输入
|
||||||
|
EMOJI_LIEK = "napcat.on_received.emoji_like" # 表情回复(注意:保持原来的拼写)
|
||||||
|
POKE = "napcat.on_received.poke" # 戳一戳
|
||||||
|
GROUP_UPLOAD = "napcat.on_received.group_upload" # 群文件上传
|
||||||
|
GROUP_BAN = "napcat.on_received.group_ban" # 群禁言
|
||||||
|
GROUP_LIFT_BAN = "napcat.on_received.group_lift_ban" # 群解禁
|
||||||
|
FRIEND_RECALL = "napcat.on_received.friend_recall" # 好友消息撤回
|
||||||
|
GROUP_RECALL = "napcat.on_received.group_recall" # 群消息撤回
|
||||||
|
|
||||||
|
class MESSAGE:
|
||||||
|
"""消息相关事件"""
|
||||||
|
|
||||||
|
GET_MSG = "napcat.message.get_msg" # 获取消息
|
||||||
|
|
||||||
|
class GROUP:
|
||||||
|
"""群组相关事件"""
|
||||||
|
|
||||||
|
SET_GROUP_BAN = "napcat.group.set_group_ban" # 设置群禁言
|
||||||
|
SET_GROUP_WHOLE_BAN = "napcat.group.set_group_whole_ban" # 设置全员禁言
|
||||||
|
SET_GROUP_KICK = "napcat.group.set_group_kick" # 踢出群聊
|
||||||
|
|
||||||
|
class FRIEND:
|
||||||
|
"""好友相关事件"""
|
||||||
|
|
||||||
|
SEND_LIKE = "napcat.friend.send_like" # 发送点赞
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["NapcatEvent"]
|
||||||
@@ -79,7 +79,7 @@ class MessageHandler:
|
|||||||
|
|
||||||
# 获取群聊配置
|
# 获取群聊配置
|
||||||
group_list_type = features_config.get("group_list_type", "blacklist")
|
group_list_type = features_config.get("group_list_type", "blacklist")
|
||||||
group_list = features_config.get("group_list", [])
|
group_list = [str(item) for item in features_config.get("group_list", [])]
|
||||||
|
|
||||||
if group_list_type == "blacklist":
|
if group_list_type == "blacklist":
|
||||||
# 黑名单模式:如果在黑名单中就过滤
|
# 黑名单模式:如果在黑名单中就过滤
|
||||||
@@ -96,7 +96,7 @@ class MessageHandler:
|
|||||||
elif message_type == "private":
|
elif message_type == "private":
|
||||||
# 获取私聊配置
|
# 获取私聊配置
|
||||||
private_list_type = features_config.get("private_list_type", "blacklist")
|
private_list_type = features_config.get("private_list_type", "blacklist")
|
||||||
private_list = features_config.get("private_list", [])
|
private_list = [str(item) for item in features_config.get("private_list", [])]
|
||||||
|
|
||||||
if private_list_type == "blacklist":
|
if private_list_type == "blacklist":
|
||||||
# 黑名单模式:如果在黑名单中就过滤
|
# 黑名单模式:如果在黑名单中就过滤
|
||||||
|
|||||||
@@ -2,40 +2,529 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import TYPE_CHECKING, Any, Dict, Optional
|
import time
|
||||||
|
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple
|
||||||
|
|
||||||
|
from mofox_wire import MessageBuilder, SegPayload, UserInfoPayload
|
||||||
from src.common.logger import get_logger
|
from src.common.logger import get_logger
|
||||||
|
from src.plugin_system.apis import config_api
|
||||||
|
|
||||||
|
from ...event_models import ACCEPT_FORMAT, NoticeType, QQ_FACE, PLUGIN_NAME
|
||||||
|
from ..utils import get_group_info, get_member_info, get_self_info, get_stranger_info, get_message_detail
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from ...plugin import NapcatAdapter
|
from ....plugin import NapcatAdapter
|
||||||
|
|
||||||
logger = get_logger("napcat_adapter")
|
logger = get_logger("napcat_adapter")
|
||||||
|
|
||||||
|
|
||||||
class NoticeHandler:
|
class NoticeHandler:
|
||||||
"""处理 Napcat 通知事件(戳一戳、表情回复等)"""
|
"""处理 Napcat 通知事件(戳一戳、表情回复、禁言、文件上传等)"""
|
||||||
|
|
||||||
def __init__(self, adapter: "NapcatAdapter"):
|
def __init__(self, adapter: "NapcatAdapter"):
|
||||||
self.adapter = adapter
|
self.adapter = adapter
|
||||||
self.plugin_config: Optional[Dict[str, Any]] = None
|
self.plugin_config: Optional[Dict[str, Any]] = None
|
||||||
|
# 戳一戳防抖时间戳
|
||||||
|
self.last_poke_time: float = 0.0
|
||||||
|
|
||||||
def set_plugin_config(self, config: Dict[str, Any]) -> None:
|
def set_plugin_config(self, config: Dict[str, Any]) -> None:
|
||||||
"""设置插件配置"""
|
"""设置插件配置"""
|
||||||
self.plugin_config = config
|
self.plugin_config = config
|
||||||
|
|
||||||
|
def _get_config(self, key: str, default: Any = None) -> Any:
|
||||||
|
"""获取插件配置的辅助方法"""
|
||||||
|
if not self.plugin_config:
|
||||||
|
return default
|
||||||
|
return config_api.get_plugin_config(self.plugin_config, key, default)
|
||||||
|
|
||||||
async def handle_notice(self, raw: Dict[str, Any]):
|
async def handle_notice(self, raw: Dict[str, Any]):
|
||||||
"""处理通知事件"""
|
"""
|
||||||
# 简化版本:返回一个空的 MessageEnvelope
|
处理通知事件
|
||||||
import time
|
|
||||||
import uuid
|
Args:
|
||||||
|
raw: OneBot 原始通知数据
|
||||||
return {
|
|
||||||
"direction": "incoming",
|
Returns:
|
||||||
"message_info": {
|
MessageEnvelope (dict) or None
|
||||||
"platform": "qq",
|
"""
|
||||||
"message_id": str(uuid.uuid4()),
|
notice_type = raw.get("notice_type")
|
||||||
"time": time.time(),
|
message_time: float = time.time()
|
||||||
},
|
|
||||||
"message_segment": {"type": "text", "data": "[通知事件]"},
|
self_id = raw.get("self_id")
|
||||||
"timestamp_ms": int(time.time() * 1000),
|
group_id = raw.get("group_id")
|
||||||
|
user_id = raw.get("user_id")
|
||||||
|
target_id = raw.get("target_id")
|
||||||
|
|
||||||
|
handled_segment: SegPayload | None = None
|
||||||
|
user_info: UserInfoPayload | None = None
|
||||||
|
system_notice: bool = False
|
||||||
|
notice_config: Dict[str, Any] = {
|
||||||
|
"is_notice": False,
|
||||||
|
"is_public_notice": False,
|
||||||
|
"target_id": target_id,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
match notice_type:
|
||||||
|
case NoticeType.friend_recall:
|
||||||
|
logger.info("好友撤回一条消息")
|
||||||
|
logger.info(f"撤回消息ID:{raw.get('message_id')}, 撤回时间:{raw.get('time')}")
|
||||||
|
logger.warning("暂时不支持撤回消息处理")
|
||||||
|
return None
|
||||||
|
|
||||||
|
case NoticeType.group_recall:
|
||||||
|
logger.info("群内用户撤回一条消息")
|
||||||
|
logger.info(f"撤回消息ID:{raw.get('message_id')}, 撤回时间:{raw.get('time')}")
|
||||||
|
logger.warning("暂时不支持撤回消息处理")
|
||||||
|
return None
|
||||||
|
|
||||||
|
case NoticeType.notify:
|
||||||
|
sub_type = raw.get("sub_type")
|
||||||
|
match sub_type:
|
||||||
|
case NoticeType.Notify.poke:
|
||||||
|
if self._get_config("features.enable_poke", True):
|
||||||
|
logger.debug("处理戳一戳消息")
|
||||||
|
handled_segment, user_info = await self._handle_poke_notify(raw, group_id, user_id)
|
||||||
|
if handled_segment and user_info:
|
||||||
|
notice_config["notice_type"] = "poke"
|
||||||
|
notice_config["is_notice"] = True
|
||||||
|
else:
|
||||||
|
logger.warning("戳一戳消息被禁用,取消戳一戳处理")
|
||||||
|
return None
|
||||||
|
|
||||||
|
case NoticeType.Notify.input_status:
|
||||||
|
from src.plugin_system.core.event_manager import event_manager
|
||||||
|
from ...event_types import NapcatEvent
|
||||||
|
await event_manager.trigger_event(
|
||||||
|
NapcatEvent.ON_RECEIVED.FRIEND_INPUT,
|
||||||
|
permission_group=PLUGIN_NAME
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
case _:
|
||||||
|
logger.warning(f"不支持的notify类型: {notice_type}.{sub_type}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
case NoticeType.group_msg_emoji_like:
|
||||||
|
if self._get_config("features.enable_emoji_like", True):
|
||||||
|
logger.debug("处理群聊表情回复")
|
||||||
|
handled_segment, user_info = await self._handle_group_emoji_like_notify(
|
||||||
|
raw, group_id, user_id
|
||||||
|
)
|
||||||
|
if handled_segment and user_info:
|
||||||
|
notice_config["notice_type"] = "emoji_like"
|
||||||
|
notice_config["is_notice"] = True
|
||||||
|
else:
|
||||||
|
logger.warning("群聊表情回复被禁用,取消群聊表情回复处理")
|
||||||
|
return None
|
||||||
|
|
||||||
|
case NoticeType.group_ban:
|
||||||
|
sub_type = raw.get("sub_type")
|
||||||
|
match sub_type:
|
||||||
|
case NoticeType.GroupBan.ban:
|
||||||
|
logger.info("处理群禁言")
|
||||||
|
handled_segment, user_info = await self._handle_ban_notify(raw, group_id)
|
||||||
|
if handled_segment and user_info:
|
||||||
|
system_notice = True
|
||||||
|
user_id_in_ban = raw.get("user_id")
|
||||||
|
if user_id_in_ban == 0:
|
||||||
|
notice_config["notice_type"] = "group_whole_ban"
|
||||||
|
else:
|
||||||
|
notice_config["notice_type"] = "group_ban"
|
||||||
|
notice_config["is_notice"] = True
|
||||||
|
|
||||||
|
case NoticeType.GroupBan.lift_ban:
|
||||||
|
logger.info("处理解除群禁言")
|
||||||
|
handled_segment, user_info = await self._handle_lift_ban_notify(raw, group_id)
|
||||||
|
if handled_segment and user_info:
|
||||||
|
system_notice = True
|
||||||
|
user_id_in_ban = raw.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"
|
||||||
|
notice_config["is_notice"] = True
|
||||||
|
|
||||||
|
case _:
|
||||||
|
logger.warning(f"不支持的group_ban类型: {notice_type}.{sub_type}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
case NoticeType.group_upload:
|
||||||
|
logger.info("群文件上传")
|
||||||
|
if user_id == self_id:
|
||||||
|
logger.info("检测到机器人自己上传文件,忽略此通知")
|
||||||
|
return None
|
||||||
|
handled_segment, user_info = await self._handle_group_upload_notify(
|
||||||
|
raw, group_id, user_id, self_id
|
||||||
|
)
|
||||||
|
if handled_segment and user_info:
|
||||||
|
notice_config["notice_type"] = "group_upload"
|
||||||
|
notice_config["is_notice"] = True
|
||||||
|
|
||||||
|
case _:
|
||||||
|
logger.warning(f"不支持的notice类型: {notice_type}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
if not handled_segment or not user_info:
|
||||||
|
logger.warning("notice处理失败或不支持")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# 使用 MessageBuilder 构建消息
|
||||||
|
msg_builder = MessageBuilder()
|
||||||
|
|
||||||
|
(
|
||||||
|
msg_builder.direction("incoming")
|
||||||
|
.message_id("notice")
|
||||||
|
.timestamp_ms(int(message_time * 1000))
|
||||||
|
.from_user(
|
||||||
|
user_id=str(user_info.get("user_id", "")),
|
||||||
|
platform="qq",
|
||||||
|
nickname=user_info.get("user_nickname", ""),
|
||||||
|
cardname=user_info.get("user_cardname", ""),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# 如果是群消息,添加群信息
|
||||||
|
if group_id:
|
||||||
|
fetched_group_info = await get_group_info(group_id)
|
||||||
|
group_name: str | None = None
|
||||||
|
if fetched_group_info:
|
||||||
|
group_name = fetched_group_info.get("group_name")
|
||||||
|
else:
|
||||||
|
logger.warning("无法获取notice消息所在群的名称")
|
||||||
|
msg_builder.from_group(
|
||||||
|
group_id=str(group_id),
|
||||||
|
platform="qq",
|
||||||
|
name=group_name or "",
|
||||||
|
)
|
||||||
|
|
||||||
|
# 设置格式信息
|
||||||
|
content_format = [handled_segment.get("type", "text")]
|
||||||
|
if "notify" not in content_format:
|
||||||
|
content_format.append("notify")
|
||||||
|
msg_builder.format_info(
|
||||||
|
content_format=content_format,
|
||||||
|
accept_format=ACCEPT_FORMAT,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 设置消息段
|
||||||
|
msg_builder.seg_list([handled_segment])
|
||||||
|
|
||||||
|
# 设置 additional_config(包含 notice 相关配置)
|
||||||
|
res = msg_builder.build()["message_info"]["additional_config"] = notice_config
|
||||||
|
return res
|
||||||
|
|
||||||
|
async def _handle_poke_notify(
|
||||||
|
self, raw: Dict[str, Any], group_id: Any, user_id: Any
|
||||||
|
) -> Tuple[SegPayload | None, UserInfoPayload | None]:
|
||||||
|
"""处理戳一戳通知"""
|
||||||
|
self_info: dict | None = await get_self_info()
|
||||||
|
|
||||||
|
if not self_info:
|
||||||
|
logger.error("自身信息获取失败")
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
self_id = raw.get("self_id")
|
||||||
|
target_id = raw.get("target_id")
|
||||||
|
|
||||||
|
# 防抖检查:如果是针对机器人的戳一戳,检查防抖时间
|
||||||
|
if self_id == target_id:
|
||||||
|
current_time = time.time()
|
||||||
|
debounce_seconds = self._get_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 = None
|
||||||
|
raw_info: list = raw.get("raw_info", [])
|
||||||
|
|
||||||
|
if group_id:
|
||||||
|
user_qq_info: dict | None = await get_member_info(group_id, user_id)
|
||||||
|
else:
|
||||||
|
user_qq_info: dict | None = await get_stranger_info(user_id)
|
||||||
|
|
||||||
|
if user_qq_info:
|
||||||
|
user_name = user_qq_info.get("nickname", "QQ用户")
|
||||||
|
user_cardname = user_qq_info.get("card", "")
|
||||||
|
else:
|
||||||
|
user_name = "QQ用户"
|
||||||
|
user_cardname = ""
|
||||||
|
logger.debug("无法获取戳一戳对方的用户昵称")
|
||||||
|
|
||||||
|
# 计算显示名称
|
||||||
|
display_name = ""
|
||||||
|
if self_id == target_id:
|
||||||
|
target_name = self_info.get("nickname", "")
|
||||||
|
elif self_id == user_id:
|
||||||
|
# 不发送机器人戳别人的消息
|
||||||
|
return None, None
|
||||||
|
else:
|
||||||
|
# 如果配置为忽略不是针对自己的戳一戳,则直接返回None
|
||||||
|
if self._get_config("features.ignore_non_self_poke", False):
|
||||||
|
logger.debug("忽略不是针对自己的戳一戳消息")
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
if group_id:
|
||||||
|
fetched_member_info: dict | None = await get_member_info(group_id, target_id)
|
||||||
|
if fetched_member_info:
|
||||||
|
target_name = fetched_member_info.get("nickname", "QQ用户")
|
||||||
|
else:
|
||||||
|
target_name = "QQ用户"
|
||||||
|
logger.debug("无法获取被戳一戳方的用户昵称")
|
||||||
|
display_name = user_name
|
||||||
|
else:
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
# 解析戳一戳文本
|
||||||
|
first_txt: str = "戳了戳"
|
||||||
|
second_txt: str = ""
|
||||||
|
try:
|
||||||
|
if len(raw_info) > 2:
|
||||||
|
first_txt = raw_info[2].get("txt", "戳了戳")
|
||||||
|
if len(raw_info) > 4:
|
||||||
|
second_txt = raw_info[4].get("txt", "")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"解析戳一戳消息失败: {str(e)},将使用默认文本")
|
||||||
|
|
||||||
|
user_info: UserInfoPayload = {
|
||||||
|
"platform": "qq",
|
||||||
|
"user_id": str(user_id),
|
||||||
|
"user_nickname": user_name,
|
||||||
|
"user_cardname": user_cardname,
|
||||||
|
}
|
||||||
|
|
||||||
|
seg_data: SegPayload = {
|
||||||
|
"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: Dict[str, Any], group_id: Any, user_id: Any
|
||||||
|
) -> Tuple[SegPayload | None, UserInfoPayload | None]:
|
||||||
|
"""处理群聊表情回复通知"""
|
||||||
|
if not group_id:
|
||||||
|
logger.error("群ID不能为空,无法处理群聊表情回复通知")
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
user_qq_info: dict | None = await get_member_info(group_id, user_id)
|
||||||
|
if user_qq_info:
|
||||||
|
user_name = user_qq_info.get("nickname", "QQ用户")
|
||||||
|
user_cardname = user_qq_info.get("card", "")
|
||||||
|
else:
|
||||||
|
user_name = "QQ用户"
|
||||||
|
user_cardname = ""
|
||||||
|
logger.debug("无法获取表情回复对方的用户昵称")
|
||||||
|
|
||||||
|
# 触发事件
|
||||||
|
from src.plugin_system.core.event_manager import event_manager
|
||||||
|
from ...event_types import NapcatEvent
|
||||||
|
|
||||||
|
target_message = await get_message_detail(raw.get("message_id", ""))
|
||||||
|
target_message_text = ""
|
||||||
|
if target_message:
|
||||||
|
target_message_text = target_message.get("raw_message", "")
|
||||||
|
else:
|
||||||
|
logger.error("未找到对应消息")
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
if len(target_message_text) > 15:
|
||||||
|
target_message_text = target_message_text[:15] + "..."
|
||||||
|
|
||||||
|
user_info: UserInfoPayload = {
|
||||||
|
"platform": "qq",
|
||||||
|
"user_id": str(user_id),
|
||||||
|
"user_nickname": user_name,
|
||||||
|
"user_cardname": user_cardname,
|
||||||
|
}
|
||||||
|
|
||||||
|
likes_list = raw.get("likes", [])
|
||||||
|
like_emoji_id = ""
|
||||||
|
if likes_list and len(likes_list) > 0:
|
||||||
|
like_emoji_id = str(likes_list[0].get("emoji_id", ""))
|
||||||
|
|
||||||
|
# 触发表情回复事件
|
||||||
|
await event_manager.trigger_event(
|
||||||
|
NapcatEvent.ON_RECEIVED.EMOJI_LIEK,
|
||||||
|
permission_group=PLUGIN_NAME,
|
||||||
|
group_id=group_id,
|
||||||
|
user_id=user_id,
|
||||||
|
message_id=raw.get("message_id", ""),
|
||||||
|
emoji_id=like_emoji_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
emoji_text = QQ_FACE.get(like_emoji_id, f"[表情{like_emoji_id}]")
|
||||||
|
seg_data: SegPayload = {
|
||||||
|
"type": "text",
|
||||||
|
"data": f"{user_name}使用Emoji表情{emoji_text}回应了消息[{target_message_text}]",
|
||||||
|
}
|
||||||
|
return seg_data, user_info
|
||||||
|
|
||||||
|
async def _handle_group_upload_notify(
|
||||||
|
self, raw: Dict[str, Any], group_id: Any, user_id: Any, self_id: Any
|
||||||
|
) -> Tuple[SegPayload | None, UserInfoPayload | None]:
|
||||||
|
"""处理群文件上传通知"""
|
||||||
|
if not group_id:
|
||||||
|
logger.error("群ID不能为空,无法处理群文件上传通知")
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
user_qq_info: dict | None = await get_member_info(group_id, user_id)
|
||||||
|
if user_qq_info:
|
||||||
|
user_name = user_qq_info.get("nickname", "QQ用户")
|
||||||
|
user_cardname = user_qq_info.get("card", "")
|
||||||
|
else:
|
||||||
|
user_name = "QQ用户"
|
||||||
|
user_cardname = ""
|
||||||
|
logger.debug("无法获取上传文件的用户昵称")
|
||||||
|
|
||||||
|
file_info = raw.get("file")
|
||||||
|
if not file_info:
|
||||||
|
logger.error("群文件上传通知中缺少文件信息")
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
user_info: UserInfoPayload = {
|
||||||
|
"platform": "qq",
|
||||||
|
"user_id": str(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: SegPayload = {
|
||||||
|
"type": "text",
|
||||||
|
"data": f"{user_name} 上传了文件: {file_name} (大小: {file_size} 字节)",
|
||||||
|
}
|
||||||
|
return seg_data, user_info
|
||||||
|
|
||||||
|
async def _handle_ban_notify(
|
||||||
|
self, raw: Dict[str, Any], group_id: Any
|
||||||
|
) -> Tuple[SegPayload | None, UserInfoPayload | None]:
|
||||||
|
"""处理群禁言通知"""
|
||||||
|
if not group_id:
|
||||||
|
logger.error("群ID不能为空,无法处理禁言通知")
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
# 获取操作者信息
|
||||||
|
operator_id = raw.get("operator_id")
|
||||||
|
operator_nickname: str = "QQ用户"
|
||||||
|
operator_cardname: str = ""
|
||||||
|
|
||||||
|
member_info: dict | None = await get_member_info(group_id, operator_id)
|
||||||
|
if member_info:
|
||||||
|
operator_nickname = member_info.get("nickname", "QQ用户")
|
||||||
|
operator_cardname = member_info.get("card", "")
|
||||||
|
else:
|
||||||
|
logger.warning("无法获取禁言执行者的昵称,消息可能会无效")
|
||||||
|
|
||||||
|
operator_info: UserInfoPayload = {
|
||||||
|
"platform": "qq",
|
||||||
|
"user_id": str(operator_id),
|
||||||
|
"user_nickname": operator_nickname,
|
||||||
|
"user_cardname": operator_cardname,
|
||||||
|
}
|
||||||
|
|
||||||
|
# 获取被禁言者信息
|
||||||
|
user_id = raw.get("user_id")
|
||||||
|
banned_user_info: Dict[str, Any] | None = None
|
||||||
|
user_nickname: str = "QQ用户"
|
||||||
|
user_cardname: str = ""
|
||||||
|
sub_type: str = ""
|
||||||
|
|
||||||
|
duration = raw.get("duration")
|
||||||
|
if duration is None:
|
||||||
|
logger.error("禁言时长不能为空,无法处理禁言通知")
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
if user_id == 0: # 全体禁言
|
||||||
|
sub_type = "whole_ban"
|
||||||
|
else: # 单人禁言
|
||||||
|
sub_type = "ban"
|
||||||
|
fetched_member_info: dict | None = await get_member_info(group_id, user_id)
|
||||||
|
if fetched_member_info:
|
||||||
|
user_nickname = fetched_member_info.get("nickname", "QQ用户")
|
||||||
|
user_cardname = fetched_member_info.get("card", "")
|
||||||
|
banned_user_info = {
|
||||||
|
"platform": "qq",
|
||||||
|
"user_id": str(user_id),
|
||||||
|
"user_nickname": user_nickname,
|
||||||
|
"user_cardname": user_cardname,
|
||||||
|
}
|
||||||
|
|
||||||
|
seg_data: SegPayload = {
|
||||||
|
"type": "notify",
|
||||||
|
"data": {
|
||||||
|
"sub_type": sub_type,
|
||||||
|
"duration": duration,
|
||||||
|
"banned_user_info": banned_user_info,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return seg_data, operator_info
|
||||||
|
|
||||||
|
async def _handle_lift_ban_notify(
|
||||||
|
self, raw: Dict[str, Any], group_id: Any
|
||||||
|
) -> Tuple[SegPayload | None, UserInfoPayload | None]:
|
||||||
|
"""处理解除群禁言通知"""
|
||||||
|
if not group_id:
|
||||||
|
logger.error("群ID不能为空,无法处理解除禁言通知")
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
# 获取操作者信息
|
||||||
|
operator_id = raw.get("operator_id")
|
||||||
|
operator_nickname: str = "QQ用户"
|
||||||
|
operator_cardname: str = ""
|
||||||
|
|
||||||
|
member_info: dict | None = await get_member_info(group_id, operator_id)
|
||||||
|
if member_info:
|
||||||
|
operator_nickname = member_info.get("nickname", "QQ用户")
|
||||||
|
operator_cardname = member_info.get("card", "")
|
||||||
|
else:
|
||||||
|
logger.warning("无法获取解除禁言执行者的昵称,消息可能会无效")
|
||||||
|
|
||||||
|
operator_info: UserInfoPayload = {
|
||||||
|
"platform": "qq",
|
||||||
|
"user_id": str(operator_id),
|
||||||
|
"user_nickname": operator_nickname,
|
||||||
|
"user_cardname": operator_cardname,
|
||||||
|
}
|
||||||
|
|
||||||
|
# 获取被解除禁言者信息
|
||||||
|
sub_type: str = ""
|
||||||
|
user_nickname: str = "QQ用户"
|
||||||
|
user_cardname: str = ""
|
||||||
|
lifted_user_info: Dict[str, Any] | None = None
|
||||||
|
|
||||||
|
user_id = raw.get("user_id")
|
||||||
|
if user_id == 0: # 全体禁言解除
|
||||||
|
sub_type = "whole_lift_ban"
|
||||||
|
else: # 单人禁言解除
|
||||||
|
sub_type = "lift_ban"
|
||||||
|
fetched_member_info: dict | None = await get_member_info(group_id, user_id)
|
||||||
|
if fetched_member_info:
|
||||||
|
user_nickname = fetched_member_info.get("nickname", "QQ用户")
|
||||||
|
user_cardname = fetched_member_info.get("card", "")
|
||||||
|
else:
|
||||||
|
logger.warning("无法获取解除禁言消息发送者的昵称,消息可能会无效")
|
||||||
|
lifted_user_info = {
|
||||||
|
"platform": "qq",
|
||||||
|
"user_id": str(user_id),
|
||||||
|
"user_nickname": user_nickname,
|
||||||
|
"user_cardname": user_cardname,
|
||||||
|
}
|
||||||
|
|
||||||
|
seg_data: SegPayload = {
|
||||||
|
"type": "notify",
|
||||||
|
"data": {
|
||||||
|
"sub_type": sub_type,
|
||||||
|
"lifted_user_info": lifted_user_info,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return seg_data, operator_info
|
||||||
|
|||||||
Reference in New Issue
Block a user