mirror of
https://github.com/Nezumi-2711/astrbot_plugin_qq_group_daily_analysis.git
synced 2026-09-22 20:01:04 +00:00
feat: complete DDD Phase 2 - add domain services, infrastructure layers, and shared components
- domain/value_objects: Add Topic, UserTitle, GoldenQuote, Statistics value objects - domain/services: Add StatisticsCalculator, ReportGenerator domain services - domain/exceptions: Add comprehensive domain exception hierarchy - infrastructure/persistence: Add HistoryRepository for data storage - infrastructure/llm: Add LLMClient wrapper for AstrBot providers - infrastructure/config: Add ConfigManager for centralized configuration - infrastructure/resilience: Add CircuitBreaker, RateLimiter, retry utilities - application: Add SchedulingService, ReportingService application services - shared: Add constants and TraceContext for request tracing All imports verified in Docker container.
This commit is contained in:
@@ -1,5 +1,12 @@
|
||||
# Application Layer - Orchestration and Use Cases
|
||||
from .analysis_orchestrator import AnalysisOrchestrator
|
||||
from .message_converter import MessageConverter
|
||||
from .scheduling_service import SchedulingService
|
||||
from .reporting_service import ReportingService
|
||||
|
||||
__all__ = ["AnalysisOrchestrator", "MessageConverter"]
|
||||
__all__ = [
|
||||
"AnalysisOrchestrator",
|
||||
"MessageConverter",
|
||||
"SchedulingService",
|
||||
"ReportingService",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
"""
|
||||
Reporting Service - Application service for generating and sending reports
|
||||
|
||||
This service coordinates report generation and delivery to groups.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from astrbot.api import logger
|
||||
|
||||
from ..domain.services import ReportGenerator
|
||||
from ..domain.value_objects.topic import Topic
|
||||
from ..domain.value_objects.user_title import UserTitle
|
||||
from ..domain.value_objects.golden_quote import GoldenQuote
|
||||
from ..domain.value_objects.statistics import GroupStatistics
|
||||
from ..infrastructure.config import ConfigManager
|
||||
from ..infrastructure.persistence import HistoryRepository
|
||||
|
||||
|
||||
class ReportingService:
|
||||
"""
|
||||
Application service for generating and managing reports.
|
||||
|
||||
This service coordinates between domain services and infrastructure
|
||||
to produce and deliver analysis reports.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: ConfigManager,
|
||||
history_repository: HistoryRepository,
|
||||
):
|
||||
"""
|
||||
Initialize the reporting service.
|
||||
|
||||
Args:
|
||||
config: Configuration manager
|
||||
history_repository: Repository for storing reports
|
||||
"""
|
||||
self.config = config
|
||||
self.history = history_repository
|
||||
|
||||
def generate_report(
|
||||
self,
|
||||
group_id: str,
|
||||
group_name: str,
|
||||
statistics: GroupStatistics,
|
||||
topics: List[Topic],
|
||||
user_titles: List[UserTitle],
|
||||
golden_quotes: List[GoldenQuote],
|
||||
date_str: Optional[str] = None,
|
||||
) -> str:
|
||||
"""
|
||||
Generate a complete analysis report.
|
||||
|
||||
Args:
|
||||
group_id: Group identifier
|
||||
group_name: Group display name
|
||||
statistics: Group statistics
|
||||
topics: List of discussion topics
|
||||
user_titles: List of user titles
|
||||
golden_quotes: List of golden quotes
|
||||
date_str: Report date (defaults to today)
|
||||
|
||||
Returns:
|
||||
Formatted report string
|
||||
"""
|
||||
date_str = date_str or datetime.now().strftime("%Y-%m-%d")
|
||||
|
||||
generator = ReportGenerator(
|
||||
group_name=group_name,
|
||||
date_str=date_str,
|
||||
)
|
||||
|
||||
# Generate report based on configuration
|
||||
report = generator.generate_full_report(
|
||||
statistics=statistics,
|
||||
topics=topics if self.config.get_include_topics() else [],
|
||||
user_titles=user_titles if self.config.get_include_user_titles() else [],
|
||||
golden_quotes=golden_quotes if self.config.get_include_golden_quotes() else [],
|
||||
include_header=True,
|
||||
include_footer=True,
|
||||
)
|
||||
|
||||
return report
|
||||
|
||||
def generate_summary(
|
||||
self,
|
||||
group_id: str,
|
||||
statistics: GroupStatistics,
|
||||
top_topic: Optional[Topic] = None,
|
||||
top_quote: Optional[GoldenQuote] = None,
|
||||
date_str: Optional[str] = None,
|
||||
) -> str:
|
||||
"""
|
||||
Generate a brief summary report.
|
||||
|
||||
Args:
|
||||
group_id: Group identifier
|
||||
statistics: Group statistics
|
||||
top_topic: Most significant topic
|
||||
top_quote: Best golden quote
|
||||
date_str: Report date
|
||||
|
||||
Returns:
|
||||
Brief summary string
|
||||
"""
|
||||
date_str = date_str or datetime.now().strftime("%Y-%m-%d")
|
||||
|
||||
generator = ReportGenerator(date_str=date_str)
|
||||
return generator.generate_summary_report(
|
||||
statistics=statistics,
|
||||
top_topic=top_topic,
|
||||
top_quote=top_quote,
|
||||
)
|
||||
|
||||
def save_report(
|
||||
self,
|
||||
group_id: str,
|
||||
report_data: Dict[str, Any],
|
||||
date_str: Optional[str] = None,
|
||||
) -> bool:
|
||||
"""
|
||||
Save a report to history.
|
||||
|
||||
Args:
|
||||
group_id: Group identifier
|
||||
report_data: Report data dictionary
|
||||
date_str: Report date
|
||||
|
||||
Returns:
|
||||
True if saved successfully
|
||||
"""
|
||||
date_str = date_str or datetime.now().strftime("%Y-%m-%d")
|
||||
|
||||
return self.history.save_analysis_result(
|
||||
group_id=group_id,
|
||||
result=report_data,
|
||||
date_str=date_str,
|
||||
)
|
||||
|
||||
def get_report(
|
||||
self,
|
||||
group_id: str,
|
||||
date_str: str,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Get a saved report.
|
||||
|
||||
Args:
|
||||
group_id: Group identifier
|
||||
date_str: Report date
|
||||
|
||||
Returns:
|
||||
Report data or None
|
||||
"""
|
||||
return self.history.get_analysis_result(group_id, date_str)
|
||||
|
||||
def get_recent_reports(
|
||||
self,
|
||||
group_id: str,
|
||||
limit: int = 7,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Get recent reports for a group.
|
||||
|
||||
Args:
|
||||
group_id: Group identifier
|
||||
limit: Maximum number of reports
|
||||
|
||||
Returns:
|
||||
List of report data dictionaries
|
||||
"""
|
||||
return self.history.get_recent_results(group_id, limit)
|
||||
|
||||
def has_report_for_today(self, group_id: str) -> bool:
|
||||
"""
|
||||
Check if a report exists for today.
|
||||
|
||||
Args:
|
||||
group_id: Group identifier
|
||||
|
||||
Returns:
|
||||
True if report exists
|
||||
"""
|
||||
today = datetime.now().strftime("%Y-%m-%d")
|
||||
return self.history.has_analysis_for_date(group_id, today)
|
||||
|
||||
def format_for_platform(
|
||||
self,
|
||||
report: str,
|
||||
platform: str,
|
||||
format_type: Optional[str] = None,
|
||||
) -> str:
|
||||
"""
|
||||
Format a report for a specific platform.
|
||||
|
||||
Args:
|
||||
report: Raw report text
|
||||
platform: Target platform
|
||||
format_type: Override format type
|
||||
|
||||
Returns:
|
||||
Platform-formatted report
|
||||
"""
|
||||
format_type = format_type or self.config.get_report_format()
|
||||
|
||||
# For now, return as-is. Can be extended for platform-specific formatting
|
||||
if format_type == "markdown":
|
||||
return report
|
||||
elif format_type == "text":
|
||||
# Strip markdown formatting
|
||||
return self._strip_markdown(report)
|
||||
else:
|
||||
return report
|
||||
|
||||
def _strip_markdown(self, text: str) -> str:
|
||||
"""Strip markdown formatting from text."""
|
||||
# Simple markdown stripping
|
||||
import re
|
||||
|
||||
# Remove bold
|
||||
text = re.sub(r"\*\*(.*?)\*\*", r"\1", text)
|
||||
# Remove italic
|
||||
text = re.sub(r"\*(.*?)\*", r"\1", text)
|
||||
# Remove headers
|
||||
text = re.sub(r"^#+\s*", "", text, flags=re.MULTILINE)
|
||||
|
||||
return text
|
||||
|
||||
def create_report_data(
|
||||
self,
|
||||
group_id: str,
|
||||
group_name: str,
|
||||
statistics: GroupStatistics,
|
||||
topics: List[Topic],
|
||||
user_titles: List[UserTitle],
|
||||
golden_quotes: List[GoldenQuote],
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Create a report data dictionary for storage.
|
||||
|
||||
Args:
|
||||
group_id: Group identifier
|
||||
group_name: Group display name
|
||||
statistics: Group statistics
|
||||
topics: List of topics
|
||||
user_titles: List of user titles
|
||||
golden_quotes: List of golden quotes
|
||||
|
||||
Returns:
|
||||
Report data dictionary
|
||||
"""
|
||||
return {
|
||||
"group_id": group_id,
|
||||
"group_name": group_name,
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"statistics": statistics.to_dict(),
|
||||
"topics": [t.to_dict() for t in topics],
|
||||
"user_titles": [u.to_dict() for u in user_titles],
|
||||
"golden_quotes": [q.to_dict() for q in golden_quotes],
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
"""
|
||||
Scheduling Service - Application service for scheduled analysis
|
||||
|
||||
This service manages scheduled analysis tasks and coordinates
|
||||
with the analysis orchestrator.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, Callable, Dict, List, Optional, Set
|
||||
|
||||
from astrbot.api import logger
|
||||
|
||||
from ..infrastructure.config import ConfigManager
|
||||
from ..shared.constants import TASK_STATE_PENDING, TASK_STATE_RUNNING, TASK_STATE_COMPLETED
|
||||
|
||||
|
||||
class ScheduledTask:
|
||||
"""Represents a scheduled analysis task."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
task_id: str,
|
||||
group_id: str,
|
||||
scheduled_time: str, # HH:MM format
|
||||
callback: Callable,
|
||||
enabled: bool = True,
|
||||
):
|
||||
self.task_id = task_id
|
||||
self.group_id = group_id
|
||||
self.scheduled_time = scheduled_time
|
||||
self.callback = callback
|
||||
self.enabled = enabled
|
||||
self.last_run: Optional[datetime] = None
|
||||
self.next_run: Optional[datetime] = None
|
||||
self._calculate_next_run()
|
||||
|
||||
def _calculate_next_run(self) -> None:
|
||||
"""Calculate the next run time."""
|
||||
if not self.enabled:
|
||||
self.next_run = None
|
||||
return
|
||||
|
||||
try:
|
||||
hours, minutes = map(int, self.scheduled_time.split(":"))
|
||||
now = datetime.now()
|
||||
next_run = now.replace(hour=hours, minute=minutes, second=0, microsecond=0)
|
||||
|
||||
# If the time has passed today, schedule for tomorrow
|
||||
if next_run <= now:
|
||||
next_run += timedelta(days=1)
|
||||
|
||||
self.next_run = next_run
|
||||
except ValueError:
|
||||
logger.error(f"Invalid scheduled time format: {self.scheduled_time}")
|
||||
self.next_run = None
|
||||
|
||||
def should_run(self) -> bool:
|
||||
"""Check if the task should run now."""
|
||||
if not self.enabled or not self.next_run:
|
||||
return False
|
||||
|
||||
now = datetime.now()
|
||||
|
||||
# Check if we're within the execution window (5 minute tolerance)
|
||||
if self.next_run <= now <= self.next_run + timedelta(minutes=5):
|
||||
# Check if we haven't run today
|
||||
if self.last_run is None or self.last_run.date() != now.date():
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def mark_completed(self) -> None:
|
||||
"""Mark the task as completed and schedule next run."""
|
||||
self.last_run = datetime.now()
|
||||
self._calculate_next_run()
|
||||
|
||||
|
||||
class SchedulingService:
|
||||
"""
|
||||
Application service for managing scheduled analysis tasks.
|
||||
|
||||
This service runs a background loop that checks for and
|
||||
executes scheduled tasks.
|
||||
"""
|
||||
|
||||
def __init__(self, config: ConfigManager):
|
||||
"""
|
||||
Initialize the scheduling service.
|
||||
|
||||
Args:
|
||||
config: Configuration manager
|
||||
"""
|
||||
self.config = config
|
||||
self._tasks: Dict[str, ScheduledTask] = {}
|
||||
self._running = False
|
||||
self._task: Optional[asyncio.Task] = None
|
||||
self._callbacks: Dict[str, Callable] = {}
|
||||
|
||||
def register_callback(self, name: str, callback: Callable) -> None:
|
||||
"""
|
||||
Register a callback for scheduled tasks.
|
||||
|
||||
Args:
|
||||
name: Callback name
|
||||
callback: Async callback function
|
||||
"""
|
||||
self._callbacks[name] = callback
|
||||
|
||||
def add_task(
|
||||
self,
|
||||
group_id: str,
|
||||
scheduled_time: Optional[str] = None,
|
||||
callback_name: str = "analyze",
|
||||
) -> str:
|
||||
"""
|
||||
Add a scheduled task for a group.
|
||||
|
||||
Args:
|
||||
group_id: Group identifier
|
||||
scheduled_time: Time in HH:MM format (uses config default if not provided)
|
||||
callback_name: Name of registered callback to use
|
||||
|
||||
Returns:
|
||||
Task ID
|
||||
"""
|
||||
scheduled_time = scheduled_time or self.config.get_analysis_time()
|
||||
task_id = f"task_{group_id}"
|
||||
|
||||
callback = self._callbacks.get(callback_name)
|
||||
if not callback:
|
||||
logger.warning(f"Callback '{callback_name}' not registered")
|
||||
return task_id
|
||||
|
||||
task = ScheduledTask(
|
||||
task_id=task_id,
|
||||
group_id=group_id,
|
||||
scheduled_time=scheduled_time,
|
||||
callback=callback,
|
||||
enabled=True,
|
||||
)
|
||||
|
||||
self._tasks[task_id] = task
|
||||
logger.info(f"Added scheduled task {task_id} for {scheduled_time}")
|
||||
|
||||
return task_id
|
||||
|
||||
def remove_task(self, task_id: str) -> bool:
|
||||
"""
|
||||
Remove a scheduled task.
|
||||
|
||||
Args:
|
||||
task_id: Task identifier
|
||||
|
||||
Returns:
|
||||
True if task was removed
|
||||
"""
|
||||
if task_id in self._tasks:
|
||||
del self._tasks[task_id]
|
||||
logger.info(f"Removed scheduled task {task_id}")
|
||||
return True
|
||||
return False
|
||||
|
||||
def enable_task(self, task_id: str) -> bool:
|
||||
"""Enable a scheduled task."""
|
||||
if task_id in self._tasks:
|
||||
self._tasks[task_id].enabled = True
|
||||
self._tasks[task_id]._calculate_next_run()
|
||||
return True
|
||||
return False
|
||||
|
||||
def disable_task(self, task_id: str) -> bool:
|
||||
"""Disable a scheduled task."""
|
||||
if task_id in self._tasks:
|
||||
self._tasks[task_id].enabled = False
|
||||
self._tasks[task_id].next_run = None
|
||||
return True
|
||||
return False
|
||||
|
||||
def get_task_status(self, task_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Get status of a scheduled task.
|
||||
|
||||
Args:
|
||||
task_id: Task identifier
|
||||
|
||||
Returns:
|
||||
Task status dictionary or None
|
||||
"""
|
||||
task = self._tasks.get(task_id)
|
||||
if not task:
|
||||
return None
|
||||
|
||||
return {
|
||||
"task_id": task.task_id,
|
||||
"group_id": task.group_id,
|
||||
"scheduled_time": task.scheduled_time,
|
||||
"enabled": task.enabled,
|
||||
"last_run": task.last_run.isoformat() if task.last_run else None,
|
||||
"next_run": task.next_run.isoformat() if task.next_run else None,
|
||||
}
|
||||
|
||||
def list_tasks(self) -> List[Dict[str, Any]]:
|
||||
"""List all scheduled tasks."""
|
||||
return [self.get_task_status(tid) for tid in self._tasks.keys()]
|
||||
|
||||
async def start(self) -> None:
|
||||
"""Start the scheduling service."""
|
||||
if self._running:
|
||||
return
|
||||
|
||||
self._running = True
|
||||
self._task = asyncio.create_task(self._run_loop())
|
||||
logger.info("Scheduling service started")
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""Stop the scheduling service."""
|
||||
self._running = False
|
||||
if self._task:
|
||||
self._task.cancel()
|
||||
try:
|
||||
await self._task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
logger.info("Scheduling service stopped")
|
||||
|
||||
async def _run_loop(self) -> None:
|
||||
"""Main scheduling loop."""
|
||||
while self._running:
|
||||
try:
|
||||
await self._check_and_run_tasks()
|
||||
# Check every minute
|
||||
await asyncio.sleep(60)
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(f"Error in scheduling loop: {e}")
|
||||
await asyncio.sleep(60)
|
||||
|
||||
async def _check_and_run_tasks(self) -> None:
|
||||
"""Check for and execute due tasks."""
|
||||
for task in list(self._tasks.values()):
|
||||
if task.should_run():
|
||||
try:
|
||||
logger.info(f"Executing scheduled task {task.task_id}")
|
||||
await task.callback(task.group_id)
|
||||
task.mark_completed()
|
||||
logger.info(f"Completed scheduled task {task.task_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to execute task {task.task_id}: {e}")
|
||||
|
||||
def setup_from_config(self) -> None:
|
||||
"""Set up scheduled tasks from configuration."""
|
||||
if not self.config.get_auto_analysis_enabled():
|
||||
logger.info("Auto analysis is disabled")
|
||||
return
|
||||
|
||||
enabled_groups = self.config.get_enabled_groups()
|
||||
analysis_time = self.config.get_analysis_time()
|
||||
|
||||
for group_id in enabled_groups:
|
||||
self.add_task(group_id, analysis_time)
|
||||
|
||||
logger.info(f"Set up {len(enabled_groups)} scheduled tasks")
|
||||
Reference in New Issue
Block a user