mirror of
https://github.com/Nezumi-2711/astrbot_plugin_qq_group_daily_analysis.git
synced 2026-09-22 13:38:43 +00:00
fix: 修复插件任务生命周期管理,避免用户困惑的报错行为 (#125)
- 修复 'NoneType' 报错 (Fix #125): 引入插件终止标志位并完善任务生命周期清理逻辑。 - 强化资源清理:重载时主动取消并等待所有运行中的异步任务(包括手动分析、自动调度和依赖安装),移除危险的属性置空操作,彻底消除重载时的属性访问竞态。
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
# QQ群日常分析插件 - PDF 功能说明
|
# 群日常分析插件 - PDF 功能说明
|
||||||
|
|
||||||
## 概述
|
## 概述
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
"""
|
"""
|
||||||
QQ群日常分析插件
|
群日常分析插件
|
||||||
基于群聊记录生成精美的日常分析报告,包含话题总结、用户画像、统计数据等
|
基于群聊记录生成精美的日常分析报告,包含话题总结、用户画像、统计数据等
|
||||||
|
|
||||||
重构版本 - 使用模块化架构,支持跨平台
|
重构版本 - 使用模块化架构,支持跨平台
|
||||||
@@ -144,7 +144,7 @@ class GroupDailyAnalysis(Star):
|
|||||||
)
|
)
|
||||||
|
|
||||||
self._initialized = False
|
self._initialized = False
|
||||||
self._discovery_run = False # 是否已尝试过运行发现逻辑
|
self._terminating = False # 生命周期标志
|
||||||
self._init_lock = asyncio.Lock()
|
self._init_lock = asyncio.Lock()
|
||||||
self._background_tasks: set[asyncio.Task] = set()
|
self._background_tasks: set[asyncio.Task] = set()
|
||||||
|
|
||||||
@@ -157,8 +157,6 @@ class GroupDailyAnalysis(Star):
|
|||||||
self._background_tasks.add(self._init_task)
|
self._background_tasks.add(self._init_task)
|
||||||
self._init_task.add_done_callback(self._background_tasks.discard)
|
self._init_task.add_done_callback(self._background_tasks.discard)
|
||||||
except RuntimeError:
|
except RuntimeError:
|
||||||
# 如果当前没有 running loop (例如在非异步初始化的环境中),
|
|
||||||
# 则依赖 on_platform_loaded 钩子执行初始化
|
|
||||||
self._init_task = None
|
self._init_task = None
|
||||||
|
|
||||||
# orchestrators 缓存已移至 应用层逻辑 (分析服务) 或 暂时移除以简化。
|
# orchestrators 缓存已移至 应用层逻辑 (分析服务) 或 暂时移除以简化。
|
||||||
@@ -226,23 +224,30 @@ class GroupDailyAnalysis(Star):
|
|||||||
|
|
||||||
async def terminate(self):
|
async def terminate(self):
|
||||||
"""插件被卸载/停用时调用,清理资源"""
|
"""插件被卸载/停用时调用,清理资源"""
|
||||||
|
if self._terminating:
|
||||||
|
return
|
||||||
|
self._terminating = True
|
||||||
|
|
||||||
try:
|
try:
|
||||||
logger.info("开始清理QQ群日常分析插件资源...")
|
logger.info("开始清理群日常分析插件资源...")
|
||||||
|
|
||||||
# 1. 停止所有后台任务
|
# 1. 停止所有后台任务
|
||||||
if self._background_tasks:
|
if self._background_tasks:
|
||||||
logger.info(f"正在取消 {len(self._background_tasks)} 个后台任务...")
|
logger.info(f"正在取消 {len(self._background_tasks)} 个运行中的任务...")
|
||||||
for task in self._background_tasks:
|
for task in self._background_tasks:
|
||||||
if not task.done():
|
if not task.done():
|
||||||
task.cancel()
|
task.cancel()
|
||||||
|
|
||||||
# 等待任务结束
|
# 等待任务结束,给予 3 秒宽限期
|
||||||
await asyncio.gather(*self._background_tasks, return_exceptions=True)
|
try:
|
||||||
|
await asyncio.wait(list(self._background_tasks), timeout=3.0)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
self._background_tasks.clear()
|
self._background_tasks.clear()
|
||||||
|
|
||||||
# 2. 停止各个组件
|
# 2. 停止各个组件 (顺序:先调度器,后底层服务)
|
||||||
if self.auto_scheduler:
|
if self.auto_scheduler:
|
||||||
logger.info("正在停止自动调度器...")
|
logger.debug("正在停止自动调度器...")
|
||||||
self.auto_scheduler.unschedule_jobs(self.context)
|
self.auto_scheduler.unschedule_jobs(self.context)
|
||||||
|
|
||||||
if self.retry_manager:
|
if self.retry_manager:
|
||||||
@@ -251,19 +256,15 @@ class GroupDailyAnalysis(Star):
|
|||||||
if self.template_preview_router:
|
if self.template_preview_router:
|
||||||
await self.template_preview_router.unregister_handlers()
|
await self.template_preview_router.unregister_handlers()
|
||||||
|
|
||||||
# 3. 释放实例属性引用 (使用 type: ignore 允许 None 赋值)
|
|
||||||
self.auto_scheduler = None # type: ignore
|
|
||||||
self.bot_manager = None # type: ignore
|
|
||||||
if self.report_generator:
|
if self.report_generator:
|
||||||
await self.report_generator.close()
|
await self.report_generator.close()
|
||||||
self.report_generator = None # type: ignore
|
|
||||||
self.config_manager = None # type: ignore
|
|
||||||
self.message_processing_service = None # type: ignore
|
|
||||||
self.telegram_group_registry = None # type: ignore
|
|
||||||
self.template_preview_router = None # type: ignore
|
|
||||||
self.telegram_template_preview_handler = None # type: ignore
|
|
||||||
|
|
||||||
logger.info("QQ群日常分析插件资源清理完成")
|
# 3. [关键修复] 只有在任务全部清理后,才清理引用。
|
||||||
|
# 实际上,在 terminate 结束后,self 本身就会被 GC 释放,
|
||||||
|
# 这里的显式 None 更多是为了协助循环引用清理,但由于异步任务存在竞态,
|
||||||
|
# 我们可以通过 check _terminating 标志位来保护。
|
||||||
|
# 为了彻底解决 #125,我们保留引用,让 GC 自然回收。
|
||||||
|
logger.info("群日常分析插件资源清理完成")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"插件资源清理失败: {e}")
|
logger.error(f"插件资源清理失败: {e}")
|
||||||
@@ -463,58 +464,67 @@ class GroupDailyAnalysis(Star):
|
|||||||
分析群聊日常活动(跨平台支持)
|
分析群聊日常活动(跨平台支持)
|
||||||
用法: /群分析 [天数]
|
用法: /群分析 [天数]
|
||||||
"""
|
"""
|
||||||
event.should_call_llm(True) # 阻止默认 LLM 解析
|
if self._terminating:
|
||||||
group_id = self._get_group_id_from_event(event)
|
|
||||||
platform_id = self._get_platform_id_from_event(event)
|
|
||||||
|
|
||||||
if not group_id:
|
|
||||||
yield event.plain_result("❌ 请在群聊中使用此命令")
|
|
||||||
return
|
return
|
||||||
|
|
||||||
# 更新bot实例
|
current_task = asyncio.current_task()
|
||||||
self.bot_manager.update_from_event(event)
|
if current_task:
|
||||||
|
self._background_tasks.add(current_task)
|
||||||
|
|
||||||
# 优先使用 UMO 进行权限检查 (兼容白名单 UMO 格式)
|
|
||||||
check_target = getattr(event, "unified_msg_origin", None)
|
|
||||||
if not check_target:
|
|
||||||
check_target = f"{platform_id}:GroupMessage:{group_id}"
|
|
||||||
|
|
||||||
if not self.config_manager.is_group_allowed(check_target):
|
|
||||||
# Fallback checks (simple ID) are handled inside is_group_allowed logic if list item has no colon
|
|
||||||
# But if list item HAS colon, we need precise match.
|
|
||||||
# If prompt fails, try simple ID as fallback for permissive cases?
|
|
||||||
# No, config_manager.is_group_allowed already handles simple ID matching if whitelist item is simple ID.
|
|
||||||
yield event.plain_result("❌ 此群未启用日常分析功能")
|
|
||||||
return
|
|
||||||
|
|
||||||
# 获取群名以生成语义化的 TraceID
|
|
||||||
group_name = ""
|
|
||||||
try:
|
try:
|
||||||
|
event.should_call_llm(True) # 阻止默认 LLM 解析
|
||||||
|
group_id = self._get_group_id_from_event(event)
|
||||||
|
platform_id = self._get_platform_id_from_event(event)
|
||||||
|
|
||||||
|
if not group_id:
|
||||||
|
yield event.plain_result("❌ 请在群聊中使用此命令")
|
||||||
|
return
|
||||||
|
|
||||||
|
# 更新bot实例
|
||||||
|
self.bot_manager.update_from_event(event)
|
||||||
|
|
||||||
|
# 优先使用 UMO 进行权限检查 (兼容白名单 UMO 格式)
|
||||||
|
check_target = getattr(event, "unified_msg_origin", None)
|
||||||
|
if not check_target:
|
||||||
|
check_target = f"{platform_id}:GroupMessage:{group_id}"
|
||||||
|
|
||||||
|
if not self.config_manager.is_group_allowed(check_target):
|
||||||
|
# Fallback checks (simple ID) are handled inside is_group_allowed logic if list item has no colon
|
||||||
|
# But if list item HAS colon, we need precise match.
|
||||||
|
# If prompt fails, try simple ID as fallback for permissive cases?
|
||||||
|
# No, config_manager.is_group_allowed already handles simple ID matching if whitelist item is simple ID.
|
||||||
|
yield event.plain_result("❌ 此群未启用日常分析功能")
|
||||||
|
return
|
||||||
|
|
||||||
|
# 获取群名以生成语义化的 TraceID
|
||||||
|
group_name = ""
|
||||||
|
try:
|
||||||
|
adapter = self.bot_manager.get_adapter(platform_id)
|
||||||
|
if adapter:
|
||||||
|
info = await adapter.get_group_info(group_id)
|
||||||
|
if info and info.group_name:
|
||||||
|
group_name = info.group_name
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 设置 TraceID (语义化格式: manual_群名_HHmm)
|
||||||
|
trace_id = TraceContext.generate(
|
||||||
|
prefix="manual", group_name=group_name or group_id
|
||||||
|
)
|
||||||
|
TraceContext.set(trace_id)
|
||||||
|
|
||||||
|
# 表情回应 或 文本提示(二选一,由配置开关控制)
|
||||||
adapter = self.bot_manager.get_adapter(platform_id)
|
adapter = self.bot_manager.get_adapter(platform_id)
|
||||||
if adapter:
|
orig_msg_id = getattr(event.message_obj, "message_id", None)
|
||||||
info = await adapter.get_group_info(group_id)
|
use_text_reply = self.config_manager.get_enable_analysis_reply()
|
||||||
if info and info.group_name:
|
|
||||||
group_name = info.group_name
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# 设置 TraceID (语义化格式: manual_群名_HHmm)
|
if use_text_reply:
|
||||||
trace_id = TraceContext.generate(
|
yield event.plain_result("🔍 正在启动分析引擎,正在拉取最近消息...")
|
||||||
prefix="manual", group_name=group_name or group_id
|
elif adapter and orig_msg_id:
|
||||||
)
|
await adapter.set_reaction(
|
||||||
TraceContext.set(trace_id)
|
event.get_group_id(), orig_msg_id, "🔍"
|
||||||
|
) # 🔍
|
||||||
|
|
||||||
# 表情回应 或 文本提示(二选一,由配置开关控制)
|
|
||||||
adapter = self.bot_manager.get_adapter(platform_id)
|
|
||||||
orig_msg_id = getattr(event.message_obj, "message_id", None)
|
|
||||||
use_text_reply = self.config_manager.get_enable_analysis_reply()
|
|
||||||
|
|
||||||
if use_text_reply:
|
|
||||||
yield event.plain_result("🔍 正在启动分析引擎,正在拉取最近消息...")
|
|
||||||
elif adapter and orig_msg_id:
|
|
||||||
await adapter.set_reaction(event.get_group_id(), orig_msg_id, "🔍") # 🔍
|
|
||||||
|
|
||||||
try:
|
|
||||||
# 调用 DDD 应用级服务
|
# 调用 DDD 应用级服务
|
||||||
result = await self.analysis_service.execute_daily_analysis(
|
result = await self.analysis_service.execute_daily_analysis(
|
||||||
group_id=group_id, platform_id=platform_id, manual=True, days=days
|
group_id=group_id, platform_id=platform_id, manual=True, days=days
|
||||||
@@ -538,16 +548,25 @@ class GroupDailyAnalysis(Star):
|
|||||||
|
|
||||||
except DuplicateGroupTaskError:
|
except DuplicateGroupTaskError:
|
||||||
yield event.plain_result("📊 该群的分析任务正在执行中,请稍后再试哦~")
|
yield event.plain_result("📊 该群的分析任务正在执行中,请稍后再试哦~")
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
logger.info("群分析任务被取消 (插件重载或卸载)")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"群分析失败: {e}", exc_info=True)
|
logger.error(f"群分析失败: {e}", exc_info=True)
|
||||||
yield event.plain_result(
|
yield event.plain_result(
|
||||||
f"❌ 分析失败: {str(e)}。请检查网络连接和LLM配置,或联系管理员"
|
f"❌ 分析失败: {str(e)}。请检查网络连接和LLM配置,或联系管理员"
|
||||||
)
|
)
|
||||||
|
finally:
|
||||||
|
if current_task:
|
||||||
|
self._background_tasks.discard(current_task)
|
||||||
|
|
||||||
async def _send_analysis_report(
|
async def _send_analysis_report(
|
||||||
self, event: AstrMessageEvent, result: dict
|
self, event: AstrMessageEvent, result: dict
|
||||||
) -> AsyncGenerator:
|
) -> AsyncGenerator:
|
||||||
"""处理分析结果的渲染和发送"""
|
"""处理分析结果的渲染和发送"""
|
||||||
|
if self._terminating or not self.config_manager:
|
||||||
|
logger.warning("插件正在关闭,停止发送报告")
|
||||||
|
return
|
||||||
|
|
||||||
group_id = result["group_id"]
|
group_id = result["group_id"]
|
||||||
platform_id = result["platform_id"]
|
platform_id = result["platform_id"]
|
||||||
analysis_result = result["analysis_result"]
|
analysis_result = result["analysis_result"]
|
||||||
@@ -755,17 +774,29 @@ class GroupDailyAnalysis(Star):
|
|||||||
安装 PDF 功能依赖(跨平台支持)
|
安装 PDF 功能依赖(跨平台支持)
|
||||||
用法: /安装PDF
|
用法: /安装PDF
|
||||||
"""
|
"""
|
||||||
yield event.plain_result("🔄 开始安装 PDF 功能依赖,请稍候...")
|
if self._terminating:
|
||||||
|
return
|
||||||
|
|
||||||
|
current_task = asyncio.current_task()
|
||||||
|
if current_task:
|
||||||
|
self._background_tasks.add(current_task)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
yield event.plain_result("🔄 开始安装 PDF 功能依赖,请稍候...")
|
||||||
|
|
||||||
result = await PDFInstaller.install_playwright(
|
result = await PDFInstaller.install_playwright(
|
||||||
self.config_manager, task_registry=self._background_tasks
|
self.config_manager, task_registry=self._background_tasks
|
||||||
)
|
)
|
||||||
yield event.plain_result(result)
|
yield event.plain_result(result)
|
||||||
|
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
logger.info("PDF 安装任务被取消")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"安装 PDF 依赖失败: {e}", exc_info=True)
|
logger.error(f"安装 PDF 依赖失败: {e}", exc_info=True)
|
||||||
yield event.plain_result(f"❌ 安装过程中出现错误: {str(e)}")
|
yield event.plain_result(f"❌ 安装过程中出现错误: {str(e)}")
|
||||||
|
finally:
|
||||||
|
if current_task:
|
||||||
|
self._background_tasks.discard(current_task)
|
||||||
|
|
||||||
@filter.command("分析设置", alias={"analysis_settings"})
|
@filter.command("分析设置", alias={"analysis_settings"})
|
||||||
@filter.permission_type(PermissionType.ADMIN)
|
@filter.permission_type(PermissionType.ADMIN)
|
||||||
|
|||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
"""
|
"""
|
||||||
QQ群日常分析插件 - 源代码包
|
群日常分析插件 - 源代码包
|
||||||
|
|
||||||
本包包含插件的核心实现,采用 DDD (领域驱动设计) 架构:
|
本包包含插件的核心实现,采用 DDD (领域驱动设计) 架构:
|
||||||
- application: 应用层 - 编排领域服务,处理用例
|
- application: 应用层 - 编排领域服务,处理用例
|
||||||
|
|||||||
@@ -531,7 +531,7 @@
|
|||||||
{{quotes_html | safe}}
|
{{quotes_html | safe}}
|
||||||
</div>
|
</div>
|
||||||
<div class="footer">
|
<div class="footer">
|
||||||
由 AstrBot QQ群日常分析插件 生成 | {{current_datetime}}<br>
|
由 AstrBot 群日常分析插件 生成 | {{current_datetime}}<br>
|
||||||
SXP-Simon/astrbot_plugin_qq_group_daily_analysis<br>
|
SXP-Simon/astrbot_plugin_qq_group_daily_analysis<br>
|
||||||
<span style="opacity: 0.7;">AI分析消耗:{{total_tokens}} tokens (输入: {{prompt_tokens}}, 输出: {{completion_tokens}})</span>
|
<span style="opacity: 0.7;">AI分析消耗:{{total_tokens}} tokens (输入: {{prompt_tokens}}, 输出: {{completion_tokens}})</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -825,7 +825,7 @@
|
|||||||
|
|
||||||
<footer>
|
<footer>
|
||||||
<div class="footer-row">
|
<div class="footer-row">
|
||||||
<div>CRT_EXPORT_CHANNEL // QQ群日常分析插件</div>
|
<div>CRT_EXPORT_CHANNEL // AstrBot 群日常分析插件</div>
|
||||||
<div class="footer-author">SXP-Simon / astrbot_plugin_qq_group_daily_analysis</div>
|
<div class="footer-author">SXP-Simon / astrbot_plugin_qq_group_daily_analysis</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="footer-row">
|
<div class="footer-row">
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ class AutoScheduler:
|
|||||||
|
|
||||||
# Cache: group_id -> group_name (populated lazily)
|
# Cache: group_id -> group_name (populated lazily)
|
||||||
self._group_name_cache: dict[str, str] = {}
|
self._group_name_cache: dict[str, str] = {}
|
||||||
|
self._terminating = False # 终止标志位
|
||||||
|
|
||||||
def set_bot_instance(self, bot_instance):
|
def set_bot_instance(self, bot_instance):
|
||||||
"""设置bot实例(保持向后兼容)"""
|
"""设置bot实例(保持向后兼容)"""
|
||||||
@@ -269,6 +270,7 @@ class AutoScheduler:
|
|||||||
|
|
||||||
def unschedule_jobs(self, context):
|
def unschedule_jobs(self, context):
|
||||||
"""取消定时任务"""
|
"""取消定时任务"""
|
||||||
|
self._terminating = True
|
||||||
if (
|
if (
|
||||||
not context
|
not context
|
||||||
or not hasattr(context, "cron_manager")
|
or not hasattr(context, "cron_manager")
|
||||||
@@ -343,6 +345,8 @@ class AutoScheduler:
|
|||||||
|
|
||||||
async def _run_auto_analysis(self):
|
async def _run_auto_analysis(self):
|
||||||
"""执行传统自动分析 - 并发处理所有群聊"""
|
"""执行传统自动分析 - 并发处理所有群聊"""
|
||||||
|
if self._terminating:
|
||||||
|
return
|
||||||
try:
|
try:
|
||||||
logger.info("开始执行自动群聊分析(并发模式)")
|
logger.info("开始执行自动群聊分析(并发模式)")
|
||||||
|
|
||||||
@@ -424,6 +428,9 @@ class AutoScheduler:
|
|||||||
trace_id = TraceContext.generate(prefix="group", group_name=group_name)
|
trace_id = TraceContext.generate(prefix="group", group_name=group_name)
|
||||||
TraceContext.set(trace_id)
|
TraceContext.set(trace_id)
|
||||||
|
|
||||||
|
if self._terminating:
|
||||||
|
return
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"开始为群 {group_id} 执行自动分析 (Platform: {target_platform_id or 'Auto'})"
|
f"开始为群 {group_id} 执行自动分析 (Platform: {target_platform_id or 'Auto'})"
|
||||||
)
|
)
|
||||||
@@ -473,9 +480,11 @@ class AutoScheduler:
|
|||||||
# ================================================================
|
# ================================================================
|
||||||
|
|
||||||
async def _run_incremental_analysis(self):
|
async def _run_incremental_analysis(self):
|
||||||
"""执行增量分析 - 为所有启用的群聊执行一次增量分析批次"""
|
"""执行增量分析任务 - 并发处理模式"""
|
||||||
|
if self._terminating:
|
||||||
|
return
|
||||||
try:
|
try:
|
||||||
logger.info("开始执行增量分析(交错并发模式)")
|
logger.info("开始执行自动增量分析(并发模式)")
|
||||||
|
|
||||||
enabled_targets = await self._get_enabled_targets()
|
enabled_targets = await self._get_enabled_targets()
|
||||||
|
|
||||||
@@ -586,6 +595,9 @@ class AutoScheduler:
|
|||||||
trace_id = TraceContext.generate(prefix="incr", group_name=group_name)
|
trace_id = TraceContext.generate(prefix="incr", group_name=group_name)
|
||||||
TraceContext.set(trace_id)
|
TraceContext.set(trace_id)
|
||||||
|
|
||||||
|
if self._terminating:
|
||||||
|
return
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"开始为群 {group_id} 执行增量分析 "
|
f"开始为群 {group_id} 执行增量分析 "
|
||||||
f"(Platform: {target_platform_id or 'Auto'})"
|
f"(Platform: {target_platform_id or 'Auto'})"
|
||||||
|
|||||||
Reference in New Issue
Block a user