Files
astrbot_plugin_qq_group_dai…/main.py
T

1204 lines
52 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.
"""
Plugin phân tích hoạt động nhóm hằng ngày.
Tạo báo cáo từ lịch sử trò chuyện, gồm tóm tắt chủ đề, hồ sơ thành viên và
thống kê. Phiên bản module hoá hỗ trợ đa nền tảng.
"""
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):
"""Lớp plugin phân tích nhóm chính."""
# ── Khai báo kiểu tường minh, được khởi tạo trong __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. Tầng infrastructure.
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)
# Registry Telegram ở tầng persistence.
self.platform_group_registry = PlatformGroupRegistry(self)
# 2. Tầng domain.
activity_visualizer = ActivityVisualizer()
self.statistics_service = StatisticsService(activity_visualizer)
self.analysis_domain_service = AnalysisDomainService()
# 3. Lõi phân tích, cầu nối LLM.
self.llm_analyzer = LLMAnalyzer(context, self.config_manager)
# 4. Thành phần phân tích gia tăng.
self.incremental_store = IncrementalStore(self)
self.incremental_merge_service = IncrementalMergeService()
# 5. Tầng application.
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,
)
# Dịch vụ xử lý tin nhắn.
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]
)
# Lập lịch và gửi.
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,
)
# Đồng bộ cấu hình bộ giới hạn toàn cục.
GlobalRateLimiter.get_instance(self.config_manager.get_llm_max_concurrent())
self._initialized = False
self._terminating = False # Cờ vòng đời.
self._init_lock = asyncio.Lock()
self._background_tasks: set[asyncio.Task] = set()
# Đăng ký tác vụ bất đồng bộ để xử lý reload plugin.
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
# Cache orchestrator đã chuyển vào application service hoặc tạm bỏ để đơn giản.
# Nếu cần cache hiệu năng cao, AnalysisApplicationService có thể quản lý nội bộ.
@filter.on_platform_loaded()
async def on_platform_loaded(self):
"""Khởi tạo sau khi nền tảng tải xong."""
await self._run_initialization("Platform Loaded")
async def _run_initialization(self, source: str):
"""Logic khởi tạo thống nhất."""
async with self._init_lock:
# Bỏ qua nếu đã phát hiện nền tảng và không phải trigger Platform Loaded.
if (
self._initialized
and self.bot_manager
and self.bot_manager.get_platform_count() > 0
and source != "Platform Loaded"
):
return
# Chờ để context, môi trường và platform manager ổn định.
await asyncio.sleep(5)
# Thoát nếu plugin đã bị gỡ trong thời gian chờ.
if not self.bot_manager:
return
try:
# Đăng ký bộ lọc 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] Đã bật theo dõi log bằng TraceID")
logger.info(f"Đang khởi tạo plugin (nguồn: {source})...")
# 0. Tự nâng cấp prompt cũ từ str.format sang string.Template.
try:
self.config_manager.upgrade_prompt_templates()
except Exception as e:
logger.warning(f"Tự nâng cấp template prompt thất bại: {e}")
# 1. Thử phát hiện instance bot.
await self.bot_manager.initialize_from_config()
# 2. Đăng ký router preview.
if self.template_preview_router:
await self.template_preview_router.ensure_handlers_registered(
self.context
)
# 3. Đăng ký tác vụ phân tích định kỳ.
if self.auto_scheduler:
self.auto_scheduler.schedule_jobs(self.context)
self._initialized = True
self._discovery_run = True
logger.info(f"Hoàn tất đăng ký tác vụ plugin (nguồn: {source})")
except Exception as e:
logger.error(f"Khởi tạo plugin thất bại: {e}", exc_info=True)
async def terminate(self):
"""Dọn tài nguyên khi plugin bị gỡ hoặc vô hiệu hoá."""
if self._terminating:
return
self._terminating = True
try:
logger.info("Bắt đầu dọn tài nguyên plugin phân tích nhóm...")
# 1. Dừng mọi tác vụ đang chạy.
if self._background_tasks:
logger.info(
f"Đang huỷ {len(self._background_tasks)} tác vụ đang chạy..."
)
for task in self._background_tasks:
if not task.done():
task.cancel()
# Chờ tác vụ kết thúc với thời gian gia hạn 3 giây.
try:
await asyncio.wait(list(self._background_tasks), timeout=3.0)
except Exception:
pass
self._background_tasks.clear()
# 2. Dừng các thành phần: scheduler trước, dịch vụ tầng dưới sau.
if self.auto_scheduler:
logger.debug("Đang dừng bộ lập lịch tự động...")
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. Chỉ dọn tham chiếu sau khi mọi tác vụ đã kết thúc.
# Giữ tham chiếu để GC thu hồi tự nhiên, tránh race với tác vụ bất đồng bộ (#125).
logger.info("Hoàn tất dọn tài nguyên plugin phân tích nhóm")
except Exception as e:
logger.error(f"Dọn tài nguyên plugin thất bại: {e}")
# ==================== Bộ chặn tin nhắn Telegram ====================
@filter.event_message_type(filter.EventMessageType.GROUP_MESSAGE)
@filter.platform_adapter_type(filter.PlatformAdapterType.TELEGRAM)
async def intercept_telegram_messages(self, event: AstrMessageEvent):
"""
Chặn tin nhắn nhóm Telegram và lưu vào cơ sở dữ liệu.
Uỷ quyền xử lý cho MessageProcessingService.
"""
try:
await self.message_processing_service.process_message(event)
except (ValueError, RuntimeError) as e:
logger.warning(f"[Telegram] Lưu tin nhắn thất bại: {e}")
except Exception as e:
logger.error(f"[Telegram] Lỗi lưu tin nhắn: {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):
"""Cache tin nhắn nhóm QQ Official; không xử lý tin nhắn kênh."""
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] Lưu tin nhắn thất bại: {e}")
except Exception as e:
logger.error(f"[QQOfficial] Lỗi lưu tin nhắn: {e}", exc_info=True)
async def get_telegram_seen_group_ids(
self, platform_id: str | None = None
) -> list[str]:
"""Đọc nhóm/chủ đề Telegram đã thấy cho scheduler fallback."""
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]:
"""Đọc các nhóm đã thấy trên mọi nền tảng hướng sự kiện."""
return await self.platform_group_registry.get_all_group_ids(platform_id)
def _get_group_id_from_event(self, event: AstrMessageEvent) -> str | None:
"""Lấy an toàn ID nhóm từ sự kiện tin nhắn."""
# Giữ helper này vì nhiều command khác vẫn dùng.
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:
"""Lấy ID nền tảng duy nhất từ sự kiện tin nhắn."""
# Giữ helper này vì nhiều command khác vẫn dùng.
try:
return event.get_platform_id()
except Exception:
# Fallback: lấy từ metadata.
if (
hasattr(event, "platform_meta")
and event.platform_meta
and hasattr(event.platform_meta, "id")
):
return event.platform_meta.id
return "default"
# ================================================================
# Upload báo cáo ảnh vào tệp/album nhóm, chỉ cho định dạng ảnh trên QQ.
# ================================================================
async def _try_upload_image(self, group_id: str, image_url: str, platform_id: str):
"""
Thử upload báo cáo ảnh vào tệp và/hoặc album nhóm; lỗi chỉ ghi log.
"""
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. Tạo tên tệp thân thiện hơn.
now = datetime.now()
timestamp = now.strftime("%H%M")
date_str = now.strftime("%Y-%m-%d")
# Tên cơ sở và phần mở rộng mặc định.
ext = (
".jpg"
if (".jpg" in image_url.lower() or ".jpeg" in image_url.lower())
else ".png"
)
nice_filename = f"bao_cao_phan_tich_nhom_{group_id}_{date_str}_{timestamp}{ext}"
try:
# Thử lấy tên nhóm qua adapter để tên tệp dễ nhận diện hơn.
group_info = await adapter.get_group_info(group_id)
if group_info and group_info.group_name:
# Lọc ký tự không hợp lệ trong tên tệp: \ / : * ? " < > |
safe_name = re.sub(r'[\\/:*?"<>|]', "", group_info.group_name).strip()
if safe_name:
nice_filename = f"bao_cao_phan_tich_nhom_{safe_name}_{date_str}_{timestamp}{ext}"
except Exception:
pass
# 2. Chuẩn bị nội dung dưới dạng tệp hoặc dữ liệu.
image_file = None
created_temp = False
MAX_PAYLOAD_SIZE = 20 * 1024 * 1024 # Giới hạn 20 MB.
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("Upload ảnh thất bại: payload Base64 quá lớn")
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("Upload ảnh thất bại: payload Data URI quá lớn")
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:
# Dùng tempfile tạo hậu tố duy nhất để tránh xung đột đồng thời.
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. Upload vào tệp nhóm.
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, # Truyền tường minh tên tệp thân thiện.
)
except Exception as e:
logger.warning(f"Upload tệp nhóm thất bại (nhóm {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"Đã bật chế độ album nghiêm ngặt: không tìm thấy album '{album_name}' trong nhóm {group_id}, dừng upload"
)
return
elif strict_mode:
logger.info(
f"Đã bật chế độ album nghiêm ngặt nhưng chưa đặt tên album đích; dừng để tránh thao tác album mặc định của nhóm {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"Upload album nhóm thất bại (nhóm {group_id}): {e}")
except Exception as e:
logger.warning(f"Lỗi xử lý upload ảnh: {e}")
finally:
if created_temp and image_file and os.path.exists(image_file):
try:
os.remove(image_file)
except OSError:
pass
@filter.command("phantichnhom", alias={"group_analysis"})
@filter.permission_type(PermissionType.ADMIN)
async def analyze_group_daily(
self, event: AstrMessageEvent, days: int | None = None
):
"""
Phân tích hoạt động nhóm hằng ngày trên nhiều nền tảng.
Cách dùng: /phantichnhom [số ngày]
"""
if self._terminating:
return
current_task = asyncio.current_task()
if current_task:
self._background_tasks.add(current_task)
try:
event.should_call_llm(True) # Ngăn LLM mặc định phân tích.
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("❌ Vui lòng sử dụng lệnh này trong nhóm chat")
return
# Cập nhật instance bot.
self.bot_manager.update_from_event(event)
# Ưu tiên UMO để kiểm tra quyền và tương thích whitelist 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(
"❌ Nhóm này chưa bật tính năng phân tích hàng ngày"
)
return
# Lấy tên nhóm để tạo TraceID có ngữ nghĩa.
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
# Thiết lập TraceID theo dạng manual_tên_nhóm_HHmm.
trace_id = TraceContext.generate(
prefix="manual", group_name=group_name or group_id
)
TraceContext.set(trace_id)
# Reaction hoặc thông báo văn bản, chọn theo cấu hình.
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()
)
# API v2 của QQ Official không hỗ trợ reaction plugin đang dùng,
# nên luôn dùng thông báo tiến độ dạng văn bản.
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(
"🔍 Đang khởi động phân tích và lấy các tin nhắn gần đây..."
)
elif adapter and orig_msg_id:
await adapter.set_reaction(
event.get_group_id(), orig_msg_id, "analysis_started"
)
# Gọi application service theo 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(
"❌ Không tìm thấy đủ dữ liệu chat trong nhóm"
)
elif reason == "muted":
logger.warning(
f"Nhóm {group_id} đã tắt chat toàn nhóm hoặc tắt quyền bot; bỏ qua phản hồi để tránh lỗi gửi"
)
else:
yield event.plain_result(
"❌ Phân tích thất bại, không rõ nguyên nhân"
)
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(
"📊 Phân tích cho nhóm này đang chạy, vui lòng thử lại sau nhé~"
)
except asyncio.CancelledError:
logger.info("Tác vụ phân tích nhóm đã bị huỷ do plugin reload hoặc bị gỡ")
except Exception as e:
logger.error(f"Phân tích nhóm thất bại: {e}", exc_info=True)
yield event.plain_result(
"❌ Phân tích thất bại. Vui lòng kiểm tra kết nối mạng, "
"cấu hình LLM hoặc liên hệ quản trị viên"
)
finally:
if current_task:
self._background_tasks.discard(current_task)
async def _send_analysis_report(
self, event: AstrMessageEvent, result: dict
) -> AsyncGenerator:
"""Render và gửi kết quả phân tích."""
if self._terminating or not self.config_manager:
logger.warning("Plugin đang đóng, dừng gửi báo cáo")
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"
# Định nghĩa callback truy xuất dữ liệu.
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 # Gửi thành công.
# Chuyển thẳng sang văn bản nếu tạo hoặc gửi ảnh thất bại.
logger.warning(
f"Gửi báo cáo ảnh thất bại, đang gửi fallback văn bản cho nhóm {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():
# Lấy thư mục output trong cấu hình.
html_output_dir = self.config_manager.get_html_output_dir()
# Dùng thư mục mặc định nếu cấu hình rỗng.
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",
)
# Tính đường dẫn tương đối và chuyển thành 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"📊 Báo cáo phân tích nhóm hôm nay đã sẵn sàng:\n{report_url}"
)
return # Đã gửi liên kết, không gửi tệp nữa.
else:
logger.warning(
f"Nhóm {group_id} được kích hoạt thủ công và chỉ bật gửi liên kết nhưng chưa cấu hình html_base_url; chuyển sang gửi tệp"
)
caption = self.report_generator.build_html_caption(html_path)
# Gửi tệp 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("⚠️ Tạo báo cáo HTML thất bại.")
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("dinhdang", alias={"set_format"})
@filter.permission_type(PermissionType.ADMIN)
async def set_output_format(self, event: AstrMessageEvent, format_input: str = ""):
"""
Thiết lập định dạng báo cáo trên nhiều nền tảng.
Cách dùng: /dinhdang [tên hoặc số thứ tự], có thể dùng ``image,html``.
"""
# Plugin xử lý command, tắt fallback LLM mặc định.
event.should_call_llm(True)
available_formats = ["image", "text", "html"]
format_display_names = {
"image": "Định dạng ảnh (mặc định)",
"text": "Định dạng văn bản",
"html": "Trang web HTML tương tác",
}
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"""📊 Định dạng đầu ra hiện tại: {current}
Các định dạng khả dụng:
{format_list_str}
Cách dùng: /dinhdang [tên hoặc số thứ tự], ví dụ: /dinhdang image,html""")
return
target_format = None
# Thử chọn theo số thứ tự.
if format_input.isdigit():
idx = int(format_input) - 1
if 0 <= idx < len(available_formats):
target_format = available_formats[idx]
# Thử chọn theo tên.
if not target_format:
input_lower = format_input.lower()
if input_lower in available_formats:
target_format = input_lower
# Hỗ trợ nhiều định dạng phân tách bằng dấu phẩy.
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"✅ Định dạng đầu ra đã đặt thành: {', '.join(parts)}"
)
except Exception as e:
logger.error(
f"Cài đặt nhiều định dạng thất bại: {e}", exc_info=True
)
yield event.plain_result("❌ Cài đặt định dạng thất bại")
return
if not target_format:
yield event.plain_result(
f"❌ Định dạng '{format_input}' không hợp lệ. Có sẵn: "
f"{', '.join(available_formats)} hoặc số thứ tự "
f"1-{len(available_formats)}"
)
return
try:
self.config_manager.set_output_format(target_format) # type: ignore[arg-type]
yield event.plain_result(
f"✅ Định dạng đầu ra đã đặt thành: {target_format}"
)
except Exception as e:
logger.error(f"Cài đặt định dạng thất bại: {e}", exc_info=True)
yield event.plain_result("❌ Cài đặt định dạng thất bại")
@filter.command("maubc", alias={"set_template"})
@filter.permission_type(PermissionType.ADMIN)
async def set_report_template(
self, event: AstrMessageEvent, template_input: str = ""
):
"""
Thiết lập mẫu báo cáo trên nhiều nền tảng.
Cách dùng: /maubc [tên mẫu hoặc số thứ tự].
"""
# Plugin xử lý command, tắt fallback LLM mặc định.
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"""🎨 Mẫu báo cáo hiện tại: {current_template}
Các mẫu khả dụng:
{template_list_str}
Cách dùng: /maubc [tên mẫu hoặc số thứ tự]
💡 Sử dụng /xemmau để xem ảnh xem trước""")
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"❌ Không thể phân tích đầu vào mẫu: {template_input}"
)
return
if not await self.template_command_service.template_exists(template_name):
yield event.plain_result(f"❌ Mẫu '{template_name}' không tồn tại")
return
self.config_manager.set_report_template(template_name)
yield event.plain_result(f"✅ Mẫu báo cáo đã đặt thành: {template_name}")
@filter.command("xemmau", alias={"view_templates"})
@filter.permission_type(PermissionType.ADMIN)
async def view_templates(self, event: AstrMessageEvent):
"""
Xem mọi mẫu báo cáo khả dụng và ảnh preview trên nhiều nền tảng.
Cách dùng: /xemmau.
"""
# Plugin xử lý command, tắt fallback LLM mặc định.
event.should_call_llm(True)
available_templates = (
await self.template_command_service.list_available_templates()
)
if not available_templates:
yield event.plain_result("❌ Không tìm thấy mẫu báo cáo nào khả dụng")
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("caidat", alias={"analysis_settings"})
@filter.permission_type(PermissionType.ADMIN)
async def analysis_settings(self, event: AstrMessageEvent, action: str = "status"):
"""
Quản lý cài đặt phân tích trên nhiều nền tảng.
Cách dùng: /caidat [enable|disable|status|reload|test|filter_bot|incremental_debug].
``filter_bot`` chuyển chế độ lọc tin bot; ``incremental_debug`` chuyển
chế độ gửi ngay báo cáo gia tăng để debug.
"""
group_id = self._get_group_id_from_event(event)
if not group_id:
yield event.plain_result("❌ Vui lòng sử dụng lệnh này trong nhóm chat")
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(
"✅ Đã tải lại cấu hình và khởi động lại tác vụ định kỳ"
)
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(
"❌ Vui lòng bật tính năng phân tích cho nhóm này trước"
)
return
yield event.plain_result("🧪 Đang kiểm tra tính năng phân tích tự động...")
# Cập nhật instance bot để kiểm tra.
self.bot_manager.update_from_event(event)
try:
await self.auto_scheduler._perform_auto_analysis_for_group(group_id)
yield event.plain_result(
"✅ Kiểm tra phân tích tự động hoàn tất, vui lòng xem tin nhắn nhóm"
)
except DuplicateGroupTaskError:
yield event.plain_result(
"📊 Phân tích cho nhóm này đang chạy, vui lòng thử lại sau nhé~"
)
except Exception as e:
logger.error(f"Kiểm tra phân tích tự động thất bại: {e}", exc_info=True)
yield event.plain_result(
"❌ Kiểm tra phân tích tự động thất bại. Vui lòng kiểm tra "
"cấu hình và nhật ký hệ thống"
)
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 = "Đã bật" if new_state else "Đã tắt"
yield event.plain_result(
f"✅ Chế độ báo cáo ngay phân tích gia tăng: {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 = "Đã bật" if new_state else "Đã tắt"
yield event.plain_result(f"✅ Lọc tin nhắn bot: {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 = "Đã bật" if is_allowed else "Chưa bật"
mode = self.config_manager.get_group_list_mode()
auto_status = (
"Đã bật"
if self.config_manager.is_auto_analysis_enabled()
else "Chưa bật"
)
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()
# Trạng thái phân tích gia tăng.
incremental_enabled = self.config_manager.get_incremental_enabled()
incremental_status_text = "Chưa bật"
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"Đã bật (mỗi {interval} phút, tối đa {max_daily} lần/ngày, "
f"khung giờ hoạt động {active_start}:00-{active_end}:00)"
)
debug_report = self.config_manager.get_incremental_report_immediately()
debug_status = "✅ Bật" if debug_report else "❌ Tắt"
filter_bot = self.config_manager.get_filter_bot_messages()
filter_bot_status = "✅ Bật" if filter_bot else "❌ Tắt"
yield event.plain_result(f"""📊 Trạng thái phân tích của nhóm hiện tại:
• Phân tích nhóm: {status} (chế độ: {mode})
• Phân tích tự động: {auto_status} ({auto_time})
• Phân tích gia tăng: {incremental_status_text}
• Chế độ gỡ lỗi: {debug_status} (báo cáo gia tăng ngay lập tức)
• Lọc bot: {filter_bot_status}
• Định dạng đầu ra: {output_format}
• Số tin nhắn tối thiểu: {min_threshold}
💡 Lệnh khả dụng: enable, disable, status, reload, test, filter_bot, incremental_debug
💡 Định dạng đầu ra được hỗ trợ: image, text (ảnh có biểu đồ hoạt động)
💡 Lệnh khác: /dinhdang, /tangcuong""")
@filter.command("tangcuong", alias={"incremental_status"})
@filter.permission_type(PermissionType.ADMIN)
async def incremental_status(self, event: AstrMessageEvent):
"""Xem trạng thái phân tích gia tăng trong cửa sổ trượt."""
group_id = self._get_group_id_from_event(event)
if not group_id:
yield event.plain_result("❌ Vui lòng sử dụng lệnh này trong nhóm chat")
return
if not self.config_manager.get_incremental_enabled():
yield event.plain_result(
"️ Chế độ phân tích gia tăng chưa bật, vui lòng bật trong cấu hình plugin"
)
return
import time as time_mod
# Tính phạm vi cửa sổ trượt.
analysis_days = self.config_manager.get_analysis_days()
window_end = time_mod.time()
window_start = window_end - (analysis_days * 24 * 3600)
# Truy vấn các batch trong cửa sổ.
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"📊 Chưa có dữ liệu phân tích gia tăng trong cửa sổ "
f"({start_str} ~ {end_str})"
)
return
# Gộp batch để tạo chế độ xem tổng hợp.
state = self.incremental_merge_service.merge_batches(
batches, window_start, window_end
)
summary = state.get_summary()
yield event.plain_result(
f"📊 Trạng thái phân tích gia tăng (cửa sổ: {summary['window']})\n"
f"• Số lần phân tích: {summary['total_analyses']}\n"
f"• Tổng số tin nhắn: {summary['total_messages']}\n"
f"• Số chủ đề: {summary['topics_count']}\n"
f"• Số trích dẫn nổi bật: {summary['quotes_count']}\n"
f"• Người tham gia: {summary['participants']}\n"
f"• Khung giờ cao điểm: {summary['peak_hours']}"
)
async def _handle_settings_enable(self, event: AstrMessageEvent, group_id: str):
"""Helper xử lý nhánh bật cài đặt."""
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"✅ Đã thêm nhóm hiện tại vào whitelist\nID: {target_id}"
)
self.auto_scheduler.schedule_jobs(self.context)
else:
yield event.plain_result("️ Nhóm hiện tại đã có trong whitelist")
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("✅ Đã xóa nhóm hiện tại khỏi blacklist")
self.auto_scheduler.schedule_jobs(self.context)
else:
yield event.plain_result("️ Nhóm hiện tại không có trong blacklist")
else:
yield event.plain_result(
"️ Đang ở chế độ không giới hạn, tất cả nhóm mặc định được bật"
)
async def _handle_settings_disable(self, event: AstrMessageEvent, group_id: str):
"""Helper xử lý nhánh tắt cài đặt."""
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("✅ Đã xóa nhóm hiện tại khỏi whitelist")
self.auto_scheduler.schedule_jobs(self.context)
else:
yield event.plain_result("️ Nhóm hiện tại không có trong whitelist")
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"✅ Đã thêm nhóm hiện tại vào blacklist\nID: {target_id}"
)
self.auto_scheduler.schedule_jobs(self.context)
else:
yield event.plain_result("️ Nhóm hiện tại đã có trong blacklist")
else:
yield event.plain_result(
"️ Đang ở chế độ không giới hạn; để tắt, hãy chuyển sang chế độ blacklist"
)