Files
astrbot_plugin_qq_group_dai…/main.py
T

1156 lines
48 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
群日常分析插件
基于群聊记录生成精美的日常分析报告,包含话题总结、用户画像、统计数据等
重构版本 - 使用模块化架构,支持跨平台
"""
import asyncio
import os
from collections.abc import AsyncGenerator, Callable
from pathlib import Path
from astrbot.api import AstrBotConfig
from astrbot.api import logger as astrbot_logger
from astrbot.api.event import AstrMessageEvent, filter
from astrbot.api.event.filter import PermissionType
from astrbot.api.star import Context, Star, StarTools
# File is only available via astrbot.core (internal API — may change).
from astrbot.core.message.components import File
from .src.application.commands.template_command_service import (
TemplateCommandService,
)
from .src.application.services.analysis_application_service import (
AnalysisApplicationService,
DuplicateGroupTaskError,
)
from .src.application.services.message_processing_service import (
MessageProcessingService,
)
from .src.domain.services.analysis_domain_service import AnalysisDomainService
from .src.domain.services.incremental_merge_service import IncrementalMergeService
from .src.domain.services.statistics_service import StatisticsService
from .src.infrastructure.analysis.llm_analyzer import LLMAnalyzer
from .src.infrastructure.config.config_manager import ConfigManager
from .src.infrastructure.messaging.message_sender import MessageSender
from .src.infrastructure.persistence.history_manager import HistoryManager
from .src.infrastructure.persistence.incremental_store import IncrementalStore
from .src.infrastructure.persistence.platform_group_registry import (
PlatformGroupRegistry,
)
from .src.infrastructure.platform.bot_manager import BotManager
from .src.infrastructure.platform.template_preview import (
TelegramTemplatePreviewHandler,
TemplatePreviewRouter,
)
from .src.infrastructure.reporting.generators import ReportGenerator
from .src.infrastructure.scheduler.auto_scheduler import AutoScheduler
from .src.infrastructure.visualization.activity_charts import ActivityVisualizer
from .src.shared.constants import PLUGIN_NAME
from .src.shared.trace_context import TraceContext, TraceLogFilter
from .src.utils.logger import logger
from .src.utils.resilience import GlobalRateLimiter
class GroupDailyAnalysis(Star):
"""群分析插件主类"""
# ── 显式类型声明 (由 __init__ 初始化) ──
config: AstrBotConfig
config_manager: ConfigManager
bot_manager: BotManager
history_manager: HistoryManager
report_generator: ReportGenerator
html_render: Callable
platform_group_registry: PlatformGroupRegistry
statistics_service: StatisticsService
analysis_domain_service: AnalysisDomainService
llm_analyzer: LLMAnalyzer
incremental_store: IncrementalStore
incremental_merge_service: IncrementalMergeService
analysis_service: AnalysisApplicationService
message_processing_service: MessageProcessingService
template_command_service: TemplateCommandService
telegram_template_preview_handler: TelegramTemplatePreviewHandler
template_preview_router: TemplatePreviewRouter
auto_scheduler: AutoScheduler
message_sender: MessageSender
def __init__(self, context: Context, config: AstrBotConfig):
super().__init__(context)
self.config = config
# 1. 基础设施层
self.config_manager = ConfigManager(config)
self.bot_manager = BotManager(self.config_manager)
self.bot_manager.set_context(context)
self.bot_manager.set_plugin_instance(self)
self.history_manager = HistoryManager(self)
plugin_data_dir = StarTools.get_data_dir(PLUGIN_NAME)
self.report_generator = ReportGenerator(self.config_manager, plugin_data_dir)
# Telegram 注册表 (持久层)
self.platform_group_registry = PlatformGroupRegistry(self)
# 2. 领域层
activity_visualizer = ActivityVisualizer()
self.statistics_service = StatisticsService(activity_visualizer)
self.analysis_domain_service = AnalysisDomainService()
# 3. 分析核心 (LLM Bridge)
self.llm_analyzer = LLMAnalyzer(context, self.config_manager)
# 4. 增量分析组件
self.incremental_store = IncrementalStore(self)
self.incremental_merge_service = IncrementalMergeService()
# 5. 应用层
self.analysis_service = AnalysisApplicationService(
self.config_manager,
self.bot_manager,
self.history_manager,
self.report_generator,
self.llm_analyzer,
self.statistics_service,
self.analysis_domain_service,
incremental_store=self.incremental_store,
incremental_merge_service=self.incremental_merge_service,
)
# 消息处理服务
self.message_processing_service = MessageProcessingService(
context, self.platform_group_registry
)
self.template_command_service = TemplateCommandService(
plugin_root=os.path.dirname(__file__)
)
self.telegram_template_preview_handler = TelegramTemplatePreviewHandler(
config_manager=self.config_manager,
template_service=self.template_command_service,
)
self.template_preview_router = TemplatePreviewRouter(
handlers=[self.telegram_template_preview_handler]
)
# 调度与发送
self.message_sender = MessageSender(self.bot_manager, self.config_manager)
self.auto_scheduler = AutoScheduler(
self.config_manager,
self.analysis_service,
self.bot_manager,
self.report_generator,
self.html_render,
plugin_instance=self,
)
# 同步全局限流并进行初始化配置
GlobalRateLimiter.get_instance(self.config_manager.get_llm_max_concurrent())
self._initialized = False
self._terminating = False # 生命周期标志
self._init_lock = asyncio.Lock()
self._background_tasks: set[asyncio.Task] = set()
# 异步注册任务,处理插件重载情况
try:
loop = asyncio.get_running_loop()
self._init_task = loop.create_task(
self._run_initialization("Plugin Reload/Init")
)
self._background_tasks.add(self._init_task)
self._init_task.add_done_callback(self._background_tasks.discard)
except RuntimeError:
self._init_task = None
# orchestrators 缓存已移至 应用层逻辑 (分析服务) 或 暂时移除以简化。
# 如果需要高性能缓存,后续可由 AnalysisApplicationService 内部维护。
@filter.on_platform_loaded()
async def on_platform_loaded(self):
"""平台加载完成后初始化"""
await self._run_initialization("Platform Loaded")
async def _run_initialization(self, source: str):
"""统一初始化逻辑"""
async with self._init_lock:
# 如果已经成功发现过平台,且不是来自 Platform Loaded 的强制触发,则跳过
if (
self._initialized
and self.bot_manager
and self.bot_manager.get_platform_count() > 0
and source != "Platform Loaded"
):
return
# 稍微延迟,确保 context 和环境稳定
# 针对极少数环境,2秒可能不足以让平台管理器就绪,增加到 5秒
await asyncio.sleep(5)
# [加固] 如果在等待期间插件已被卸载(terminate),则直接退出
if not self.bot_manager:
return
try:
# 注册 TraceID 过滤器
trace_filter = TraceLogFilter()
if not any(
isinstance(f, TraceLogFilter) for f in astrbot_logger.filters
):
astrbot_logger.addFilter(trace_filter)
astrbot_logger.info("[Trace] TraceID 日志追踪已启用")
logger.info(f"正在执行插件初始化 (来源: {source})...")
# 0. 自动升级旧版 prompt 模板(str.format -> string.Template)并回写配置
try:
self.config_manager.upgrade_prompt_templates()
except Exception as e:
logger.warning(f"自动升级 prompt 模板失败: {e}")
# 1. 尝试发现 bot 实例
await self.bot_manager.initialize_from_config()
# 2. 注册预览路由器
if self.template_preview_router:
await self.template_preview_router.ensure_handlers_registered(
self.context
)
# 3. 强制注册定时分析任务
if self.auto_scheduler:
self.auto_scheduler.schedule_jobs(self.context)
self._initialized = True
self._discovery_run = True
logger.info(f"插件任务注册完成 (来源: {source})")
except Exception as e:
logger.error(f"插件初始化失败: {e}", exc_info=True)
async def terminate(self):
"""插件被卸载/停用时调用,清理资源"""
if self._terminating:
return
self._terminating = True
try:
logger.info("开始清理群日常分析插件资源...")
# 1. 停止所有后台任务
if self._background_tasks:
logger.info(f"正在取消 {len(self._background_tasks)} 个运行中的任务...")
for task in self._background_tasks:
if not task.done():
task.cancel()
# 等待任务结束,给予 3 秒宽限期
try:
await asyncio.wait(list(self._background_tasks), timeout=3.0)
except Exception:
pass
self._background_tasks.clear()
# 2. 停止各个组件 (顺序:先调度器,后底层服务)
if self.auto_scheduler:
logger.debug("正在停止自动调度器...")
self.auto_scheduler.unschedule_jobs(self.context)
if self.template_preview_router:
await self.template_preview_router.unregister_handlers()
if self.report_generator:
await self.report_generator.close()
# 3. [关键修复] 只有在任务全部清理后,才清理引用。
# 实际上,在 terminate 结束后,self 本身就会被 GC 释放,
# 这里的显式 None 更多是为了协助循环引用清理,但由于异步任务存在竞态,
# 我们可以通过 check _terminating 标志位来保护。
# 为了彻底解决 #125,我们保留引用,让 GC 自然回收。
logger.info("群日常分析插件资源清理完成")
except Exception as e:
logger.error(f"插件资源清理失败: {e}")
# ==================== Telegram 消息拦截器 ====================
@filter.event_message_type(filter.EventMessageType.GROUP_MESSAGE)
@filter.platform_adapter_type(filter.PlatformAdapterType.TELEGRAM)
async def intercept_telegram_messages(self, event: AstrMessageEvent):
"""
拦截 Telegram 群消息并存储到数据库
委托给 MessageProcessingService 处理
"""
try:
await self.message_processing_service.process_message(event)
except (ValueError, RuntimeError) as e:
logger.warning(f"[Telegram] 消息存储失败: {e}")
except Exception as e:
logger.error(f"[Telegram] 消息存储异常: {e}", exc_info=True)
@filter.event_message_type(filter.EventMessageType.GROUP_MESSAGE)
@filter.platform_adapter_type(
filter.PlatformAdapterType.QQOFFICIAL
| filter.PlatformAdapterType.QQOFFICIAL_WEBHOOK
)
async def intercept_qq_official_messages(self, event: AstrMessageEvent):
"""缓存 QQ 官方机器人群消息;频道消息不在本插件适配范围内。"""
raw_message = getattr(getattr(event, "message_obj", None), "raw_message", None)
if isinstance(raw_message, dict):
author = raw_message.get("author") or {}
group_openid = str(raw_message.get("group_openid", "") or "").strip()
member_openid = str(
author.get("member_openid", "") if isinstance(author, dict) else ""
).strip()
else:
author = getattr(raw_message, "author", None)
group_openid = str(getattr(raw_message, "group_openid", "") or "").strip()
member_openid = str(getattr(author, "member_openid", "") or "").strip()
if not group_openid or not member_openid:
return
try:
await self.message_processing_service.process_message(event)
except (ValueError, RuntimeError) as e:
logger.warning(f"[QQOfficial] 消息存储失败: {e}")
except Exception as e:
logger.error(f"[QQOfficial] 消息存储异常: {e}", exc_info=True)
async def get_telegram_seen_group_ids(
self, platform_id: str | None = None
) -> list[str]:
"""读取 Telegram 已见群/话题列表(给调度器回退使用)。"""
return await self.platform_group_registry.get_all_group_ids(platform_id)
async def get_seen_group_ids(self, platform_id: str | None = None) -> list[str]:
"""读取任意事件驱动平台已经见过的群组。"""
return await self.platform_group_registry.get_all_group_ids(platform_id)
def _get_group_id_from_event(self, event: AstrMessageEvent) -> str | None:
"""从消息事件中安全获取群组 ID"""
# 保留此辅助方法,因为在其他 command 中仍被频繁使用
try:
group_id = event.get_group_id()
return group_id if group_id else None
except Exception:
return None
def _get_platform_id_from_event(self, event: AstrMessageEvent) -> str:
"""从消息事件中获取平台唯一 ID"""
# 保留此辅助方法,因为在其他 command 中仍被频繁使用
try:
return event.get_platform_id()
except Exception:
# 后备方案:从元数据获取
if (
hasattr(event, "platform_meta")
and event.platform_meta
and hasattr(event.platform_meta, "id")
):
return event.platform_meta.id
return "default"
# ================================================================
# 图片报告上传到群文件 / 群相册(仅 QQ 平台 image 格式)
# ================================================================
async def _try_upload_image(self, group_id: str, image_url: str, platform_id: str):
"""
尝试将图片报告上传到群文件和/或群相册(静默处理,失败仅日志提示)。
"""
import base64
import re
import tempfile
from datetime import datetime
enable_file = self.config_manager.get_enable_group_file_upload()
enable_album = self.config_manager.get_enable_group_album_upload()
if not enable_file and not enable_album:
return
adapter = self.bot_manager.get_adapter(platform_id)
if not adapter or not hasattr(adapter, "upload_group_file_to_folder"):
return
# 1. 构造一个更友好的文件名
now = datetime.now()
timestamp = now.strftime("%H%M")
date_str = now.strftime("%Y-%m-%d")
# 默认基础名和后缀
ext = (
".jpg"
if (".jpg" in image_url.lower() or ".jpeg" in image_url.lower())
else ".png"
)
nice_filename = f"群分析报告_{group_id}_{date_str}_{timestamp}{ext}"
try:
# 尝试通过适配器获取群名称,使文件名更具辨识度
group_info = await adapter.get_group_info(group_id)
if group_info and group_info.group_name:
# 过滤非法文件名字符:\ / : * ? " < > |
safe_name = re.sub(r'[\\/:*?"<>|]', "", group_info.group_name).strip()
if safe_name:
nice_filename = (
f"群分析报告_{safe_name}_{date_str}_{timestamp}{ext}"
)
except Exception:
pass
# 2. 将内容准备为文件或数据
image_file = None
created_temp = False
MAX_PAYLOAD_SIZE = 20 * 1024 * 1024 # 20MB 限制
try:
data = None
if image_url.startswith("base64://"):
base64_str = image_url[len("base64://") :]
if len(base64_str) * 3 / 4 > MAX_PAYLOAD_SIZE:
logger.warning("图片上传失败:Base64 负载过大")
return
data = base64.b64decode(base64_str)
elif image_url.startswith("data:"):
parts = image_url.split(",", 1)
if len(parts) == 2:
if len(parts[1]) * 3 / 4 > MAX_PAYLOAD_SIZE:
logger.warning("图片上传失败:Data URI 负载过大")
return
data = base64.b64decode(parts[1])
elif os.path.isfile(image_url):
image_file = os.path.abspath(image_url)
if data and not image_file:
# 使用 tempfile 生成唯一后缀,防止并发冲突
fd, image_file = tempfile.mkstemp(suffix=ext, prefix="group_report_")
try:
with os.fdopen(fd, "wb") as f:
f.write(data)
created_temp = True
except Exception:
os.close(fd)
raise
if not image_file:
return
# 3. 执行上传:群文件
if enable_file:
try:
folder_name = self.config_manager.get_group_file_folder()
folder_id = None
if folder_name:
folder_id = await adapter.find_or_create_folder( # type: ignore[attr-defined]
group_id, folder_name
)
await adapter.upload_group_file_to_folder( # type: ignore[attr-defined]
group_id=group_id,
file_path=image_file,
folder_id=folder_id,
filename=nice_filename, # 显式传递漂亮的文件名
)
except Exception as e:
logger.warning(f"群文件上传失败 (群 {group_id}): {e}")
if enable_album and hasattr(adapter, "upload_group_album"):
try:
album_name = self.config_manager.get_group_album_name()
strict_mode = self.config_manager.get_group_album_strict_mode()
album_id = None
if hasattr(adapter, "find_album_id"):
if album_name:
album_id = await adapter.find_album_id(group_id, album_name) # type: ignore[attr-defined]
if not album_id and strict_mode:
logger.info(
f"群相册严格模式开启:在群 {group_id} 中未找到名为 '{album_name}' 的相册,停止上传。"
)
return
elif strict_mode:
logger.info(
f"群相册严格模式开启:未设置目标相册名称,停止上传以防止操作群 {group_id} 的默认相册。"
)
return
await adapter.upload_group_album( # type: ignore[attr-defined]
group_id,
image_file,
album_id=album_id,
album_name=album_name,
strict_mode=strict_mode,
)
except Exception as e:
logger.warning(f"群相册上传失败 (群 {group_id}): {e}")
except Exception as e:
logger.warning(f"图片上传处理异常: {e}")
finally:
if created_temp and image_file and os.path.exists(image_file):
try:
os.remove(image_file)
except OSError:
pass
@filter.command("群分析", alias={"group_analysis"})
@filter.permission_type(PermissionType.ADMIN)
async def analyze_group_daily(
self, event: AstrMessageEvent, days: int | None = None
):
"""
分析群聊日常活动(跨平台支持)
用法: /群分析 [天数]
"""
if self._terminating:
return
current_task = asyncio.current_task()
if current_task:
self._background_tasks.add(current_task)
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)
orig_msg_id = getattr(event.message_obj, "message_id", None)
adapter_platform_name = (
(adapter.get_platform_name() if adapter else "").strip().lower()
)
# QQ 官方机器人 API v2 不支持本插件使用的表情回应接口,
# 因此始终沿用原有的文字进度提示,避免触发无效的 reaction 请求。
use_text_reply = (
adapter_platform_name in {"qq_official", "qq_official_webhook"}
or 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, "analysis_started"
)
# 调用 DDD 应用级服务
result = await self.analysis_service.execute_daily_analysis(
group_id=group_id, platform_id=platform_id, manual=True, days=days
)
if not result.get("success"):
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
if not use_text_reply and adapter and orig_msg_id:
await adapter.set_reaction(
event.get_group_id(), orig_msg_id, "analysis_done"
)
async for res in self._send_analysis_report(event, result):
yield res
except DuplicateGroupTaskError:
yield event.plain_result("📊 该群的分析任务正在执行中,请稍后再试哦~")
except asyncio.CancelledError:
logger.info("群分析任务被取消 (插件重载或卸载)")
except Exception as e:
logger.error(f"群分析失败: {e}", exc_info=True)
yield event.plain_result(
f"❌ 分析失败: {str(e)}。请检查网络连接和LLM配置,或联系管理员"
)
finally:
if current_task:
self._background_tasks.discard(current_task)
async def _send_analysis_report(
self, event: AstrMessageEvent, result: dict
) -> AsyncGenerator:
"""处理分析结果的渲染和发送"""
if self._terminating or not self.config_manager:
logger.warning("插件正在关闭,停止发送报告")
return
group_id = result["group_id"]
platform_id = result["platform_id"]
analysis_result = result["analysis_result"]
adapter = result["adapter"]
output_format = self.config_manager.get_output_format()[0]
is_qq_official = adapter.get_platform_name() == "qq_official"
# 定义获取回调
async def avatar_url_getter(user_id: str) -> str | None:
return await adapter.get_user_avatar_url(user_id)
async def nickname_getter(user_id: str) -> str | None:
try:
member = await adapter.get_member_info(group_id, user_id)
if member:
return member.card or member.nickname
except Exception:
pass
return None
if output_format == "image":
image_url, html_content = await self.report_generator.generate_image_report(
analysis_result,
group_id,
self.html_render,
avatar_url_getter=avatar_url_getter,
nickname_getter=nickname_getter,
avatar_cache_namespace=platform_id,
allow_alphanumeric_user_ids=is_qq_official,
)
if image_url:
caption = TraceContext.make_report_caption() if self.config_manager.get_show_report_caption() else ""
sent = await adapter.send_image(group_id, image_url, caption=caption)
if sent:
await self._try_upload_image(group_id, image_url, platform_id)
return # 成功发送
# 如果图片生成或发送失败,直接回退到文本
logger.warning(f"图片报告发送失败,正在发送文本回退报告。群: {group_id}")
await self._send_text_reports(
group_id, analysis_result, is_qq_official, adapter
)
return
elif output_format == "html":
html_path, json_path = await self.report_generator.generate_html_report(
analysis_result,
group_id,
avatar_url_getter=avatar_url_getter,
nickname_getter=nickname_getter,
avatar_cache_namespace=platform_id,
allow_alphanumeric_user_ids=is_qq_official,
)
if html_path:
is_only_url = self.config_manager.get_html_only_url()
base_url = self.config_manager.get_html_base_url()
if is_only_url:
if base_url and base_url.strip():
# 获取配置中的输出目录
html_output_dir = self.config_manager.get_html_output_dir()
# 若用户配置为空,使用默认目录
if not html_output_dir:
from astrbot.api.star import StarTools
html_output_dir = os.path.join(
StarTools.get_data_dir(PLUGIN_NAME),
"self_hosted_html_reports",
)
# 计算相对路径并转换为URL
rel_path = os.path.relpath(html_path, html_output_dir)
url_path = rel_path.replace(os.sep, "/")
report_url = f"{base_url.rstrip('/')}/{url_path.lstrip('/')}"
yield event.plain_result(
f"📊 今日群聊分析报告已生成:\n{report_url}"
)
return # 拦截成功,直接退出,不再发文件
else:
logger.warning(
f"手动触发群 {group_id} 开启了仅发送外链,但未配置 html_base_url,回退至发送文件。"
)
caption = self.report_generator.build_html_caption(html_path)
# 发送 HTML 文件
sender = getattr(self, "message_sender", None)
if sender:
sent = await sender.send_file(
group_id,
html_path,
caption=caption,
platform_id=platform_id,
)
else:
sent = await adapter.send_file(group_id, html_path)
if sent and caption:
await adapter.send_text(group_id, caption)
if not sent:
yield event.chain_result(
[File(name=Path(html_path).name, file=html_path)]
)
if caption:
yield event.plain_result(caption)
else:
yield event.plain_result("⚠️ HTML 生成失败。")
else:
await self._send_text_reports(
group_id, analysis_result, is_qq_official, adapter
)
async def _generate_text_reports(
self, analysis_result: dict, use_qq_official_markdown: bool
) -> tuple[str, str | None]:
"""Generate text or QQ-official-markdown reports."""
if use_qq_official_markdown:
return await self.report_generator.generate_qq_official_markdown_report(
analysis_result, self.html_render
)
return self.report_generator.generate_text_report(analysis_result), None
async def _send_text_reports(
self,
group_id: str,
analysis_result: dict,
use_qq_official_markdown: bool,
adapter,
) -> bool:
"""Send text reports via platform adapter."""
tr, fr = await self._generate_text_reports(
analysis_result, use_qq_official_markdown
)
if use_qq_official_markdown:
return await adapter.send_text_report(group_id, tr, fallback_content=fr)
return await adapter.send_text_report(group_id, tr)
@filter.command("设置格式", alias={"set_format"})
@filter.permission_type(PermissionType.ADMIN)
async def set_output_format(self, event: AstrMessageEvent, format_input: str = ""):
"""
设置分析报告输出格式(跨平台支持)
用法: /设置格式 [格式名称或序号] 或 image,html 等逗号分隔的组合
"""
# 命令由插件处理,禁用默认 LLM 回退。
event.should_call_llm(True)
available_formats = ["image", "text", "html"]
format_display_names = {
"image": "图片格式 (默认)",
"text": "文本格式",
"html": "交互式 HTML 网页",
}
if not format_input:
current = ", ".join(self.config_manager.get_output_format())
format_list_str = "\n".join(
[
f"【{i}{f} - {format_display_names[f]}"
for i, f in enumerate(available_formats, start=1)
]
)
yield event.plain_result(f"""📊 当前输出格式: {current}
可用格式:
{format_list_str}
用法: /设置格式 [名称或序号] 如 /设置格式 image,html""")
return
target_format = None
# 尝试由序号选择
if format_input.isdigit():
idx = int(format_input) - 1
if 0 <= idx < len(available_formats):
target_format = available_formats[idx]
# 尝试按名称选择
if not target_format:
input_lower = format_input.lower()
if input_lower in available_formats:
target_format = input_lower
# 支持逗号分隔的多个格式
if not target_format:
parts = [f.strip() for f in format_input.replace("", ",").split(",")]
if all(p in available_formats for p in parts) and len(parts) > 1:
try:
self.config_manager.set_output_format(parts)
yield event.plain_result(f"✅ 输出格式已设置为: {', '.join(parts)}")
except Exception as e:
yield event.plain_result(f"❌ 设置失败: {e}")
return
if not target_format:
yield event.plain_result(
f"❌ 无效的格式类型 '{format_input}'。可用: {', '.join(available_formats)} 或序号 1-{len(available_formats)}"
)
return
try:
self.config_manager.set_output_format(target_format) # type: ignore[arg-type]
yield event.plain_result(f"✅ 输出格式已设置为: {target_format}")
except Exception as e:
yield event.plain_result(f"❌ 设置失败: {e}")
@filter.command("设置模板", alias={"set_template"})
@filter.permission_type(PermissionType.ADMIN)
async def set_report_template(
self, event: AstrMessageEvent, template_input: str = ""
):
"""
设置分析报告模板(跨平台支持)
用法: /设置模板 [模板名称或序号]
"""
# 命令由插件处理,禁用默认 LLM 回退。
event.should_call_llm(True)
available_templates = (
await self.template_command_service.list_available_templates()
)
if not template_input:
current_template = self.config_manager.get_report_template()
template_list_str = "\n".join(
[f"【{i}{t}" for i, t in enumerate(available_templates, start=1)]
)
yield event.plain_result(f"""🎨 当前报告模板: {current_template}
可用模板:
{template_list_str}
用法: /设置模板 [模板名称或序号]
💡 使用 /查看模板 查看预览图""")
return
template_name, parse_error = self.template_command_service.parse_template_input(
template_input, available_templates
)
if parse_error:
yield event.plain_result(parse_error)
return
if not template_name:
yield event.plain_result(f"❌ 无法解析模板输入: {template_input}")
return
if not await self.template_command_service.template_exists(template_name):
yield event.plain_result(f"❌ 模板 '{template_name}' 不存在")
return
self.config_manager.set_report_template(template_name)
yield event.plain_result(f"✅ 报告模板已设置为: {template_name}")
@filter.command("查看模板", alias={"view_templates"})
@filter.permission_type(PermissionType.ADMIN)
async def view_templates(self, event: AstrMessageEvent):
"""
查看所有可用的报告模板及预览图(跨平台支持)
用法: /查看模板
"""
# 命令由插件处理,禁用默认 LLM 回退。
event.should_call_llm(True)
available_templates = (
await self.template_command_service.list_available_templates()
)
if not available_templates:
yield event.plain_result("❌ 未找到任何可用的报告模板")
return
platform_id = self._get_platform_id_from_event(event)
await self.template_preview_router.ensure_handlers_registered(self.context)
(
handled,
handler_results,
) = await self.template_preview_router.handle_view_templates(
event=event,
platform_id=platform_id,
available_templates=available_templates,
)
if handled:
for result in handler_results:
yield result
return
current_template = self.config_manager.get_report_template()
bot_id = event.get_self_id()
preview_nodes = self.template_command_service.build_template_preview_nodes(
available_templates=available_templates,
current_template=current_template,
bot_id=bot_id,
)
yield event.chain_result([preview_nodes])
@filter.command("分析设置", alias={"analysis_settings"})
@filter.permission_type(PermissionType.ADMIN)
async def analysis_settings(self, event: AstrMessageEvent, action: str = "status"):
"""
管理分析设置(跨平台支持)
用法: /分析设置 [enable|disable|status|reload|test]
- enable: 启用当前群的分析功能
- disable: 禁用当前群的分析功能
- status: 查看当前状态
- reload: 重新加载配置并重启定时任务
- test: 测试自动分析功能
- filter_bot: 切换是否在分析中包含机器人自己的消息
- incremental_debug: 切换增量分析立即报告模式(调试用)
"""
group_id = self._get_group_id_from_event(event)
if not group_id:
yield event.plain_result("❌ 请在群聊中使用此命令")
return
if action == "enable":
async for result in self._handle_settings_enable(event, group_id):
yield result
elif action == "disable":
async for result in self._handle_settings_disable(event, group_id):
yield result
elif action == "reload":
self.auto_scheduler.schedule_jobs(self.context)
yield event.plain_result("✅ 已重新加载配置并重启定时任务")
elif action == "test":
check_target = getattr(event, "unified_msg_origin", None)
if not check_target:
check_target = (
f"{self._get_platform_id_from_event(event)}:GroupMessage:{group_id}"
)
if not self.config_manager.is_group_allowed(check_target):
yield event.plain_result("❌ 请先启用当前群的分析功能")
return
yield event.plain_result("🧪 开始测试自动分析功能...")
# 更新bot实例(用于测试)
self.bot_manager.update_from_event(event)
try:
await self.auto_scheduler._perform_auto_analysis_for_group(group_id)
yield event.plain_result("✅ 自动分析测试完成,请查看群消息")
except DuplicateGroupTaskError:
yield event.plain_result("📊 该群的分析任务正在执行中,请稍后再试哦~")
except Exception as e:
yield event.plain_result(f"❌ 自动分析测试失败: {str(e)}")
elif action == "incremental_debug":
current_state = self.config_manager.get_incremental_report_immediately()
new_state = not current_state
self.config_manager.set_incremental_report_immediately(new_state)
status_text = "已启用" if new_state else "已禁用"
yield event.plain_result(f"✅ 增量分析立即报告模式: {status_text}")
elif action == "filter_bot":
current = self.config_manager.get_filter_bot_messages()
new_state = not current
self.config_manager.set_filter_bot_messages(new_state)
status_text = "已启用" if new_state else "已禁用"
yield event.plain_result(f"✅ 过滤机器人消息: {status_text}")
else: # status
check_target = getattr(event, "unified_msg_origin", None)
if not check_target:
check_target = (
f"{self._get_platform_id_from_event(event)}:GroupMessage:{group_id}"
)
is_allowed = self.config_manager.is_group_allowed(check_target)
status = "已启用" if is_allowed else "未启用"
mode = self.config_manager.get_group_list_mode()
auto_status = (
"已启用" if self.config_manager.is_auto_analysis_enabled() else "未启用"
)
auto_time = self.config_manager.get_auto_analysis_time()
output_format = self.config_manager.get_output_format()[0]
min_threshold = self.config_manager.get_min_messages_threshold()
# 增量分析状态
incremental_enabled = self.config_manager.get_incremental_enabled()
incremental_status_text = "未启用"
if incremental_enabled:
interval = self.config_manager.get_incremental_interval_minutes()
max_daily = self.config_manager.get_incremental_max_daily_analyses()
active_start = self.config_manager.get_incremental_active_start_hour()
active_end = self.config_manager.get_incremental_active_end_hour()
incremental_status_text = (
f"已启用 (间隔{interval}分钟, 最多{max_daily}次/天, "
f"活跃时段{active_start}:00-{active_end}:00)"
)
debug_report = self.config_manager.get_incremental_report_immediately()
debug_status = "✅ 开启" if debug_report else "❌ 关闭"
filter_bot = self.config_manager.get_filter_bot_messages()
filter_bot_status = "✅ 开启" if filter_bot else "❌ 关闭"
yield event.plain_result(f"""📊 当前群分析功能状态:
• 群分析功能: {status} (模式: {mode})
• 自动分析: {auto_status} ({auto_time})
• 增量分析: {incremental_status_text}
• 调试模式: {debug_status} (增量立即报告)
• 过滤机器人: {filter_bot_status}
• 输出格式: {output_format}
• 最小消息数: {min_threshold}
💡 可用命令: enable, disable, status, reload, test, filter_bot, incremental_debug
💡 支持的输出格式: image, text (图片包含活跃度可视化)
💡 其他命令: /设置格式, /增量状态""")
@filter.command("增量状态", alias={"incremental_status"})
@filter.permission_type(PermissionType.ADMIN)
async def incremental_status(self, event: AstrMessageEvent):
"""查看当前增量分析状态(滑动窗口)"""
group_id = self._get_group_id_from_event(event)
if not group_id:
yield event.plain_result("❌ 请在群聊中使用此命令")
return
if not self.config_manager.get_incremental_enabled():
yield event.plain_result("ℹ️ 增量分析模式未启用,请在插件配置中开启")
return
import time as time_mod
# 计算滑动窗口范围
analysis_days = self.config_manager.get_analysis_days()
window_end = time_mod.time()
window_start = window_end - (analysis_days * 24 * 3600)
# 查询窗口内的批次
batches = await self.incremental_store.query_batches(
group_id, window_start, window_end
)
if not batches:
from datetime import datetime
start_str = datetime.fromtimestamp(window_start).strftime("%m-%d %H:%M")
end_str = datetime.fromtimestamp(window_end).strftime("%m-%d %H:%M")
yield event.plain_result(
f"📊 滑动窗口 ({start_str} ~ {end_str}) 内尚无增量分析数据"
)
return
# 合并批次获取聚合视图
state = self.incremental_merge_service.merge_batches(
batches, window_start, window_end
)
summary = state.get_summary()
yield event.plain_result(
f"📊 增量分析状态 (窗口: {summary['window']})\n"
f"• 分析次数: {summary['total_analyses']}\n"
f"• 累计消息: {summary['total_messages']}\n"
f"• 话题数: {summary['topics_count']}\n"
f"• 金句数: {summary['quotes_count']}\n"
f"• 参与者: {summary['participants']}\n"
f"• 高峰时段: {summary['peak_hours']}"
)
async def _handle_settings_enable(self, event: AstrMessageEvent, group_id: str):
"""协助逻辑:处理启用设置的分支逻辑"""
mode = self.config_manager.get_group_list_mode()
target_id = event.unified_msg_origin or group_id
if mode == "whitelist":
glist = self.config_manager.get_group_list()
if not self.config_manager.is_group_allowed(target_id):
glist.append(target_id)
self.config_manager.set_group_list(glist)
yield event.plain_result(f"✅ 已将当前群加入白名单\nID: {target_id}")
self.auto_scheduler.schedule_jobs(self.context)
else:
yield event.plain_result("ℹ️ 当前群已在白名单中")
elif mode == "blacklist":
glist = self.config_manager.get_group_list()
removed = False
if target_id in glist:
glist.remove(target_id)
removed = True
if group_id in glist:
glist.remove(group_id)
removed = True
if removed:
self.config_manager.set_group_list(glist)
yield event.plain_result("✅ 已将当前群从黑名单移除")
self.auto_scheduler.schedule_jobs(self.context)
else:
yield event.plain_result("ℹ️ 当前群不在黑名单中")
else:
yield event.plain_result("ℹ️ 当前为无限制模式,所有群聊默认启用")
async def _handle_settings_disable(self, event: AstrMessageEvent, group_id: str):
"""协助逻辑:处理禁用设置的分支逻辑"""
mode = self.config_manager.get_group_list_mode()
target_id = event.unified_msg_origin or group_id
if mode == "whitelist":
glist = self.config_manager.get_group_list()
removed = False
if target_id in glist:
glist.remove(target_id)
removed = True
if group_id in glist:
glist.remove(group_id)
removed = True
if removed:
self.config_manager.set_group_list(glist)
yield event.plain_result("✅ 已将当前群从白名单移除")
self.auto_scheduler.schedule_jobs(self.context)
else:
yield event.plain_result("ℹ️ 当前群不在白名单中")
elif mode == "blacklist":
glist = self.config_manager.get_group_list()
if self.config_manager.is_group_allowed(target_id):
glist.append(target_id)
self.config_manager.set_group_list(glist)
yield event.plain_result(f"✅ 已将当前群加入黑名单\nID: {target_id}")
self.auto_scheduler.schedule_jobs(self.context)
else:
yield event.plain_result("ℹ️ 当前群已在黑名单中")
else:
yield event.plain_result("ℹ️ 当前为无限制模式,如需禁用请切换到黑名单模式")