feat: add DDD architecture layers (domain, infrastructure, application)

- Domain layer: UnifiedMessage, PlatformCapabilities, repository interfaces
- Infrastructure layer: PlatformAdapter base, OneBotAdapter, factory
- Application layer: AnalysisOrchestrator, MessageConverter

This provides cross-platform abstraction for group analysis plugin.
This commit is contained in:
SXP-Simon
2026-02-08 14:13:09 +08:00
parent a3c6ea737a
commit c1d3bf5ece
20 changed files with 1690 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
# Application Layer - Orchestration and Use Cases
from .analysis_orchestrator import AnalysisOrchestrator
from .message_converter import MessageConverter
__all__ = ["AnalysisOrchestrator", "MessageConverter"]
+227
View File
@@ -0,0 +1,227 @@
"""
Analysis Orchestrator - Application layer coordinator
This orchestrator bridges the new DDD architecture with the existing
analysis logic, providing a gradual migration path.
Architecture Decision:
- The orchestrator uses PlatformAdapter for message fetching (new DDD way)
- But delegates to existing analyzers for LLM analysis (preserving working code)
- MessageConverter provides bidirectional conversion for compatibility
"""
from typing import Optional, List, Dict, Any
from dataclasses import dataclass
from astrbot.api import logger
from ..domain.value_objects.unified_message import UnifiedMessage
from ..domain.value_objects.platform_capabilities import PlatformCapabilities
from ..infrastructure.platform import PlatformAdapter, PlatformAdapterFactory
from .message_converter import MessageConverter
@dataclass
class AnalysisConfig:
"""Configuration for analysis operation"""
days: int = 1
max_messages: int = 1000
min_messages_threshold: int = 10
output_format: str = "image"
class AnalysisOrchestrator:
"""
Analysis orchestrator - coordinates the analysis workflow.
Responsibilities:
1. Use PlatformAdapter to fetch messages (DDD approach)
2. Convert messages for compatibility with existing analyzers
3. Coordinate analysis flow
4. Provide platform capability checks
This class serves as the bridge between:
- New DDD infrastructure (PlatformAdapter, UnifiedMessage)
- Existing analysis logic (MessageHandler, LLMAnalyzer, etc.)
"""
def __init__(
self,
adapter: PlatformAdapter,
config: AnalysisConfig = None,
):
"""
Initialize the orchestrator.
Args:
adapter: Platform adapter for message operations
config: Analysis configuration
"""
self.adapter = adapter
self.config = config or AnalysisConfig()
@classmethod
def create_for_platform(
cls,
platform_name: str,
bot_instance: Any,
config: dict = None,
analysis_config: AnalysisConfig = None,
) -> Optional["AnalysisOrchestrator"]:
"""
Factory method to create orchestrator for a specific platform.
Args:
platform_name: Platform name (e.g., "aiocqhttp", "telegram")
bot_instance: Bot instance from AstrBot
config: Platform-specific config
analysis_config: Analysis configuration
Returns:
AnalysisOrchestrator or None if platform not supported
"""
adapter = PlatformAdapterFactory.create(platform_name, bot_instance, config)
if adapter is None:
logger.warning(f"Platform '{platform_name}' not supported for analysis")
return None
return cls(adapter, analysis_config)
def get_capabilities(self) -> PlatformCapabilities:
"""Get platform capabilities."""
return self.adapter.get_capabilities()
def can_analyze(self) -> bool:
"""Check if the platform supports analysis."""
return self.adapter.get_capabilities().can_analyze()
def can_send_report(self, format: str = "image") -> bool:
"""Check if the platform can send reports in the specified format."""
return self.adapter.get_capabilities().can_send_report(format)
async def fetch_messages(
self,
group_id: str,
days: int = None,
max_count: int = None,
) -> List[UnifiedMessage]:
"""
Fetch messages using the platform adapter.
Args:
group_id: Group ID to fetch messages from
days: Number of days (defaults to config)
max_count: Maximum message count (defaults to config)
Returns:
List of UnifiedMessage
"""
days = days or self.config.days
max_count = max_count or self.config.max_messages
# Apply platform capability limits
caps = self.adapter.get_capabilities()
effective_days = caps.get_effective_days(days)
effective_count = caps.get_effective_count(max_count)
if effective_days < days:
logger.info(
f"Platform limits: requested {days} days, "
f"using {effective_days} days"
)
return await self.adapter.fetch_messages(
group_id=group_id,
days=effective_days,
max_count=effective_count,
)
async def fetch_messages_as_raw(
self,
group_id: str,
days: int = None,
max_count: int = None,
) -> List[dict]:
"""
Fetch messages and convert to raw dict format.
This provides backward compatibility with existing analyzers
that expect raw dict messages.
Args:
group_id: Group ID to fetch messages from
days: Number of days
max_count: Maximum message count
Returns:
List of raw message dicts (OneBot format)
"""
unified_messages = await self.fetch_messages(group_id, days, max_count)
return MessageConverter.batch_to_onebot(unified_messages)
async def get_group_info(self, group_id: str):
"""Get group information."""
return await self.adapter.get_group_info(group_id)
async def get_member_avatars(
self,
user_ids: List[str],
size: int = 100,
) -> Dict[str, Optional[str]]:
"""
Batch get user avatar URLs.
Args:
user_ids: List of user IDs
size: Avatar size
Returns:
Dict mapping user_id to avatar URL (or None)
"""
return await self.adapter.batch_get_avatar_urls(user_ids, size)
async def send_text(self, group_id: str, text: str) -> bool:
"""Send text message to group."""
return await self.adapter.send_text(group_id, text)
async def send_image(
self,
group_id: str,
image_path: str,
caption: str = "",
) -> bool:
"""Send image to group."""
return await self.adapter.send_image(group_id, image_path, caption)
async def send_file(
self,
group_id: str,
file_path: str,
filename: str = None,
) -> bool:
"""Send file to group."""
return await self.adapter.send_file(group_id, file_path, filename)
def validate_message_count(self, messages: List[UnifiedMessage]) -> bool:
"""
Check if message count meets minimum threshold.
Args:
messages: List of messages
Returns:
True if count is sufficient
"""
return len(messages) >= self.config.min_messages_threshold
def get_analysis_text(self, messages: List[UnifiedMessage]) -> str:
"""
Convert messages to analysis text format for LLM.
Args:
messages: List of UnifiedMessage
Returns:
Formatted text for LLM analysis
"""
return MessageConverter.unified_to_analysis_text(messages)
+195
View File
@@ -0,0 +1,195 @@
"""
Message Converter - Bridges raw platform messages to UnifiedMessage
This module provides backward compatibility by converting between
raw platform message formats and the new UnifiedMessage format.
"""
from typing import List, Dict, Any, Optional
from datetime import datetime
from ..domain.value_objects.unified_message import (
UnifiedMessage,
MessageContent,
MessageContentType,
)
class MessageConverter:
"""
Converts between raw platform messages and UnifiedMessage format.
This provides a migration path: existing code can continue using
raw dicts while new code uses UnifiedMessage.
"""
@staticmethod
def from_onebot_message(raw_msg: dict, group_id: str) -> Optional[UnifiedMessage]:
"""
Convert OneBot v11 raw message to UnifiedMessage.
Args:
raw_msg: Raw message dict from OneBot API
group_id: Group ID
Returns:
UnifiedMessage or None if conversion fails
"""
try:
sender = raw_msg.get("sender", {})
message_chain = raw_msg.get("message", [])
# Handle string message format
if isinstance(message_chain, str):
message_chain = [{"type": "text", "data": {"text": message_chain}}]
contents = []
text_parts = []
for seg in message_chain:
seg_type = seg.get("type", "")
seg_data = seg.get("data", {})
if seg_type == "text":
text = seg_data.get("text", "")
text_parts.append(text)
contents.append(MessageContent(type=MessageContentType.TEXT, text=text))
elif seg_type == "image":
contents.append(MessageContent(
type=MessageContentType.IMAGE,
url=seg_data.get("url", seg_data.get("file", ""))
))
elif seg_type == "at":
contents.append(MessageContent(
type=MessageContentType.AT,
at_user_id=str(seg_data.get("qq", ""))
))
elif seg_type in ("face", "mface", "bface", "sface"):
contents.append(MessageContent(
type=MessageContentType.EMOJI,
emoji_id=str(seg_data.get("id", "")),
raw_data={"face_type": seg_type}
))
elif seg_type == "reply":
contents.append(MessageContent(
type=MessageContentType.REPLY,
raw_data={"reply_id": seg_data.get("id", "")}
))
elif seg_type == "forward":
contents.append(MessageContent(
type=MessageContentType.FORWARD,
raw_data=seg_data
))
elif seg_type == "record":
contents.append(MessageContent(
type=MessageContentType.VOICE,
url=seg_data.get("url", seg_data.get("file", ""))
))
elif seg_type == "video":
contents.append(MessageContent(
type=MessageContentType.VIDEO,
url=seg_data.get("url", seg_data.get("file", ""))
))
else:
contents.append(MessageContent(
type=MessageContentType.UNKNOWN,
raw_data=seg
))
# Extract reply_to from contents
reply_to = None
for c in contents:
if c.type == MessageContentType.REPLY and c.raw_data:
reply_to = str(c.raw_data.get("reply_id", ""))
break
return UnifiedMessage(
message_id=str(raw_msg.get("message_id", "")),
sender_id=str(sender.get("user_id", "")),
sender_name=sender.get("nickname", ""),
sender_card=sender.get("card", "") or None,
group_id=group_id,
text_content="".join(text_parts),
contents=tuple(contents),
timestamp=raw_msg.get("time", 0),
platform="onebot",
reply_to_id=reply_to,
)
except Exception:
return None
@staticmethod
def to_onebot_message(unified: UnifiedMessage) -> dict:
"""
Convert UnifiedMessage back to OneBot v11 raw format.
For backward compatibility with existing code that expects raw dicts.
"""
message_chain = []
for content in unified.contents:
if content.type == MessageContentType.TEXT:
message_chain.append({"type": "text", "data": {"text": content.text}})
elif content.type == MessageContentType.IMAGE:
message_chain.append({"type": "image", "data": {"url": content.url}})
elif content.type == MessageContentType.AT:
message_chain.append({"type": "at", "data": {"qq": content.at_user_id}})
elif content.type == MessageContentType.EMOJI:
face_type = content.raw_data.get("face_type", "face") if content.raw_data else "face"
message_chain.append({"type": face_type, "data": {"id": content.emoji_id}})
elif content.type == MessageContentType.REPLY:
reply_id = content.raw_data.get("reply_id", "") if content.raw_data else ""
message_chain.append({"type": "reply", "data": {"id": reply_id}})
elif content.type == MessageContentType.VOICE:
message_chain.append({"type": "record", "data": {"url": content.url}})
elif content.type == MessageContentType.VIDEO:
message_chain.append({"type": "video", "data": {"url": content.url}})
return {
"message_id": unified.message_id,
"sender": {
"user_id": unified.sender_id,
"nickname": unified.sender_name,
"card": unified.sender_card or "",
},
"group_id": unified.group_id,
"message": message_chain,
"time": unified.timestamp,
}
@staticmethod
def batch_from_onebot(raw_messages: List[dict], group_id: str) -> List[UnifiedMessage]:
"""Convert a batch of OneBot messages to UnifiedMessage list."""
result = []
for raw_msg in raw_messages:
unified = MessageConverter.from_onebot_message(raw_msg, group_id)
if unified:
result.append(unified)
return result
@staticmethod
def batch_to_onebot(unified_messages: List[UnifiedMessage]) -> List[dict]:
"""Convert a batch of UnifiedMessage to OneBot raw format."""
return [MessageConverter.to_onebot_message(msg) for msg in unified_messages]
@staticmethod
def unified_to_analysis_text(messages: List[UnifiedMessage]) -> str:
"""
Convert UnifiedMessage list to analysis text format for LLM.
This is the format expected by the existing LLM analyzers.
"""
lines = []
for msg in messages:
if msg.has_text():
lines.append(msg.to_analysis_format())
return "\n".join(lines)