mirror of
https://github.com/Nezumi-2711/astrbot_plugin_qq_group_daily_analysis.git
synced 2026-09-22 05:31:52 +00:00
* 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:
@@ -37,3 +37,5 @@ debug_atri.html
|
||||
data/test/avatar/cache.db
|
||||
test_mainland.html
|
||||
test_overseas.html
|
||||
.tmp_refs/
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user