feat(muted): 被禁言避免触发分析功能浪费 token (#191) (#203)

* 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
This commit is contained in:
Helian Nuits
2026-07-08 11:44:19 +08:00
committed by GitHub
parent e53016eb01
commit e8af00e016
6 changed files with 301 additions and 20 deletions
+2
View File
@@ -37,3 +37,5 @@ debug_atri.html
data/test/avatar/cache.db
test_mainland.html
test_overseas.html
.tmp_refs/
+4
View File
@@ -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
@@ -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()
@@ -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
+7
View File
@@ -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
+40 -10
View File
@@ -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: