From b8ea7adf5a612eda9ed830d13f43d4598a1a30a2 Mon Sep 17 00:00:00 2001 From: SXP-Simon Date: Sun, 8 Feb 2026 23:18:08 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BD=BF=E7=94=A8=20Adapter=20?= =?UTF-8?q?=E8=BF=9B=E8=A1=8C=E8=A7=84=E8=8C=83=E6=8A=BD=E8=B1=A1=EF=BC=8C?= =?UTF-8?q?=E4=BC=98=E5=85=88=E4=BD=BF=E7=94=A8=20Adapter=20=E6=8E=A5?= =?UTF-8?q?=E5=8F=A3=EF=BC=8C=E6=96=B9=E4=BE=BF=E5=90=8E=E6=9C=9F=E6=89=A9?= =?UTF-8?q?=E5=B1=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- main.py | 2 +- src/core/bot_manager.py | 28 ++- src/core/message_sender.py | 200 +++++++++++++----- .../platform/adapters/discord_adapter.py | 23 +- src/scheduler/retry.py | 18 ++ 5 files changed, 210 insertions(+), 61 deletions(-) diff --git a/main.py b/main.py index ced3cf2..c03706d 100644 --- a/main.py +++ b/main.py @@ -174,7 +174,7 @@ class QQGroupDailyAnalysis(Star): f" - 平台 {platform_id}: {type(bot_instance).__name__}" ) # 预先创建编排器 - self._get_orchestrator(platform_id, bot_instance) + self._get_orchestrator(platform_id, bot_instance=bot_instance) # 启动调度器 self.auto_scheduler.schedule_jobs(self.context) diff --git a/src/core/bot_manager.py b/src/core/bot_manager.py index 6d204ee..b5b4bd0 100644 --- a/src/core/bot_manager.py +++ b/src/core/bot_manager.py @@ -117,11 +117,15 @@ class BotManager: continue bot_client = None + # 优先尝试 get_client() if hasattr(platform, "get_client"): bot_client = platform.get_client() - elif hasattr(platform, "bot"): + + # 如果 get_client() 返回 None,尝试直接访问属性 + if not bot_client and hasattr(platform, "bot"): bot_client = platform.bot - elif hasattr(platform, "client"): + if not bot_client and hasattr(platform, "client"): + # AstrBot v4.14.4 DiscordPlatformAdapter uses 'client' attribute bot_client = platform.client if bot_client: @@ -264,18 +268,16 @@ class BotManager: logger.info( f"auto_discover_bot_instances: 在管理器中发现 {len(platforms)} 个平台。" ) - for p in platforms: - p_id = p.metadata.id if hasattr(p, "metadata") else "unknown" - logger.info(f" - 正在检查平台: {p_id}, 类型: {type(p).__name__}") for platform in platforms: # 获取bot实例 bot_client = None if hasattr(platform, "get_client"): bot_client = platform.get_client() - elif hasattr(platform, "bot"): + + if not bot_client and hasattr(platform, "bot"): bot_client = platform.bot - elif hasattr(platform, "client"): + if not bot_client and hasattr(platform, "client"): bot_client = platform.client # 健壮地获取元数据 @@ -295,6 +297,11 @@ class BotManager: platform_id = metadata.get("id") if platform_id: + # KNOWLEDGE DISCOVERY: Log metadata for debugging custom IDs + logger.info( + f"[群分析插件 BotManager]: Log metadata for debugging custom IDs ,Platform: {platform_id}, Metadata Type: {getattr(metadata, 'type', 'N/A')}, Metadata Name: {getattr(metadata, 'name', 'N/A')}" + ) + # 从元数据检测平台名称 platform_name = None # 优先使用 type @@ -307,6 +314,10 @@ class BotManager: elif isinstance(metadata, dict) and "name" in metadata: platform_name = metadata["name"] + logger.info( + f"[群分析插件 BotManager] Initial platform_name detection: {platform_name}" + ) + # 验证此平台名称是否受支持,如果不支持,尝试从bot实例检测(如果可用) if ( not platform_name @@ -431,6 +442,9 @@ class BotManager: return str(bot_instance.qq) elif hasattr(bot_instance, "user_id") and bot_instance.user_id: return str(bot_instance.user_id) + # Discord.py style: client.user.id + elif hasattr(bot_instance, "user") and hasattr(bot_instance.user, "id"): + return str(bot_instance.user.id) return None def _extract_bot_qq_id(self, bot_instance): diff --git a/src/core/message_sender.py b/src/core/message_sender.py index 0fec5cf..90a2a33 100644 --- a/src/core/message_sender.py +++ b/src/core/message_sender.py @@ -32,14 +32,24 @@ class MessageSender: logger.error(f"[{trace_id}] No available platforms for group {group_id}") return False - for pid, bot in platforms: + for pid, adapter in platforms: try: logger.info(f"[{trace_id}] Trying platform {pid}...") - await bot.api.call_action( - "send_group_msg", group_id=group_id, message=text - ) - logger.info(f"[{trace_id}] Successfully sent text via {pid}") - return True + + # 优先使用 Adapter 接口 + if hasattr(adapter, "send_text"): + if await adapter.send_text(group_id, text): + logger.info(f"[{trace_id}] Successfully sent text via {pid}") + return True + + # Fallback to OneBot API (for backward compatibility or if adapter wrapping failed) + if hasattr(adapter, "api") and hasattr(adapter.api, "call_action"): + await adapter.api.call_action( + "send_group_msg", group_id=group_id, message=text + ) + logger.info(f"[{trace_id}] Successfully sent text via {pid} (API)") + return True + except Exception as e: self._log_send_error(pid, group_id, "text", e) continue @@ -62,19 +72,36 @@ class MessageSender: if not platforms: return False - message_chain = [] - if text_prefix: - message_chain.append({"type": "text", "data": {"text": text_prefix}}) - message_chain.append({"type": "image", "data": {"url": image_url}}) - - for pid, bot in platforms: + for pid, adapter in platforms: try: logger.info(f"[{trace_id}] Trying sending image (URL) via {pid}...") - await bot.api.call_action( - "send_group_msg", group_id=group_id, message=message_chain - ) - logger.info(f"[{trace_id}] Successfully sent image (URL) via {pid}") - return True + + # 优先使用 Adapter 接口 + if hasattr(adapter, "send_image"): + if await adapter.send_image( + group_id, image_url, caption=text_prefix + ): + logger.info( + f"[{trace_id}] Successfully sent image (URL) via {pid}" + ) + return True + + # Fallback to OneBot API + if hasattr(adapter, "api") and hasattr(adapter.api, "call_action"): + message_chain = [] + if text_prefix: + message_chain.append( + {"type": "text", "data": {"text": text_prefix}} + ) + message_chain.append({"type": "image", "data": {"url": image_url}}) + + await adapter.api.call_action( + "send_group_msg", group_id=group_id, message=message_chain + ) + logger.info( + f"[{trace_id}] Successfully sent image (URL) via {pid} (API)" + ) + return True except Exception as e: self._log_send_error(pid, group_id, "image_url", e) continue @@ -99,26 +126,51 @@ class MessageSender: return False image_b64 = base64.b64encode(image_bytes).decode() + # file URI for Base64 (OneBot style) + base64_uri = f"base64://{image_b64}" platforms = self._get_available_platforms(group_id, platform_id) if not platforms: return False - message_chain = [] - if text_prefix: - message_chain.append({"type": "text", "data": {"text": text_prefix}}) - message_chain.append( - {"type": "image", "data": {"file": f"base64://{image_b64}"}} - ) - - for pid, bot in platforms: + for pid, adapter in platforms: try: logger.info(f"[{trace_id}] Trying sending image (Base64) via {pid}...") - await bot.api.call_action( - "send_group_msg", group_id=group_id, message=message_chain - ) - logger.info(f"[{trace_id}] Successfully sent image (Base64) via {pid}") - return True + + # 优先使用 Adapter 接口 (注意 Adapter 接口通常接受 path/url,这里我们传 base64 uri 它是支持的吗?) + # 大多数 Adapter 的 send_image 如果识别 base64:// 应该能处理 + # 如果是 DiscordAdapter, 它需要特殊处理 local file. + # 但这里是 Base64 string. + # 为了稳妥,我们可以先尝试 Adapter,如果 Adapter 明确支持 base64:// + + if hasattr(adapter, "send_image"): + # 尝试发送 base64 URI + if await adapter.send_image( + group_id, base64_uri, caption=text_prefix + ): + logger.info( + f"[{trace_id}] Successfully sent image (Base64) via {pid}" + ) + return True + + # Fallback to OneBot API + if hasattr(adapter, "api") and hasattr(adapter.api, "call_action"): + message_chain = [] + if text_prefix: + message_chain.append( + {"type": "text", "data": {"text": text_prefix}} + ) + message_chain.append( + {"type": "image", "data": {"file": base64_uri}} + ) + + await adapter.api.call_action( + "send_group_msg", group_id=group_id, message=message_chain + ) + logger.info( + f"[{trace_id}] Successfully sent image (Base64) via {pid} (API)" + ) + return True except Exception as e: self._log_send_error(pid, group_id, "image_base64", e) continue @@ -159,19 +211,30 @@ class MessageSender: if not platforms: return False - message_chain = [] - if text_prefix: - message_chain.append({"type": "text", "data": {"text": text_prefix}}) - message_chain.append({"type": "file", "data": {"file": pdf_path}}) - - for pid, bot in platforms: + for pid, adapter in platforms: try: logger.info(f"[{trace_id}] Trying sending PDF via {pid}...") - await bot.api.call_action( - "send_group_msg", group_id=group_id, message=message_chain - ) - logger.info(f"[{trace_id}] Successfully sent PDF via {pid}") - return True + + if hasattr(adapter, "send_file"): + if await adapter.send_file(group_id, pdf_path): + logger.info(f"[{trace_id}] Successfully sent PDF via {pid}") + return True + + # Fallback to OneBot API + if hasattr(adapter, "api") and hasattr(adapter.api, "call_action"): + message_chain = [] + if text_prefix: + message_chain.append( + {"type": "text", "data": {"text": text_prefix}} + ) + message_chain.append({"type": "file", "data": {"file": pdf_path}}) + + await adapter.api.call_action( + "send_group_msg", group_id=group_id, message=message_chain + ) + logger.info(f"[{trace_id}] Successfully sent PDF via {pid} (API)") + return True + except Exception as e: self._log_send_error(pid, group_id, "pdf", e) continue @@ -181,21 +244,58 @@ class MessageSender: self, group_id: str, specific_platform_id: str | None = None ) -> list[tuple]: """ - 获取可用的发送平台列表 + 获取可用的发送平台列表 (返回 Adapter 实例) """ + from ..infrastructure.platform.factory import PlatformAdapterFactory + from ..infrastructure.platform.base import PlatformAdapter + + instances = [] + if specific_platform_id: bot = self.bot_manager.get_bot_instance(specific_platform_id) if bot: - return [(specific_platform_id, bot)] - logger.warning(f"Specified platform {specific_platform_id} not found") + instances.append((specific_platform_id, bot)) + else: + logger.warning(f"Specified platform {specific_platform_id} not found") + else: + # 获取所有已发现的平台 + all_instances = self.bot_manager.get_all_bot_instances() + if all_instances: + instances = list(all_instances.items()) - # 获取所有已发现的平台 - all_instances = self.bot_manager.get_all_bot_instances() - if all_instances: - # 这里可以加入逻辑判断哪些平台在该群中,目前简单返回所有 - return list(all_instances.items()) + # Wrap instances with Adapters if needed + adapters = [] + for pid, bot in instances: + # Check if it's already an adapter + if isinstance(bot, PlatformAdapter): + adapters.append((pid, bot)) + continue - return [] + # If not, try to create an adapter + # We need to detect platform name first + platform_name = self.bot_manager._detect_platform_name(bot) + if not platform_name: + # If cannot detect, assume it's a OneBot raw object if it has api + if hasattr(bot, "api"): + adapters.append((pid, bot)) # Return raw bot for backward compat + continue + + # Create adapter + try: + # We need config for adapter, here we use empty config or try to fetch from somewhere + # Ideally config_manager should provide it but it's complex. + # Passing empty config is fine for basic sending tasks as long as bot instance is valid. + adapter = PlatformAdapterFactory.create(platform_name, bot, config={}) + if adapter: + adapters.append((pid, adapter)) + else: + # Fallback: return raw bot + adapters.append((pid, bot)) + except Exception as e: + logger.warning(f"Failed to create adapter for {pid}: {e}") + adapters.append((pid, bot)) + + return adapters async def _download_image(self, url: str) -> bytes | None: """下载图片 helper""" diff --git a/src/infrastructure/platform/adapters/discord_adapter.py b/src/infrastructure/platform/adapters/discord_adapter.py index 05d60cf..c231de1 100644 --- a/src/infrastructure/platform/adapters/discord_adapter.py +++ b/src/infrastructure/platform/adapters/discord_adapter.py @@ -625,11 +625,20 @@ class DiscordAdapter(PlatformAdapter): ) -> Optional[str]: """获取 Discord 用户头像 URL""" if not discord: + logger.warning("[群分析插件 DiscordAdapter] py-cord 未安装") return None try: + logger.debug(f"[群分析插件 DiscordAdapter] 正在获取用户头像 {user_id}") + if not self._discord_client: + logger.warning("[群分析插件 DiscordAdapter] Discord 客户端未准备就绪") + return None + user = self._discord_client.get_user(int(user_id)) if not user: + logger.debug( + f"[群分析插件 DiscordAdapter] 用户 {user_id} 不在缓存中,正在获取..." + ) user = await self._discord_client.fetch_user(int(user_id)) if user: @@ -637,10 +646,18 @@ class DiscordAdapter(PlatformAdapter): allowed_sizes = [16, 32, 64, 128, 256, 512, 1024, 2048, 4096] target_size = min(allowed_sizes, key=lambda x: abs(x - size)) - # display_avatar 自动处理默认头像 - return user.display_avatar.with_size(target_size).url + url = user.display_avatar.with_size(target_size).url + logger.debug( + f"[群分析插件 DiscordAdapter] 获取用户头像 {user_id} 成功: {url}" + ) + return url + + logger.warning(f"[群分析插件 DiscordAdapter] 用户 {user_id} 未找到") return None - except Exception: + except Exception as e: + logger.error( + f"[群分析插件 DiscordAdapter] 获取用户头像 {user_id} 失败: {e}" + ) return None async def get_user_avatar_data( diff --git a/src/scheduler/retry.py b/src/scheduler/retry.py index c85b4fe..36ed410 100644 --- a/src/scheduler/retry.py +++ b/src/scheduler/retry.py @@ -2,6 +2,7 @@ import asyncio import base64 import random import time +import aiohttp from collections.abc import Callable from dataclasses import dataclass @@ -158,6 +159,23 @@ class RetryManager: image_options, ) + # Fix: html_render might return URL (str) even if return_url=False in some implementations + if isinstance(image_data, str) and image_data.startswith( + ("http://", "https://") + ): + logger.warning( + f"[RetryManager] html_render 返回了 URL 而不是 bytes,尝试下载: {image_data}" + ) + async with aiohttp.ClientSession() as session: + async with session.get(image_data) as resp: + if resp.status == 200: + image_data = await resp.read() + else: + logger.error( + f"[RetryManager] 下载重试图片失败: {resp.status}" + ) + image_data = None + if not image_data: logger.warning( f"[RetryManager] 重新渲染失败(返回空数据){task.group_id}"