mirror of
https://github.com/Nezumi-2711/astrbot_plugin_qq_group_daily_analysis.git
synced 2026-09-22 13:38:43 +00:00
[v4.11.0] - ✨ 新增 QQ 官方机器人群聊分析与专用 Markdown 报告 (@clown145 #206)
* feat: add QQ official bot group analysis support * fix: use text progress replies for QQ official bot * fix: harden QQ official proactive reporting * feat: enhance QQ official markdown reports * refactor: isolate QQ official markdown reporting * fix: restore platform adapter exports * style: format QQ official changes * fix(main): 添加 html_render 类型声明和 Callable 导入 为 self.html_render 属性添加显式类型声明,解决定时任务中可能因缺少类型注解导致的隐式错误。 * fix(analysis): 消除 execute_daily_analysis 中重复的 bot_self_ids 赋值 去除第二次冗余的 config_manager.get_bot_self_ids() 调用,直接复用已获取的变量。 * cleanup(domain): 移除未使用的分析器适配器和死代码服务文件 删除 golden_quote_analyzer.py、topic_analyzer.py、user_title_analyzer.py(均为未被引用的接口+适配器包装)report_generator.py(旧的文本报告生成器)statistics_calculator.py(与 StatisticsService 功能重复)更新 domain/services/__init__.py 移除对上述文件的引用。 * cleanup(domain): 移除冗余的实体和值对象文件,收敛数据模型至 domain/models/data_models.py 删除 analysis_result.py(与 data_models.py 重复的 SummaryTopic/UserTitle/GoldenQuote 等类定义)value_objects/golden_quote.py、statistics.py、topic.py、user_title.py(均为未被引用的 frozen dataclass 迁移残留)。所有活跃代码已统一导入 domain/models/data_models.py。 * refactor(llm): 抽取 _make_session_id 辅助方法消除重复代码 在 5 个方法(analyze_topics、analyze_user_titles、analyze_golden_quotes、analyze_all_concurrent、analyze_incremental_concurrent)中出现完全相同的 datetime.now().strftime(...) + umo 拼接逻辑,已提取为 _make_session_id 静态方法。 * refactor(message): 正则表达式提升为模块级常量避免重复编译 DISCORD_CUSTOM_EMOJI_PATTERN 和 COMMAND_PATTERN 从类属性移至模块级常量,避免每次实例化 MessageCleanerService 时重新编译正则。 * arch(domain): 定义 IActivityVisualizer 接口并通过依赖注入消除领域层反向依赖 创建 IActivityVisualizer 接口于 domain/repositories/visualization_repository.py,StatisticsService 改由依赖注入接收该接口;ActivityVisualizer 继承接口。消除原 StatisticsService 直接 import infrastructure.visualization 的 DDD 违规。 * fix(main): 补全 Callable 导入和 html_render 类型声明 初次提交(877adb6)因 git add -p 交互式分块时 BOM 导致错误的 hunk 被暂存,Callable 导入和 html_render 类型声明丢失。本次补全这两项修改。 * fix(platform): 补充 QQOfficialAdapter、TelegramAdapter、DiscordAdapter 导出 * refactor(main): 提取内嵌的文本报告生成/发送函数为独立类方法 将 _send_analysis_report 方法中的 generate_text_reports() 和 send_text_reports() 内嵌异步函数提取为 _generate_text_reports 和 _send_text_reports 私有方法。减少闭包复杂度,提升可维护性。 * cleanup(config): 移除废弃的 get_qq_official_t2i_activity_histogram_enabled 向后兼容方法 删除 config_manager.py 中的旧名别名方法,简化 qq_official_markdown.py 中对应的 getattr 回退逻辑为直接方法调用,移除测试中专门验证旧名兼容性的 LegacyDisabledConfig 测试用例。该兼容层仅在迁移期间临时存在,现已完成过渡。 * fix(types): 修复 Pylance 类型告警 — platform_key 未绑定、int(object) 和 template.filename 可能为 None platform_group_registry.py: 将 platform_key 定义提前,消除 Pylance reportPossiblyUnboundVariable 告警。 qq_official_markdown.py: 将 int(value or 0) 改为 int(value) if value is not None else 0,消除 reportArgumentType 告警。 templates.py: 在使用 template.filename 前增加 None 检查,消除 str|None 不可分配给 str 的告警。 qq_official_adapter.py: 为 post_group_message 添加类型忽略注解,消除 await 不可等待对象的告警。 * fix(types): 修复残留的 Pylance 类型告警 platform_group_registry.py: 将 (platform_key, group_id) 改为 (str(platform_key), group_id),消除 str|None 不可分配给 str 的 reportArgumentType qq_official_markdown.py: int(value) 添加 # type: ignore[arg-type],消除 object 不可分配给 ConvertibleToInt qq_official_adapter.py: 移除无意义的中间变量,await 行直接添加 # type: ignore[arg-type] * docs(message): 更新 MessageProcessingService 的文档和注释,消除 Telegram 特殊性表述 类 docstring:移除 '维护 Telegram 群组注册表' 等过时描述,补充 QQ 官方去重职责。 group_registry.upsert 注释:改为泛化的跨平台描述,不再限定 Telegram。 _extract_event_timestamp / _reserve_event_id / _commit_event_id / _release_event_id:英文 docstring 统一为中文。 * docs(message): 修正类注释中只提 QQ 官方的问题,明示 Telegram 也由本服务处理 Telegram 和 QQ 官方消息都经过 MessageProcessingService.process_message()。修正前类 docstring 只提了 QQ 官方的事件去重,缺少 Telegram 作为主要调用者的说明。 * fix(types): 修正 _sanitize_analysis_result_for_export 返回类型注解 函数声明 -> dict 但 _sanitize_export_identity_text 可返回 str|dict|list,Pylance 报 reportReturnType。 改为 -> dict[str, Any] 准确表达实际返回类型,补充缺失的 from typing import Any。 * fix(types): 抑制 Pylance reportReturnType 误报 _sanitize_analysis_result_for_export 的 analysis_result 参数运行时始终为 dict,但 _to_plain_export_data 递归返回 Any 导致 Pylance 推导出 str|dict|list 联合类型。 添加 # type: ignore[return-type] 抑制此误报。 * fix(report): 渲染匿名模式下的未知引用 token 不再静默丢弃 当 hide_user_names=True 且 [id] 不在 known_ids 中时,之前返回空 Markup 导致 token 被静默移除,可能扭曲文本语义。改为返回转义后的原始 [id] 字符串,保留文本布局和语义,同时不泄露身份信息。 参考 Sourcery AI code review 建议。 * fix(import): 避免从 astrbot.core 直接导入 File,优先使用公开 API astrbot.core 是内部模块,不在公开 API 契约中,可能随版本变化。 改为 try 优先导入 astrbot.api.message_components.File(公开 API), 失败时回退到 astrbot.core.message.components.File(向后兼容)。 同时修复 main.py 和 qq_official_adapter.py 两处导入。 * fix(import): 回退 try/except 伪装,改为直接导入 + 风险注释 astrbot.api.message_components 不存在,try/except 永远走 except 分支,是无效代码。 改为直接 from astrbot.core.message.components import File,用注释说明这是内部 API 可能变化。 * fix(ruff): 代码质量 * docs(README): 调整文档说明,删除 lark 相关的描述和功能标识 * docs(desc): 更新 desc * fix(message): 将 TG 和 QQ 官方消息缓存成功日志降为 debug * perf(message): 将 _extract_event_timestamp 延迟到 QQ 官方分支内计算 该时间戳仅用于 QQ 官方消息的 history_content 元数据,但对所有平台都执行了深度 getattr 链。 改为只在 QQ 官方分支内延迟计算,消除 Telegram 等平台上每次消息的白算开销。 * chore(CHANGELOG) --------- Co-authored-by: SXP-Simon <sxp20061207@163.com>
This commit is contained in:
@@ -7,7 +7,7 @@
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
from collections.abc import AsyncGenerator
|
||||
from collections.abc import AsyncGenerator, Callable
|
||||
from pathlib import Path
|
||||
|
||||
from astrbot.api import AstrBotConfig
|
||||
@@ -15,6 +15,8 @@ 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 (
|
||||
@@ -35,8 +37,8 @@ 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.telegram_group_registry import (
|
||||
TelegramGroupRegistry,
|
||||
from .src.infrastructure.persistence.platform_group_registry import (
|
||||
PlatformGroupRegistry,
|
||||
)
|
||||
from .src.infrastructure.platform.bot_manager import BotManager
|
||||
from .src.infrastructure.platform.template_preview import (
|
||||
@@ -45,6 +47,7 @@ from .src.infrastructure.platform.template_preview import (
|
||||
)
|
||||
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
|
||||
@@ -60,7 +63,8 @@ class GroupDailyAnalysis(Star):
|
||||
bot_manager: BotManager
|
||||
history_manager: HistoryManager
|
||||
report_generator: ReportGenerator
|
||||
telegram_group_registry: TelegramGroupRegistry
|
||||
html_render: Callable
|
||||
platform_group_registry: PlatformGroupRegistry
|
||||
statistics_service: StatisticsService
|
||||
analysis_domain_service: AnalysisDomainService
|
||||
llm_analyzer: LLMAnalyzer
|
||||
@@ -90,10 +94,11 @@ class GroupDailyAnalysis(Star):
|
||||
self.report_generator = ReportGenerator(self.config_manager, plugin_data_dir)
|
||||
|
||||
# Telegram 注册表 (持久层)
|
||||
self.telegram_group_registry = TelegramGroupRegistry(self)
|
||||
self.platform_group_registry = PlatformGroupRegistry(self)
|
||||
|
||||
# 2. 领域层
|
||||
self.statistics_service = StatisticsService()
|
||||
activity_visualizer = ActivityVisualizer()
|
||||
self.statistics_service = StatisticsService(activity_visualizer)
|
||||
self.analysis_domain_service = AnalysisDomainService()
|
||||
|
||||
# 3. 分析核心 (LLM Bridge)
|
||||
@@ -118,7 +123,7 @@ class GroupDailyAnalysis(Star):
|
||||
|
||||
# 消息处理服务
|
||||
self.message_processing_service = MessageProcessingService(
|
||||
context, self.telegram_group_registry
|
||||
context, self.platform_group_registry
|
||||
)
|
||||
self.template_command_service = TemplateCommandService(
|
||||
plugin_root=os.path.dirname(__file__)
|
||||
@@ -287,11 +292,43 @@ class GroupDailyAnalysis(Star):
|
||||
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.telegram_group_registry.get_all_group_ids(platform_id)
|
||||
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"""
|
||||
@@ -517,7 +554,15 @@ class GroupDailyAnalysis(Star):
|
||||
# 表情回应 或 文本提示(二选一,由配置开关控制)
|
||||
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()
|
||||
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("🔍 正在启动分析引擎,正在拉取最近消息...")
|
||||
@@ -577,6 +622,7 @@ class GroupDailyAnalysis(Star):
|
||||
analysis_result = result["analysis_result"]
|
||||
adapter = result["adapter"]
|
||||
output_format = self.config_manager.get_output_format()
|
||||
hide_user_names = adapter.get_platform_name() == "qq_official"
|
||||
|
||||
# 定义获取回调
|
||||
async def avatar_url_getter(user_id: str) -> str | None:
|
||||
@@ -599,6 +645,7 @@ class GroupDailyAnalysis(Star):
|
||||
avatar_url_getter=avatar_url_getter,
|
||||
nickname_getter=nickname_getter,
|
||||
avatar_cache_namespace=platform_id,
|
||||
hide_user_names=hide_user_names,
|
||||
)
|
||||
|
||||
if image_url:
|
||||
@@ -610,8 +657,9 @@ class GroupDailyAnalysis(Star):
|
||||
|
||||
# 如果图片生成或发送失败,直接回退到文本
|
||||
logger.warning(f"图片报告发送失败,正在发送文本回退报告。群: {group_id}")
|
||||
text_report = self.report_generator.generate_text_report(analysis_result)
|
||||
await adapter.send_text_report(group_id, text_report)
|
||||
await self._send_text_reports(
|
||||
group_id, analysis_result, hide_user_names, adapter
|
||||
)
|
||||
return
|
||||
|
||||
elif output_format == "html":
|
||||
@@ -621,6 +669,7 @@ class GroupDailyAnalysis(Star):
|
||||
avatar_url_getter=avatar_url_getter,
|
||||
nickname_getter=nickname_getter,
|
||||
avatar_cache_namespace=platform_id,
|
||||
hide_user_names=hide_user_names,
|
||||
)
|
||||
if html_path:
|
||||
is_only_url = self.config_manager.get_html_only_url()
|
||||
@@ -681,8 +730,28 @@ class GroupDailyAnalysis(Star):
|
||||
yield event.plain_result("⚠️ HTML 生成失败。")
|
||||
|
||||
else:
|
||||
text_report = self.report_generator.generate_text_report(analysis_result)
|
||||
await adapter.send_text_report(group_id, text_report)
|
||||
await self._send_text_reports(
|
||||
group_id, analysis_result, hide_user_names, adapter
|
||||
)
|
||||
|
||||
async def _generate_text_reports(
|
||||
self, analysis_result: dict, hide_user_names: bool
|
||||
) -> tuple[str, str | None]:
|
||||
"""Generate text or QQ-official-markdown reports."""
|
||||
if hide_user_names:
|
||||
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, hide_user_names: bool, adapter
|
||||
) -> bool:
|
||||
"""Send text reports via platform adapter."""
|
||||
tr, fr = await self._generate_text_reports(analysis_result, hide_user_names)
|
||||
if hide_user_names:
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user