From 86e04638a26bab07eb0040d6d581a7da34cac176 Mon Sep 17 00:00:00 2001 From: Windpicker-owo <3431391539@qq.com> Date: Thu, 27 Nov 2025 23:18:05 +0800 Subject: [PATCH 01/12] =?UTF-8?q?fix:=20=E7=A7=BB=E9=99=A4=E8=BF=81?= =?UTF-8?q?=E7=A7=BB=E6=95=B0=E6=8D=AE=E4=B8=AD=E7=9A=84=20NUL=20=E5=AD=97?= =?UTF-8?q?=E7=AC=A6=E5=B9=B6=E8=AE=B0=E5=BD=95=E8=AD=A6=E5=91=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/migrate_database.py | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/scripts/migrate_database.py b/scripts/migrate_database.py index 477608026..90ff89493 100644 --- a/scripts/migrate_database.py +++ b/scripts/migrate_database.py @@ -400,9 +400,18 @@ def migrate_table_data( error_count += len(rows) batch: list[dict] = [] + null_char_replacements = 0 + for row in result: # 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} + row_dict = {} + for col in source_table.columns: + val = row._mapping[col] + if isinstance(val, str) and "\x00" in val: + val = val.replace("\x00", "") + null_char_replacements += 1 + row_dict[col.key] = val + batch.append(row_dict) if len(batch) >= batch_size: insert_batch(batch) @@ -417,6 +426,12 @@ def migrate_table_data( migrated_rows, error_count, ) + if null_char_replacements: + logger.warning( + "表 %s 中 %d 个字符串值包含 NUL 已被移除后写入目标库", + source_table.name, + null_char_replacements, + ) return migrated_rows, error_count From c9c6a11593e9ee1e797b40942fc9c0504e5491b3 Mon Sep 17 00:00:00 2001 From: Windpicker-owo <3431391539@qq.com> Date: Fri, 28 Nov 2025 00:08:51 +0800 Subject: [PATCH 02/12] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E8=BF=81=E7=A7=BB=E4=B8=AD=E7=9A=84=E4=BA=8B=E5=8A=A1?= =?UTF-8?q?=E5=A4=84=E7=90=86=EF=BC=8C=E7=A1=AE=E4=BF=9D=E6=AF=8F=E5=BC=A0?= =?UTF-8?q?=E8=A1=A8=E7=9A=84=E8=BF=81=E7=A7=BB=E5=9C=A8=E7=8B=AC=E7=AB=8B?= =?UTF-8?q?=E4=BA=8B=E5=8A=A1=E4=B8=AD=E8=BF=9B=E8=A1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/migrate_database.py | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/scripts/migrate_database.py b/scripts/migrate_database.py index 90ff89493..9b13cd675 100644 --- a/scripts/migrate_database.py +++ b/scripts/migrate_database.py @@ -663,20 +663,21 @@ class DatabaseMigrator: self._drop_target_tables() # 开始迁移 - with self.source_engine.connect() as source_conn, self.target_engine.connect() as target_conn: + with self.source_engine.connect() as source_conn: for source_table in tables: try: # 在目标库中创建表结构 target_table = copy_table_structure(source_table, MetaData(), self.target_engine) - # 迁移数据 - migrated_rows, error_count = migrate_table_data( - source_conn, - target_conn, - source_table, - target_table, - batch_size=self.batch_size, - ) + # 每张表单独事务,避免退出上下文被自动回滚 + with self.target_engine.begin() as target_conn: + migrated_rows, error_count = migrate_table_data( + source_conn, + target_conn, + source_table, + target_table, + batch_size=self.batch_size, + ) self.stats["tables_migrated"] += 1 self.stats["rows_migrated"] += migrated_rows From ac017986fd571240a5ae40cb443c5b4cfe158211 Mon Sep 17 00:00:00 2001 From: Windpicker-owo <3431391539@qq.com> Date: Fri, 28 Nov 2025 00:29:44 +0800 Subject: [PATCH 03/12] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E9=87=8D?= =?UTF-8?q?=E7=BD=AE=20PostgreSQL=20=E5=BA=8F=E5=88=97=E5=80=BC=E7=9A=84?= =?UTF-8?q?=E8=84=9A=E6=9C=AC=EF=BC=8C=E8=87=AA=E5=8A=A8=E6=A3=80=E6=B5=8B?= =?UTF-8?q?=E5=B9=B6=E9=87=8D=E7=BD=AE=E6=89=80=E6=9C=89=E5=BA=8F=E5=88=97?= =?UTF-8?q?=E4=BB=A5=E9=81=BF=E5=85=8D=E4=B8=BB=E9=94=AE=E5=86=B2=E7=AA=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/reset_pg_sequences.py | 77 +++++++++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 scripts/reset_pg_sequences.py diff --git a/scripts/reset_pg_sequences.py b/scripts/reset_pg_sequences.py new file mode 100644 index 000000000..cd36091f0 --- /dev/null +++ b/scripts/reset_pg_sequences.py @@ -0,0 +1,77 @@ +#!/usr/bin/env python3 +"""重置 PostgreSQL 序列值 + +迁移数据后,PostgreSQL 的序列(用于自增主键)可能没有更新到正确的值, +导致插入新记录时出现主键冲突。此脚本会自动检测并重置所有序列。 + +使用方法: + python scripts/reset_pg_sequences.py --host localhost --port 5432 --database maibot --user postgres --password your_password +""" + +import argparse +import psycopg + + +def reset_sequences(host: str, port: int, database: str, user: str, password: str): + """重置所有序列值""" + conn_str = f"host={host} port={port} dbname={database} user={user} password={password}" + + print(f"连接到 PostgreSQL: {host}:{port}/{database}") + conn = psycopg.connect(conn_str) + conn.autocommit = True + + # 查询所有序列及其关联的表和列 + query = """ + SELECT + t.relname AS table_name, + a.attname AS column_name, + s.relname AS sequence_name + FROM pg_class s + JOIN pg_depend d ON d.objid = s.oid + JOIN pg_class t ON d.refobjid = t.oid + JOIN pg_attribute a ON (d.refobjid, d.refobjsubid) = (a.attrelid, a.attnum) + WHERE s.relkind = 'S' + """ + + cursor = conn.execute(query) + sequences = cursor.fetchall() + + print(f"发现 {len(sequences)} 个序列") + + reset_count = 0 + for table_name, col_name, seq_name in sequences: + try: + # 获取当前最大 ID + max_result = conn.execute(f'SELECT MAX("{col_name}") FROM "{table_name}"') + max_id = max_result.fetchone()[0] + + if max_id is not None: + # 重置序列 + conn.execute(f"SELECT setval('{seq_name}', {max_id}, true)") + print(f" ✓ {seq_name} -> {max_id}") + reset_count += 1 + else: + print(f" - {seq_name}: 表为空,跳过") + + except Exception as e: + print(f" ✗ {table_name}.{col_name}: {e}") + + conn.close() + print(f"\n✅ 重置完成!共重置 {reset_count} 个序列") + + +def main(): + parser = argparse.ArgumentParser(description="重置 PostgreSQL 序列值") + parser.add_argument("--host", default="localhost", help="PostgreSQL 主机") + parser.add_argument("--port", type=int, default=5432, help="PostgreSQL 端口") + parser.add_argument("--database", default="maibot", help="数据库名") + parser.add_argument("--user", default="postgres", help="用户名") + parser.add_argument("--password", required=True, help="密码") + + args = parser.parse_args() + + reset_sequences(args.host, args.port, args.database, args.user, args.password) + + +if __name__ == "__main__": + main() From 252379db52650f2c154d5a69eac63c5a1fba276a Mon Sep 17 00:00:00 2001 From: tt-P607 <68868379+tt-P607@users.noreply.github.com> Date: Fri, 28 Nov 2025 00:47:03 +0800 Subject: [PATCH 04/12] =?UTF-8?q?refactor(prompt):=20=E9=80=9A=E8=BF=87?= =?UTF-8?q?=E7=A7=BB=E9=99=A4=E6=96=B0=E8=AF=9D=E9=A2=98=E7=94=9F=E6=88=90?= =?UTF-8?q?=E7=AE=80=E5=8C=96=E4=B8=BB=E5=8A=A8=E8=81=8A=E5=A4=A9=E9=80=BB?= =?UTF-8?q?=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 从群聊和私聊的主动互动决策提示中移除了“开启新话题”选项。此更改使机器人行为更加简化,专注于继续现有对话和表达关心,避免引入可能不相关或突兀的话题。 “新话题头脑风暴”步骤已被移除,`throw_topic` 的适用场景现在仅限于继续对话或表达关心。此外,推荐的消息长度已缩短至15-25个字符,以鼓励更简洁自然的互动。 --- .../proactive/proactive_thinking_executor.py | 20 ++++--------------- 1 file changed, 4 insertions(+), 16 deletions(-) diff --git a/src/plugins/built_in/affinity_flow_chatter/proactive/proactive_thinking_executor.py b/src/plugins/built_in/affinity_flow_chatter/proactive/proactive_thinking_executor.py index 7b9e96867..6da21b8bd 100644 --- a/src/plugins/built_in/affinity_flow_chatter/proactive/proactive_thinking_executor.py +++ b/src/plugins/built_in/affinity_flow_chatter/proactive/proactive_thinking_executor.py @@ -60,10 +60,9 @@ decision_prompt_template_group = Prompt( - 心情影响:心情会影响你冒泡的方式和内容。 **选项3:发起一次有目的的互动 (throw_topic)** -- 适用场景:你想延续对话、表达关心、或深入讨论某个具体话题。 +- 适用场景:你想延续对话或表达关心。 - **【互动类型1:延续约定或提醒】(最高优先级)**:检查最近的聊天记录,是否存在可以延续的互动。例如,如果昨晚的最后一条消息是“晚安”,现在是早上,一个“早安”的回应是绝佳的选择。如果之前提到过某个约定(如“待会聊”),现在可以主动跟进。 - **【互动类型2:展现真诚的关心】(次高优先级)**:如果不存在可延续的约定,请仔细阅读聊天记录,寻找**群友**提及的个人状况(如天气、出行、身体、情绪、工作学习等),并主动表达关心。 -- **【互动类型3:开启新话题】**:当以上两点都不适用时,可以考虑开启一个你感兴趣的新话题。 - 心情影响:心情会影响你想发起互动的方式和内容。 请以JSON格式回复你的决策: @@ -138,14 +137,9 @@ throw_topic_reply_prompt_template_group = Prompt( - 如果意图是**延续约定**(如回应“晚安”),请直接生成对应的问候。 - 如果意图是**表达关心**(如跟进群友提到的事),请生成自然、真诚的关心话语。 -- 如果意图是**开启新话题**:请严格遵守以下“新话题构思三步法”: - 1. **寻找灵感**:**首选**从【最近的聊天记录】中寻找一个可以自然延续的**生活化**细节。**严禁**引入与聊天记录完全无关的、凭空出现的话题。如果记录为空,可以根据你的【人设】,提出一个**非常普适、开放式**的生活化问题或感想。 - 2. **确定风格**:请**确保新话题与最近的聊天内容有关联**,自然地引入话题,避免过于跳脱。 - 3. **最终检查**:你提出的话题是否合理?是否贴近现实和聊天内容?说话方式是否正常?是否像一个真正的人类? - 请根据这个意图,生成一条消息,要求: 1. 要与最近的聊天记录相关,自然地引入话题或表达关心。 -2. 长度适中(20-40字)。 +2. 长度适中(15-25字左右)。 3. 结合最近的聊天记录确保对话连贯,不要显得突兀。 4. 符合你的人设和当前聊天风格。 5. **你的心情会影响你的表达方式**。 @@ -189,10 +183,9 @@ decision_prompt_template_private = Prompt( - 心情影响:心情会影响你问候的方式和内容。 **选项3:发起一次有目的的互动 (throw_topic)** -- 适用场景:你想延续对话、表达关心、或深入讨论某个具体话题。 +- 适用场景:你想延续对话或表达关心。 - **【互动类型1:延续约定或提醒】(最高优先级)**:检查最近的聊天记录,是否存在可以延续的互动。例如,如果昨晚的最后一条消息是“晚安”,现在是早上,一个“早安”的回应是绝佳的选择。如果之前提到过某个约定(如“待会聊”),现在可以主动跟进。 - **【互动类型2:展现真诚的关心】(次高优先级)**:如果不存在可延续的约定,请仔细阅读聊天记录,寻找**对方**提及的个人状况(如天气、出行、身体、情绪、工作学习等),并主动表达关心。 -- **【互动类型3:开启新话题】**:当以上两点都不适用时,可以考虑开启一个你感兴趣的新话题。 - 心情影响:心情会影响你想发起互动的方式和内容。 请以JSON格式回复你的决策: @@ -266,14 +259,9 @@ throw_topic_reply_prompt_template_private = Prompt( 请根据你的互动意图,并参考最近的聊天记录,生成一条有温度的、**适合在私聊中说**的消息。 - 如果意图是**延续约定**(如回应“晚安”),请直接生成对应的问候。 - 如果意ت意图是**表达关心**(如跟进对方提到的事),请生成自然、真诚的关心话语。 -- 如果意图是**开启新话题**:请严格遵守以下“新话题构思三步法”: - 1. **寻找灵感**:**首选**从【最近的聊天记录】中寻找一个可以自然延续的**生活化**细节。**严禁**引入与聊天记录完全无关的、凭空出现的话题。如果记录为空,可以根据你的【人设】,提出一个**非常普适、开放式**的生活化问题或感想。 - 2. **确定风格**:请**确保新话题与最近的聊天内容有关联**,自然地引入话题,避免过于跳脱。 - 3. **最终检查**:你提出的话题是否合理?是否贴近现实和聊天内容?说话方式是否正常?是否像一个真正的人类? - 请根据这个意图,生成一条消息,要求: 1. 要与最近的聊天记录相关,自然地引入话题或表达关心。 -2. 长度适中(20-40字)。 +2. 长度适中(15-25字左右)。 3. 结合最近的聊天记录确保对话连贯,不要显得突兀。 4. 符合你的人设和当前聊天风格。 5. **你的心情会影响你的表达方式**。 From e5117720c6bb764e69648a0d1a73c58a3f5ee246 Mon Sep 17 00:00:00 2001 From: tt-P607 <68868379+tt-P607@users.noreply.github.com> Date: Fri, 28 Nov 2025 02:54:47 +0800 Subject: [PATCH 05/12] =?UTF-8?q?feat(chat):=20=E9=80=9A=E8=BF=87=E5=8A=A8?= =?UTF-8?q?=E4=BD=9C=E5=8F=82=E6=95=B0=E5=AE=9E=E7=8E=B0=E4=B8=93=E7=94=A8?= =?UTF-8?q?=E7=9A=84=20@=E7=94=A8=E6=88=B7=20=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 此提交引入了一种在回复中提及(@)用户的稳健机制。此功能不再由 LLM 直接生成“@”文本,而是通过 `reply` 和 `respond` 动作中新增加的 `at_user_id` 参数来处理。 主要变化包括: - **核心动作:** 在 `ReplyAction` 和 `RespondAction` 中添加可选的 `at_user_id` 参数,用于指定要提及的用户。 - **提示工程:** 更新了 `default_generator` 提示,以指导 LLM 如何使用新的 `at_user_id` 参数,而不是生成“@”文本。 - **动作管理器:** `ChatterActionManager` 现在会检查动作数据中的 `at_user_id`。如果存在,它会使用新的 `SEND_AT_MESSAGE` 命令,确保提及格式正确并发送。 - **发送 API:** 在 `send_api` 中引入新的 `at_user_to_stream` 函数,用于处理发送独立的 `@` 段落。 - **类型安全与重构:** 改进了类型提示跨多个文件(`mofox_wire` 类型、`cast` 等)进行了修改,并增加了对 `global_config` 是否存在的检查,以防止运行时可能出现的 `NoneType` 错误,从而提高了整体代码的稳定性。 --- src/chat/message_receive/message_handler.py | 40 +++-- src/chat/planner_actions/action_manager.py | 48 +++++- src/chat/replyer/default_generator.py | 8 + src/plugin_system/apis/send_api.py | 90 +++++++++-- src/plugins/built_in/core_actions/plugin.py | 2 +- src/plugins/built_in/core_actions/reply.py | 4 + .../src/handlers/to_core/message_handler.py | 67 ++++---- .../src/handlers/to_napcat/send_handler.py | 150 ++++++++---------- 8 files changed, 257 insertions(+), 152 deletions(-) diff --git a/src/chat/message_receive/message_handler.py b/src/chat/message_receive/message_handler.py index a7238c2fd..92ee4f779 100644 --- a/src/chat/message_receive/message_handler.py +++ b/src/chat/message_receive/message_handler.py @@ -30,16 +30,16 @@ from __future__ import annotations import os import re import traceback -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, cast -from mofox_wire import MessageEnvelope, MessageRuntime +from mofox_wire import MessageEnvelope from src.chat.message_manager import message_manager from src.chat.message_receive.storage import MessageStorage from src.chat.utils.utils import is_mentioned_bot_in_message from src.common.data_models.database_data_model import DatabaseGroupInfo, DatabaseMessages, DatabaseUserInfo from src.common.logger import get_logger -from src.config.config import global_config +from src.config.config import Config, global_config from src.mood.mood_manager import mood_manager from src.plugin_system.base import BaseCommand, EventType from src.plugin_system.core import component_registry, event_manager, global_announcement_manager @@ -48,6 +48,10 @@ if TYPE_CHECKING: from src.chat.message_receive.chat_stream import ChatStream from src.common.core_sink_manager import CoreSinkManager +if TYPE_CHECKING: + from mofox_wire import MessageRuntime + global_config: Config | None + logger = get_logger("message_handler") # 项目根目录 @@ -55,7 +59,13 @@ PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "../..")) def _check_ban_words(text: str, chat: "ChatStream", userinfo) -> bool: """检查消息是否包含过滤词""" - for word in global_config.message_receive.ban_words: + if global_config: + for word in global_config.message_receive.ban_words: + if word in text: + chat_name = chat.group_info.group_name if chat.group_info else "私聊" + logger.info(f"[{chat_name}]{userinfo.user_nickname}:{text}") + logger.info(f"[过滤词识别]消息中含有{word},filtered") + return True if word in text: chat_name = chat.group_info.group_name if chat.group_info else "私聊" logger.info(f"[{chat_name}]{userinfo.user_nickname}:{text}") @@ -66,7 +76,13 @@ def _check_ban_words(text: str, chat: "ChatStream", userinfo) -> bool: def _check_ban_regex(text: str, chat: "ChatStream", userinfo) -> bool: """检查消息是否匹配过滤正则表达式""" - for pattern in global_config.message_receive.ban_msgs_regex: + if global_config: + for pattern in global_config.message_receive.ban_msgs_regex: + if re.search(pattern, text): + chat_name = chat.group_info.group_name if chat.group_info else "私聊" + logger.info(f"[{chat_name}]{userinfo.user_nickname}:{text}") + logger.info(f"[正则表达式过滤]消息匹配到{pattern},filtered") + return True if re.search(pattern, text): chat_name = chat.group_info.group_name if chat.group_info else "私聊" logger.info(f"[{chat_name}]{userinfo.user_nickname}:{text}") @@ -100,13 +116,13 @@ class MessageHandler: self._message_manager_started = False self._core_sink_manager: CoreSinkManager | None = None self._shutting_down = False - self._runtime: MessageRuntime | None = None + self._runtime: "MessageRuntime | None" = None def set_core_sink_manager(self, manager: "CoreSinkManager") -> None: """设置 CoreSinkManager 引用""" self._core_sink_manager = manager - def register_handlers(self, runtime: MessageRuntime) -> None: + def register_handlers(self, runtime: "MessageRuntime") -> None: """ 向 MessageRuntime 注册消息处理器和钩子 @@ -281,7 +297,7 @@ class MessageHandler: chat = await get_chat_manager().get_or_create_stream( platform=platform, user_info=DatabaseUserInfo.from_dict(user_info) if user_info else None, # type: ignore - group_info=DatabaseGroupInfo.from_dict(group_info) if group_info else None, + group_info=DatabaseGroupInfo.from_dict(cast(dict, group_info)) if group_info else None, ) # 将消息信封转换为 DatabaseMessages @@ -431,7 +447,7 @@ class MessageHandler: chat = await get_chat_manager().get_or_create_stream( platform=platform, user_info=DatabaseUserInfo.from_dict(user_info) if user_info else None, # type: ignore - group_info=DatabaseGroupInfo.from_dict(group_info) if group_info else None, + group_info=DatabaseGroupInfo.from_dict(cast(dict, group_info)) if group_info else None, ) # 将消息信封转换为 DatabaseMessages @@ -535,7 +551,7 @@ class MessageHandler: text = message.processed_plain_text or "" # 获取配置的命令前缀 - prefixes = global_config.command.command_prefixes + prefixes = global_config.command.command_prefixes if global_config else [] # 检查是否以任何前缀开头 matched_prefix = None @@ -707,7 +723,7 @@ class MessageHandler: # 检查是否需要处理消息 should_process_in_manager = True - if group_info and str(group_info.group_id) in global_config.message_receive.mute_group_list: + if global_config and group_info and str(group_info.group_id) in global_config.message_receive.mute_group_list: is_image_or_emoji = message.is_picid or message.is_emoji if not message.is_mentioned and not is_image_or_emoji: logger.debug( @@ -731,7 +747,7 @@ class MessageHandler: # 情绪系统更新 try: - if global_config.mood.enable_mood: + if global_config and global_config.mood.enable_mood: interest_rate = message.interest_value or 0.0 logger.debug(f"开始更新情绪状态,兴趣度: {interest_rate:.2f}") diff --git a/src/chat/planner_actions/action_manager.py b/src/chat/planner_actions/action_manager.py index ef8b24657..58f920237 100644 --- a/src/chat/planner_actions/action_manager.py +++ b/src/chat/planner_actions/action_manager.py @@ -104,7 +104,7 @@ class ChatterActionManager: log_prefix=log_prefix, shutting_down=shutting_down, plugin_config=plugin_config, - action_message=action_message, + action_message=action_message.flatten() if action_message else None, ) logger.debug(f"创建Action实例成功: {action_name}") @@ -252,7 +252,7 @@ class ChatterActionManager: # 检查目标消息是否为表情包消息以及配置是否允许回复表情包 if target_message and getattr(target_message, "is_emoji", False): # 如果是表情包消息且配置不允许回复表情包,则跳过回复 - if not getattr(global_config.chat, "allow_reply_to_emoji", True): + if global_config and not getattr(global_config.chat, "allow_reply_to_emoji", True): logger.info(f"{log_prefix} 目标消息为表情包且配置不允许回复表情包,跳过回复") return {"action_type": action_name, "success": True, "reply_text": "", "skip_reason": "emoji_not_allowed"} @@ -288,7 +288,7 @@ class ChatterActionManager: reply_message=target_message, action_data=action_data_with_mode, available_actions=current_actions, # type: ignore - enable_tool=global_config.tool.enable_tool, + enable_tool=global_config.tool.enable_tool if global_config else False, request_type="chat.replyer", from_plugin=False, ) @@ -325,6 +325,7 @@ class ChatterActionManager: thinking_id, [], # actions should_quote_reply, # 传递should_quote_reply参数 + action_data=action_data or {} ) # 记录回复动作到目标消息 @@ -492,6 +493,7 @@ class ChatterActionManager: thinking_id, actions, should_quote_reply: bool | None = None, + action_data: dict | None = None ) -> tuple[str, dict[str, float]]: """ 发送并存储回复信息 @@ -509,11 +511,39 @@ class ChatterActionManager: Returns: Tuple[Dict[str, Any], str, Dict[str, float]]: 循环信息, 回复文本, 循环计时器 """ - # 发送回复 - with Timer("回复发送", cycle_timers): - reply_text = await self.send_response( - chat_stream, response_set, loop_start_time, action_message, should_quote_reply + # 提取回复文本 + reply_text = "" + for reply_seg in response_set: + if isinstance(reply_seg, tuple) and len(reply_seg) >= 2: + _, data = reply_seg + else: + data = str(reply_seg) + if isinstance(data, list): + data = "".join(map(str, data)) + reply_text += data + + # 检查是否需要@用户 + at_user_id = action_data.get("at_user_id") if action_data else None + if at_user_id and chat_stream.group_info: + logger.info(f"检测到需要@用户: {at_user_id},将使用 SEND_AT_MESSAGE 命令发送") + from src.plugins.built_in.napcat_adapter.src.event_models import CommandType + command_payload = { + "name": CommandType.SEND_AT_MESSAGE.name, + "args": { + "qq_id": str(at_user_id), + "text": reply_text + } + } + await send_api.command_to_stream( + command=command_payload, + stream_id=chat_stream.stream_id ) + else: + # 正常发送回复 + with Timer("回复发送", cycle_timers): + reply_text = await self.send_response( + chat_stream, response_set, loop_start_time, action_message, should_quote_reply, action_data + ) # 存储reply action信息 person_info_manager = get_person_info_manager() @@ -558,7 +588,7 @@ class ChatterActionManager: return reply_text, cycle_timers async def send_response( - self, chat_stream, reply_set, thinking_start_time, message_data, should_quote_reply: bool | None = None + self, chat_stream, reply_set, thinking_start_time, message_data, should_quote_reply: bool | None = None, action_data: dict | None = None ) -> str: """ 发送回复内容的具体实现 @@ -569,6 +599,7 @@ class ChatterActionManager: thinking_start_time: 思考开始时间 message_data: 消息数据 should_quote_reply: 是否应该引用回复原消息,None表示自动决定 + action_data: 动作数据,用于检查是否需要@ Returns: str: 完整的回复文本 @@ -597,6 +628,7 @@ class ChatterActionManager: logger.debug(f"[send_response] message_data: {message_data}") first_replied = False + for reply_seg in reply_set: # 调试日志:验证reply_seg的格式 logger.debug(f"Processing reply_seg type: {type(reply_seg)}, content: {reply_seg}") diff --git a/src/chat/replyer/default_generator.py b/src/chat/replyer/default_generator.py index fe9be0494..55fc23e12 100644 --- a/src/chat/replyer/default_generator.py +++ b/src/chat/replyer/default_generator.py @@ -38,6 +38,10 @@ from src.plugin_system.base.component_types import ActionInfo, EventType if TYPE_CHECKING: from src.chat.message_receive.chat_stream import ChatStream + from src.config.config import APIAdapterConfig, Config + + global_config: "Config" + model_config: "APIAdapterConfig" logger = get_logger("replyer") @@ -119,6 +123,10 @@ def init_prompt(): {action_descriptions} +- **关于@功能的重要说明**: + - 如果你需要在一个回复中`@`某个用户,**请不要**在你的回复内容中直接输出`@`符号或`艾特`等文字。 + - 你应该使用`reply`或`respond`动作中的`at_user_id`参数。只需要将目标的QQ号填入该参数,系统就会自动为你完成`@`操作。 + ## 任务 *{chat_scene}* diff --git a/src/plugin_system/apis/send_api.py b/src/plugin_system/apis/send_api.py index 3aac3d76c..9a596f444 100644 --- a/src/plugin_system/apis/send_api.py +++ b/src/plugin_system/apis/send_api.py @@ -92,10 +92,11 @@ import traceback import uuid from typing import TYPE_CHECKING, Any -from mofox_wire import MessageEnvelope +from mofox_wire import MessageEnvelope, MessageInfoPayload, SegPayload from src.common.data_models.database_data_model import DatabaseUserInfo if TYPE_CHECKING: from src.common.data_models.database_data_model import DatabaseMessages + from src.config.config import Config # 导入依赖 from src.chat.message_receive.chat_stream import ChatStream, get_chat_manager @@ -103,6 +104,9 @@ from src.chat.message_receive.uni_message_sender import HeartFCSender from src.common.logger import get_logger from src.config.config import global_config +if TYPE_CHECKING: + assert global_config is not None + # 日志记录器 logger = get_logger("send_api") @@ -193,32 +197,44 @@ def _build_message_envelope( ) -> MessageEnvelope: """构建发送的 MessageEnvelope 数据结构""" target_user_info = target_stream.user_info or bot_user_info - message_info: dict[str, Any] = { + message_info: MessageInfoPayload = { "message_id": message_id, "time": timestamp, "platform": target_stream.platform, "user_info": { - "user_id": target_user_info.user_id, - "user_nickname": target_user_info.user_nickname, - "user_cardname": getattr(target_user_info, "user_cardname", None), - "platform": target_user_info.platform, + "user_id": target_user_info.user_id or "", + "user_nickname": target_user_info.user_nickname or "", + "user_cardname": getattr(target_user_info, "user_cardname", "") or "", + "platform": target_user_info.platform or "", }, + "group_info": None, # type: ignore } if target_stream.group_info: message_info["group_info"] = { - "group_id": target_stream.group_info.group_id, - "group_name": target_stream.group_info.group_name, - "platform": target_stream.group_info.platform, + "group_id": target_stream.group_info.group_id or "", + "group_name": target_stream.group_info.group_name or "", + "platform": target_stream.group_info.platform or "", } - return { - "id": str(uuid.uuid4()), + # Ensure message_segment is of the correct type + seg_payload: SegPayload + if isinstance(message_segment, list): + seg_payload = {"type": "seglist", "data": message_segment} + elif isinstance(message_segment, dict) and "type" in message_segment and "data" in message_segment: + seg_payload = message_segment # type: ignore + else: + # Fallback for simple string content or other unexpected formats + seg_payload = {"type": "text", "data": str(message_segment)} + + + envelope: MessageEnvelope = { "direction": "outgoing", "platform": target_stream.platform, "message_info": message_info, - "message_segment": message_segment, + "message_segment": seg_payload, } + return envelope @@ -257,9 +273,18 @@ async def _send_to_target( current_time = time.time() message_id = f"send_api_{int(current_time * 1000)}" + # Use a safer way to get bot config + if not global_config: + logger.error("[SendAPI] Global config is not initialized!") + return False + bot_config = global_config.bot + if not bot_config: + logger.error("[SendAPI] Bot configuration not found!") + return False + bot_user_info = DatabaseUserInfo( - user_id=str(global_config.bot.qq_account), - user_nickname=global_config.bot.nickname, + user_id=str(bot_config.qq_account), + user_nickname=bot_config.nickname, platform=target_stream.platform, ) @@ -412,6 +437,30 @@ async def image_to_stream( ) +async def at_user_to_stream( + user_id: str, + stream_id: str, + display_text: str = "", + storage_message: bool = False, +) -> bool: + """向指定流发送@用户消息 + 这是一个特殊的、独立的段,通常用于在发送文本之前先发送@信息 + + Args: + user_id: 要@的用户的ID + stream_id: 聊天流ID + display_text: 在数据库中存储的显示文本 + storage_message: 是否存储此消息段到数据库 + + Returns: + bool: 是否发送成功 + """ + at_segment = {"qq": user_id} + return await _send_to_target( + "at", at_segment, stream_id, display_text, typing=False, storage_message=storage_message, set_reply=False + ) + + async def command_to_stream( command: str | dict, stream_id: str, @@ -518,9 +567,18 @@ async def adapter_command_to_stream( current_time = time.time() message_id = f"adapter_cmd_{int(current_time * 1000)}" + # Use a safer way to get bot config + if not global_config: + logger.error("[SendAPI] Global config is not initialized!") + return {"status": "error", "message": "Global config is not initialized"} + bot_config = global_config.bot + if not bot_config: + logger.error("[SendAPI] Bot configuration not found!") + return {"status": "error", "message": "Bot configuration not found"} + bot_user_info = DatabaseUserInfo( - user_id=str(global_config.bot.qq_account), - user_nickname=global_config.bot.nickname, + user_id=str(bot_config.qq_account), + user_nickname=bot_config.nickname, platform=target_stream.platform, ) diff --git a/src/plugins/built_in/core_actions/plugin.py b/src/plugins/built_in/core_actions/plugin.py index 5baaa3a8e..008e42b09 100644 --- a/src/plugins/built_in/core_actions/plugin.py +++ b/src/plugins/built_in/core_actions/plugin.py @@ -63,7 +63,7 @@ class CoreActionsPlugin(BasePlugin): """返回插件包含的组件列表""" # --- 根据配置注册组件 --- - components: ClassVar = [] + components = [] # 注册 reply 动作 if self.get_config("components.enable_reply", True): diff --git a/src/plugins/built_in/core_actions/reply.py b/src/plugins/built_in/core_actions/reply.py index 9a90f7e33..06d00a23d 100644 --- a/src/plugins/built_in/core_actions/reply.py +++ b/src/plugins/built_in/core_actions/reply.py @@ -37,6 +37,7 @@ class ReplyAction(BaseAction): "target_message_id": "要回复的目标消息ID(必需,来自未读消息的 标签)", "content": "回复的具体内容(可选,由LLM生成)", "should_quote_reply": "是否引用原消息(可选,true/false,默认false。群聊中回复较早消息或需要明确指向时使用true)", + "at_user_id": "需要@的用户的QQ号(可选,string)。如果需要在回复中@某个用户,请提供此参数。", } # 动作使用场景 @@ -47,6 +48,7 @@ class ReplyAction(BaseAction): "私聊场景必须使用此动作(不支持 respond)", "群聊中需要明确回应某个特定用户或问题时使用", "关注单条消息的具体内容和上下文细节", + "如果回复时需要@某个用户,请在参数中提供'at_user_id'。", ] # 关联类型 @@ -83,6 +85,7 @@ class RespondAction(BaseAction): # 动作参数定义 action_parameters: ClassVar = { "content": "回复的具体内容(可选,由LLM生成)", + "at_user_id": "需要@的用户的QQ号(可选,string)。如果需要在回复中@某个用户,请提供此参数。", } # 动作使用场景 @@ -93,6 +96,7 @@ class RespondAction(BaseAction): "关注对话流程、话题走向和整体氛围", "适合群聊中的自然对话流,无需精确指向特定消息", "可以同时回应多个话题或参与者", + "如果回复时需要@某个用户,请在参数中提供'at_user_id'。", ] # 关联类型 diff --git a/src/plugins/built_in/napcat_adapter/src/handlers/to_core/message_handler.py b/src/plugins/built_in/napcat_adapter/src/handlers/to_core/message_handler.py index 0cc74681b..17f8df00f 100644 --- a/src/plugins/built_in/napcat_adapter/src/handlers/to_core/message_handler.py +++ b/src/plugins/built_in/napcat_adapter/src/handlers/to_core/message_handler.py @@ -5,7 +5,7 @@ from __future__ import annotations import base64 import time from pathlib import Path -from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple +from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple, cast import uuid from mofox_wire import MessageBuilder @@ -224,7 +224,7 @@ class MessageHandler: if not messages: logger.warning("转发消息内容为空或获取失败") return None - return await self.handle_forward_message(messages) + return await self.handle_forward_message(cast(list, messages)) case RealMessageType.json: return await self._handle_json_message(segment) case RealMessageType.file: @@ -331,10 +331,13 @@ class MessageHandler: {"type": seg.get("type", "text"), "data": seg.get("data", "")} for seg in reply_segments ] or [{"type": "text", "data": "[无法获取被引用的消息]"}] - return { - "type": "seglist", - "data": [{"type": "text", "data": prefix_text}, *brief_segments, {"type": "text", "data": suffix_text}], - } + return cast( + SegPayload, + { + "type": "seglist", + "data": [{"type": "text", "data": prefix_text}, *brief_segments, {"type": "text", "data": suffix_text}], + }, + ) async def _handle_record_message(self, segment: dict) -> SegPayload | None: """处理语音消息""" @@ -380,14 +383,17 @@ class MessageHandler: video_base64 = base64.b64encode(video_data).decode("utf-8") logger.debug(f"视频文件大小: {len(video_data) / (1024 * 1024):.2f} MB") - return { - "type": "video", - "data": { - "base64": video_base64, - "filename": Path(file_path).name, - "size_mb": len(video_data) / (1024 * 1024), + return cast( + SegPayload, + { + "type": "video", + "data": { + "base64": video_base64, + "filename": Path(file_path).name, + "size_mb": len(video_data) / (1024 * 1024), + }, }, - } + ) elif video_url: # URL下载处理 from ..video_handler import get_video_downloader @@ -401,15 +407,18 @@ class MessageHandler: video_base64 = base64.b64encode(download_result["data"]).decode("utf-8") logger.debug(f"视频下载成功,大小: {len(download_result['data']) / (1024 * 1024):.2f} MB") - return { - "type": "video", - "data": { - "base64": video_base64, - "filename": download_result.get("filename", "video.mp4"), - "size_mb": len(download_result["data"]) / (1024 * 1024), - "url": video_url, + return cast( + SegPayload, + { + "type": "video", + "data": { + "base64": video_base64, + "filename": download_result.get("filename", "video.mp4"), + "size_mb": len(download_result["data"]) / (1024 * 1024), + "url": video_url, + }, }, - } + ) else: logger.warning("既没有有效的本地文件路径,也没有有效的视频URL") return None @@ -454,14 +463,14 @@ class MessageHandler: processed_message = handled_message forward_hint = {"type": "text", "data": "这是一条转发消息:\n"} - return {"type": "seglist", "data": [forward_hint, processed_message]} + return cast(SegPayload, {"type": "seglist", "data": [forward_hint, processed_message]}) async def _recursive_parse_image_seg(self, seg_data: SegPayload, to_image: bool) -> SegPayload: # sourcery skip: merge-else-if-into-elif if seg_data.get("type") == "seglist": new_seg_list = [] for i_seg in seg_data.get("data", []): - parsed_seg = await self._recursive_parse_image_seg(i_seg, to_image) + parsed_seg = await self._recursive_parse_image_seg(cast(SegPayload, i_seg), to_image) new_seg_list.append(parsed_seg) return {"type": "seglist", "data": new_seg_list} @@ -469,7 +478,7 @@ class MessageHandler: if seg_data.get("type") == "image": image_url = seg_data.get("data") try: - encoded_image = await get_image_base64(image_url) + encoded_image = await get_image_base64(cast(str, image_url)) except Exception as e: logger.error(f"图片处理失败: {str(e)}") return {"type": "text", "data": "[图片]"} @@ -477,7 +486,7 @@ class MessageHandler: if seg_data.get("type") == "emoji": image_url = seg_data.get("data") try: - encoded_image = await get_image_base64(image_url) + encoded_image = await get_image_base64(cast(str, image_url)) except Exception as e: logger.error(f"图片处理失败: {str(e)}") return {"type": "text", "data": "[表情包]"} @@ -492,7 +501,7 @@ class MessageHandler: logger.debug(f"不处理类型: {seg_data.get('type')}") return seg_data - async def _handle_forward_message(self, message_list: list, layer: int) -> Tuple[SegPayload | None, int]: + async def _handle_forward_message(self, message_list: list, layer: int) -> Tuple[Optional[SegPayload], int]: # sourcery skip: low-code-quality """ 递归处理实际转发消息 @@ -530,7 +539,7 @@ class MessageHandler: continue contents = sub_message_data.get("content") seg_data, count = await self._handle_forward_message(contents, layer + 1) - if seg_data is None: + if not seg_data: continue image_count += count head_tip: SegPayload = { @@ -595,7 +604,7 @@ class MessageHandler: "id": file_id, } - return {"type": "file", "data": file_data} + return cast(SegPayload, {"type": "file", "data": file_data}) async def _handle_json_message(self, segment: dict) -> SegPayload | None: """ @@ -623,7 +632,7 @@ class MessageHandler: # 从回声消息中提取文件信息 file_info = self._extract_file_info_from_echo(nested_data) if file_info: - return {"type": "file", "data": file_info} + return cast(SegPayload, {"type": "file", "data": file_info}) # 检查是否是QQ小程序分享消息 if "app" in nested_data and "com.tencent.miniapp" in str(nested_data.get("app", "")): diff --git a/src/plugins/built_in/napcat_adapter/src/handlers/to_napcat/send_handler.py b/src/plugins/built_in/napcat_adapter/src/handlers/to_napcat/send_handler.py index b832d3ae3..47d56b996 100644 --- a/src/plugins/built_in/napcat_adapter/src/handlers/to_napcat/send_handler.py +++ b/src/plugins/built_in/napcat_adapter/src/handlers/to_napcat/send_handler.py @@ -5,7 +5,7 @@ from __future__ import annotations import random import time import uuid -from typing import TYPE_CHECKING, Any, Dict, List, Optional +from typing import TYPE_CHECKING, Any, Dict, List, Optional, Union, cast, TypedDict from mofox_wire import MessageEnvelope, SegPayload, GroupInfoPayload, UserInfoPayload, MessageInfoPayload from src.common.logger import get_logger @@ -19,6 +19,12 @@ if TYPE_CHECKING: from ....plugin import NapcatAdapter +class AtPayload(TypedDict, total=False): + """@ 消息段数据""" + + user_id: str + + class SendHandler: """负责向 Napcat 发送消息""" @@ -41,10 +47,11 @@ class SendHandler: return message_segment = envelope.get("message_segment") + segment: SegPayload if isinstance(message_segment, list): - segment: SegPayload = {"type": "seglist", "data": message_segment} + segment = {"type": "seglist", "data": message_segment} else: - segment = message_segment or {} + segment = message_segment or {} # type: ignore if segment: seg_type = segment.get("type") @@ -66,11 +73,12 @@ class SendHandler: 处理普通消息发送 """ logger.info("处理普通信息中") - message_info: MessageInfoPayload = envelope.get("message_info", {}) - message_segment: SegPayload = envelope.get("message_segment", {}) # type: ignore[assignment] + message_info: MessageInfoPayload = envelope.get("message_info", {}) or {} + message_segment: Union[SegPayload, List[SegPayload]] = envelope.get("message_segment") or cast(SegPayload, {}) + seg_data: SegPayload if isinstance(message_segment, list): - seg_data: SegPayload = {"type": "seglist", "data": message_segment} + seg_data = {"type": "seglist", "data": message_segment} else: seg_data = message_segment @@ -81,7 +89,9 @@ class SendHandler: id_name: Optional[str] = None processed_message: list = [] try: - processed_message = await self.handle_seg_recursive(seg_data, user_info or {}) + processed_message = await self.handle_seg_recursive( + seg_data, cast(UserInfoPayload, user_info if user_info is not None else {}) + ) except Exception as e: logger.error(f"处理消息时发生错误: {e}") return None @@ -123,10 +133,10 @@ class SendHandler: 处理命令类 """ logger.debug("处理命令中") - message_info: Dict[str, Any] = envelope.get("message_info", {}) - group_info: Optional[Dict[str, Any]] = message_info.get("group_info") - segment: SegPayload = envelope.get("message_segment", {}) # type: ignore[assignment] - seg_data: Dict[str, Any] = segment.get("data", {}) if isinstance(segment, dict) else {} + message_info: MessageInfoPayload = envelope.get("message_info", {}) or {} + group_info: Optional[GroupInfoPayload] = message_info.get("group_info") + segment: SegPayload = envelope.get("message_segment", {}) # type: ignore + seg_data: Dict[str, Any] = segment.get("data", {}) if isinstance(segment, dict) else {} # type: ignore command_name: Optional[str] = seg_data.get("name") try: args = seg_data.get("args", {}) @@ -147,10 +157,10 @@ class SendHandler: command, args_dict = self.handle_ai_voice_send_command(args, group_info) elif command_name == CommandType.SET_EMOJI_LIKE.name: command, args_dict = self.handle_set_emoji_like_command(args) - elif command_name == CommandType.SEND_AT_MESSAGE.name: - command, args_dict = self.handle_at_message_command(args, group_info) elif command_name == CommandType.SEND_LIKE.name: command, args_dict = self.handle_send_like_command(args) + elif command_name == CommandType.SEND_AT_MESSAGE.name: + command, args_dict = self.handle_at_message_command(args, group_info) else: logger.error(f"未知命令: {command_name}") return @@ -176,8 +186,8 @@ class SendHandler: 处理适配器命令类 - 用于直接向Napcat发送命令并返回结果 """ logger.info("处理适配器命令中") - segment: SegPayload = envelope.get("message_segment", {}) # type: ignore[assignment] - seg_data: Dict[str, Any] = segment.get("data", {}) if isinstance(segment, dict) else {} + segment: SegPayload = envelope.get("message_segment", {}) # type: ignore + seg_data: Dict[str, Any] = segment.get("data", {}) if isinstance(segment, dict) else {} # type: ignore try: action = seg_data.get("action") @@ -245,6 +255,9 @@ class SendHandler: if not text: return payload new_payload = self.build_payload(payload, self.handle_text_message(str(text)), False) + elif seg_type == "at": + at_data: AtPayload = seg.get("data", {}) # type: ignore + new_payload = self.build_payload(payload, self.handle_at_message(at_data), False) elif seg_type == "face": logger.warning("MoFox-Bot 发送了qq原生表情,暂时不支持") elif seg_type == "image": @@ -299,50 +312,21 @@ class SendHandler: payload.append(addon) return payload - async def handle_reply_message(self, message_id: str, user_info: UserInfoPayload) -> dict | list: + async def handle_reply_message(self, message_id: str, user_info: UserInfoPayload) -> dict: """处理回复消息""" logger.debug(f"开始处理回复消息,消息ID: {message_id}") reply_seg = {"type": "reply", "data": {"id": message_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": message_id}) - logger.debug(f"获取消息 {message_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") - - if not replied_user_id: - logger.warning(f"无法获取消息 {message_id} 的发送者信息,跳过 @") - logger.debug(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.debug(f"最终返回的回复段: {result_seg}") - return result_seg - - except Exception as e: - logger.error(f"处理引用回复并尝试@时出错: {e}") - logger.debug(f"最终返回的回复段: {reply_seg}") - return reply_seg - - logger.debug(f"最终返回的回复段: {reply_seg}") return reply_seg def handle_text_message(self, message: str) -> dict: """处理文本消息""" return {"type": "text", "data": {"text": message}} + def handle_at_message(self, at_data: AtPayload) -> dict: + """处理@消息""" + user_id = at_data.get("user_id") + return {"type": "at", "data": {"qq": str(user_id)}} + def handle_image_message(self, encoded_image: str) -> dict: """处理图片消息""" return { @@ -370,14 +354,8 @@ class SendHandler: 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: + logger.warning("接收到空的语音消息,跳过处理") return {} return { "type": "record", @@ -416,7 +394,7 @@ class SendHandler: """处理删除消息命令""" return "delete_msg", {"message_id": args["message_id"]} - def handle_ban_command(self, args: Dict[str, Any], group_info: Optional[Dict[str, Any]]) -> tuple[str, Dict[str, Any]]: + def handle_ban_command(self, args: Dict[str, Any], group_info: Optional[GroupInfoPayload]) -> tuple[str, Dict[str, Any]]: """处理封禁命令""" duration: int = int(args["duration"]) user_id: int = int(args["qq_id"]) @@ -436,7 +414,7 @@ class SendHandler: }, ) - def handle_whole_ban_command(self, args: Dict[str, Any], group_info: Optional[Dict[str, Any]]) -> tuple[str, Dict[str, Any]]: + def handle_whole_ban_command(self, args: Dict[str, Any], group_info: Optional[GroupInfoPayload]) -> tuple[str, Dict[str, Any]]: """处理全体禁言命令""" enable = args["enable"] assert isinstance(enable, bool), "enable参数必须是布尔值" @@ -451,7 +429,7 @@ class SendHandler: }, ) - def handle_kick_command(self, args: Dict[str, Any], group_info: Optional[Dict[str, Any]]) -> tuple[str, Dict[str, Any]]: + def handle_kick_command(self, args: Dict[str, Any], group_info: Optional[GroupInfoPayload]) -> tuple[str, Dict[str, Any]]: """处理群成员踢出命令""" user_id: int = int(args["qq_id"]) group_id: int = int(group_info["group_id"]) if group_info and group_info.get("group_id") else 0 @@ -468,7 +446,7 @@ class SendHandler: }, ) - def handle_poke_command(self, args: Dict[str, Any], group_info: Optional[Dict[str, Any]]) -> tuple[str, Dict[str, Any]]: + def handle_poke_command(self, args: Dict[str, Any], group_info: Optional[GroupInfoPayload]) -> tuple[str, Dict[str, Any]]: """处理戳一戳命令""" user_id: int = int(args["qq_id"]) group_id: Optional[int] = None @@ -515,31 +493,7 @@ class SendHandler: {"user_id": user_id, "times": times}, ) - def handle_at_message_command(self, args: Dict[str, Any], group_info: Optional[Dict[str, Any]]) -> tuple[str, Dict[str, Any]]: - """处理艾特并发送消息命令""" - 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 or not group_info.get("group_id"): - 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, - }, - ) - - def handle_ai_voice_send_command(self, args: Dict[str, Any], group_info: Optional[Dict[str, Any]]) -> tuple[str, Dict[str, Any]]: + def handle_ai_voice_send_command(self, args: Dict[str, Any], group_info: Optional[GroupInfoPayload]) -> tuple[str, Dict[str, Any]]: """ 处理AI语音发送命令的逻辑。 并返回 NapCat 兼容的 (action, params) 元组。 @@ -565,6 +519,30 @@ class SendHandler: }, ) + def handle_at_message_command(self, args: Dict[str, Any], group_info: Optional[GroupInfoPayload]) -> tuple[str, Dict[str, Any]]: + """处理艾特并发送消息命令""" + 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 or not group_info.get("group_id"): + 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, + }, + ) + async def send_message_to_napcat(self, action: str, params: dict, timeout: float = 20.0) -> dict: """通过 adapter API 发送到 napcat""" try: From 655e535d96fc7f7edbefee0dd74982252e8069ac Mon Sep 17 00:00:00 2001 From: tt-P607 <68868379+tt-P607@users.noreply.github.com> Date: Fri, 28 Nov 2025 08:21:06 +0800 Subject: [PATCH 06/12] =?UTF-8?q?Revert=20"feat(chat):=20=E9=80=9A?= =?UTF-8?q?=E8=BF=87=E5=8A=A8=E4=BD=9C=E5=8F=82=E6=95=B0=E5=AE=9E=E7=8E=B0?= =?UTF-8?q?=E4=B8=93=E7=94=A8=E7=9A=84=20@=E7=94=A8=E6=88=B7=20=E5=8A=9F?= =?UTF-8?q?=E8=83=BD"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit e5117720c6bb764e69648a0d1a73c58a3f5ee246. --- src/chat/message_receive/message_handler.py | 40 ++--- src/chat/planner_actions/action_manager.py | 48 +----- src/chat/replyer/default_generator.py | 8 - src/plugin_system/apis/send_api.py | 90 ++--------- src/plugins/built_in/core_actions/plugin.py | 2 +- src/plugins/built_in/core_actions/reply.py | 4 - .../src/handlers/to_core/message_handler.py | 67 ++++---- .../src/handlers/to_napcat/send_handler.py | 150 ++++++++++-------- 8 files changed, 152 insertions(+), 257 deletions(-) diff --git a/src/chat/message_receive/message_handler.py b/src/chat/message_receive/message_handler.py index 92ee4f779..a7238c2fd 100644 --- a/src/chat/message_receive/message_handler.py +++ b/src/chat/message_receive/message_handler.py @@ -30,16 +30,16 @@ from __future__ import annotations import os import re import traceback -from typing import TYPE_CHECKING, Any, cast +from typing import TYPE_CHECKING, Any -from mofox_wire import MessageEnvelope +from mofox_wire import MessageEnvelope, MessageRuntime from src.chat.message_manager import message_manager from src.chat.message_receive.storage import MessageStorage from src.chat.utils.utils import is_mentioned_bot_in_message from src.common.data_models.database_data_model import DatabaseGroupInfo, DatabaseMessages, DatabaseUserInfo from src.common.logger import get_logger -from src.config.config import Config, global_config +from src.config.config import global_config from src.mood.mood_manager import mood_manager from src.plugin_system.base import BaseCommand, EventType from src.plugin_system.core import component_registry, event_manager, global_announcement_manager @@ -48,10 +48,6 @@ if TYPE_CHECKING: from src.chat.message_receive.chat_stream import ChatStream from src.common.core_sink_manager import CoreSinkManager -if TYPE_CHECKING: - from mofox_wire import MessageRuntime - global_config: Config | None - logger = get_logger("message_handler") # 项目根目录 @@ -59,13 +55,7 @@ PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "../..")) def _check_ban_words(text: str, chat: "ChatStream", userinfo) -> bool: """检查消息是否包含过滤词""" - if global_config: - for word in global_config.message_receive.ban_words: - if word in text: - chat_name = chat.group_info.group_name if chat.group_info else "私聊" - logger.info(f"[{chat_name}]{userinfo.user_nickname}:{text}") - logger.info(f"[过滤词识别]消息中含有{word},filtered") - return True + for word in global_config.message_receive.ban_words: if word in text: chat_name = chat.group_info.group_name if chat.group_info else "私聊" logger.info(f"[{chat_name}]{userinfo.user_nickname}:{text}") @@ -76,13 +66,7 @@ def _check_ban_words(text: str, chat: "ChatStream", userinfo) -> bool: def _check_ban_regex(text: str, chat: "ChatStream", userinfo) -> bool: """检查消息是否匹配过滤正则表达式""" - if global_config: - for pattern in global_config.message_receive.ban_msgs_regex: - if re.search(pattern, text): - chat_name = chat.group_info.group_name if chat.group_info else "私聊" - logger.info(f"[{chat_name}]{userinfo.user_nickname}:{text}") - logger.info(f"[正则表达式过滤]消息匹配到{pattern},filtered") - return True + for pattern in global_config.message_receive.ban_msgs_regex: if re.search(pattern, text): chat_name = chat.group_info.group_name if chat.group_info else "私聊" logger.info(f"[{chat_name}]{userinfo.user_nickname}:{text}") @@ -116,13 +100,13 @@ class MessageHandler: self._message_manager_started = False self._core_sink_manager: CoreSinkManager | None = None self._shutting_down = False - self._runtime: "MessageRuntime | None" = None + self._runtime: MessageRuntime | None = None def set_core_sink_manager(self, manager: "CoreSinkManager") -> None: """设置 CoreSinkManager 引用""" self._core_sink_manager = manager - def register_handlers(self, runtime: "MessageRuntime") -> None: + def register_handlers(self, runtime: MessageRuntime) -> None: """ 向 MessageRuntime 注册消息处理器和钩子 @@ -297,7 +281,7 @@ class MessageHandler: chat = await get_chat_manager().get_or_create_stream( platform=platform, user_info=DatabaseUserInfo.from_dict(user_info) if user_info else None, # type: ignore - group_info=DatabaseGroupInfo.from_dict(cast(dict, group_info)) if group_info else None, + group_info=DatabaseGroupInfo.from_dict(group_info) if group_info else None, ) # 将消息信封转换为 DatabaseMessages @@ -447,7 +431,7 @@ class MessageHandler: chat = await get_chat_manager().get_or_create_stream( platform=platform, user_info=DatabaseUserInfo.from_dict(user_info) if user_info else None, # type: ignore - group_info=DatabaseGroupInfo.from_dict(cast(dict, group_info)) if group_info else None, + group_info=DatabaseGroupInfo.from_dict(group_info) if group_info else None, ) # 将消息信封转换为 DatabaseMessages @@ -551,7 +535,7 @@ class MessageHandler: text = message.processed_plain_text or "" # 获取配置的命令前缀 - prefixes = global_config.command.command_prefixes if global_config else [] + prefixes = global_config.command.command_prefixes # 检查是否以任何前缀开头 matched_prefix = None @@ -723,7 +707,7 @@ class MessageHandler: # 检查是否需要处理消息 should_process_in_manager = True - if global_config and group_info and str(group_info.group_id) in global_config.message_receive.mute_group_list: + if group_info and str(group_info.group_id) in global_config.message_receive.mute_group_list: is_image_or_emoji = message.is_picid or message.is_emoji if not message.is_mentioned and not is_image_or_emoji: logger.debug( @@ -747,7 +731,7 @@ class MessageHandler: # 情绪系统更新 try: - if global_config and global_config.mood.enable_mood: + if global_config.mood.enable_mood: interest_rate = message.interest_value or 0.0 logger.debug(f"开始更新情绪状态,兴趣度: {interest_rate:.2f}") diff --git a/src/chat/planner_actions/action_manager.py b/src/chat/planner_actions/action_manager.py index 58f920237..ef8b24657 100644 --- a/src/chat/planner_actions/action_manager.py +++ b/src/chat/planner_actions/action_manager.py @@ -104,7 +104,7 @@ class ChatterActionManager: log_prefix=log_prefix, shutting_down=shutting_down, plugin_config=plugin_config, - action_message=action_message.flatten() if action_message else None, + action_message=action_message, ) logger.debug(f"创建Action实例成功: {action_name}") @@ -252,7 +252,7 @@ class ChatterActionManager: # 检查目标消息是否为表情包消息以及配置是否允许回复表情包 if target_message and getattr(target_message, "is_emoji", False): # 如果是表情包消息且配置不允许回复表情包,则跳过回复 - if global_config and not getattr(global_config.chat, "allow_reply_to_emoji", True): + if not getattr(global_config.chat, "allow_reply_to_emoji", True): logger.info(f"{log_prefix} 目标消息为表情包且配置不允许回复表情包,跳过回复") return {"action_type": action_name, "success": True, "reply_text": "", "skip_reason": "emoji_not_allowed"} @@ -288,7 +288,7 @@ class ChatterActionManager: reply_message=target_message, action_data=action_data_with_mode, available_actions=current_actions, # type: ignore - enable_tool=global_config.tool.enable_tool if global_config else False, + enable_tool=global_config.tool.enable_tool, request_type="chat.replyer", from_plugin=False, ) @@ -325,7 +325,6 @@ class ChatterActionManager: thinking_id, [], # actions should_quote_reply, # 传递should_quote_reply参数 - action_data=action_data or {} ) # 记录回复动作到目标消息 @@ -493,7 +492,6 @@ class ChatterActionManager: thinking_id, actions, should_quote_reply: bool | None = None, - action_data: dict | None = None ) -> tuple[str, dict[str, float]]: """ 发送并存储回复信息 @@ -511,39 +509,11 @@ class ChatterActionManager: Returns: Tuple[Dict[str, Any], str, Dict[str, float]]: 循环信息, 回复文本, 循环计时器 """ - # 提取回复文本 - reply_text = "" - for reply_seg in response_set: - if isinstance(reply_seg, tuple) and len(reply_seg) >= 2: - _, data = reply_seg - else: - data = str(reply_seg) - if isinstance(data, list): - data = "".join(map(str, data)) - reply_text += data - - # 检查是否需要@用户 - at_user_id = action_data.get("at_user_id") if action_data else None - if at_user_id and chat_stream.group_info: - logger.info(f"检测到需要@用户: {at_user_id},将使用 SEND_AT_MESSAGE 命令发送") - from src.plugins.built_in.napcat_adapter.src.event_models import CommandType - command_payload = { - "name": CommandType.SEND_AT_MESSAGE.name, - "args": { - "qq_id": str(at_user_id), - "text": reply_text - } - } - await send_api.command_to_stream( - command=command_payload, - stream_id=chat_stream.stream_id + # 发送回复 + with Timer("回复发送", cycle_timers): + reply_text = await self.send_response( + chat_stream, response_set, loop_start_time, action_message, should_quote_reply ) - else: - # 正常发送回复 - with Timer("回复发送", cycle_timers): - reply_text = await self.send_response( - chat_stream, response_set, loop_start_time, action_message, should_quote_reply, action_data - ) # 存储reply action信息 person_info_manager = get_person_info_manager() @@ -588,7 +558,7 @@ class ChatterActionManager: return reply_text, cycle_timers async def send_response( - self, chat_stream, reply_set, thinking_start_time, message_data, should_quote_reply: bool | None = None, action_data: dict | None = None + self, chat_stream, reply_set, thinking_start_time, message_data, should_quote_reply: bool | None = None ) -> str: """ 发送回复内容的具体实现 @@ -599,7 +569,6 @@ class ChatterActionManager: thinking_start_time: 思考开始时间 message_data: 消息数据 should_quote_reply: 是否应该引用回复原消息,None表示自动决定 - action_data: 动作数据,用于检查是否需要@ Returns: str: 完整的回复文本 @@ -628,7 +597,6 @@ class ChatterActionManager: logger.debug(f"[send_response] message_data: {message_data}") first_replied = False - for reply_seg in reply_set: # 调试日志:验证reply_seg的格式 logger.debug(f"Processing reply_seg type: {type(reply_seg)}, content: {reply_seg}") diff --git a/src/chat/replyer/default_generator.py b/src/chat/replyer/default_generator.py index 55fc23e12..fe9be0494 100644 --- a/src/chat/replyer/default_generator.py +++ b/src/chat/replyer/default_generator.py @@ -38,10 +38,6 @@ from src.plugin_system.base.component_types import ActionInfo, EventType if TYPE_CHECKING: from src.chat.message_receive.chat_stream import ChatStream - from src.config.config import APIAdapterConfig, Config - - global_config: "Config" - model_config: "APIAdapterConfig" logger = get_logger("replyer") @@ -123,10 +119,6 @@ def init_prompt(): {action_descriptions} -- **关于@功能的重要说明**: - - 如果你需要在一个回复中`@`某个用户,**请不要**在你的回复内容中直接输出`@`符号或`艾特`等文字。 - - 你应该使用`reply`或`respond`动作中的`at_user_id`参数。只需要将目标的QQ号填入该参数,系统就会自动为你完成`@`操作。 - ## 任务 *{chat_scene}* diff --git a/src/plugin_system/apis/send_api.py b/src/plugin_system/apis/send_api.py index 9a596f444..3aac3d76c 100644 --- a/src/plugin_system/apis/send_api.py +++ b/src/plugin_system/apis/send_api.py @@ -92,11 +92,10 @@ import traceback import uuid from typing import TYPE_CHECKING, Any -from mofox_wire import MessageEnvelope, MessageInfoPayload, SegPayload +from mofox_wire import MessageEnvelope from src.common.data_models.database_data_model import DatabaseUserInfo if TYPE_CHECKING: from src.common.data_models.database_data_model import DatabaseMessages - from src.config.config import Config # 导入依赖 from src.chat.message_receive.chat_stream import ChatStream, get_chat_manager @@ -104,9 +103,6 @@ from src.chat.message_receive.uni_message_sender import HeartFCSender from src.common.logger import get_logger from src.config.config import global_config -if TYPE_CHECKING: - assert global_config is not None - # 日志记录器 logger = get_logger("send_api") @@ -197,44 +193,32 @@ def _build_message_envelope( ) -> MessageEnvelope: """构建发送的 MessageEnvelope 数据结构""" target_user_info = target_stream.user_info or bot_user_info - message_info: MessageInfoPayload = { + message_info: dict[str, Any] = { "message_id": message_id, "time": timestamp, "platform": target_stream.platform, "user_info": { - "user_id": target_user_info.user_id or "", - "user_nickname": target_user_info.user_nickname or "", - "user_cardname": getattr(target_user_info, "user_cardname", "") or "", - "platform": target_user_info.platform or "", + "user_id": target_user_info.user_id, + "user_nickname": target_user_info.user_nickname, + "user_cardname": getattr(target_user_info, "user_cardname", None), + "platform": target_user_info.platform, }, - "group_info": None, # type: ignore } if target_stream.group_info: message_info["group_info"] = { - "group_id": target_stream.group_info.group_id or "", - "group_name": target_stream.group_info.group_name or "", - "platform": target_stream.group_info.platform or "", + "group_id": target_stream.group_info.group_id, + "group_name": target_stream.group_info.group_name, + "platform": target_stream.group_info.platform, } - # Ensure message_segment is of the correct type - seg_payload: SegPayload - if isinstance(message_segment, list): - seg_payload = {"type": "seglist", "data": message_segment} - elif isinstance(message_segment, dict) and "type" in message_segment and "data" in message_segment: - seg_payload = message_segment # type: ignore - else: - # Fallback for simple string content or other unexpected formats - seg_payload = {"type": "text", "data": str(message_segment)} - - - envelope: MessageEnvelope = { + return { + "id": str(uuid.uuid4()), "direction": "outgoing", "platform": target_stream.platform, "message_info": message_info, - "message_segment": seg_payload, + "message_segment": message_segment, } - return envelope @@ -273,18 +257,9 @@ async def _send_to_target( current_time = time.time() message_id = f"send_api_{int(current_time * 1000)}" - # Use a safer way to get bot config - if not global_config: - logger.error("[SendAPI] Global config is not initialized!") - return False - bot_config = global_config.bot - if not bot_config: - logger.error("[SendAPI] Bot configuration not found!") - return False - bot_user_info = DatabaseUserInfo( - user_id=str(bot_config.qq_account), - user_nickname=bot_config.nickname, + user_id=str(global_config.bot.qq_account), + user_nickname=global_config.bot.nickname, platform=target_stream.platform, ) @@ -437,30 +412,6 @@ async def image_to_stream( ) -async def at_user_to_stream( - user_id: str, - stream_id: str, - display_text: str = "", - storage_message: bool = False, -) -> bool: - """向指定流发送@用户消息 - 这是一个特殊的、独立的段,通常用于在发送文本之前先发送@信息 - - Args: - user_id: 要@的用户的ID - stream_id: 聊天流ID - display_text: 在数据库中存储的显示文本 - storage_message: 是否存储此消息段到数据库 - - Returns: - bool: 是否发送成功 - """ - at_segment = {"qq": user_id} - return await _send_to_target( - "at", at_segment, stream_id, display_text, typing=False, storage_message=storage_message, set_reply=False - ) - - async def command_to_stream( command: str | dict, stream_id: str, @@ -567,18 +518,9 @@ async def adapter_command_to_stream( current_time = time.time() message_id = f"adapter_cmd_{int(current_time * 1000)}" - # Use a safer way to get bot config - if not global_config: - logger.error("[SendAPI] Global config is not initialized!") - return {"status": "error", "message": "Global config is not initialized"} - bot_config = global_config.bot - if not bot_config: - logger.error("[SendAPI] Bot configuration not found!") - return {"status": "error", "message": "Bot configuration not found"} - bot_user_info = DatabaseUserInfo( - user_id=str(bot_config.qq_account), - user_nickname=bot_config.nickname, + user_id=str(global_config.bot.qq_account), + user_nickname=global_config.bot.nickname, platform=target_stream.platform, ) diff --git a/src/plugins/built_in/core_actions/plugin.py b/src/plugins/built_in/core_actions/plugin.py index 008e42b09..5baaa3a8e 100644 --- a/src/plugins/built_in/core_actions/plugin.py +++ b/src/plugins/built_in/core_actions/plugin.py @@ -63,7 +63,7 @@ class CoreActionsPlugin(BasePlugin): """返回插件包含的组件列表""" # --- 根据配置注册组件 --- - components = [] + components: ClassVar = [] # 注册 reply 动作 if self.get_config("components.enable_reply", True): diff --git a/src/plugins/built_in/core_actions/reply.py b/src/plugins/built_in/core_actions/reply.py index 06d00a23d..9a90f7e33 100644 --- a/src/plugins/built_in/core_actions/reply.py +++ b/src/plugins/built_in/core_actions/reply.py @@ -37,7 +37,6 @@ class ReplyAction(BaseAction): "target_message_id": "要回复的目标消息ID(必需,来自未读消息的 标签)", "content": "回复的具体内容(可选,由LLM生成)", "should_quote_reply": "是否引用原消息(可选,true/false,默认false。群聊中回复较早消息或需要明确指向时使用true)", - "at_user_id": "需要@的用户的QQ号(可选,string)。如果需要在回复中@某个用户,请提供此参数。", } # 动作使用场景 @@ -48,7 +47,6 @@ class ReplyAction(BaseAction): "私聊场景必须使用此动作(不支持 respond)", "群聊中需要明确回应某个特定用户或问题时使用", "关注单条消息的具体内容和上下文细节", - "如果回复时需要@某个用户,请在参数中提供'at_user_id'。", ] # 关联类型 @@ -85,7 +83,6 @@ class RespondAction(BaseAction): # 动作参数定义 action_parameters: ClassVar = { "content": "回复的具体内容(可选,由LLM生成)", - "at_user_id": "需要@的用户的QQ号(可选,string)。如果需要在回复中@某个用户,请提供此参数。", } # 动作使用场景 @@ -96,7 +93,6 @@ class RespondAction(BaseAction): "关注对话流程、话题走向和整体氛围", "适合群聊中的自然对话流,无需精确指向特定消息", "可以同时回应多个话题或参与者", - "如果回复时需要@某个用户,请在参数中提供'at_user_id'。", ] # 关联类型 diff --git a/src/plugins/built_in/napcat_adapter/src/handlers/to_core/message_handler.py b/src/plugins/built_in/napcat_adapter/src/handlers/to_core/message_handler.py index 17f8df00f..0cc74681b 100644 --- a/src/plugins/built_in/napcat_adapter/src/handlers/to_core/message_handler.py +++ b/src/plugins/built_in/napcat_adapter/src/handlers/to_core/message_handler.py @@ -5,7 +5,7 @@ from __future__ import annotations import base64 import time from pathlib import Path -from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple, cast +from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple import uuid from mofox_wire import MessageBuilder @@ -224,7 +224,7 @@ class MessageHandler: if not messages: logger.warning("转发消息内容为空或获取失败") return None - return await self.handle_forward_message(cast(list, messages)) + return await self.handle_forward_message(messages) case RealMessageType.json: return await self._handle_json_message(segment) case RealMessageType.file: @@ -331,13 +331,10 @@ class MessageHandler: {"type": seg.get("type", "text"), "data": seg.get("data", "")} for seg in reply_segments ] or [{"type": "text", "data": "[无法获取被引用的消息]"}] - return cast( - SegPayload, - { - "type": "seglist", - "data": [{"type": "text", "data": prefix_text}, *brief_segments, {"type": "text", "data": suffix_text}], - }, - ) + return { + "type": "seglist", + "data": [{"type": "text", "data": prefix_text}, *brief_segments, {"type": "text", "data": suffix_text}], + } async def _handle_record_message(self, segment: dict) -> SegPayload | None: """处理语音消息""" @@ -383,17 +380,14 @@ class MessageHandler: video_base64 = base64.b64encode(video_data).decode("utf-8") logger.debug(f"视频文件大小: {len(video_data) / (1024 * 1024):.2f} MB") - return cast( - SegPayload, - { - "type": "video", - "data": { - "base64": video_base64, - "filename": Path(file_path).name, - "size_mb": len(video_data) / (1024 * 1024), - }, + return { + "type": "video", + "data": { + "base64": video_base64, + "filename": Path(file_path).name, + "size_mb": len(video_data) / (1024 * 1024), }, - ) + } elif video_url: # URL下载处理 from ..video_handler import get_video_downloader @@ -407,18 +401,15 @@ class MessageHandler: video_base64 = base64.b64encode(download_result["data"]).decode("utf-8") logger.debug(f"视频下载成功,大小: {len(download_result['data']) / (1024 * 1024):.2f} MB") - return cast( - SegPayload, - { - "type": "video", - "data": { - "base64": video_base64, - "filename": download_result.get("filename", "video.mp4"), - "size_mb": len(download_result["data"]) / (1024 * 1024), - "url": video_url, - }, + return { + "type": "video", + "data": { + "base64": video_base64, + "filename": download_result.get("filename", "video.mp4"), + "size_mb": len(download_result["data"]) / (1024 * 1024), + "url": video_url, }, - ) + } else: logger.warning("既没有有效的本地文件路径,也没有有效的视频URL") return None @@ -463,14 +454,14 @@ class MessageHandler: processed_message = handled_message forward_hint = {"type": "text", "data": "这是一条转发消息:\n"} - return cast(SegPayload, {"type": "seglist", "data": [forward_hint, processed_message]}) + return {"type": "seglist", "data": [forward_hint, processed_message]} async def _recursive_parse_image_seg(self, seg_data: SegPayload, to_image: bool) -> SegPayload: # sourcery skip: merge-else-if-into-elif if seg_data.get("type") == "seglist": new_seg_list = [] for i_seg in seg_data.get("data", []): - parsed_seg = await self._recursive_parse_image_seg(cast(SegPayload, i_seg), to_image) + parsed_seg = await self._recursive_parse_image_seg(i_seg, to_image) new_seg_list.append(parsed_seg) return {"type": "seglist", "data": new_seg_list} @@ -478,7 +469,7 @@ class MessageHandler: if seg_data.get("type") == "image": image_url = seg_data.get("data") try: - encoded_image = await get_image_base64(cast(str, image_url)) + encoded_image = await get_image_base64(image_url) except Exception as e: logger.error(f"图片处理失败: {str(e)}") return {"type": "text", "data": "[图片]"} @@ -486,7 +477,7 @@ class MessageHandler: if seg_data.get("type") == "emoji": image_url = seg_data.get("data") try: - encoded_image = await get_image_base64(cast(str, image_url)) + encoded_image = await get_image_base64(image_url) except Exception as e: logger.error(f"图片处理失败: {str(e)}") return {"type": "text", "data": "[表情包]"} @@ -501,7 +492,7 @@ class MessageHandler: logger.debug(f"不处理类型: {seg_data.get('type')}") return seg_data - async def _handle_forward_message(self, message_list: list, layer: int) -> Tuple[Optional[SegPayload], int]: + async def _handle_forward_message(self, message_list: list, layer: int) -> Tuple[SegPayload | None, int]: # sourcery skip: low-code-quality """ 递归处理实际转发消息 @@ -539,7 +530,7 @@ class MessageHandler: continue contents = sub_message_data.get("content") seg_data, count = await self._handle_forward_message(contents, layer + 1) - if not seg_data: + if seg_data is None: continue image_count += count head_tip: SegPayload = { @@ -604,7 +595,7 @@ class MessageHandler: "id": file_id, } - return cast(SegPayload, {"type": "file", "data": file_data}) + return {"type": "file", "data": file_data} async def _handle_json_message(self, segment: dict) -> SegPayload | None: """ @@ -632,7 +623,7 @@ class MessageHandler: # 从回声消息中提取文件信息 file_info = self._extract_file_info_from_echo(nested_data) if file_info: - return cast(SegPayload, {"type": "file", "data": file_info}) + return {"type": "file", "data": file_info} # 检查是否是QQ小程序分享消息 if "app" in nested_data and "com.tencent.miniapp" in str(nested_data.get("app", "")): diff --git a/src/plugins/built_in/napcat_adapter/src/handlers/to_napcat/send_handler.py b/src/plugins/built_in/napcat_adapter/src/handlers/to_napcat/send_handler.py index 47d56b996..b832d3ae3 100644 --- a/src/plugins/built_in/napcat_adapter/src/handlers/to_napcat/send_handler.py +++ b/src/plugins/built_in/napcat_adapter/src/handlers/to_napcat/send_handler.py @@ -5,7 +5,7 @@ from __future__ import annotations import random import time import uuid -from typing import TYPE_CHECKING, Any, Dict, List, Optional, Union, cast, TypedDict +from typing import TYPE_CHECKING, Any, Dict, List, Optional from mofox_wire import MessageEnvelope, SegPayload, GroupInfoPayload, UserInfoPayload, MessageInfoPayload from src.common.logger import get_logger @@ -19,12 +19,6 @@ if TYPE_CHECKING: from ....plugin import NapcatAdapter -class AtPayload(TypedDict, total=False): - """@ 消息段数据""" - - user_id: str - - class SendHandler: """负责向 Napcat 发送消息""" @@ -47,11 +41,10 @@ class SendHandler: return message_segment = envelope.get("message_segment") - segment: SegPayload if isinstance(message_segment, list): - segment = {"type": "seglist", "data": message_segment} + segment: SegPayload = {"type": "seglist", "data": message_segment} else: - segment = message_segment or {} # type: ignore + segment = message_segment or {} if segment: seg_type = segment.get("type") @@ -73,12 +66,11 @@ class SendHandler: 处理普通消息发送 """ logger.info("处理普通信息中") - message_info: MessageInfoPayload = envelope.get("message_info", {}) or {} - message_segment: Union[SegPayload, List[SegPayload]] = envelope.get("message_segment") or cast(SegPayload, {}) + message_info: MessageInfoPayload = envelope.get("message_info", {}) + message_segment: SegPayload = envelope.get("message_segment", {}) # type: ignore[assignment] - seg_data: SegPayload if isinstance(message_segment, list): - seg_data = {"type": "seglist", "data": message_segment} + seg_data: SegPayload = {"type": "seglist", "data": message_segment} else: seg_data = message_segment @@ -89,9 +81,7 @@ class SendHandler: id_name: Optional[str] = None processed_message: list = [] try: - processed_message = await self.handle_seg_recursive( - seg_data, cast(UserInfoPayload, user_info if user_info is not None else {}) - ) + processed_message = await self.handle_seg_recursive(seg_data, user_info or {}) except Exception as e: logger.error(f"处理消息时发生错误: {e}") return None @@ -133,10 +123,10 @@ class SendHandler: 处理命令类 """ logger.debug("处理命令中") - message_info: MessageInfoPayload = envelope.get("message_info", {}) or {} - group_info: Optional[GroupInfoPayload] = message_info.get("group_info") - segment: SegPayload = envelope.get("message_segment", {}) # type: ignore - seg_data: Dict[str, Any] = segment.get("data", {}) if isinstance(segment, dict) else {} # type: ignore + message_info: Dict[str, Any] = envelope.get("message_info", {}) + group_info: Optional[Dict[str, Any]] = message_info.get("group_info") + segment: SegPayload = envelope.get("message_segment", {}) # type: ignore[assignment] + seg_data: Dict[str, Any] = segment.get("data", {}) if isinstance(segment, dict) else {} command_name: Optional[str] = seg_data.get("name") try: args = seg_data.get("args", {}) @@ -157,10 +147,10 @@ class SendHandler: command, args_dict = self.handle_ai_voice_send_command(args, group_info) elif command_name == CommandType.SET_EMOJI_LIKE.name: command, args_dict = self.handle_set_emoji_like_command(args) - elif command_name == CommandType.SEND_LIKE.name: - command, args_dict = self.handle_send_like_command(args) elif command_name == CommandType.SEND_AT_MESSAGE.name: command, args_dict = self.handle_at_message_command(args, group_info) + elif command_name == CommandType.SEND_LIKE.name: + command, args_dict = self.handle_send_like_command(args) else: logger.error(f"未知命令: {command_name}") return @@ -186,8 +176,8 @@ class SendHandler: 处理适配器命令类 - 用于直接向Napcat发送命令并返回结果 """ logger.info("处理适配器命令中") - segment: SegPayload = envelope.get("message_segment", {}) # type: ignore - seg_data: Dict[str, Any] = segment.get("data", {}) if isinstance(segment, dict) else {} # type: ignore + segment: SegPayload = envelope.get("message_segment", {}) # type: ignore[assignment] + seg_data: Dict[str, Any] = segment.get("data", {}) if isinstance(segment, dict) else {} try: action = seg_data.get("action") @@ -255,9 +245,6 @@ class SendHandler: if not text: return payload new_payload = self.build_payload(payload, self.handle_text_message(str(text)), False) - elif seg_type == "at": - at_data: AtPayload = seg.get("data", {}) # type: ignore - new_payload = self.build_payload(payload, self.handle_at_message(at_data), False) elif seg_type == "face": logger.warning("MoFox-Bot 发送了qq原生表情,暂时不支持") elif seg_type == "image": @@ -312,21 +299,50 @@ class SendHandler: payload.append(addon) return payload - async def handle_reply_message(self, message_id: str, user_info: UserInfoPayload) -> dict: + async def handle_reply_message(self, message_id: str, user_info: UserInfoPayload) -> dict | list: """处理回复消息""" logger.debug(f"开始处理回复消息,消息ID: {message_id}") reply_seg = {"type": "reply", "data": {"id": message_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": message_id}) + logger.debug(f"获取消息 {message_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") + + if not replied_user_id: + logger.warning(f"无法获取消息 {message_id} 的发送者信息,跳过 @") + logger.debug(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.debug(f"最终返回的回复段: {result_seg}") + return result_seg + + except Exception as e: + logger.error(f"处理引用回复并尝试@时出错: {e}") + logger.debug(f"最终返回的回复段: {reply_seg}") + return reply_seg + + logger.debug(f"最终返回的回复段: {reply_seg}") return reply_seg def handle_text_message(self, message: str) -> dict: """处理文本消息""" return {"type": "text", "data": {"text": message}} - def handle_at_message(self, at_data: AtPayload) -> dict: - """处理@消息""" - user_id = at_data.get("user_id") - return {"type": "at", "data": {"qq": str(user_id)}} - def handle_image_message(self, encoded_image: str) -> dict: """处理图片消息""" return { @@ -354,8 +370,14 @@ class SendHandler: 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: - logger.warning("接收到空的语音消息,跳过处理") return {} return { "type": "record", @@ -394,7 +416,7 @@ class SendHandler: """处理删除消息命令""" return "delete_msg", {"message_id": args["message_id"]} - def handle_ban_command(self, args: Dict[str, Any], group_info: Optional[GroupInfoPayload]) -> tuple[str, Dict[str, Any]]: + def handle_ban_command(self, args: Dict[str, Any], group_info: Optional[Dict[str, Any]]) -> tuple[str, Dict[str, Any]]: """处理封禁命令""" duration: int = int(args["duration"]) user_id: int = int(args["qq_id"]) @@ -414,7 +436,7 @@ class SendHandler: }, ) - def handle_whole_ban_command(self, args: Dict[str, Any], group_info: Optional[GroupInfoPayload]) -> tuple[str, Dict[str, Any]]: + def handle_whole_ban_command(self, args: Dict[str, Any], group_info: Optional[Dict[str, Any]]) -> tuple[str, Dict[str, Any]]: """处理全体禁言命令""" enable = args["enable"] assert isinstance(enable, bool), "enable参数必须是布尔值" @@ -429,7 +451,7 @@ class SendHandler: }, ) - def handle_kick_command(self, args: Dict[str, Any], group_info: Optional[GroupInfoPayload]) -> tuple[str, Dict[str, Any]]: + def handle_kick_command(self, args: Dict[str, Any], group_info: Optional[Dict[str, Any]]) -> tuple[str, Dict[str, Any]]: """处理群成员踢出命令""" user_id: int = int(args["qq_id"]) group_id: int = int(group_info["group_id"]) if group_info and group_info.get("group_id") else 0 @@ -446,7 +468,7 @@ class SendHandler: }, ) - def handle_poke_command(self, args: Dict[str, Any], group_info: Optional[GroupInfoPayload]) -> tuple[str, Dict[str, Any]]: + def handle_poke_command(self, args: Dict[str, Any], group_info: Optional[Dict[str, Any]]) -> tuple[str, Dict[str, Any]]: """处理戳一戳命令""" user_id: int = int(args["qq_id"]) group_id: Optional[int] = None @@ -493,7 +515,31 @@ class SendHandler: {"user_id": user_id, "times": times}, ) - def handle_ai_voice_send_command(self, args: Dict[str, Any], group_info: Optional[GroupInfoPayload]) -> tuple[str, Dict[str, Any]]: + def handle_at_message_command(self, args: Dict[str, Any], group_info: Optional[Dict[str, Any]]) -> tuple[str, Dict[str, Any]]: + """处理艾特并发送消息命令""" + 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 or not group_info.get("group_id"): + 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, + }, + ) + + def handle_ai_voice_send_command(self, args: Dict[str, Any], group_info: Optional[Dict[str, Any]]) -> tuple[str, Dict[str, Any]]: """ 处理AI语音发送命令的逻辑。 并返回 NapCat 兼容的 (action, params) 元组。 @@ -519,30 +565,6 @@ class SendHandler: }, ) - def handle_at_message_command(self, args: Dict[str, Any], group_info: Optional[GroupInfoPayload]) -> tuple[str, Dict[str, Any]]: - """处理艾特并发送消息命令""" - 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 or not group_info.get("group_id"): - 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, - }, - ) - async def send_message_to_napcat(self, action: str, params: dict, timeout: float = 20.0) -> dict: """通过 adapter API 发送到 napcat""" try: From 883e391010cebb9c5b5459412f5e1c68ba0861ab Mon Sep 17 00:00:00 2001 From: tt-P607 <68868379+tt-P607@users.noreply.github.com> Date: Fri, 28 Nov 2025 08:24:02 +0800 Subject: [PATCH 07/12] =?UTF-8?q?refactor(napcat):=20=E5=9C=A8=E8=AF=AD?= =?UTF-8?q?=E9=9F=B3=E6=B6=88=E6=81=AF=E5=A4=84=E7=90=86=E7=A8=8B=E5=BA=8F?= =?UTF-8?q?=E4=B8=AD=E7=A7=BB=E9=99=A4=E6=9C=AA=E4=BD=BF=E7=94=A8=E7=9A=84?= =?UTF-8?q?=20TTS=20=E6=A3=80=E6=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `handle_voice_message` 方法中对 `voice.use_tts` 配置的检查已被移除,因为它是多余的。将消息编码为语音的决定在上游处理,因此在此阶段无需此检查。此外,添加了日志警告以处理接收到空语音消息的情况。 --- .../napcat_adapter/src/handlers/to_napcat/send_handler.py | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/src/plugins/built_in/napcat_adapter/src/handlers/to_napcat/send_handler.py b/src/plugins/built_in/napcat_adapter/src/handlers/to_napcat/send_handler.py index b832d3ae3..f2a985974 100644 --- a/src/plugins/built_in/napcat_adapter/src/handlers/to_napcat/send_handler.py +++ b/src/plugins/built_in/napcat_adapter/src/handlers/to_napcat/send_handler.py @@ -370,14 +370,8 @@ class SendHandler: 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: + logger.warning("接收到空的语音消息,跳过处理") return {} return { "type": "record", From abfcf569419fe994fb97be5f7a9bcb93fa04b09b Mon Sep 17 00:00:00 2001 From: tt-P607 <68868379+tt-P607@users.noreply.github.com> Date: Fri, 28 Nov 2025 10:15:53 +0800 Subject: [PATCH 08/12] =?UTF-8?q?refactor(core):=20=E6=8F=90=E5=8D=87?= =?UTF-8?q?=E7=B1=BB=E5=9E=8B=E5=AE=89=E5=85=A8=E6=80=A7=E5=B9=B6=E6=B7=BB?= =?UTF-8?q?=E5=8A=A0=E9=85=8D=E7=BD=AE=E7=A9=BA=E5=80=BC=E6=A3=80=E6=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 此提交在核心模块中引入了多项改进,以增强类型安全性和健壮性,主要通过为类型提示添加 `cast` 并在访问 `global_config` 属性前进行空值检查实现。 主要改动包括: - **类型安全**:在 `message_handler.py`、`unified_manager.py` 和 `napcat_adapter` 的消息处理器中使用 `typing.cast` 来解决类型不一致问题并提高静态分析的准确性。 - **配置空值检查**:在 `message_handler.py` 和 `message_processor.py` 中添加对 `global_config` 及其嵌套属性的检查,以防止在应用启动或配置加载过程中出现 `NoneType` 错误。 - **内存管理提示**:优化了 `unified_manager.py` 中内存判断器的提示,使其在获取长期记忆时更加保守,从而提升简单交互的性能。 - **Napcat 适配器**:新增了视频处理的配置选项以及回复行为。同时改进了消息解析逻辑的鲁棒性。- **消息处理器**:重构了 `_process_message_segments` 及相关函数,移除了未使用的 `message_info` 参数,从而简化了函数签名。 --- src/chat/message_receive/message_handler.py | 39 ++++---- src/chat/message_receive/message_processor.py | 44 +++------ src/memory_graph/unified_manager.py | 34 +++++-- src/plugins/built_in/core_actions/plugin.py | 2 +- src/plugins/built_in/napcat_adapter/plugin.py | 3 + .../src/handlers/to_core/message_handler.py | 91 +++++++++++-------- 6 files changed, 119 insertions(+), 94 deletions(-) diff --git a/src/chat/message_receive/message_handler.py b/src/chat/message_receive/message_handler.py index a7238c2fd..0ee66fbe8 100644 --- a/src/chat/message_receive/message_handler.py +++ b/src/chat/message_receive/message_handler.py @@ -43,6 +43,7 @@ from src.config.config import global_config from src.mood.mood_manager import mood_manager from src.plugin_system.base import BaseCommand, EventType from src.plugin_system.core import component_registry, event_manager, global_announcement_manager +from typing import cast if TYPE_CHECKING: from src.chat.message_receive.chat_stream import ChatStream @@ -55,23 +56,25 @@ PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "../..")) def _check_ban_words(text: str, chat: "ChatStream", userinfo) -> bool: """检查消息是否包含过滤词""" - for word in global_config.message_receive.ban_words: - if word in text: - chat_name = chat.group_info.group_name if chat.group_info else "私聊" - logger.info(f"[{chat_name}]{userinfo.user_nickname}:{text}") - logger.info(f"[过滤词识别]消息中含有{word},filtered") - return True + if global_config and global_config.message_receive: + for word in global_config.message_receive.ban_words: + if word in text: + chat_name = chat.group_info.group_name if chat.group_info else "私聊" + logger.info(f"[{chat_name}]{userinfo.user_nickname}:{text}") + logger.info(f"[过滤词识别]消息中含有{word},filtered") + return True return False def _check_ban_regex(text: str, chat: "ChatStream", userinfo) -> bool: """检查消息是否匹配过滤正则表达式""" - for pattern in global_config.message_receive.ban_msgs_regex: - if re.search(pattern, text): - chat_name = chat.group_info.group_name if chat.group_info else "私聊" - logger.info(f"[{chat_name}]{userinfo.user_nickname}:{text}") - logger.info(f"[正则表达式过滤]消息匹配到{pattern},filtered") - return True + if global_config and global_config.message_receive: + for pattern in global_config.message_receive.ban_msgs_regex: + if re.search(pattern, text): + chat_name = chat.group_info.group_name if chat.group_info else "私聊" + logger.info(f"[{chat_name}]{userinfo.user_nickname}:{text}") + logger.info(f"[正则表达式过滤]消息匹配到{pattern},filtered") + return True return False @@ -281,7 +284,7 @@ class MessageHandler: chat = await get_chat_manager().get_or_create_stream( platform=platform, user_info=DatabaseUserInfo.from_dict(user_info) if user_info else None, # type: ignore - group_info=DatabaseGroupInfo.from_dict(group_info) if group_info else None, + group_info=DatabaseGroupInfo.from_dict(cast(dict, group_info)) if group_info else None, ) # 将消息信封转换为 DatabaseMessages @@ -431,7 +434,7 @@ class MessageHandler: chat = await get_chat_manager().get_or_create_stream( platform=platform, user_info=DatabaseUserInfo.from_dict(user_info) if user_info else None, # type: ignore - group_info=DatabaseGroupInfo.from_dict(group_info) if group_info else None, + group_info=DatabaseGroupInfo.from_dict(cast(dict, group_info)) if group_info else None, ) # 将消息信封转换为 DatabaseMessages @@ -535,7 +538,9 @@ class MessageHandler: text = message.processed_plain_text or "" # 获取配置的命令前缀 - prefixes = global_config.command.command_prefixes + prefixes = [] + if global_config and global_config.command: + prefixes = global_config.command.command_prefixes # 检查是否以任何前缀开头 matched_prefix = None @@ -707,7 +712,7 @@ class MessageHandler: # 检查是否需要处理消息 should_process_in_manager = True - if group_info and str(group_info.group_id) in global_config.message_receive.mute_group_list: + if group_info and global_config and global_config.message_receive and str(group_info.group_id) in global_config.message_receive.mute_group_list: is_image_or_emoji = message.is_picid or message.is_emoji if not message.is_mentioned and not is_image_or_emoji: logger.debug( @@ -731,7 +736,7 @@ class MessageHandler: # 情绪系统更新 try: - if global_config.mood.enable_mood: + if global_config and global_config.mood and global_config.mood.enable_mood: interest_rate = message.interest_value or 0.0 logger.debug(f"开始更新情绪状态,兴趣度: {interest_rate:.2f}") diff --git a/src/chat/message_receive/message_processor.py b/src/chat/message_receive/message_processor.py index 96aa56650..c196d0322 100644 --- a/src/chat/message_receive/message_processor.py +++ b/src/chat/message_receive/message_processor.py @@ -56,7 +56,7 @@ async def process_message_from_dict(message_dict: MessageEnvelope, stream_id: st } # 异步处理消息段,生成纯文本 - processed_plain_text = await _process_message_segments(message_segment, processing_state, message_info) + processed_plain_text = await _process_message_segments(message_segment, processing_state) # 解析 notice 信息 is_notify = False @@ -155,15 +155,13 @@ async def process_message_from_dict(message_dict: MessageEnvelope, stream_id: st async def _process_message_segments( segment: SegPayload | list[SegPayload], - state: dict, - message_info: MessageInfoPayload + state: dict ) -> str: """递归处理消息段,转换为文字描述 Args: segment: 要处理的消息段(TypedDict 或列表) state: 处理状态字典(用于记录消息类型标记) - message_info: 消息基础信息(TypedDict 格式) Returns: str: 处理后的文本 @@ -172,7 +170,7 @@ async def _process_message_segments( if isinstance(segment, list): segments_text = [] for seg in segment: - processed = await _process_message_segments(seg, state, message_info) + processed = await _process_message_segments(seg, state) if processed: segments_text.append(processed) return " ".join(segments_text) @@ -186,28 +184,26 @@ async def _process_message_segments( if seg_type == "seglist" and isinstance(seg_data, list): segments_text = [] for sub_seg in seg_data: - processed = await _process_message_segments(sub_seg, state, message_info) + processed = await _process_message_segments(sub_seg, state) if processed: segments_text.append(processed) return " ".join(segments_text) # 处理其他类型 - return await _process_single_segment(segment, state, message_info) + return await _process_single_segment(segment, state) return "" async def _process_single_segment( segment: SegPayload, - state: dict, - message_info: MessageInfoPayload + state: dict ) -> str: """处理单个消息段 Args: segment: 消息段(TypedDict 格式) state: 处理状态字典 - message_info: 消息基础信息(TypedDict 格式) Returns: str: 处理后的文本 @@ -234,7 +230,6 @@ async def _process_single_segment( return f"@{seg_data}" if isinstance(seg_data, str) else "@未知用户" elif seg_type == "image": - # 如果是base64图片数据 if isinstance(seg_data, str): state["has_picid"] = True state["is_picid"] = True @@ -247,27 +242,17 @@ async def _process_single_segment( state["has_emoji"] = True state["is_emoji"] = True if isinstance(seg_data, str): - return await get_image_manager().get_emoji_description(seg_data) + image_manager = get_image_manager() + return await image_manager.get_emoji_description(seg_data) return "[发了一个表情包,网卡了加载不出来]" elif seg_type == "voice": state["is_voice"] = True - - # 检查消息是否由机器人自己发送 - user_info = message_info.get("user_info", {}) - user_id_str = str(user_info.get("user_id", "")) - if user_id_str == str(global_config.bot.qq_account): - logger.info(f"检测到机器人自身发送的语音消息 (User ID: {user_id_str}),尝试从缓存获取文本。") - if isinstance(seg_data, str): - cached_text = consume_self_voice_text(seg_data) - if cached_text: - logger.info(f"成功从缓存中获取语音文本: '{cached_text[:70]}...'") - return f"[语音:{cached_text}]" - else: - logger.warning("机器人自身语音消息缓存未命中,将回退到标准语音识别。") - - # 标准语音识别流程 + # 检查是否是自己发送的语音 if isinstance(seg_data, str): + cached_text = consume_self_voice_text(seg_data) + if cached_text: + return f"[语音:{cached_text}]" return await get_voice_text(seg_data) return "[发了一段语音,网卡了加载不出来]" @@ -299,7 +284,7 @@ async def _process_single_segment( logger.warning("⚠️ Rust视频处理模块不可用,跳过视频分析") return "[视频]" - if global_config.video_analysis.enable: + if global_config and global_config.video_analysis and global_config.video_analysis.enable: logger.info("已启用视频识别,开始识别") if isinstance(seg_data, dict): try: @@ -317,8 +302,9 @@ async def _process_single_segment( # 使用video analyzer分析视频 video_analyzer = get_video_analyzer() + prompt = global_config.video_analysis.batch_analysis_prompt if global_config and global_config.video_analysis else "" result = await video_analyzer.analyze_video_from_bytes( - video_bytes, filename, prompt=global_config.video_analysis.batch_analysis_prompt + video_bytes, filename, prompt=prompt ) logger.info(f"视频分析结果: {result}") diff --git a/src/memory_graph/unified_manager.py b/src/memory_graph/unified_manager.py index 1b4a7dbd5..89390d57d 100644 --- a/src/memory_graph/unified_manager.py +++ b/src/memory_graph/unified_manager.py @@ -13,9 +13,10 @@ import asyncio import time from datetime import datetime from pathlib import Path -from typing import Any +from typing import Any, cast from src.common.logger import get_logger +from src.config.config import TaskConfig from src.memory_graph.manager import MemoryManager from src.memory_graph.long_term_manager import LongTermMemoryManager from src.memory_graph.models import JudgeDecision, MemoryBlock, ShortTermMemory @@ -83,7 +84,7 @@ class UnifiedMemoryManager: self.long_term_manager: LongTermMemoryManager # 底层 MemoryManager(长期记忆) - self.memory_manager: MemoryManager = memory_manager + self.memory_manager: MemoryManager = cast(MemoryManager, memory_manager) # 配置参数存储(用于初始化) self._config = { @@ -330,7 +331,11 @@ class UnifiedMemoryManager: """ - prompt = f"""你是一个记忆检索评估专家。请判断检索到的记忆是否足以回答用户的问题。 + prompt = f"""你是一个记忆检索评估专家。你的任务是判断当前检索到的“感知记忆”(即时对话)和“短期记忆”(结构化信息)是否足以支撑一次有深度、有上下文的回复。 + +**核心原则:** +- **不要轻易检索长期记忆!** 只有在当前对话需要深入探讨、回忆过去复杂事件或需要特定背景知识时,才认为记忆不足。 +- **闲聊、简单问候、表情互动或无特定主题的对话,现有记忆通常是充足的。** 频繁检索长期记忆会拖慢响应速度。 **用户查询:** {query} @@ -341,27 +346,36 @@ class UnifiedMemoryManager: **检索到的短期记忆(结构化信息,自然语言描述):** {short_term_desc or '(无)'} -**任务要求:** -1. 判断这些记忆是否足以回答用户的问题 -2. 如果不充足,分析缺少哪些方面的信息 -3. 生成额外需要检索的 query(用于在长期记忆中检索) +**评估指南:** +1. **分析用户意图**:用户是在闲聊,还是在讨论一个需要深入挖掘的话题? +2. **检查现有记忆**:当前的感知和短期记忆是否已经包含了足够的信息来回应用户的查询? + - 对于闲聊(如“你好”、“哈哈”、“[表情]”),现有记忆总是充足的 (`"is_sufficient": true`)。 + - 对于需要回忆具体细节、深入探讨个人经历或专业知识的查询,如果现有记忆中没有相关信息,则可能不充足。 +3. **决策**: + - 如果记忆充足,设置 `"is_sufficient": true`。 + - 如果确实需要更多信息才能进行有意义的对话,设置 `"is_sufficient": false`,并提供具体的补充查询。 **输出格式(JSON):** ```json {{ "is_sufficient": true/false, "confidence": 0.85, - "reasoning": "判断理由", + "reasoning": "在这里解释你的判断理由。例如:‘用户只是在打招呼,现有记忆已足够’或‘用户问到了一个具体的历史事件,需要检索长期记忆’。", "missing_aspects": ["缺失的信息1", "缺失的信息2"], "additional_queries": ["补充query1", "补充query2"] }} ``` -请输出JSON:""" +请严格按照上述原则进行判断,并输出JSON:""" # 调用记忆裁判模型 + model_set = ( + model_config.model_task_config.memory_judge + if model_config and model_config.model_task_config + else TaskConfig(model_name="deepseek/deepseek-v2", provider="deepseek") + ) llm = LLMRequest( - model_set=model_config.model_task_config.memory_judge, + model_set=model_set, request_type="unified_memory.judge", ) diff --git a/src/plugins/built_in/core_actions/plugin.py b/src/plugins/built_in/core_actions/plugin.py index 5baaa3a8e..008e42b09 100644 --- a/src/plugins/built_in/core_actions/plugin.py +++ b/src/plugins/built_in/core_actions/plugin.py @@ -63,7 +63,7 @@ class CoreActionsPlugin(BasePlugin): """返回插件包含的组件列表""" # --- 根据配置注册组件 --- - components: ClassVar = [] + components = [] # 注册 reply 动作 if self.get_config("components.enable_reply", True): diff --git a/src/plugins/built_in/napcat_adapter/plugin.py b/src/plugins/built_in/napcat_adapter/plugin.py index 57dde90ce..9bed812d7 100644 --- a/src/plugins/built_in/napcat_adapter/plugin.py +++ b/src/plugins/built_in/napcat_adapter/plugin.py @@ -317,6 +317,9 @@ class NapcatAdapterPlugin(BasePlugin): "ignore_non_self_poke": ConfigField(type=bool, default=False, description="是否忽略不是针对自己的戳一戳消息"), "poke_debounce_seconds": ConfigField(type=float, default=2.0, description="戳一戳防抖时间(秒)"), "enable_emoji_like": ConfigField(type=bool, default=True, 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_video_processing": ConfigField(type=bool, default=True, description="是否启用视频消息处理(下载和解析)"), }, } diff --git a/src/plugins/built_in/napcat_adapter/src/handlers/to_core/message_handler.py b/src/plugins/built_in/napcat_adapter/src/handlers/to_core/message_handler.py index 0cc74681b..31c6a867b 100644 --- a/src/plugins/built_in/napcat_adapter/src/handlers/to_core/message_handler.py +++ b/src/plugins/built_in/napcat_adapter/src/handlers/to_core/message_handler.py @@ -5,7 +5,7 @@ from __future__ import annotations import base64 import time from pathlib import Path -from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple +from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple, cast import uuid from mofox_wire import MessageBuilder @@ -214,6 +214,9 @@ class MessageHandler: case RealMessageType.record: return await self._handle_record_message(segment) case RealMessageType.video: + if not config_api.get_plugin_config(self.plugin_config, "features.enable_video_processing", True): + logger.debug("视频消息处理已禁用,跳过") + return None return await self._handle_video_message(segment) case RealMessageType.rps: return await self._handle_rps_message(segment) @@ -331,10 +334,13 @@ class MessageHandler: {"type": seg.get("type", "text"), "data": seg.get("data", "")} for seg in reply_segments ] or [{"type": "text", "data": "[无法获取被引用的消息]"}] - return { - "type": "seglist", - "data": [{"type": "text", "data": prefix_text}, *brief_segments, {"type": "text", "data": suffix_text}], - } + return cast( + SegPayload, + { + "type": "seglist", + "data": [{"type": "text", "data": prefix_text}, *brief_segments, {"type": "text", "data": suffix_text}], + }, + ) async def _handle_record_message(self, segment: dict) -> SegPayload | None: """处理语音消息""" @@ -380,14 +386,17 @@ class MessageHandler: video_base64 = base64.b64encode(video_data).decode("utf-8") logger.debug(f"视频文件大小: {len(video_data) / (1024 * 1024):.2f} MB") - return { - "type": "video", - "data": { - "base64": video_base64, - "filename": Path(file_path).name, - "size_mb": len(video_data) / (1024 * 1024), + return cast( + SegPayload, + { + "type": "video", + "data": { + "base64": video_base64, + "filename": Path(file_path).name, + "size_mb": len(video_data) / (1024 * 1024), + }, }, - } + ) elif video_url: # URL下载处理 from ..video_handler import get_video_downloader @@ -401,15 +410,18 @@ class MessageHandler: video_base64 = base64.b64encode(download_result["data"]).decode("utf-8") logger.debug(f"视频下载成功,大小: {len(download_result['data']) / (1024 * 1024):.2f} MB") - return { - "type": "video", - "data": { - "base64": video_base64, - "filename": download_result.get("filename", "video.mp4"), - "size_mb": len(download_result["data"]) / (1024 * 1024), - "url": video_url, + return cast( + SegPayload, + { + "type": "video", + "data": { + "base64": video_base64, + "filename": download_result.get("filename", "video.mp4"), + "size_mb": len(download_result["data"]) / (1024 * 1024), + "url": video_url, + }, }, - } + ) else: logger.warning("既没有有效的本地文件路径,也没有有效的视频URL") return None @@ -454,34 +466,39 @@ class MessageHandler: processed_message = handled_message forward_hint = {"type": "text", "data": "这是一条转发消息:\n"} - return {"type": "seglist", "data": [forward_hint, processed_message]} + return cast(SegPayload, {"type": "seglist", "data": [forward_hint, processed_message]}) async def _recursive_parse_image_seg(self, seg_data: SegPayload, to_image: bool) -> SegPayload: # sourcery skip: merge-else-if-into-elif if seg_data.get("type") == "seglist": new_seg_list = [] for i_seg in seg_data.get("data", []): - parsed_seg = await self._recursive_parse_image_seg(i_seg, to_image) - new_seg_list.append(parsed_seg) + if isinstance(i_seg, dict): # 确保是字典类型 + parsed_seg = await self._recursive_parse_image_seg(i_seg, to_image) + new_seg_list.append(parsed_seg) return {"type": "seglist", "data": new_seg_list} if to_image: if seg_data.get("type") == "image": image_url = seg_data.get("data") - try: - encoded_image = await get_image_base64(image_url) - except Exception as e: - logger.error(f"图片处理失败: {str(e)}") - return {"type": "text", "data": "[图片]"} - return {"type": "image", "data": encoded_image} + if isinstance(image_url, str): + try: + encoded_image = await get_image_base64(image_url) + except Exception as e: + logger.error(f"图片处理失败: {str(e)}") + return {"type": "text", "data": "[图片]"} + return {"type": "image", "data": encoded_image} + return {"type": "text", "data": "[图片]"} if seg_data.get("type") == "emoji": image_url = seg_data.get("data") - try: - encoded_image = await get_image_base64(image_url) - except Exception as e: - logger.error(f"图片处理失败: {str(e)}") - return {"type": "text", "data": "[表情包]"} - return {"type": "emoji", "data": encoded_image} + if isinstance(image_url, str): + try: + encoded_image = await get_image_base64(image_url) + except Exception as e: + logger.error(f"图片处理失败: {str(e)}") + return {"type": "text", "data": "[表情包]"} + return {"type": "emoji", "data": encoded_image} + return {"type": "text", "data": "[表情包]"} logger.debug(f"不处理类型: {seg_data.get('type')}") return seg_data @@ -595,7 +612,7 @@ class MessageHandler: "id": file_id, } - return {"type": "file", "data": file_data} + return cast(SegPayload, {"type": "file", "data": file_data}) async def _handle_json_message(self, segment: dict) -> SegPayload | None: """ @@ -623,7 +640,7 @@ class MessageHandler: # 从回声消息中提取文件信息 file_info = self._extract_file_info_from_echo(nested_data) if file_info: - return {"type": "file", "data": file_info} + return cast(SegPayload, {"type": "file", "data": file_info}) # 检查是否是QQ小程序分享消息 if "app" in nested_data and "com.tencent.miniapp" in str(nested_data.get("app", "")): From c040cf08e856237eb1e84099f34cd031d0dd2cb5 Mon Sep 17 00:00:00 2001 From: tt-P607 <68868379+tt-P607@users.noreply.github.com> Date: Fri, 28 Nov 2025 10:33:49 +0800 Subject: [PATCH 09/12] =?UTF-8?q?Revert=20"refactor(core):=20=E6=8F=90?= =?UTF-8?q?=E5=8D=87=E7=B1=BB=E5=9E=8B=E5=AE=89=E5=85=A8=E6=80=A7=E5=B9=B6?= =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E9=85=8D=E7=BD=AE=E7=A9=BA=E5=80=BC=E6=A3=80?= =?UTF-8?q?=E6=9F=A5"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit abfcf569419fe994fb97be5f7a9bcb93fa04b09b. --- src/chat/message_receive/message_handler.py | 39 ++++---- src/chat/message_receive/message_processor.py | 44 ++++++--- src/memory_graph/unified_manager.py | 34 ++----- src/plugins/built_in/core_actions/plugin.py | 2 +- src/plugins/built_in/napcat_adapter/plugin.py | 3 - .../src/handlers/to_core/message_handler.py | 91 ++++++++----------- 6 files changed, 94 insertions(+), 119 deletions(-) diff --git a/src/chat/message_receive/message_handler.py b/src/chat/message_receive/message_handler.py index 0ee66fbe8..a7238c2fd 100644 --- a/src/chat/message_receive/message_handler.py +++ b/src/chat/message_receive/message_handler.py @@ -43,7 +43,6 @@ from src.config.config import global_config from src.mood.mood_manager import mood_manager from src.plugin_system.base import BaseCommand, EventType from src.plugin_system.core import component_registry, event_manager, global_announcement_manager -from typing import cast if TYPE_CHECKING: from src.chat.message_receive.chat_stream import ChatStream @@ -56,25 +55,23 @@ PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "../..")) def _check_ban_words(text: str, chat: "ChatStream", userinfo) -> bool: """检查消息是否包含过滤词""" - if global_config and global_config.message_receive: - for word in global_config.message_receive.ban_words: - if word in text: - chat_name = chat.group_info.group_name if chat.group_info else "私聊" - logger.info(f"[{chat_name}]{userinfo.user_nickname}:{text}") - logger.info(f"[过滤词识别]消息中含有{word},filtered") - return True + for word in global_config.message_receive.ban_words: + if word in text: + chat_name = chat.group_info.group_name if chat.group_info else "私聊" + logger.info(f"[{chat_name}]{userinfo.user_nickname}:{text}") + logger.info(f"[过滤词识别]消息中含有{word},filtered") + return True return False def _check_ban_regex(text: str, chat: "ChatStream", userinfo) -> bool: """检查消息是否匹配过滤正则表达式""" - if global_config and global_config.message_receive: - for pattern in global_config.message_receive.ban_msgs_regex: - if re.search(pattern, text): - chat_name = chat.group_info.group_name if chat.group_info else "私聊" - logger.info(f"[{chat_name}]{userinfo.user_nickname}:{text}") - logger.info(f"[正则表达式过滤]消息匹配到{pattern},filtered") - return True + for pattern in global_config.message_receive.ban_msgs_regex: + if re.search(pattern, text): + chat_name = chat.group_info.group_name if chat.group_info else "私聊" + logger.info(f"[{chat_name}]{userinfo.user_nickname}:{text}") + logger.info(f"[正则表达式过滤]消息匹配到{pattern},filtered") + return True return False @@ -284,7 +281,7 @@ class MessageHandler: chat = await get_chat_manager().get_or_create_stream( platform=platform, user_info=DatabaseUserInfo.from_dict(user_info) if user_info else None, # type: ignore - group_info=DatabaseGroupInfo.from_dict(cast(dict, group_info)) if group_info else None, + group_info=DatabaseGroupInfo.from_dict(group_info) if group_info else None, ) # 将消息信封转换为 DatabaseMessages @@ -434,7 +431,7 @@ class MessageHandler: chat = await get_chat_manager().get_or_create_stream( platform=platform, user_info=DatabaseUserInfo.from_dict(user_info) if user_info else None, # type: ignore - group_info=DatabaseGroupInfo.from_dict(cast(dict, group_info)) if group_info else None, + group_info=DatabaseGroupInfo.from_dict(group_info) if group_info else None, ) # 将消息信封转换为 DatabaseMessages @@ -538,9 +535,7 @@ class MessageHandler: text = message.processed_plain_text or "" # 获取配置的命令前缀 - prefixes = [] - if global_config and global_config.command: - prefixes = global_config.command.command_prefixes + prefixes = global_config.command.command_prefixes # 检查是否以任何前缀开头 matched_prefix = None @@ -712,7 +707,7 @@ class MessageHandler: # 检查是否需要处理消息 should_process_in_manager = True - if group_info and global_config and global_config.message_receive and str(group_info.group_id) in global_config.message_receive.mute_group_list: + if group_info and str(group_info.group_id) in global_config.message_receive.mute_group_list: is_image_or_emoji = message.is_picid or message.is_emoji if not message.is_mentioned and not is_image_or_emoji: logger.debug( @@ -736,7 +731,7 @@ class MessageHandler: # 情绪系统更新 try: - if global_config and global_config.mood and global_config.mood.enable_mood: + if global_config.mood.enable_mood: interest_rate = message.interest_value or 0.0 logger.debug(f"开始更新情绪状态,兴趣度: {interest_rate:.2f}") diff --git a/src/chat/message_receive/message_processor.py b/src/chat/message_receive/message_processor.py index c196d0322..96aa56650 100644 --- a/src/chat/message_receive/message_processor.py +++ b/src/chat/message_receive/message_processor.py @@ -56,7 +56,7 @@ async def process_message_from_dict(message_dict: MessageEnvelope, stream_id: st } # 异步处理消息段,生成纯文本 - processed_plain_text = await _process_message_segments(message_segment, processing_state) + processed_plain_text = await _process_message_segments(message_segment, processing_state, message_info) # 解析 notice 信息 is_notify = False @@ -155,13 +155,15 @@ async def process_message_from_dict(message_dict: MessageEnvelope, stream_id: st async def _process_message_segments( segment: SegPayload | list[SegPayload], - state: dict + state: dict, + message_info: MessageInfoPayload ) -> str: """递归处理消息段,转换为文字描述 Args: segment: 要处理的消息段(TypedDict 或列表) state: 处理状态字典(用于记录消息类型标记) + message_info: 消息基础信息(TypedDict 格式) Returns: str: 处理后的文本 @@ -170,7 +172,7 @@ async def _process_message_segments( if isinstance(segment, list): segments_text = [] for seg in segment: - processed = await _process_message_segments(seg, state) + processed = await _process_message_segments(seg, state, message_info) if processed: segments_text.append(processed) return " ".join(segments_text) @@ -184,26 +186,28 @@ async def _process_message_segments( if seg_type == "seglist" and isinstance(seg_data, list): segments_text = [] for sub_seg in seg_data: - processed = await _process_message_segments(sub_seg, state) + processed = await _process_message_segments(sub_seg, state, message_info) if processed: segments_text.append(processed) return " ".join(segments_text) # 处理其他类型 - return await _process_single_segment(segment, state) + return await _process_single_segment(segment, state, message_info) return "" async def _process_single_segment( segment: SegPayload, - state: dict + state: dict, + message_info: MessageInfoPayload ) -> str: """处理单个消息段 Args: segment: 消息段(TypedDict 格式) state: 处理状态字典 + message_info: 消息基础信息(TypedDict 格式) Returns: str: 处理后的文本 @@ -230,6 +234,7 @@ async def _process_single_segment( return f"@{seg_data}" if isinstance(seg_data, str) else "@未知用户" elif seg_type == "image": + # 如果是base64图片数据 if isinstance(seg_data, str): state["has_picid"] = True state["is_picid"] = True @@ -242,17 +247,27 @@ async def _process_single_segment( state["has_emoji"] = True state["is_emoji"] = True if isinstance(seg_data, str): - image_manager = get_image_manager() - return await image_manager.get_emoji_description(seg_data) + return await get_image_manager().get_emoji_description(seg_data) return "[发了一个表情包,网卡了加载不出来]" elif seg_type == "voice": state["is_voice"] = True - # 检查是否是自己发送的语音 + + # 检查消息是否由机器人自己发送 + user_info = message_info.get("user_info", {}) + user_id_str = str(user_info.get("user_id", "")) + if user_id_str == str(global_config.bot.qq_account): + logger.info(f"检测到机器人自身发送的语音消息 (User ID: {user_id_str}),尝试从缓存获取文本。") + if isinstance(seg_data, str): + cached_text = consume_self_voice_text(seg_data) + if cached_text: + logger.info(f"成功从缓存中获取语音文本: '{cached_text[:70]}...'") + return f"[语音:{cached_text}]" + else: + logger.warning("机器人自身语音消息缓存未命中,将回退到标准语音识别。") + + # 标准语音识别流程 if isinstance(seg_data, str): - cached_text = consume_self_voice_text(seg_data) - if cached_text: - return f"[语音:{cached_text}]" return await get_voice_text(seg_data) return "[发了一段语音,网卡了加载不出来]" @@ -284,7 +299,7 @@ async def _process_single_segment( logger.warning("⚠️ Rust视频处理模块不可用,跳过视频分析") return "[视频]" - if global_config and global_config.video_analysis and global_config.video_analysis.enable: + if global_config.video_analysis.enable: logger.info("已启用视频识别,开始识别") if isinstance(seg_data, dict): try: @@ -302,9 +317,8 @@ async def _process_single_segment( # 使用video analyzer分析视频 video_analyzer = get_video_analyzer() - prompt = global_config.video_analysis.batch_analysis_prompt if global_config and global_config.video_analysis else "" result = await video_analyzer.analyze_video_from_bytes( - video_bytes, filename, prompt=prompt + video_bytes, filename, prompt=global_config.video_analysis.batch_analysis_prompt ) logger.info(f"视频分析结果: {result}") diff --git a/src/memory_graph/unified_manager.py b/src/memory_graph/unified_manager.py index 89390d57d..1b4a7dbd5 100644 --- a/src/memory_graph/unified_manager.py +++ b/src/memory_graph/unified_manager.py @@ -13,10 +13,9 @@ import asyncio import time from datetime import datetime from pathlib import Path -from typing import Any, cast +from typing import Any from src.common.logger import get_logger -from src.config.config import TaskConfig from src.memory_graph.manager import MemoryManager from src.memory_graph.long_term_manager import LongTermMemoryManager from src.memory_graph.models import JudgeDecision, MemoryBlock, ShortTermMemory @@ -84,7 +83,7 @@ class UnifiedMemoryManager: self.long_term_manager: LongTermMemoryManager # 底层 MemoryManager(长期记忆) - self.memory_manager: MemoryManager = cast(MemoryManager, memory_manager) + self.memory_manager: MemoryManager = memory_manager # 配置参数存储(用于初始化) self._config = { @@ -331,11 +330,7 @@ class UnifiedMemoryManager: """ - prompt = f"""你是一个记忆检索评估专家。你的任务是判断当前检索到的“感知记忆”(即时对话)和“短期记忆”(结构化信息)是否足以支撑一次有深度、有上下文的回复。 - -**核心原则:** -- **不要轻易检索长期记忆!** 只有在当前对话需要深入探讨、回忆过去复杂事件或需要特定背景知识时,才认为记忆不足。 -- **闲聊、简单问候、表情互动或无特定主题的对话,现有记忆通常是充足的。** 频繁检索长期记忆会拖慢响应速度。 + prompt = f"""你是一个记忆检索评估专家。请判断检索到的记忆是否足以回答用户的问题。 **用户查询:** {query} @@ -346,36 +341,27 @@ class UnifiedMemoryManager: **检索到的短期记忆(结构化信息,自然语言描述):** {short_term_desc or '(无)'} -**评估指南:** -1. **分析用户意图**:用户是在闲聊,还是在讨论一个需要深入挖掘的话题? -2. **检查现有记忆**:当前的感知和短期记忆是否已经包含了足够的信息来回应用户的查询? - - 对于闲聊(如“你好”、“哈哈”、“[表情]”),现有记忆总是充足的 (`"is_sufficient": true`)。 - - 对于需要回忆具体细节、深入探讨个人经历或专业知识的查询,如果现有记忆中没有相关信息,则可能不充足。 -3. **决策**: - - 如果记忆充足,设置 `"is_sufficient": true`。 - - 如果确实需要更多信息才能进行有意义的对话,设置 `"is_sufficient": false`,并提供具体的补充查询。 +**任务要求:** +1. 判断这些记忆是否足以回答用户的问题 +2. 如果不充足,分析缺少哪些方面的信息 +3. 生成额外需要检索的 query(用于在长期记忆中检索) **输出格式(JSON):** ```json {{ "is_sufficient": true/false, "confidence": 0.85, - "reasoning": "在这里解释你的判断理由。例如:‘用户只是在打招呼,现有记忆已足够’或‘用户问到了一个具体的历史事件,需要检索长期记忆’。", + "reasoning": "判断理由", "missing_aspects": ["缺失的信息1", "缺失的信息2"], "additional_queries": ["补充query1", "补充query2"] }} ``` -请严格按照上述原则进行判断,并输出JSON:""" +请输出JSON:""" # 调用记忆裁判模型 - model_set = ( - model_config.model_task_config.memory_judge - if model_config and model_config.model_task_config - else TaskConfig(model_name="deepseek/deepseek-v2", provider="deepseek") - ) llm = LLMRequest( - model_set=model_set, + model_set=model_config.model_task_config.memory_judge, request_type="unified_memory.judge", ) diff --git a/src/plugins/built_in/core_actions/plugin.py b/src/plugins/built_in/core_actions/plugin.py index 008e42b09..5baaa3a8e 100644 --- a/src/plugins/built_in/core_actions/plugin.py +++ b/src/plugins/built_in/core_actions/plugin.py @@ -63,7 +63,7 @@ class CoreActionsPlugin(BasePlugin): """返回插件包含的组件列表""" # --- 根据配置注册组件 --- - components = [] + components: ClassVar = [] # 注册 reply 动作 if self.get_config("components.enable_reply", True): diff --git a/src/plugins/built_in/napcat_adapter/plugin.py b/src/plugins/built_in/napcat_adapter/plugin.py index 9bed812d7..57dde90ce 100644 --- a/src/plugins/built_in/napcat_adapter/plugin.py +++ b/src/plugins/built_in/napcat_adapter/plugin.py @@ -317,9 +317,6 @@ class NapcatAdapterPlugin(BasePlugin): "ignore_non_self_poke": ConfigField(type=bool, default=False, description="是否忽略不是针对自己的戳一戳消息"), "poke_debounce_seconds": ConfigField(type=float, default=2.0, description="戳一戳防抖时间(秒)"), "enable_emoji_like": ConfigField(type=bool, default=True, 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_video_processing": ConfigField(type=bool, default=True, description="是否启用视频消息处理(下载和解析)"), }, } diff --git a/src/plugins/built_in/napcat_adapter/src/handlers/to_core/message_handler.py b/src/plugins/built_in/napcat_adapter/src/handlers/to_core/message_handler.py index 31c6a867b..0cc74681b 100644 --- a/src/plugins/built_in/napcat_adapter/src/handlers/to_core/message_handler.py +++ b/src/plugins/built_in/napcat_adapter/src/handlers/to_core/message_handler.py @@ -5,7 +5,7 @@ from __future__ import annotations import base64 import time from pathlib import Path -from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple, cast +from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple import uuid from mofox_wire import MessageBuilder @@ -214,9 +214,6 @@ class MessageHandler: case RealMessageType.record: return await self._handle_record_message(segment) case RealMessageType.video: - if not config_api.get_plugin_config(self.plugin_config, "features.enable_video_processing", True): - logger.debug("视频消息处理已禁用,跳过") - return None return await self._handle_video_message(segment) case RealMessageType.rps: return await self._handle_rps_message(segment) @@ -334,13 +331,10 @@ class MessageHandler: {"type": seg.get("type", "text"), "data": seg.get("data", "")} for seg in reply_segments ] or [{"type": "text", "data": "[无法获取被引用的消息]"}] - return cast( - SegPayload, - { - "type": "seglist", - "data": [{"type": "text", "data": prefix_text}, *brief_segments, {"type": "text", "data": suffix_text}], - }, - ) + return { + "type": "seglist", + "data": [{"type": "text", "data": prefix_text}, *brief_segments, {"type": "text", "data": suffix_text}], + } async def _handle_record_message(self, segment: dict) -> SegPayload | None: """处理语音消息""" @@ -386,17 +380,14 @@ class MessageHandler: video_base64 = base64.b64encode(video_data).decode("utf-8") logger.debug(f"视频文件大小: {len(video_data) / (1024 * 1024):.2f} MB") - return cast( - SegPayload, - { - "type": "video", - "data": { - "base64": video_base64, - "filename": Path(file_path).name, - "size_mb": len(video_data) / (1024 * 1024), - }, + return { + "type": "video", + "data": { + "base64": video_base64, + "filename": Path(file_path).name, + "size_mb": len(video_data) / (1024 * 1024), }, - ) + } elif video_url: # URL下载处理 from ..video_handler import get_video_downloader @@ -410,18 +401,15 @@ class MessageHandler: video_base64 = base64.b64encode(download_result["data"]).decode("utf-8") logger.debug(f"视频下载成功,大小: {len(download_result['data']) / (1024 * 1024):.2f} MB") - return cast( - SegPayload, - { - "type": "video", - "data": { - "base64": video_base64, - "filename": download_result.get("filename", "video.mp4"), - "size_mb": len(download_result["data"]) / (1024 * 1024), - "url": video_url, - }, + return { + "type": "video", + "data": { + "base64": video_base64, + "filename": download_result.get("filename", "video.mp4"), + "size_mb": len(download_result["data"]) / (1024 * 1024), + "url": video_url, }, - ) + } else: logger.warning("既没有有效的本地文件路径,也没有有效的视频URL") return None @@ -466,39 +454,34 @@ class MessageHandler: processed_message = handled_message forward_hint = {"type": "text", "data": "这是一条转发消息:\n"} - return cast(SegPayload, {"type": "seglist", "data": [forward_hint, processed_message]}) + return {"type": "seglist", "data": [forward_hint, processed_message]} async def _recursive_parse_image_seg(self, seg_data: SegPayload, to_image: bool) -> SegPayload: # sourcery skip: merge-else-if-into-elif if seg_data.get("type") == "seglist": new_seg_list = [] for i_seg in seg_data.get("data", []): - if isinstance(i_seg, dict): # 确保是字典类型 - parsed_seg = await self._recursive_parse_image_seg(i_seg, to_image) - new_seg_list.append(parsed_seg) + parsed_seg = await self._recursive_parse_image_seg(i_seg, to_image) + new_seg_list.append(parsed_seg) return {"type": "seglist", "data": new_seg_list} if to_image: if seg_data.get("type") == "image": image_url = seg_data.get("data") - if isinstance(image_url, str): - try: - encoded_image = await get_image_base64(image_url) - except Exception as e: - logger.error(f"图片处理失败: {str(e)}") - return {"type": "text", "data": "[图片]"} - return {"type": "image", "data": encoded_image} - return {"type": "text", "data": "[图片]"} + try: + encoded_image = await get_image_base64(image_url) + except Exception as e: + logger.error(f"图片处理失败: {str(e)}") + return {"type": "text", "data": "[图片]"} + return {"type": "image", "data": encoded_image} if seg_data.get("type") == "emoji": image_url = seg_data.get("data") - if isinstance(image_url, str): - try: - encoded_image = await get_image_base64(image_url) - except Exception as e: - logger.error(f"图片处理失败: {str(e)}") - return {"type": "text", "data": "[表情包]"} - return {"type": "emoji", "data": encoded_image} - return {"type": "text", "data": "[表情包]"} + try: + encoded_image = await get_image_base64(image_url) + except Exception as e: + logger.error(f"图片处理失败: {str(e)}") + return {"type": "text", "data": "[表情包]"} + return {"type": "emoji", "data": encoded_image} logger.debug(f"不处理类型: {seg_data.get('type')}") return seg_data @@ -612,7 +595,7 @@ class MessageHandler: "id": file_id, } - return cast(SegPayload, {"type": "file", "data": file_data}) + return {"type": "file", "data": file_data} async def _handle_json_message(self, segment: dict) -> SegPayload | None: """ @@ -640,7 +623,7 @@ class MessageHandler: # 从回声消息中提取文件信息 file_info = self._extract_file_info_from_echo(nested_data) if file_info: - return cast(SegPayload, {"type": "file", "data": file_info}) + return {"type": "file", "data": file_info} # 检查是否是QQ小程序分享消息 if "app" in nested_data and "com.tencent.miniapp" in str(nested_data.get("app", "")): From 82fe1152680700c02b5ec32e802315bc8e8d4aa1 Mon Sep 17 00:00:00 2001 From: tt-P607 <68868379+tt-P607@users.noreply.github.com> Date: Fri, 28 Nov 2025 10:57:01 +0800 Subject: [PATCH 10/12] =?UTF-8?q?refactor(core):=20=E6=8F=90=E9=AB=98?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E8=AE=BF=E9=97=AE=E5=AE=89=E5=85=A8=E6=80=A7?= =?UTF-8?q?=E5=B9=B6=E4=BC=98=E5=8C=96=E8=AE=B0=E5=BF=86=E5=88=A4=E6=96=AD?= =?UTF-8?q?=E6=8F=90=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 此提交引入了若干关键改进,以增强应用程序的稳定性和智能性。 首先,通过在 `main.py` 和 `memory_graph/manager_singleton.py` 的关键初始化路径中为 `global_config` 及其嵌套属性添加全面的空值检查,增强了系统的稳健性。这可以防止在配置加载失败或不完整时出现潜在的 `AttributeError` 异常,使应用程序的启动过程更加可靠。 其次,在 `UnifiedMemoryManager` 中的“记忆判断”模型提示已大幅优化。新的提示提供了更严格的指导,避免在简单交互(如问候或闲聊)中进行不必要的长期记忆查询。这一优化通过使记忆检索过程更加高效和具上下文意识,提高了响应速度并降低了计算开销。 最后,新配置已在 Napcat 适配器中添加了选项,以更精细地控制消息处理,包括启用/禁用视频处理和配置自动@回复。 --- src/main.py | 10 ++++---- src/memory_graph/manager_singleton.py | 9 ++++--- src/memory_graph/unified_manager.py | 25 +++++++++++++------ src/plugins/built_in/napcat_adapter/plugin.py | 3 +++ .../src/handlers/to_core/message_handler.py | 3 +++ 5 files changed, 35 insertions(+), 15 deletions(-) diff --git a/src/main.py b/src/main.py index fc5712116..ad7be829a 100644 --- a/src/main.py +++ b/src/main.py @@ -360,7 +360,7 @@ class MainSystem: async def initialize(self) -> None: """初始化系统组件""" # 检查必要的配置 - if not hasattr(global_config, "bot") or not hasattr(global_config.bot, "nickname"): + if not global_config or not global_config.bot or not global_config.bot.nickname: logger.error("缺少必要的bot配置") raise ValueError("Bot配置不完整") @@ -386,7 +386,7 @@ class MainSystem: selected_egg = choices(egg_texts, weights=weights, k=1)[0] logger.info(f""" -全部系统初始化完成,{global_config.bot.nickname}已成功唤醒 +全部系统初始化完成,{global_config.bot.nickname if global_config and global_config.bot else 'Bot'}已成功唤醒 ========================================================= MoFox_Bot(第三方修改版) 全部组件已成功启动! @@ -484,7 +484,7 @@ MoFox_Bot(第三方修改版) # 初始化三层记忆系统(如果启用) try: - if global_config.memory and global_config.memory.enable: + if global_config and global_config.memory and global_config.memory.enable: from src.memory_graph.manager_singleton import initialize_unified_memory_manager logger.info("三层记忆系统已启用,正在初始化...") await initialize_unified_memory_manager() @@ -568,7 +568,7 @@ MoFox_Bot(第三方修改版) async def _init_planning_components(self) -> None: """初始化计划相关组件""" # 初始化月度计划管理器 - if global_config.planning_system.monthly_plan_enable: + if global_config and global_config.planning_system and global_config.planning_system.monthly_plan_enable: try: await monthly_plan_manager.start_monthly_plan_generation() logger.info("月度计划管理器初始化成功") @@ -576,7 +576,7 @@ MoFox_Bot(第三方修改版) logger.error(f"月度计划管理器初始化失败: {e}") # 初始化日程管理器 - if global_config.planning_system.schedule_enable: + if global_config and global_config.planning_system and global_config.planning_system.schedule_enable: try: await schedule_manager.load_or_generate_today_schedule() await schedule_manager.start_daily_schedule_generation() diff --git a/src/memory_graph/manager_singleton.py b/src/memory_graph/manager_singleton.py index 9ddc609ac..66b5d4abc 100644 --- a/src/memory_graph/manager_singleton.py +++ b/src/memory_graph/manager_singleton.py @@ -50,7 +50,7 @@ async def initialize_memory_manager( from src.config.config import global_config # 检查是否启用 - if not global_config.memory or not getattr(global_config.memory, "enable", False): + if not global_config or not global_config.memory or not getattr(global_config.memory, "enable", False): logger.info("记忆图系统已在配置中禁用") _initialized = False _memory_manager = None @@ -58,7 +58,7 @@ async def initialize_memory_manager( # 处理数据目录 if data_dir is None: - data_dir = getattr(global_config.memory, "data_dir", "data/memory_graph") + data_dir = getattr(global_config.memory, "data_dir", "data/memory_graph") if global_config and global_config.memory else "data/memory_graph" if isinstance(data_dir, str): data_dir = Path(data_dir) @@ -136,12 +136,15 @@ async def initialize_unified_memory_manager(): from src.memory_graph.unified_manager import UnifiedMemoryManager # 检查是否启用三层记忆系统 - if not hasattr(global_config, "memory") or not getattr( + if not global_config or not global_config.memory or not getattr( global_config.memory, "enable", False ): logger.warning("三层记忆系统未启用,跳过初始化") return None + if not global_config or not global_config.memory: + logger.warning("未找到内存配置,跳过统一内存管理器初始化。") + return None config = global_config.memory # 创建管理器实例 diff --git a/src/memory_graph/unified_manager.py b/src/memory_graph/unified_manager.py index 1b4a7dbd5..20f6e34d8 100644 --- a/src/memory_graph/unified_manager.py +++ b/src/memory_graph/unified_manager.py @@ -330,7 +330,11 @@ class UnifiedMemoryManager: """ - prompt = f"""你是一个记忆检索评估专家。请判断检索到的记忆是否足以回答用户的问题。 + prompt = f"""你是一个记忆检索评估专家。你的任务是判断当前检索到的“感知记忆”(即时对话)和“短期记忆”(结构化信息)是否足以支撑一次有深度、有上下文的回复。 + +**核心原则:** +- **不要轻易检索长期记忆!** 只有在当前对话需要深入探讨、回忆过去复杂事件或需要特定背景知识时,才认为记忆不足。 +- **闲聊、简单问候、表情互动或无特定主题的对话,现有记忆通常是充足的。** 频繁检索长期记忆会拖慢响应速度。 **用户查询:** {query} @@ -341,25 +345,32 @@ class UnifiedMemoryManager: **检索到的短期记忆(结构化信息,自然语言描述):** {short_term_desc or '(无)'} -**任务要求:** -1. 判断这些记忆是否足以回答用户的问题 -2. 如果不充足,分析缺少哪些方面的信息 -3. 生成额外需要检索的 query(用于在长期记忆中检索) +**评估指南:** +1. **分析用户意图**:用户是在闲聊,还是在讨论一个需要深入挖掘的话题? +2. **检查现有记忆**:当前的感知和短期记忆是否已经包含了足够的信息来回应用户的查询? + - 对于闲聊(如“你好”、“哈哈”、“[表情]”),现有记忆总是充足的 (`"is_sufficient": true`)。 + - 对于需要回忆具体细节、深入探讨个人经历或专业知识的查询,如果现有记忆中没有相关信息,则可能不充足。 +3. **决策**: + - 如果记忆充足,设置 `"is_sufficient": true`。 + - 如果确实需要更多信息才能进行有意义的对话,设置 `"is_sufficient": false`,并提供具体的补充查询。 **输出格式(JSON):** ```json {{ "is_sufficient": true/false, "confidence": 0.85, - "reasoning": "判断理由", + "reasoning": "在这里解释你的判断理由。例如:‘用户只是在打招呼,现有记忆已足够’或‘用户问到了一个具体的历史事件,需要检索长期记忆’。", "missing_aspects": ["缺失的信息1", "缺失的信息2"], "additional_queries": ["补充query1", "补充query2"] }} ``` -请输出JSON:""" +请严格按照上述原则进行判断,并输出JSON:""" # 调用记忆裁判模型 + from src.config.config import model_config + if not model_config.model_task_config: + raise ValueError("模型任务配置未加载") llm = LLMRequest( model_set=model_config.model_task_config.memory_judge, request_type="unified_memory.judge", diff --git a/src/plugins/built_in/napcat_adapter/plugin.py b/src/plugins/built_in/napcat_adapter/plugin.py index 57dde90ce..9bed812d7 100644 --- a/src/plugins/built_in/napcat_adapter/plugin.py +++ b/src/plugins/built_in/napcat_adapter/plugin.py @@ -317,6 +317,9 @@ class NapcatAdapterPlugin(BasePlugin): "ignore_non_self_poke": ConfigField(type=bool, default=False, description="是否忽略不是针对自己的戳一戳消息"), "poke_debounce_seconds": ConfigField(type=float, default=2.0, description="戳一戳防抖时间(秒)"), "enable_emoji_like": ConfigField(type=bool, default=True, 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_video_processing": ConfigField(type=bool, default=True, description="是否启用视频消息处理(下载和解析)"), }, } diff --git a/src/plugins/built_in/napcat_adapter/src/handlers/to_core/message_handler.py b/src/plugins/built_in/napcat_adapter/src/handlers/to_core/message_handler.py index 0cc74681b..b0188a8c3 100644 --- a/src/plugins/built_in/napcat_adapter/src/handlers/to_core/message_handler.py +++ b/src/plugins/built_in/napcat_adapter/src/handlers/to_core/message_handler.py @@ -214,6 +214,9 @@ class MessageHandler: case RealMessageType.record: return await self._handle_record_message(segment) case RealMessageType.video: + if not config_api.get_plugin_config(self.plugin_config, "features.enable_video_processing", False): + logger.debug("视频消息处理已禁用,跳过") + return {"type": "text", "data": "[视频消息]"} return await self._handle_video_message(segment) case RealMessageType.rps: return await self._handle_rps_message(segment) From 588830c8197464da4e313717e9d69feec9245cb2 Mon Sep 17 00:00:00 2001 From: tt-P607 <68868379+tt-P607@users.noreply.github.com> Date: Fri, 28 Nov 2025 11:17:23 +0800 Subject: [PATCH 11/12] =?UTF-8?q?feat(tts):=20=E5=9C=A8=E5=88=9D=E5=A7=8B?= =?UTF-8?q?=E5=8C=96=E6=97=B6=E8=87=AA=E5=8A=A8=E7=94=9F=E6=88=90=E9=BB=98?= =?UTF-8?q?=E8=AE=A4=E9=85=8D=E7=BD=AE=E6=96=87=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 当 TTS 插件首次初始化时,现在会检查其配置文件是否存在。如果未找到文件,将自动创建一个默认的 `config.toml`,其中包含预填充的、已注释的设置。 这一改进显著提升了用户体验,通过提供一个可立即使用的模板,引导用户如何配置 TTS 服务,而无需查阅文档。它可以防止因缺少配置文件而导致的启动错误,并简化初始设置过程。 --- .../built_in/tts_voice_plugin/plugin.py | 105 ++++++++++++++++++ 1 file changed, 105 insertions(+) diff --git a/src/plugins/built_in/tts_voice_plugin/plugin.py b/src/plugins/built_in/tts_voice_plugin/plugin.py index 305b8b96b..14d26655c 100644 --- a/src/plugins/built_in/tts_voice_plugin/plugin.py +++ b/src/plugins/built_in/tts_voice_plugin/plugin.py @@ -50,6 +50,106 @@ class TTSVoicePlugin(BasePlugin): super().__init__(*args, **kwargs) self.tts_service = None + def _create_default_config(self, config_file: Path): + """ + 如果配置文件不存在,则创建一个默认的配置文件。 + """ + if config_file.is_file(): + return + + logger.info(f"TTS 配置文件不存在,正在创建默认配置文件于: {config_file}") + + default_config_content = """# 插件基础配置 +[plugin] +enable = true +keywords = [ + "发语音", "语音", "说句话", "用语音说", "听你", "听声音", "想听你", "想听声音", + "讲个话", "说段话", "念一下", "读一下", "用嘴说", "说", "能发语音吗","亲口" +] + +# 组件启用控制 +[components] +action_enabled = true +command_enabled = true + +# TTS 语音合成基础配置 +[tts] +server = "http://127.0.0.1:9880" +timeout = 180 +max_text_length = 1000 + +# TTS 风格参数配置 +# 每个 [[tts_styles]] 代表一个独立的语音风格配置 +[[tts_styles]] +# 风格的唯一标识符,必须有一个名为 "default" +style_name = "default" +# 显示名称 +name = "默认" +# 参考音频路径 +refer_wav_path = "C:/path/to/your/reference.wav" +# 参考音频文本 +prompt_text = "这是一个示例文本,请替换为您自己的参考音频文本。" +# 参考音频语言 +prompt_language = "zh" +# GPT 模型路径 +gpt_weights = "C:/path/to/your/gpt_weights.ckpt" +# SoVITS 模型路径 +sovits_weights = "C:/path/to/your/sovits_weights.pth" +# 语速 +speed_factor = 1.0 + +# TTS 高级参数配置 +[tts_advanced] +media_type = "wav" +top_k = 9 +top_p = 0.8 +temperature = 0.8 +batch_size = 6 +batch_threshold = 0.75 +text_split_method = "cut5" +repetition_penalty = 1.4 +sample_steps = 150 +super_sampling = true + +# 空间音效配置 +[spatial_effects] + +# 是否启用空间音效处理 +enabled = false + +# 是否启用标准混响效果 +reverb_enabled = false + +# 混响的房间大小 (建议范围 0.0-1.0) +room_size = 0.2 + +# 混响的阻尼/高频衰减 (建议范围 0.0-1.0) +damping = 0.6 + +# 混响的湿声(效果声)比例 (建议范围 0.0-1.0) +wet_level = 0.3 + +# 混响的干声(原声)比例 (建议范围 0.0-1.0) +dry_level = 0.8 + +# 混响的立体声宽度 (建议范围 0.0-1.0) +width = 1.0 + +# 是否启用卷积混响(需要assets/small_room_ir.wav文件) +convolution_enabled = false + +# 卷积混响的干湿比 (建议范围 0.0-1.0) +convolution_mix = 0.7 +""" + + try: + config_file.parent.mkdir(parents=True, exist_ok=True) + with open(config_file, "w", encoding="utf-8") as f: + f.write(default_config_content.strip()) + logger.info("默认 TTS 配置文件创建成功。") + except Exception as e: + logger.error(f"创建默认 TTS 配置文件失败: {e}") + def _get_config_wrapper(self, key: str, default: Any = None) -> Any: """ 配置获取的包装器,用于解决 get_config 无法直接获取动态表(如 tts_styles)和未在 schema 中定义的节的问题。 @@ -93,6 +193,11 @@ class TTSVoicePlugin(BasePlugin): """ logger.info("初始化 TTSVoicePlugin...") + plugin_file = Path(__file__).resolve() + bot_root = plugin_file.parent.parent.parent.parent.parent + config_file = bot_root / "config" / "plugins" / self.plugin_name / self.config_file_name + self._create_default_config(config_file) + # 实例化 TTSService,并传入 get_config 方法 self.tts_service = TTSService(self._get_config_wrapper) From 876f20e847eef1fae7441ef31efe8a05e0fc6502 Mon Sep 17 00:00:00 2001 From: tt-P607 <68868379+tt-P607@users.noreply.github.com> Date: Fri, 28 Nov 2025 12:00:40 +0800 Subject: [PATCH 12/12] =?UTF-8?q?feat(llm):=20=E4=B8=BA=E4=B8=8D=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E7=9A=84=E6=A8=A1=E5=9E=8B=E6=B7=BB=E5=8A=A0=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=E5=B0=86=20GIF=20=E8=BD=AC=E6=8D=A2=E4=B8=BA=20PNG=20?= =?UTF-8?q?=E5=B8=A7=E7=9A=84=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 当语言模型不支持用于图像输入的 GIF 格式时,此功能会自动将 GIF 转换为一系列 PNG 帧。它智能地从 GIF 中采样最多 4 帧,确保即使是动画图像也可以被仅支持静态格式(如 PNG)的模型处理。 这增强了模型的多功能性,让用户无需担心原生格式支持问题即可提交 GIF,从而提供更顺畅和强大的用户体验。 --- src/llm_models/payload_content/message.py | 58 +++++++++++++++++++++-- 1 file changed, 54 insertions(+), 4 deletions(-) diff --git a/src/llm_models/payload_content/message.py b/src/llm_models/payload_content/message.py index 7a34349a3..2cda33727 100644 --- a/src/llm_models/payload_content/message.py +++ b/src/llm_models/payload_content/message.py @@ -1,5 +1,9 @@ +import base64 +import io from enum import Enum +from PIL import Image + # 设计这系列类的目的是为未来可能的扩展做准备 @@ -53,6 +57,35 @@ class MessageBuilder: self.__content.append(text) return self + def _convert_gif_to_png_frames(self, gif_base64: str, max_frames: int = 4) -> list[str]: + """将GIF的Base64编码分解为多个PNG帧的Base64编码列表""" + gif_bytes = base64.b64decode(gif_base64) + gif_image = Image.open(io.BytesIO(gif_bytes)) + + frames = [] + total_frames = getattr(gif_image, "n_frames", 1) + + # 如果总帧数小于等于最大帧数,则全部提取 + if total_frames <= max_frames: + indices = range(total_frames) + else: + # 否则,在总帧数中均匀选取 max_frames 帧 + indices = [int(i * (total_frames - 1) / (max_frames - 1)) for i in range(max_frames)] + + for i in indices: + try: + gif_image.seek(i) + frame = gif_image.convert("RGBA") + + output_buffer = io.BytesIO() + frame.save(output_buffer, format="PNG") + png_bytes = output_buffer.getvalue() + frames.append(base64.b64encode(png_bytes).decode("utf-8")) + except EOFError: + # 到达文件末尾,停止提取 + break + return frames + def add_image_content( self, image_format: str, @@ -60,18 +93,35 @@ class MessageBuilder: support_formats=None, # 默认支持格式 ) -> "MessageBuilder": """ - 添加图片内容 + 添加图片内容, 如果是GIF且模型不支持, 则会分解为最多4帧PNG图片。 :param image_format: 图片格式 :param image_base64: 图片的base64编码 :return: MessageBuilder对象 """ if support_formats is None: support_formats = SUPPORTED_IMAGE_FORMATS - if image_format.lower() not in support_formats: - raise ValueError("不受支持的图片格式") + + current_format = image_format.lower() + + # 如果是GIF且模型不支持, 则分解为多个PNG帧 + if current_format == "gif" and "gif" not in support_formats: + if "png" in support_formats: + png_frames_base64 = self._convert_gif_to_png_frames(image_base64) + for frame_base64 in png_frames_base64: + if not frame_base64: + continue + self.__content.append(("png", frame_base64)) + return self + else: + raise ValueError("模型不支持GIF, 且无法转换为PNG") + + # 对于其他格式或模型支持GIF的情况 + if current_format not in support_formats: + raise ValueError(f"不受支持的图片格式: {current_format}") if not image_base64: raise ValueError("图片的base64编码不能为空") - self.__content.append((image_format, image_base64)) + + self.__content.append((current_format, image_base64)) return self def add_tool_call(self, tool_call_id: str) -> "MessageBuilder":