feat(auto_scheduler): 添加自动调度器,使用信号量(Semaphore)并发限制和弱引用字典(WeakValueDictionary)优化锁管理;群黑白名单机制。 (#50)

* feat(auto_scheduler): 添加自动调度器,使用信号量(Semaphore)并发限制和弱引用字典(WeakValueDictionary)优化锁管理。

* feat(auto_scheduler): 群黑白名单机制

* feat(自动分析最大并发数): 可配置项添加信号量自定义控制

* debug(获取群)

* fix(config)

* fix(pre-commit): cr
This commit is contained in:
Helian Nuits
2025-11-23 13:26:09 +08:00
committed by GitHub
parent 06290b8118
commit e5320969ee
4 changed files with 215 additions and 60 deletions
+16 -3
View File
@@ -1,13 +1,26 @@
{
"enabled_groups": {
"group_list_mode": {
"description": "群聊权限模式",
"type": "string",
"options": ["whitelist", "blacklist", "none"],
"default": "none",
"hint": "whitelist: 仅允许列表内群;blacklist: 拒绝列表内群;none: 不限制"
},
"group_list": {
"type": "list",
"description": "启用分析功能的QQ群列表",
"description": "群组名单列表",
"default": [],
"hint": "填入允许使用分析功能的QQ群号",
"hint": "黑白名单模式下使用的群号列表",
"items": {
"type": "string"
}
},
"max_concurrent_tasks": {
"type": "int",
"description": "自动分析最大并发数",
"default": 5,
"hint": "同时进行的群聊分析任务数量,建议根据机器性能调整,过高可能导致LLM API RPM 超出限制,卡顿或被风控"
},
"max_messages": {
"type": "int",
"description": "单次分析的获取最大消息条数基准",
+53 -23
View File
@@ -159,8 +159,7 @@ class QQGroupDailyAnalysis(Star):
bot_manager.update_from_event(event)
# 检查群组权限
enabled_groups = config_manager.get_enabled_groups()
if enabled_groups and group_id not in enabled_groups:
if not config_manager.is_group_allowed(group_id):
yield event.plain_result("❌ 此群未启用日常分析功能")
return
@@ -371,26 +370,56 @@ class QQGroupDailyAnalysis(Star):
return
if action == "enable":
enabled_groups = config_manager.get_enabled_groups()
if group_id not in enabled_groups:
config_manager.add_enabled_group(group_id)
yield event.plain_result("✅ 已为当前群启用日常分析功能")
# 重新启动定时任务
await auto_scheduler.restart_scheduler()
mode = config_manager.get_group_list_mode()
if mode == "whitelist":
glist = config_manager.get_group_list()
if group_id not in glist:
glist.append(group_id)
config_manager.set_group_list(glist)
yield event.plain_result("✅ 已将当前群加入白名单")
# 重新启动定时任务
await auto_scheduler.restart_scheduler()
else:
yield event.plain_result("️ 当前群已在白名单中")
elif mode == "blacklist":
glist = config_manager.get_group_list()
if group_id in glist:
glist.remove(group_id)
config_manager.set_group_list(glist)
yield event.plain_result("✅ 已将当前群从黑名单移除")
# 重新启动定时任务
await auto_scheduler.restart_scheduler()
else:
yield event.plain_result("️ 当前群不在黑名单中")
else:
yield event.plain_result("️ 当前群已启用日常分析功能")
yield event.plain_result("️ 当前为无限制模式,所有群聊默认启用")
elif action == "disable":
enabled_groups = config_manager.get_enabled_groups()
if group_id in enabled_groups:
config_manager.remove_enabled_group(group_id)
yield event.plain_result("✅ 已为当前群禁用日常分析功能")
# 重新启动定时任务
await auto_scheduler.restart_scheduler()
mode = config_manager.get_group_list_mode()
if mode == "whitelist":
glist = config_manager.get_group_list()
if group_id in glist:
glist.remove(group_id)
config_manager.set_group_list(glist)
yield event.plain_result("✅ 已将当前群从白名单移除")
# 重新启动定时任务
await auto_scheduler.restart_scheduler()
else:
yield event.plain_result("️ 当前群不在白名单中")
elif mode == "blacklist":
glist = config_manager.get_group_list()
if group_id not in glist:
glist.append(group_id)
config_manager.set_group_list(glist)
yield event.plain_result("✅ 已将当前群加入黑名单")
# 重新启动定时任务
await auto_scheduler.restart_scheduler()
else:
yield event.plain_result("️ 当前群已在黑名单中")
else:
yield event.plain_result("️ 当前群未启用日常分析功能")
yield event.plain_result(
"ℹ️ 当前为无限制模式,如需禁用请切换到黑名单模式"
)
elif action == "reload":
# 重新启动定时任务
@@ -399,8 +428,7 @@ class QQGroupDailyAnalysis(Star):
elif action == "test":
# 测试自动分析功能
enabled_groups = config_manager.get_enabled_groups()
if group_id not in enabled_groups:
if not config_manager.is_group_allowed(group_id):
yield event.plain_result("❌ 请先启用当前群的分析功能")
return
@@ -417,8 +445,10 @@ class QQGroupDailyAnalysis(Star):
yield event.plain_result(f"❌ 自动分析测试失败: {str(e)}")
else: # status
enabled_groups = config_manager.get_enabled_groups()
status = "已启用" if group_id in enabled_groups else "未启用"
is_allowed = config_manager.is_group_allowed(group_id)
status = "已启用" if is_allowed else "未启用"
mode = config_manager.get_group_list_mode()
auto_status = (
"已启用" if config_manager.get_enable_auto_analysis() else "未启用"
)
@@ -429,7 +459,7 @@ class QQGroupDailyAnalysis(Star):
min_threshold = config_manager.get_min_messages_threshold()
yield event.plain_result(f"""📊 当前群分析功能状态:
• 群分析功能: {status}
• 群分析功能: {status} (模式: {mode})
• 自动分析: {auto_status} ({auto_time})
• 输出格式: {output_format}
• PDF 功能: {pdf_status}
+44 -22
View File
@@ -16,9 +16,37 @@ class ConfigManager:
self._pyppeteer_version = None
self._check_pyppeteer_availability()
def get_enabled_groups(self) -> list[str]:
"""获取启用的群组列表"""
return self.config.get("enabled_groups", [])
def get_group_list_mode(self) -> str:
"""获取群组列表模式 (whitelist/blacklist/none)"""
return self.config.get("group_list_mode", "none")
def get_group_list(self) -> list[str]:
"""获取群组列表(用于黑白名单)"""
return self.config.get("group_list", [])
def is_group_allowed(self, group_id: str) -> bool:
"""根据配置的白/黑名单判断是否允许在该群聊中使用"""
mode = self.get_group_list_mode().lower()
if mode not in ("whitelist", "blacklist", "none"):
mode = "none"
# none模式下,不进行黑白名单检查,由调用方决定(通常是回退到 enabled_groups
if mode == "none":
return True
glist = [str(g) for g in self.get_group_list()]
group_id_str = str(group_id)
if mode == "whitelist":
return group_id_str in glist if glist else False
if mode == "blacklist":
return group_id_str not in glist if glist else True
return True
def get_max_concurrent_tasks(self) -> int:
"""获取自动分析最大并发数"""
return self.config.get("max_concurrent_tasks", 5)
def get_max_messages(self) -> int:
"""获取最大消息数量"""
@@ -203,9 +231,19 @@ class ConfigManager:
self.config["output_format"] = format_type
self.config.save_config()
def set_enabled_groups(self, groups: list[str]):
"""设置启用的群组列表"""
self.config["enabled_groups"] = groups
def set_group_list_mode(self, mode: str):
"""设置群组列表模式"""
self.config["group_list_mode"] = mode
self.config.save_config()
def set_group_list(self, groups: list[str]):
"""设置群组列表"""
self.config["group_list"] = groups
self.config.save_config()
def set_max_concurrent_tasks(self, count: int):
"""设置自动分析最大并发数"""
self.config["max_concurrent_tasks"] = count
self.config.save_config()
def set_max_messages(self, count: int):
@@ -273,22 +311,6 @@ class ConfigManager:
self.config["pdf_filename_format"] = format_str
self.config.save_config()
def add_enabled_group(self, group_id: str):
"""添加启用的群组"""
enabled_groups = self.get_enabled_groups()
if group_id not in enabled_groups:
enabled_groups.append(group_id)
self.config["enabled_groups"] = enabled_groups
self.config.save_config()
def remove_enabled_group(self, group_id: str):
"""移除启用的群组"""
enabled_groups = self.get_enabled_groups()
if group_id in enabled_groups:
enabled_groups.remove(group_id)
self.config["enabled_groups"] = enabled_groups
self.config.save_config()
def get_enable_user_card(self) -> bool:
"""获取是否使用用户群名片"""
return self.config.get("enable_user_card", False)
+102 -12
View File
@@ -4,6 +4,7 @@
"""
import asyncio
import weakref
from datetime import datetime, timedelta
from astrbot.api import logger
@@ -189,7 +190,22 @@ class AutoScheduler:
try:
logger.info("开始执行自动群聊分析(并发模式)")
enabled_groups = self.config_manager.get_enabled_groups()
# 根据配置确定需要分析的群组
group_list_mode = self.config_manager.get_group_list_mode()
# 始终获取所有群组并进行过滤
logger.info(f"自动分析使用 {group_list_mode} 模式,正在获取群列表...")
all_groups = await self._get_all_groups()
logger.info(f"共获取到 {len(all_groups)} 个群组: {all_groups}")
enabled_groups = []
for group_id in all_groups:
if self.config_manager.is_group_allowed(group_id):
enabled_groups.append(group_id)
logger.info(
f"根据 {group_list_mode} 过滤后,共有 {len(enabled_groups)} 个群聊需要分析"
)
if not enabled_groups:
logger.info("没有启用的群聊需要分析")
return
@@ -199,10 +215,21 @@ class AutoScheduler:
)
# 创建并发任务 - 为每个群聊创建独立的分析任务
# 限制最大并发数
max_concurrent = self.config_manager.get_max_concurrent_tasks()
logger.info(f"自动分析并发数限制: {max_concurrent}")
sem = asyncio.Semaphore(max_concurrent)
async def safe_perform_analysis(group_id):
async with sem:
return await self._perform_auto_analysis_for_group_with_timeout(
group_id
)
analysis_tasks = []
for group_id in enabled_groups:
task = asyncio.create_task(
self._perform_auto_analysis_for_group_with_timeout(group_id),
safe_perform_analysis(group_id),
name=f"analysis_group_{group_id}",
)
analysis_tasks.append(task)
@@ -246,12 +273,16 @@ class AutoScheduler:
# 为每个群聊使用独立的锁,避免全局锁导致串行化
group_lock_key = f"analysis_{group_id}"
if not hasattr(self, "_group_locks"):
self._group_locks = {}
self._group_locks = weakref.WeakValueDictionary()
if group_lock_key not in self._group_locks:
self._group_locks[group_lock_key] = asyncio.Lock()
# 从 WeakValueDictionary 获取锁,如果不存在则创建
# 注意:必须将锁赋值给局部变量以保持引用,否则可能会被回收
lock = self._group_locks.get(group_lock_key)
if lock is None:
lock = asyncio.Lock()
self._group_locks[group_lock_key] = lock
async with self._group_locks[group_lock_key]:
async with lock:
try:
start_time = asyncio.get_event_loop().time()
@@ -381,12 +412,71 @@ class AutoScheduler:
logger.error(f"{group_id} 自动分析执行失败: {e}", exc_info=True)
finally:
# 清理群聊锁资源(可选,防止内存泄漏)
if hasattr(self, "_group_locks") and len(self._group_locks) > 50:
old_locks = list(self._group_locks.keys())[:10]
for lock_key in old_locks:
if not self._group_locks[lock_key].locked():
del self._group_locks[lock_key]
# 锁资源由 WeakValueDictionary 自动管理,无需手动清理
logger.info(f"{group_id} 自动分析完成")
async def _get_all_groups(self) -> list[str]:
"""获取所有bot实例所在的群列表"""
all_groups = set()
if (
not hasattr(self.bot_manager, "_bot_instances")
or not self.bot_manager._bot_instances
):
return []
for platform_id, bot_instance in self.bot_manager._bot_instances.items():
try:
# 尝试使用 call_action 获取群列表
call_action_func = None
if hasattr(bot_instance, "call_action"):
call_action_func = bot_instance.call_action
elif hasattr(bot_instance, "api") and hasattr(
bot_instance.api, "call_action"
):
call_action_func = bot_instance.api.call_action
if call_action_func:
# 尝试 OneBot v11 get_group_list
try:
result = await call_action_func("get_group_list")
logger.debug(
f"平台 {platform_id} get_group_list 返回类型: {type(result)}"
)
# 处理可能的字典返回 (e.g. {'data': [...], 'retcode': 0})
if (
isinstance(result, dict)
and "data" in result
and isinstance(result["data"], list)
):
logger.debug("检测到字典格式返回,提取 data 字段")
result = result["data"]
if isinstance(result, list):
for group in result:
if isinstance(group, dict) and "group_id" in group:
all_groups.add(str(group["group_id"]))
logger.info(
f"平台 {platform_id} 成功获取 {len(result)} 个群组"
)
else:
logger.warning(
f"平台 {platform_id} get_group_list 返回格式非列表: {result}"
)
except Exception as e:
logger.debug(
f"平台 {platform_id} 获取群列表失败 (get_group_list): {e}"
)
# 如果需要,尝试其他方法(例如针对其他协议)
# 目前专注于 OneBot v11,因为它是最常见的
else:
logger.debug(f"平台 {platform_id} 的 bot 实例没有 call_action 方法")
except Exception as e:
logger.error(f"平台 {platform_id} 获取群列表异常: {e}")
return list(all_groups)
async def _send_analysis_report(self, group_id: str, analysis_result: dict):
"""发送分析报告到群"""