From e8af00e01617bfeb1bf22fd31215243ad526d84b Mon Sep 17 00:00:00 2001 From: Helian Nuits Date: Wed, 8 Jul 2026 11:44:19 +0800 Subject: [PATCH] =?UTF-8?q?feat(muted):=20=E8=A2=AB=E7=A6=81=E8=A8=80?= =?UTF-8?q?=E9=81=BF=E5=85=8D=E8=A7=A6=E5=8F=91=E5=88=86=E6=9E=90=E5=8A=9F?= =?UTF-8?q?=E8=83=BD=E6=B5=AA=E8=B4=B9=20token=20(#191)=20(#203)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(AnalysisApplicationService): 增加针对禁言的优化 * fix: 优先从 Event 中提取 bot_self_id * fix: 禁言异常处理 * fix: 不传递 no_cache=True 以免超时 * fix: 如果 bot_instance 没变,且已经有适配器,跳过重新创建,防止丢失内部状态 * fix: set_reaction 方法操作异常拦截 * fix: 修复错误的禁言发送消息,改为日志提示 * chore: add .tmp_refs to .gitignore * refactor(onebot): optimize and prune group mute cache * fix(onebot): resolve pylance type mismatch warning in min key * fix(onebot): annotate params dict with precise union type to resolve pylance warning --- .gitignore | 2 + main.py | 4 + .../services/analysis_application_service.py | 33 +++ .../platform/adapters/onebot_adapter.py | 225 +++++++++++++++++- src/infrastructure/platform/base.py | 7 + src/infrastructure/platform/bot_manager.py | 50 +++- 6 files changed, 301 insertions(+), 20 deletions(-) diff --git a/.gitignore b/.gitignore index 9268170..d37aa06 100644 --- a/.gitignore +++ b/.gitignore @@ -37,3 +37,5 @@ debug_atri.html data/test/avatar/cache.db test_mainland.html test_overseas.html +.tmp_refs/ + diff --git a/main.py b/main.py index 86c0a07..8d4d1eb 100644 --- a/main.py +++ b/main.py @@ -535,6 +535,10 @@ class GroupDailyAnalysis(Star): reason = result.get("reason") if reason == "no_messages": yield event.plain_result("❌ 未找到足够的群聊记录") + elif reason == "muted": + logger.warning( + f"群 {group_id} 开启了全群禁言或对 Bot 禁言,跳过回复以防抛出发送异常" + ) else: yield event.plain_result("❌ 分析失败,原因未知") return diff --git a/src/application/services/analysis_application_service.py b/src/application/services/analysis_application_service.py index c860a4b..67bb8b5 100644 --- a/src/application/services/analysis_application_service.py +++ b/src/application/services/analysis_application_service.py @@ -131,6 +131,17 @@ class AnalysisApplicationService: if not adapter: raise ValueError(f"未找到平台 {platform_id} 的适配器") + # 检查群聊是否被禁言(包括全体禁言或对 Bot 自身禁言) + if hasattr(adapter, "is_group_muted"): + try: + if await adapter.is_group_muted(group_id): + logger.info( + f"群 {group_id} 开启了全群禁言或对 Bot 禁言,跳过本次群分析" + ) + return {"success": False, "reason": "muted"} + except Exception as e: + logger.warning(f"检查群 {group_id} 禁言状态时出错: {e}") + # 飞书平台在分析前进行一次性权限与成员头像预热,避免报告阶段出现大面积默认头像。 if hasattr(adapter, "prepare_group_member_cache"): try: @@ -341,6 +352,17 @@ class AnalysisApplicationService: if not adapter: raise ValueError(f"未找到平台 {platform_id} 的适配器") + # 检查群聊是否被禁言(包括全体禁言或对 Bot 自身禁言) + if hasattr(adapter, "is_group_muted"): + try: + if await adapter.is_group_muted(group_id): + logger.info( + f"群 {group_id} 开启了全群禁言或对 Bot 禁言,跳过本次增量群分析" + ) + return {"success": False, "reason": "muted"} + except Exception as e: + logger.warning(f"检查群 {group_id} 禁言状态时出错: {e}") + # 2. 拉取消息,获取进度并确定拉取量 last_analyzed_ts = await self.incremental_store.get_last_analyzed_timestamp( group_id @@ -625,6 +647,17 @@ class AnalysisApplicationService: if not adapter: raise ValueError(f"未找到平台 {platform_id} 的适配器") + # 检查群聊是否被禁言(包括全体禁言或对 Bot 自身禁言) + if hasattr(adapter, "is_group_muted"): + try: + if await adapter.is_group_muted(group_id): + logger.info( + f"群 {group_id} 开启了全群禁言或对 Bot 禁言,跳过本次增量最终报告生成" + ) + return {"success": False, "reason": "muted"} + except Exception as e: + logger.warning(f"检查群 {group_id} 禁言状态时出错: {e}") + # 6. 执行分析相关的变量准备 user_titles = [] user_title_enabled = self.config_manager.get_user_title_analysis_enabled() diff --git a/src/infrastructure/platform/adapters/onebot_adapter.py b/src/infrastructure/platform/adapters/onebot_adapter.py index 4dcf41f..1bb23d1 100644 --- a/src/infrastructure/platform/adapters/onebot_adapter.py +++ b/src/infrastructure/platform/adapters/onebot_adapter.py @@ -7,6 +7,7 @@ OneBot v11 平台适配器 import asyncio import base64 import os +import time from datetime import datetime, timedelta from typing import Any @@ -70,6 +71,9 @@ class OneBotAdapter(PlatformAdapter): self._is_snowluma = False self._snowluma_checked = False + # 禁言状态缓存 (group_id -> timestamp) + self._muted_groups_cache = {} + def _init_capabilities(self) -> PlatformCapabilities: """返回预定义的 OneBot v11 能力集。""" return ONEBOT_V11_CAPABILITIES @@ -167,7 +171,7 @@ class OneBotAdapter(PlatformAdapter): while len(all_raw_messages) < max_count: fetch_count = min(chunk_size, max_count - len(all_raw_messages)) - params = { + params: dict[str, int | str | bool | None] = { "group_id": int(group_id), "count": fetch_count, } @@ -517,8 +521,11 @@ class OneBotAdapter(PlatformAdapter): group_id=int(group_id), message=message, ) + self._record_mute_status(group_id, False) # 成功发送,清除禁言缓存 return True except Exception as e: + if self._is_mute_exception(e): + self._record_mute_status(group_id, True) logger.error(f"OneBot 文本发送失败: {e}") return False @@ -600,9 +607,15 @@ class OneBotAdapter(PlatformAdapter): if caption: msg.append({"type": "text", "data": {"text": caption}}) msg.append({"type": "image", "data": {"file": file_val}}) - await self.bot.call_action( - "send_group_msg", group_id=int(group_id), message=msg - ) + try: + await self.bot.call_action( + "send_group_msg", group_id=int(group_id), message=msg + ) + self._record_mute_status(group_id, False) + except Exception as e: + if self._is_mute_exception(e): + self._record_mute_status(group_id, True) + raise logger.debug(f"[OneBot] 图片发送成功 ({label}): 群 {group_id}") return await self._execute_transmission_strategy( @@ -618,12 +631,18 @@ class OneBotAdapter(PlatformAdapter): """通过群文件功能上传并发送文件。""" async def do_upload(content: str, label: str): - await self.bot.call_action( - "upload_group_file", - group_id=int(group_id), - file=content, - name=filename or os.path.basename(file_path), - ) + try: + await self.bot.call_action( + "upload_group_file", + group_id=int(group_id), + file=content, + name=filename or os.path.basename(file_path), + ) + self._record_mute_status(group_id, False) + except Exception as e: + if self._is_mute_exception(e): + self._record_mute_status(group_id, True) + raise logger.debug(f"[OneBot] 文件发送成功 ({label}): {filename or file_path}") return await self._execute_transmission_strategy( @@ -653,8 +672,11 @@ class OneBotAdapter(PlatformAdapter): group_id=int(group_id), messages=nodes, ) + self._record_mute_status(group_id, False) return True except Exception as e: + if self._is_mute_exception(e): + self._record_mute_status(group_id, True) logger.warning(f"[OneBot] 发送合并转发消息失败: {e}") return False @@ -833,6 +855,186 @@ class OneBotAdapter(PlatformAdapter): for user_id in user_ids } + async def is_group_muted(self, group_id: str) -> bool: + """ + 检查 OneBot 平台下的群聊是否被禁言(包括全体禁言或对 Bot 自身禁言)。 + """ + group_id_str = str(group_id) + + # 1. 检查最近缓存的禁言状态(5分钟内有效) + last_mute_time = self._muted_groups_cache.get(group_id_str) + if last_mute_time and (time.time() - last_mute_time) < 300: + logger.info( + f"[OneBot] 从缓存中检测到群 {group_id_str} 最近处于禁言状态,跳过分析" + ) + return True + + if not hasattr(self.bot, "call_action"): + return False + + # 2. 获取 Bot 自身的 QQ 号,并过滤掉非法的字符串(如 functools.partial 或含字母/特殊字符的异常值) + bot_user_id = None + if self.bot_self_ids: + valid_ids = [ + str(uid) + for uid in self.bot_self_ids + if uid + and isinstance(uid, (str, int)) + and not callable(uid) + and "partial" not in str(uid) + and str(uid).isdigit() + ] + if valid_ids: + bot_user_id = valid_ids[0] + + if not bot_user_id: + try: + # 设定 5.0 秒超时,防止接口请求无限挂起 + login_info = await asyncio.wait_for( + self.bot.call_action("get_login_info"), timeout=5.0 + ) + if login_info and "user_id" in login_info: + bot_user_id = str(login_info["user_id"]) + self.bot_self_ids = [bot_user_id] + except Exception as e: + if self._is_mute_exception(e): + self._record_mute_status(group_id, True) + logger.info( + f"[OneBot] 从 get_login_info 异常中检测到 Bot 在群 {group_id} 中已被禁言" + ) + return True + logger.warning(f"[OneBot] 获取 Bot 自身登录信息失败: {e}") + + # 3. 检查 Bot 是否被个人禁言,并获取 Bot 在群内的角色 + is_individually_muted = False + role = "member" # 默认为 member 以防万一 + if bot_user_id: + try: + # 设定 5.0 秒超时,且不传递 no_cache=True 以免强制向腾讯服务器同步导致高延时超时 + member_info = await asyncio.wait_for( + self.bot.call_action( + "get_group_member_info", + group_id=int(group_id), + user_id=int(bot_user_id), + ), + timeout=5.0, + ) + if member_info: + role = member_info.get("role", "member") + shut_up_time = member_info.get("shut_up_time", 0) + if shut_up_time > 0: + # 如果 shut_up_time 是 Unix 时间戳 + if shut_up_time > 1000000000: + if shut_up_time > time.time(): + is_individually_muted = True + else: + # 否则认为是相对禁言剩余时间(秒) + is_individually_muted = True + except asyncio.TimeoutError: + logger.warning( + f"[OneBot] 获取群成员信息超时 (group_id={group_id}, user_id={bot_user_id})" + ) + except Exception as e: + if self._is_mute_exception(e): + self._record_mute_status(group_id, True) + logger.info( + f"[OneBot] 从 get_group_member_info 异常中检测到 Bot 在群 {group_id} 中已被禁言" + ) + return True + logger.warning( + f"[OneBot] 获取群成员信息失败 (group_id={group_id}, user_id={bot_user_id}): {e}" + ) + + if is_individually_muted: + self._record_mute_status(group_id, True) + logger.info(f"[OneBot] 检测到 Bot 在群 {group_id} 中已被单独禁言") + return True + + # 4. 如果 Bot 不是管理员或群主,则需要检查群聊是否开启了全群禁言 + # 管理员 (admin) 和群主 (owner) 在全群禁言下依然可以发言 + if role not in ("admin", "owner"): + try: + # 设定 5.0 秒超时,不传 no_cache=True + group_info = await asyncio.wait_for( + self.bot.call_action( + "get_group_info", + group_id=int(group_id), + ), + timeout=5.0, + ) + if group_info: + # 兼容 LLOneBot, Lagrange, NapCat/SnowLuma 以及标准 OneBot 各种全群禁言状态字段 + is_whole_ban = ( + group_info.get("group_all_shut") + or group_info.get("shutup_all") + or group_info.get("is_whole_ban") + or group_info.get("whole_ban") + or group_info.get("shutup") + or group_info.get("shut_up") + ) + if is_whole_ban: + self._record_mute_status(group_id, True) + logger.info( + f"[OneBot] 检测到群 {group_id} 开启了全群禁言,且 Bot 为普通成员" + ) + return True + except asyncio.TimeoutError: + logger.warning(f"[OneBot] 获取群信息超时 (group_id={group_id})") + except Exception as e: + if self._is_mute_exception(e): + self._record_mute_status(group_id, True) + logger.info( + f"[OneBot] 从 get_group_info 异常中检测到 Bot 在群 {group_id} 中已被禁言" + ) + return True + logger.warning(f"[OneBot] 获取群信息失败 (group_id={group_id}): {e}") + + # 如果所有检测均未发现禁言,则暂时视为未禁言 + return False + + def _is_mute_exception(self, e: Exception) -> bool: + if not e: + return False + err_str = str(e) + if "1200" in err_str and ("禁言" in err_str or "操作失败" in err_str): + return True + err_msg = getattr(e, "message", "") or "" + err_word = getattr(e, "wording", "") or "" + if ( + "禁言" in err_msg + or "禁言" in err_word + or "操作失败" in err_msg + or "操作失败" in err_word + or "shut up" in err_msg.lower() + or "shut up" in err_word.lower() + ): + return True + return False + + def _record_mute_status(self, group_id: Any, is_muted: bool): + group_id_str = str(group_id) + if is_muted: + # Prune expired cache entries if cache size grows too large (threshold of 1000) + if len(self._muted_groups_cache) >= 1000: + now = time.time() + expired_keys = [ + k for k, t in self._muted_groups_cache.items() if now - t >= 300 + ] + for k in expired_keys: + self._muted_groups_cache.pop(k, None) + + # If still over threshold, evict the oldest entry to prevent unbounded growth + if len(self._muted_groups_cache) >= 1000: + oldest_key = min( + self._muted_groups_cache, + key=lambda k: self._muted_groups_cache[k], + ) + self._muted_groups_cache.pop(oldest_key, None) + + self._muted_groups_cache[group_id_str] = time.time() + else: + self._muted_groups_cache.pop(group_id_str, None) + # ================================================================ # 群文件 / 群相册上传 # ================================================================ @@ -1183,8 +1385,11 @@ class OneBotAdapter(PlatformAdapter): emoji_type="1", # 还原为最稳定的系统表情类型 set=is_add, ) + self._record_mute_status(group_id, False) return True except Exception as e: + if self._is_mute_exception(e): + self._record_mute_status(group_id, True) logger.debug(f"OneBot set_reaction 失败 (API 可能不支持): {e}") return False diff --git a/src/infrastructure/platform/base.py b/src/infrastructure/platform/base.py index 9ef06fb..7f11db3 100644 --- a/src/infrastructure/platform/base.py +++ b/src/infrastructure/platform/base.py @@ -209,3 +209,10 @@ class PlatformAdapter( except Exception: # 兜底:直接发送 return await self.send_text(group_id, str(content)) + + async def is_group_muted(self, group_id: str) -> bool: + """ + 检查群聊是否被禁言(包括全体禁言或对 Bot 自身禁言)。 + 默认返回 False。各平台适配器可以根据需要重写此方法。 + """ + return False diff --git a/src/infrastructure/platform/bot_manager.py b/src/infrastructure/platform/bot_manager.py index 8d8a966..961f896 100644 --- a/src/infrastructure/platform/bot_manager.py +++ b/src/infrastructure/platform/bot_manager.py @@ -55,6 +55,14 @@ class BotManager: platform_id = self._get_platform_id_from_instance(bot_instance) if bot_instance and platform_id: + # 如果 bot_instance 没变,且已经有适配器,跳过重新创建,防止丢失内部状态(如缓存等) + old_instance = self._bot_instances.get(platform_id) + if bot_instance is old_instance and platform_id in self._adapters: + bot_self_id = self._extract_bot_self_id(bot_instance) + if bot_self_id and bot_self_id not in self._bot_self_ids: + self._bot_self_ids.append(str(bot_self_id)) + return + self._bot_instances[platform_id] = bot_instance # 为 DDD 集成创建 PlatformAdapter @@ -486,8 +494,22 @@ class BotManager: platform_id = event.platform self.set_bot_instance(bot_instance, platform_id) - # 每次都尝试从bot实例提取ID - bot_self_id = self._extract_bot_self_id(bot_instance) + + # 优先从事件中提取机器人自身 ID,避免获取到 functools.partial 等异常对象 + bot_self_id = None + if hasattr(event, "get_self_id"): + val = event.get_self_id() + if ( + val + and isinstance(val, (str, int)) + and not callable(val) + and "partial" not in str(val) + ): + bot_self_id = str(val) + + if not bot_self_id: + bot_self_id = self._extract_bot_self_id(bot_instance) + if bot_self_id: # 将单个ID转换为列表,保持统一处理 self.set_bot_self_ids([bot_self_id]) @@ -505,17 +527,25 @@ class BotManager: def _extract_bot_self_id_impl(self, bot_instance): """从bot实例中提取ID(通用实现)""" - # 尝试多种方式获取bot ID + # 尝试多种方式获取bot ID,并严格限制类型为 str/int 且不可调用,防止 OneBot (aiocqhttp) 动态代理返回 functools.partial if hasattr(bot_instance, "self_id") and bot_instance.self_id: - return str(bot_instance.self_id) - elif hasattr(bot_instance, "user_id") and bot_instance.user_id: - return str(bot_instance.user_id) + val = bot_instance.self_id + if isinstance(val, (str, int)) and not callable(val): + return str(val) + if hasattr(bot_instance, "user_id") and bot_instance.user_id: + val = bot_instance.user_id + if isinstance(val, (str, int)) and not callable(val): + return str(val) # Discord.py style: client.user.id - elif hasattr(bot_instance, "user") and hasattr(bot_instance.user, "id"): - return str(bot_instance.user.id) + if hasattr(bot_instance, "user") and hasattr(bot_instance.user, "id"): + val = bot_instance.user.id + if isinstance(val, (str, int)) and not callable(val): + return str(val) # python-telegram-bot style: bot.id - elif hasattr(bot_instance, "id") and bot_instance.id: - return str(bot_instance.id) + if hasattr(bot_instance, "id") and bot_instance.id: + val = bot_instance.id + if isinstance(val, (str, int)) and not callable(val): + return str(val) return None def validate_for_message_fetching(self, group_id: str) -> bool: