diff --git a/.gitignore b/.gitignore
index ec98f59..3dd0281 100644
--- a/.gitignore
+++ b/.gitignore
@@ -39,6 +39,7 @@ share/python-wheels/
.installed.cfg
*.egg
MANIFEST
+dev/
# PyInstaller
# Usually these files are written by a python script from a template
diff --git a/main.py b/main.py
index 6f824a6..b0986a6 100644
--- a/main.py
+++ b/main.py
@@ -64,26 +64,73 @@ def check_napcat_server_token(conn, request):
return None
async def napcat_server():
- logger.info("正在启动adapter...")
- async with Server.serve(message_recv, global_config.napcat_server.host, global_config.napcat_server.port, max_size=2**26, process_request=check_napcat_server_token) as server:
- logger.info(
- f"Adapter已启动,监听地址: ws://{global_config.napcat_server.host}:{global_config.napcat_server.port}"
- )
- await server.serve_forever()
-
-
-async def graceful_shutdown():
+ logger.info("正在启动 MaiBot-Napcat-Adapter...")
+ logger.debug(f"日志等级: {global_config.debug.level}")
+ logger.debug("日志文件: logs/adapter_*.log")
try:
- logger.info("正在关闭adapter...")
+ async with Server.serve(
+ message_recv,
+ global_config.napcat_server.host,
+ global_config.napcat_server.port,
+ max_size=2**26,
+ process_request=check_napcat_server_token
+ ) as server:
+ logger.success(
+ f"✅ Adapter 启动成功! 监听: ws://{global_config.napcat_server.host}:{global_config.napcat_server.port}"
+ )
+ await server.serve_forever()
+ except OSError:
+ # 端口绑定失败时抛出异常让外层处理
+ raise
+
+
+async def graceful_shutdown(silent: bool = False):
+ """
+ 优雅关闭adapter
+ Args:
+ silent: 静默模式,控制台不输出日志,但仍记录到文件
+ """
+ try:
+ if not silent:
+ logger.info("正在关闭adapter...")
+ else:
+ logger.debug("正在清理资源...")
+
+ # 先关闭MMC连接
+ try:
+ await asyncio.wait_for(mmc_stop_com(), timeout=3)
+ except asyncio.TimeoutError:
+ logger.debug("关闭MMC连接超时")
+ except Exception as e:
+ logger.debug(f"关闭MMC连接时出现错误: {e}")
+
+ # 取消所有任务
tasks = [t for t in asyncio.all_tasks() if t is not asyncio.current_task()]
+ if tasks:
+ logger.debug(f"正在取消 {len(tasks)} 个任务")
for task in tasks:
if not task.done():
task.cancel()
- await asyncio.wait_for(asyncio.gather(*tasks, return_exceptions=True), 15)
- await mmc_stop_com() # 后置避免神秘exception
- logger.info("Adapter已成功关闭")
+
+ # 等待任务完成,记录异常到日志文件
+ if tasks:
+ try:
+ results = await asyncio.wait_for(asyncio.gather(*tasks, return_exceptions=True), timeout=3)
+ # 记录任务取消的详细信息到日志文件
+ for i, result in enumerate(results):
+ if isinstance(result, Exception):
+ logger.debug(f"任务 {i+1} 清理时产生异常: {type(result).__name__}: {result}")
+ except asyncio.TimeoutError:
+ logger.debug("任务清理超时")
+ except Exception as e:
+ logger.debug(f"任务清理时出现错误: {e}")
+
+ if not silent:
+ logger.info("Adapter已成功关闭")
+ else:
+ logger.debug("资源清理完成")
except Exception as e:
- logger.error(f"Adapter关闭中出现错误: {e}")
+ logger.debug(f"graceful_shutdown异常: {e}", exc_info=True)
if __name__ == "__main__":
@@ -93,11 +140,57 @@ if __name__ == "__main__":
loop.run_until_complete(main())
except KeyboardInterrupt:
logger.warning("收到中断信号,正在优雅关闭...")
- loop.run_until_complete(graceful_shutdown())
+ try:
+ loop.run_until_complete(graceful_shutdown(silent=False))
+ except Exception:
+ pass
+ except OSError as e:
+ # 处理端口占用等网络错误
+ if e.errno == 10048 or "address already in use" in str(e).lower():
+ logger.error(f"❌ 端口 {global_config.napcat_server.port} 已被占用,请检查:")
+ logger.error(" 1. 是否有其他 MaiBot-Napcat-Adapter 实例正在运行")
+ logger.error(" 2. 修改 config.toml 中的 port 配置")
+ logger.error(f" 3. 使用命令查看占用进程: netstat -ano | findstr {global_config.napcat_server.port}")
+ logger.debug("完整错误信息:", exc_info=True)
+ else:
+ logger.error(f"❌ 网络错误: {str(e)}")
+ logger.debug("完整错误信息:", exc_info=True)
+ # 端口占用时静默清理(控制台不输出,但记录到日志文件)
+ try:
+ loop.run_until_complete(graceful_shutdown(silent=True))
+ except Exception as e:
+ logger.debug(f"清理资源时出现错误: {e}", exc_info=True)
+ sys.exit(1)
except Exception as e:
- logger.exception(f"主程序异常: {str(e)}")
+ logger.error(f"❌ 主程序异常: {str(e)}")
+ logger.debug("详细错误信息:", exc_info=True)
+ try:
+ loop.run_until_complete(graceful_shutdown(silent=True))
+ except Exception as e:
+ logger.debug(f"清理资源时出现错误: {e}", exc_info=True)
sys.exit(1)
finally:
- if loop and not loop.is_closed():
- loop.close()
+ # 清理事件循环
+ try:
+ # 取消所有剩余任务
+ pending = asyncio.all_tasks(loop)
+ if pending:
+ logger.debug(f"finally块清理 {len(pending)} 个剩余任务")
+ for task in pending:
+ task.cancel()
+ # 给任务一点时间完成取消
+ try:
+ results = loop.run_until_complete(asyncio.gather(*pending, return_exceptions=True))
+ # 记录清理结果到日志文件
+ for i, result in enumerate(results):
+ if isinstance(result, Exception) and not isinstance(result, asyncio.CancelledError):
+ logger.debug(f"剩余任务 {i+1} 清理异常: {type(result).__name__}: {result}")
+ except Exception as e:
+ logger.debug(f"清理剩余任务时出现错误: {e}")
+ except Exception as e:
+ logger.debug(f"finally块清理出现错误: {e}")
+ finally:
+ if loop and not loop.is_closed():
+ logger.debug("关闭事件循环")
+ loop.close()
sys.exit(0)
diff --git a/src/logger.py b/src/logger.py
index 4100964..ab509e9 100644
--- a/src/logger.py
+++ b/src/logger.py
@@ -1,21 +1,106 @@
from loguru import logger
from .config import global_config
import sys
+from pathlib import Path
+from datetime import datetime, timedelta
-# 默认 logger
+# 日志目录配置
+LOG_DIR = Path(__file__).parent.parent / "logs"
+LOG_DIR.mkdir(exist_ok=True)
+
+# 日志等级映射(用于显示单字母)
+LEVEL_ABBR = {
+ "TRACE": "T",
+ "DEBUG": "D",
+ "INFO": "I",
+ "SUCCESS": "S",
+ "WARNING": "W",
+ "ERROR": "E",
+ "CRITICAL": "C"
+}
+
+def get_level_abbr(record):
+ """获取日志等级的缩写"""
+ return LEVEL_ABBR.get(record["level"].name, record["level"].name[0])
+
+def clean_old_logs(days: int = 30):
+ """清理超过指定天数的日志文件"""
+ try:
+ cutoff_date = datetime.now() - timedelta(days=days)
+ for log_file in LOG_DIR.glob("*.log"):
+ try:
+ file_time = datetime.fromtimestamp(log_file.stat().st_mtime)
+ if file_time < cutoff_date:
+ log_file.unlink()
+ print(f"已清理过期日志: {log_file.name}")
+ except Exception as e:
+ print(f"清理日志文件 {log_file.name} 失败: {e}")
+ except Exception as e:
+ print(f"清理日志目录失败: {e}")
+
+# 清理过期日志
+clean_old_logs(30)
+
+# 移除默认处理器
logger.remove()
+
+# 自定义格式化函数
+def format_log(record):
+ """格式化日志记录"""
+ record["extra"]["level_abbr"] = get_level_abbr(record)
+ if "module_name" not in record["extra"]:
+ record["extra"]["module_name"] = "Adapter"
+ return True
+
+# 控制台输出处理器 - 简洁格式
logger.add(
sys.stderr,
level=global_config.debug.level,
- format="{time:YYYY-MM-DD HH:mm:ss} | {level: <8} | {name}:{function}:{line} - {message}",
- filter=lambda record: "name" not in record["extra"] or record["extra"].get("name") != "maim_message",
+ format="{time:MM-DD HH:mm:ss} | [{extra[level_abbr]}] | {extra[module_name]} | {message}",
+ filter=lambda record: format_log(record) and record["extra"].get("module_name") != "maim_message",
)
+
+# maim_message 单独处理
logger.add(
sys.stderr,
level="INFO",
- format="{time:YYYY-MM-DD HH:mm:ss} | {level: <8} | {name}:{function}:{line} - {message}",
- filter=lambda record: record["extra"].get("name") == "maim_message",
+ format="{time:MM-DD HH:mm:ss} | [{extra[level_abbr]}] | {extra[module_name]} | {message}",
+ filter=lambda record: format_log(record) and record["extra"].get("module_name") == "maim_message",
)
-# 创建样式不同的 logger
-custom_logger = logger.bind(name="maim_message")
-logger = logger.bind(name="MaiBot-Napcat-Adapter")
+
+# 文件输出处理器 - 详细格式,记录所有TRACE级别
+log_file = LOG_DIR / f"adapter_{datetime.now().strftime('%Y%m%d_%H%M%S')}.log"
+logger.add(
+ log_file,
+ level="TRACE",
+ format="{time:YYYY-MM-DD HH:mm:ss.SSS} | [{level}] | {extra[module_name]} | {name}:{function}:{line} - {message}",
+ rotation="100 MB", # 单个日志文件最大100MB
+ retention="30 days", # 保留30天
+ encoding="utf-8",
+ enqueue=True, # 异步写入,避免阻塞
+ filter=format_log, # 确保extra字段存在
+)
+
+def get_logger(module_name: str = "Adapter"):
+ """
+ 获取自定义模块名的logger
+
+ Args:
+ module_name: 模块名称,用于日志输出中标识来源
+
+ Returns:
+ 配置好的logger实例
+
+ Example:
+ >>> from src.logger import get_logger
+ >>> logger = get_logger("MyModule")
+ >>> logger.info("这是一条日志")
+ MM-DD HH:mm:ss | [I] | MyModule | 这是一条日志
+ """
+ return logger.bind(module_name=module_name)
+
+# 默认logger实例(用于向后兼容)
+logger = logger.bind(module_name="Adapter")
+
+# maim_message的logger
+custom_logger = logger.bind(module_name="maim_message")
diff --git a/src/recv_handler/__init__.py b/src/recv_handler/__init__.py
index 767ae77..f8904ee 100644
--- a/src/recv_handler/__init__.py
+++ b/src/recv_handler/__init__.py
@@ -32,6 +32,12 @@ class NoticeType: # 通知事件
group_recall = "group_recall" # 群聊消息撤回
notify = "notify"
group_ban = "group_ban" # 群禁言
+ group_msg_emoji_like = "group_msg_emoji_like" # 群消息表情回应
+ group_upload = "group_upload" # 群文件上传
+ group_increase = "group_increase" # 群成员增加
+ group_decrease = "group_decrease" # 群成员减少
+ group_admin = "group_admin" # 群管理员变动
+ essence = "essence" # 精华消息
class Notify:
poke = "poke" # 戳一戳
@@ -40,6 +46,23 @@ class NoticeType: # 通知事件
ban = "ban" # 禁言
lift_ban = "lift_ban" # 解除禁言
+ class GroupIncrease:
+ approve = "approve" # 管理员同意入群
+ invite = "invite" # 被邀请入群
+
+ class GroupDecrease:
+ leave = "leave" # 主动退群
+ kick = "kick" # 被踢出群
+ kick_me = "kick_me" # 机器人被踢
+
+ class GroupAdmin:
+ set = "set" # 设置管理员
+ unset = "unset" # 取消管理员
+
+ class Essence:
+ add = "add" # 添加精华消息
+ delete = "delete" # 移除精华消息
+
class RealMessageType: # 实际消息分类
text = "text" # 纯文本
@@ -56,6 +79,8 @@ class RealMessageType: # 实际消息分类
reply = "reply" # 回复消息
forward = "forward" # 转发消息
node = "node" # 转发消息节点
+ json = "json" # JSON卡片消息
+ file = "file" # 文件消息
class MessageSentType:
diff --git a/src/recv_handler/message_handler.py b/src/recv_handler/message_handler.py
index 126c7e9..afa7d20 100644
--- a/src/recv_handler/message_handler.py
+++ b/src/recv_handler/message_handler.py
@@ -300,7 +300,23 @@ class MessageHandler:
else:
logger.warning("record处理失败或不支持")
case RealMessageType.video:
- logger.warning("不支持视频解析")
+ ret_seg = await self.handle_video_message(sub_message)
+ if ret_seg:
+ seg_message.append(ret_seg)
+ else:
+ logger.warning("video处理失败")
+ case RealMessageType.json:
+ ret_seg = await self.handle_json_message(sub_message)
+ if ret_seg:
+ seg_message.append(ret_seg)
+ else:
+ logger.warning("json处理失败")
+ case RealMessageType.file:
+ ret_seg = await self.handle_file_message(sub_message)
+ if ret_seg:
+ seg_message.append(ret_seg)
+ else:
+ logger.warning("file处理失败")
case RealMessageType.at:
ret_seg = await self.handle_at_message(
sub_message,
@@ -445,6 +461,77 @@ class MessageHandler:
return None
return Seg(type="voice", data=audio_base64)
+ async def handle_video_message(self, raw_message: dict) -> Seg | None:
+ """
+ 处理视频消息
+ Parameters:
+ raw_message: dict: 原始消息
+ Returns:
+ seg_data: Seg: 处理后的消息段
+ """
+ message_data: dict = raw_message.get("data")
+ file: str = message_data.get("file")
+ url: str = message_data.get("url")
+ file_size: str = message_data.get("file_size", "未知大小")
+
+ if not file:
+ logger.warning("视频消息缺少文件信息")
+ return None
+
+ # 视频消息返回文本描述,包含文件名和大小
+ video_text = f"[视频: {file}, 大小: {file_size}字节]"
+ if url:
+ video_text += f"\n视频链接: {url}"
+
+ return Seg(type="text", data=video_text)
+
+ async def handle_json_message(self, raw_message: dict) -> Seg | None:
+ """
+ 处理JSON卡片消息(小程序、分享等)
+ Parameters:
+ raw_message: dict: 原始消息
+ Returns:
+ seg_data: Seg: 处理后的消息段
+ """
+ message_data: dict = raw_message.get("data")
+ json_data: str = message_data.get("data")
+
+ if not json_data:
+ logger.warning("JSON消息缺少数据")
+ return None
+
+ try:
+ # 尝试解析JSON获取prompt提示信息
+ parsed_json = json.loads(json_data)
+ prompt = parsed_json.get("prompt", "[卡片消息]")
+ return Seg(type="text", data=prompt)
+ except json.JSONDecodeError:
+ logger.warning("JSON消息解析失败")
+ return Seg(type="text", data="[卡片消息]")
+
+ async def handle_file_message(self, raw_message: dict) -> Seg | None:
+ """
+ 处理文件消息
+ Parameters:
+ raw_message: dict: 原始消息
+ Returns:
+ seg_data: Seg: 处理后的消息段
+ """
+ message_data: dict = raw_message.get("data")
+ file_name: str = message_data.get("file")
+ file_size: str = message_data.get("file_size", "未知大小")
+ file_url: str = message_data.get("url")
+
+ if not file_name:
+ logger.warning("文件消息缺少文件名")
+ return None
+
+ file_text = f"[文件: {file_name}, 大小: {file_size}字节]"
+ if file_url:
+ file_text += f"\n文件链接: {file_url}"
+
+ return Seg(type="text", data=file_text)
+
async def handle_reply_message(self, raw_message: dict, additional_config: dict) -> Tuple[List[Seg] | None, dict]:
# sourcery skip: move-assign-in-block, use-named-expression
"""
@@ -489,18 +576,25 @@ class MessageHandler:
image_count: int
if not handled_message:
return None
+
+ # 添加转发消息的标题和结束标识
+ forward_header = Seg(type="text", data="========== 转发消息开始 ==========\n")
+ forward_footer = Seg(type="text", data="========== 转发消息结束 ==========")
+
if image_count < 5 and image_count > 0:
# 处理图片数量小于5的情况,此时解析图片为base64
logger.trace("图片数量小于5,开始解析图片为base64")
- return await self._recursive_parse_image_seg(handled_message, True)
+ parsed_message = await self._recursive_parse_image_seg(handled_message, True)
+ return Seg(type="seglist", data=[forward_header, parsed_message, forward_footer])
elif image_count > 0:
logger.trace("图片数量大于等于5,开始解析图片为占位符")
# 处理图片数量大于等于5的情况,此时解析图片为占位符
- return await self._recursive_parse_image_seg(handled_message, False)
+ parsed_message = await self._recursive_parse_image_seg(handled_message, False)
+ return Seg(type="seglist", data=[forward_header, parsed_message, forward_footer])
else:
# 处理没有图片的情况,此时直接返回
logger.trace("没有图片,直接返回")
- return handled_message
+ return Seg(type="seglist", data=[forward_header, handled_message, forward_footer])
async def _recursive_parse_image_seg(self, seg_data: Seg, to_image: bool) -> Seg:
# sourcery skip: merge-else-if-into-elif
diff --git a/src/recv_handler/notice_handler.py b/src/recv_handler/notice_handler.py
index 1e51ea4..2915241 100644
--- a/src/recv_handler/notice_handler.py
+++ b/src/recv_handler/notice_handler.py
@@ -87,12 +87,13 @@ class NoticeHandler:
match notice_type:
case NoticeType.friend_recall:
logger.info("好友撤回一条消息")
- logger.info(f"撤回消息ID:{raw_message.get('message_id')}, 撤回时间:{raw_message.get('time')}")
- logger.warning("暂时不支持撤回消息处理")
+ handled_message, user_info = await self.handle_friend_recall_notify(raw_message)
case NoticeType.group_recall:
+ if not await message_handler.check_allow_to_chat(user_id, group_id, True, False):
+ return None
logger.info("群内用户撤回一条消息")
- logger.info(f"撤回消息ID:{raw_message.get('message_id')}, 撤回时间:{raw_message.get('time')}")
- logger.warning("暂时不支持撤回消息处理")
+ handled_message, user_info = await self.handle_group_recall_notify(raw_message, group_id, user_id)
+ system_notice = True
case NoticeType.notify:
sub_type = raw_message.get("sub_type")
match sub_type:
@@ -123,6 +124,37 @@ class NoticeHandler:
system_notice = True
case _:
logger.warning(f"不支持的group_ban类型: {notice_type}.{sub_type}")
+ case NoticeType.group_msg_emoji_like:
+ 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_emoji_like_notify(raw_message, group_id, user_id)
+ case NoticeType.group_upload:
+ 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_group_upload_notify(raw_message, group_id, user_id)
+ system_notice = True
+ case NoticeType.group_increase:
+ sub_type = raw_message.get("sub_type")
+ logger.info(f"处理群成员增加: {sub_type}")
+ handled_message, user_info = await self.handle_group_increase_notify(raw_message, group_id, user_id)
+ system_notice = True
+ case NoticeType.group_decrease:
+ sub_type = raw_message.get("sub_type")
+ logger.info(f"处理群成员减少: {sub_type}")
+ handled_message, user_info = await self.handle_group_decrease_notify(raw_message, group_id, user_id)
+ system_notice = True
+ case NoticeType.group_admin:
+ sub_type = raw_message.get("sub_type")
+ logger.info(f"处理群管理员变动: {sub_type}")
+ handled_message, user_info = await self.handle_group_admin_notify(raw_message, group_id, user_id)
+ system_notice = True
+ case NoticeType.essence:
+ sub_type = raw_message.get("sub_type")
+ logger.info(f"处理精华消息: {sub_type}")
+ handled_message, user_info = await self.handle_essence_notify(raw_message, group_id)
+ system_notice = True
case _:
logger.warning(f"不支持的notice类型: {notice_type}")
return None
@@ -240,6 +272,322 @@ class NoticeHandler:
)
return seg_data, user_info
+ async def handle_friend_recall_notify(self, raw_message: dict) -> Tuple[Seg | None, UserInfo | None]:
+ """处理好友消息撤回"""
+ user_id = raw_message.get("user_id")
+ message_id = raw_message.get("message_id")
+
+ if not user_id:
+ logger.error("用户ID不能为空,无法处理好友撤回通知")
+ return None, None
+
+ # 获取好友信息
+ user_qq_info: dict = await get_stranger_info(self.server_connection, user_id)
+ if user_qq_info:
+ user_name = user_qq_info.get("nickname")
+ else:
+ user_name = "QQ用户"
+ logger.warning("无法获取撤回消息好友的昵称")
+
+ user_info = UserInfo(
+ platform=global_config.maibot_server.platform_name,
+ user_id=user_id,
+ user_nickname=user_name,
+ user_cardname=None,
+ )
+
+ seg_data = Seg(
+ type="notify",
+ data={
+ "sub_type": "friend_recall",
+ "message_id": message_id,
+ },
+ )
+
+ return seg_data, user_info
+
+ async def handle_group_recall_notify(
+ self, raw_message: dict, group_id: int, user_id: int
+ ) -> Tuple[Seg | None, UserInfo | None]:
+ """处理群消息撤回"""
+ if not group_id:
+ logger.error("群ID不能为空,无法处理群撤回通知")
+ return None, None
+
+ message_id = raw_message.get("message_id")
+ operator_id = raw_message.get("operator_id")
+
+ # 获取撤回操作者信息
+ operator_nickname: str = None
+ operator_cardname: str = None
+
+ member_info: dict = await get_member_info(self.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(
+ platform=global_config.maibot_server.platform_name,
+ user_id=operator_id,
+ user_nickname=operator_nickname,
+ user_cardname=operator_cardname,
+ )
+
+ # 获取被撤回消息发送者信息(如果不是自己撤回的话)
+ recalled_user_info: UserInfo | None = None
+ if user_id != operator_id:
+ user_member_info: dict = await get_member_info(self.server_connection, group_id, user_id)
+ if user_member_info:
+ user_nickname = user_member_info.get("nickname")
+ user_cardname = user_member_info.get("card")
+ else:
+ user_nickname = "QQ用户"
+ user_cardname = None
+ logger.warning("无法获取被撤回消息发送者的昵称")
+
+ recalled_user_info = UserInfo(
+ platform=global_config.maibot_server.platform_name,
+ user_id=user_id,
+ user_nickname=user_nickname,
+ user_cardname=user_cardname,
+ )
+
+ seg_data = Seg(
+ type="notify",
+ data={
+ "sub_type": "group_recall",
+ "message_id": message_id,
+ "recalled_user_info": recalled_user_info.to_dict() if recalled_user_info else None,
+ },
+ )
+
+ return seg_data, operator_info
+
+ async def handle_emoji_like_notify(
+ self, raw_message: dict, group_id: int, user_id: int
+ ) -> Tuple[Seg | None, UserInfo | None]:
+ """处理群消息表情回应"""
+ if not group_id:
+ logger.error("群ID不能为空,无法处理表情回应通知")
+ return None, None
+
+ # 获取用户信息
+ user_qq_info: dict = await get_member_info(self.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.warning("无法获取表情回应用户的昵称")
+
+ # 解析表情列表
+ likes = raw_message.get("likes", [])
+ message_id = raw_message.get("message_id")
+
+ # 构建表情文本
+ emoji_texts = []
+ # QQ 官方表情映射表 (EmojiType=1 为 QQ 系统表情,EmojiType=2 为 Emoji Unicode)
+ emoji_map = {
+ # QQ 系统表情 (Type 1)
+ "4": "得意",
+ "5": "流泪",
+ "8": "睡",
+ "9": "大哭",
+ "10": "尴尬",
+ "12": "调皮",
+ "14": "微笑",
+ "16": "酷",
+ "21": "可爱",
+ "23": "傲慢",
+ "24": "饥饿",
+ "25": "困",
+ "26": "惊恐",
+ "27": "流汗",
+ "28": "憨笑",
+ "29": "悠闲",
+ "30": "奋斗",
+ "32": "疑问",
+ "33": "嘘",
+ "34": "晕",
+ "38": "敲打",
+ "39": "再见",
+ "41": "发抖",
+ "42": "爱情",
+ "43": "跳跳",
+ "49": "拥抱",
+ "53": "蛋糕",
+ "60": "咖啡",
+ "63": "玫瑰",
+ "66": "爱心",
+ "74": "太阳",
+ "75": "月亮",
+ "76": "赞",
+ "78": "握手",
+ "79": "胜利",
+ "85": "飞吻",
+ "89": "西瓜",
+ "96": "冷汗",
+ "97": "擦汗",
+ "98": "抠鼻",
+ "99": "鼓掌",
+ "100": "糗大了",
+ "101": "坏笑",
+ "102": "左哼哼",
+ "103": "右哼哼",
+ "104": "哈欠",
+ "106": "委屈",
+ "109": "左亲亲",
+ "111": "可怜",
+ "116": "示爱",
+ "118": "抱拳",
+ "120": "拳头",
+ "122": "爱你",
+ "123": "NO",
+ "124": "OK",
+ "125": "转圈",
+ "129": "挥手",
+ "144": "喝彩",
+ "147": "棒棒糖",
+ "171": "茶",
+ "173": "泪奔",
+ "174": "无奈",
+ "175": "卖萌",
+ "176": "小纠结",
+ "179": "doge",
+ "180": "惊喜",
+ "181": "骚扰",
+ "182": "笑哭",
+ "183": "我最美",
+ "201": "点赞",
+ "203": "托脸",
+ "212": "托腮",
+ "214": "啵啵",
+ "219": "蹭一蹭",
+ "222": "抱抱",
+ "227": "拍手",
+ "232": "佛系",
+ "240": "喷脸",
+ "243": "甩头",
+ "246": "加油抱抱",
+ "262": "脑阔疼",
+ "264": "捂脸",
+ "265": "辣眼睛",
+ "266": "哦哟",
+ "267": "头秃",
+ "268": "问号脸",
+ "269": "暗中观察",
+ "270": "emm",
+ "271": "吃瓜",
+ "272": "呵呵哒",
+ "273": "我酸了",
+ "277": "汪汪",
+ "278": "汗",
+ "281": "无眼笑",
+ "282": "敬礼",
+ "284": "面无表情",
+ "285": "摸鱼",
+ "287": "哦",
+ "289": "睁眼",
+ "290": "敲开心",
+ "293": "摸锦鲤",
+ "294": "期待",
+ "297": "拜谢",
+ "298": "元宝",
+ "299": "牛啊",
+ "305": "右亲亲",
+ "306": "牛气冲天",
+ "307": "喵喵",
+ "314": "仔细分析",
+ "315": "加油",
+ "318": "崇拜",
+ "319": "比心",
+ "320": "庆祝",
+ "322": "拒绝",
+ "324": "吃糖",
+ "326": "生气",
+ # Unicode Emoji (Type 2)
+ "9728": "☀",
+ "9749": "☕",
+ "9786": "☺",
+ "10024": "✨",
+ "10060": "❌",
+ "10068": "❔",
+ "127801": "🌹",
+ "127817": "🍉",
+ "127822": "🍎",
+ "127827": "🍓",
+ "127836": "🍜",
+ "127838": "🍞",
+ "127847": "🍧",
+ "127866": "🍺",
+ "127867": "🍻",
+ "127881": "🎉",
+ "128027": "🐛",
+ "128046": "🐮",
+ "128051": "🐳",
+ "128053": "🐵",
+ "128074": "👊",
+ "128076": "👌",
+ "128077": "👍",
+ "128079": "👏",
+ "128089": "👙",
+ "128102": "👦",
+ "128104": "👨",
+ "128147": "💓",
+ "128157": "💝",
+ "128164": "💤",
+ "128166": "💦",
+ "128168": "💨",
+ "128170": "💪",
+ "128235": "📫",
+ "128293": "🔥",
+ "128513": "😁",
+ "128514": "😂",
+ "128516": "😄",
+ "128522": "😊",
+ "128524": "😌",
+ "128527": "😏",
+ "128530": "😒",
+ "128531": "😓",
+ "128532": "😔",
+ "128536": "😘",
+ "128538": "😚",
+ "128540": "😜",
+ "128541": "😝",
+ "128557": "😭",
+ "128560": "😰",
+ "128563": "😳",
+ }
+
+ for like in likes:
+ emoji_id = like.get("emoji_id", "")
+ count = like.get("count", 1)
+ emoji = emoji_map.get(emoji_id, f"表情{emoji_id}")
+ if count > 1:
+ emoji_texts.append(f"{emoji}x{count}")
+ else:
+ emoji_texts.append(emoji)
+
+ emoji_str = "、".join(emoji_texts) if emoji_texts else "未知表情"
+ display_name = user_cardname if user_cardname and user_cardname != "QQ用户" else user_name
+
+ # 构建消息文本
+ message_text = f"{display_name} 对消息(ID:{message_id})表达了 {emoji_str}"
+
+ user_info = UserInfo(
+ platform=global_config.maibot_server.platform_name,
+ user_id=user_id,
+ user_nickname=user_name,
+ user_cardname=user_cardname,
+ )
+
+ seg_data = Seg(type="text", data=message_text)
+ 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不能为空,无法处理禁言通知")
@@ -512,5 +860,256 @@ class NoticeHandler:
await unsuccessful_notice_queue.put(to_be_send)
await asyncio.sleep(1)
+ async def handle_group_upload_notify(
+ self, raw_message: dict, group_id: int, user_id: int
+ ) -> Tuple[Seg | None, UserInfo | None]:
+ """
+ 处理群文件上传通知
+ """
+ file_info: dict = raw_message.get("file", {})
+ file_name = file_info.get("name", "未知文件")
+ file_size = file_info.get("size", 0)
+ file_id = file_info.get("id", "")
+
+ user_qq_info: dict = await get_member_info(self.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:
+ logger.warning("无法获取上传者信息")
+ user_name = "QQ用户"
+ user_cardname = None
+
+ user_info = UserInfo(
+ platform=global_config.maibot_server.platform_name,
+ user_id=user_id,
+ user_nickname=user_name,
+ user_cardname=user_cardname,
+ )
+
+ # 格式化文件大小
+ if file_size < 1024:
+ size_str = f"{file_size}B"
+ elif file_size < 1024 * 1024:
+ size_str = f"{file_size / 1024:.2f}KB"
+ else:
+ size_str = f"{file_size / (1024 * 1024):.2f}MB"
+
+ notify_seg = Seg(
+ type="notify",
+ data={
+ "sub_type": "group_upload",
+ "file_name": file_name,
+ "file_size": size_str,
+ "file_id": file_id,
+ },
+ )
+
+ return notify_seg, user_info
+
+ async def handle_group_increase_notify(
+ self, raw_message: dict, group_id: int, user_id: int
+ ) -> Tuple[Seg | None, UserInfo | None]:
+ """
+ 处理群成员增加通知
+ """
+ sub_type = raw_message.get("sub_type")
+ operator_id = raw_message.get("operator_id")
+
+ # 获取新成员信息
+ user_qq_info: dict = await get_member_info(self.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:
+ logger.warning("无法获取新成员信息")
+ user_name = "QQ用户"
+ user_cardname = None
+
+ user_info = UserInfo(
+ platform=global_config.maibot_server.platform_name,
+ user_id=user_id,
+ user_nickname=user_name,
+ user_cardname=user_cardname,
+ )
+
+ # 获取操作者信息
+ operator_name = "未知"
+ if operator_id:
+ operator_info: dict = await get_member_info(self.server_connection, group_id, operator_id)
+ if operator_info:
+ operator_name = operator_info.get("card") or operator_info.get("nickname", "未知")
+
+ if sub_type == NoticeType.GroupIncrease.invite:
+ action_text = f"被 {operator_name} 邀请"
+ elif sub_type == NoticeType.GroupIncrease.approve:
+ action_text = f"经 {operator_name} 同意"
+ else:
+ action_text = "加入"
+
+ notify_seg = Seg(
+ type="notify",
+ data={
+ "sub_type": "group_increase",
+ "action": action_text,
+ "increase_type": sub_type,
+ "operator_id": operator_id,
+ },
+ )
+
+ return notify_seg, user_info
+
+ async def handle_group_decrease_notify(
+ self, raw_message: dict, group_id: int, user_id: int
+ ) -> Tuple[Seg | None, UserInfo | None]:
+ """
+ 处理群成员减少通知
+ """
+ sub_type = raw_message.get("sub_type")
+ operator_id = raw_message.get("operator_id")
+
+ # 获取离开成员信息
+ user_qq_info: dict = await get_member_info(self.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:
+ logger.warning("无法获取离开成员信息")
+ user_name = "QQ用户"
+ user_cardname = None
+
+ user_info = UserInfo(
+ platform=global_config.maibot_server.platform_name,
+ user_id=user_id,
+ user_nickname=user_name,
+ user_cardname=user_cardname,
+ )
+
+ # 获取操作者信息
+ operator_name = "未知"
+ if operator_id and operator_id != 0:
+ operator_info: dict = await get_member_info(self.server_connection, group_id, operator_id)
+ if operator_info:
+ operator_name = operator_info.get("card") or operator_info.get("nickname", "未知")
+
+ if sub_type == NoticeType.GroupDecrease.leave:
+ action_text = "主动退群"
+ elif sub_type == NoticeType.GroupDecrease.kick:
+ action_text = f"被 {operator_name} 踢出"
+ elif sub_type == NoticeType.GroupDecrease.kick_me:
+ action_text = "机器人被踢出"
+ else:
+ action_text = "离开群聊"
+
+ notify_seg = Seg(
+ type="notify",
+ data={
+ "sub_type": "group_decrease",
+ "action": action_text,
+ "decrease_type": sub_type,
+ "operator_id": operator_id,
+ },
+ )
+
+ return notify_seg, user_info
+
+ async def handle_group_admin_notify(
+ self, raw_message: dict, group_id: int, user_id: int
+ ) -> Tuple[Seg | None, UserInfo | None]:
+ """
+ 处理群管理员变动通知
+ """
+ sub_type = raw_message.get("sub_type")
+
+ # 获取目标用户信息
+ user_qq_info: dict = await get_member_info(self.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:
+ logger.warning("无法获取目标用户信息")
+ user_name = "QQ用户"
+ user_cardname = None
+
+ user_info = UserInfo(
+ platform=global_config.maibot_server.platform_name,
+ user_id=user_id,
+ user_nickname=user_name,
+ user_cardname=user_cardname,
+ )
+
+ if sub_type == NoticeType.GroupAdmin.set:
+ action_text = "被设置为管理员"
+ elif sub_type == NoticeType.GroupAdmin.unset:
+ action_text = "被取消管理员"
+ else:
+ action_text = "管理员变动"
+
+ notify_seg = Seg(
+ type="notify",
+ data={
+ "sub_type": "group_admin",
+ "action": action_text,
+ "admin_type": sub_type,
+ },
+ )
+
+ return notify_seg, user_info
+
+ async def handle_essence_notify(
+ self, raw_message: dict, group_id: int
+ ) -> Tuple[Seg | None, UserInfo | None]:
+ """
+ 处理精华消息通知
+ """
+ sub_type = raw_message.get("sub_type")
+ sender_id = raw_message.get("sender_id")
+ operator_id = raw_message.get("operator_id")
+ message_id = raw_message.get("message_id")
+
+ # 获取操作者信息(设置精华的人)
+ operator_info: dict = await get_member_info(self.server_connection, group_id, operator_id)
+ if operator_info:
+ operator_name = operator_info.get("nickname")
+ operator_cardname = operator_info.get("card")
+ else:
+ logger.warning("无法获取操作者信息")
+ operator_name = "QQ用户"
+ operator_cardname = None
+
+ user_info = UserInfo(
+ platform=global_config.maibot_server.platform_name,
+ user_id=operator_id,
+ user_nickname=operator_name,
+ user_cardname=operator_cardname,
+ )
+
+ # 获取消息发送者信息
+ sender_name = "未知用户"
+ if sender_id:
+ sender_info: dict = await get_member_info(self.server_connection, group_id, sender_id)
+ if sender_info:
+ sender_name = sender_info.get("card") or sender_info.get("nickname", "未知用户")
+
+ if sub_type == NoticeType.Essence.add:
+ action_text = f"将 {sender_name} 的消息设为精华"
+ elif sub_type == NoticeType.Essence.delete:
+ action_text = f"移除了 {sender_name} 的精华消息"
+ else:
+ action_text = "精华消息变动"
+
+ notify_seg = Seg(
+ type="notify",
+ data={
+ "sub_type": "essence",
+ "action": action_text,
+ "essence_type": sub_type,
+ "sender_id": sender_id,
+ "message_id": message_id,
+ },
+ )
+
+ return notify_seg, user_info
+
notice_handler = NoticeHandler()
diff --git a/uv.lock b/uv.lock
new file mode 100644
index 0000000..8c87f71
--- /dev/null
+++ b/uv.lock
@@ -0,0 +1,8 @@
+version = 1
+revision = 2
+requires-python = ">=3.13"
+
+[[package]]
+name = "maibotnapcatadapter"
+version = "0.5.5"
+source = { virtual = "." }