mirror of
https://github.com/Nezumi-2711/astrbot_plugin_qq_group_daily_analysis.git
synced 2026-09-22 20:01:04 +00:00
feat(feishu): 尝试接入飞书
This commit is contained in:
@@ -4,11 +4,14 @@
|
||||
负责协调领域服务、基础设施适配器及持久化层。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import datetime as dt
|
||||
import time as time_mod
|
||||
import weakref
|
||||
from collections import defaultdict
|
||||
from collections.abc import Mapping
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Any
|
||||
|
||||
@@ -16,7 +19,10 @@ from ...domain.entities.incremental_state import IncrementalBatch
|
||||
from ...domain.models.data_models import TokenUsage
|
||||
from ...domain.repositories.analysis_repository import IAnalysisProvider
|
||||
from ...domain.repositories.report_repository import IReportGenerator
|
||||
from ...domain.services.analysis_domain_service import AnalysisDomainService
|
||||
from ...domain.services.analysis_domain_service import (
|
||||
AnalysisDomainService,
|
||||
UserActivityStats,
|
||||
)
|
||||
from ...domain.services.incremental_merge_service import IncrementalMergeService
|
||||
from ...domain.services.statistics_service import StatisticsService
|
||||
from ...domain.value_objects.unified_message import UnifiedMessage
|
||||
@@ -125,6 +131,17 @@ class AnalysisApplicationService:
|
||||
if not adapter:
|
||||
raise ValueError(f"未找到平台 {platform_id} 的适配器")
|
||||
|
||||
# 飞书平台在分析前进行一次性权限与成员头像预热,避免报告阶段出现大面积默认头像。
|
||||
if hasattr(adapter, "prepare_group_member_cache"):
|
||||
try:
|
||||
ok, err = await adapter.prepare_group_member_cache(group_id) # type: ignore[attr-defined]
|
||||
if not ok and err:
|
||||
raise ValueError(err)
|
||||
except Exception as e:
|
||||
raise ValueError(
|
||||
f"飞书成员信息预检查失败,请先完成应用权限授权:{e}"
|
||||
) from e
|
||||
|
||||
# 2. 拉取消息
|
||||
if days is None:
|
||||
days = self.config_manager.get_analysis_days()
|
||||
@@ -731,7 +748,7 @@ class AnalysisApplicationService:
|
||||
|
||||
@staticmethod
|
||||
def _convert_user_activity_for_merge(
|
||||
user_activity: dict[str, dict],
|
||||
user_activity: Mapping[str, UserActivityStats],
|
||||
messages: list[UnifiedMessage],
|
||||
) -> dict[str, dict]:
|
||||
"""
|
||||
|
||||
@@ -231,6 +231,29 @@ SLACK_CAPABILITIES = PlatformCapabilities(
|
||||
avatar_sizes=(24, 32, 48, 72, 192, 512, 1024),
|
||||
)
|
||||
|
||||
# Feishu/Lark Open Platform API
|
||||
LARK_CAPABILITIES = PlatformCapabilities(
|
||||
platform_name="lark",
|
||||
platform_version="open_api_v1",
|
||||
supports_message_history=True,
|
||||
max_message_history_days=7,
|
||||
max_message_count=1000,
|
||||
supports_group_list=False,
|
||||
supports_group_info=True,
|
||||
supports_member_list=True,
|
||||
supports_member_info=True,
|
||||
supports_text_message=True,
|
||||
supports_image_message=True,
|
||||
supports_file_message=True,
|
||||
supports_reply_message=True,
|
||||
max_text_length=30000,
|
||||
max_image_size_mb=10.0,
|
||||
supports_user_avatar=True,
|
||||
supports_group_avatar=True,
|
||||
avatar_needs_api_call=True,
|
||||
avatar_sizes=(72, 240, 640),
|
||||
)
|
||||
|
||||
# 能力查找表(映射平台标识到能力对象)
|
||||
PLATFORM_CAPABILITIES: dict[str, PlatformCapabilities] = {
|
||||
"aiocqhttp": ONEBOT_V11_CAPABILITIES,
|
||||
@@ -238,6 +261,7 @@ PLATFORM_CAPABILITIES: dict[str, PlatformCapabilities] = {
|
||||
"telegram": TELEGRAM_CAPABILITIES,
|
||||
"discord": DISCORD_CAPABILITIES,
|
||||
"slack": SLACK_CAPABILITIES,
|
||||
"lark": LARK_CAPABILITIES,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# 平台适配器
|
||||
from .adapters.lark_adapter import LarkAdapter
|
||||
from .adapters.onebot_adapter import OneBotAdapter
|
||||
from .base import PlatformAdapter
|
||||
from .factory import PlatformAdapterFactory
|
||||
|
||||
__all__ = ["PlatformAdapterFactory", "PlatformAdapter", "OneBotAdapter"]
|
||||
__all__ = ["PlatformAdapterFactory", "PlatformAdapter", "OneBotAdapter", "LarkAdapter"]
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
# 平台适配器
|
||||
from .discord_adapter import DiscordAdapter
|
||||
from .lark_adapter import LarkAdapter
|
||||
from .onebot_adapter import OneBotAdapter
|
||||
|
||||
__all__ = ["OneBotAdapter", "DiscordAdapter"]
|
||||
__all__ = ["OneBotAdapter", "DiscordAdapter", "LarkAdapter"]
|
||||
|
||||
@@ -0,0 +1,916 @@
|
||||
"""
|
||||
Feishu/Lark 平台适配器
|
||||
|
||||
复用 AstrBot 已有 lark_oapi 生态能力,实现飞书群分析消息读取、成员信息与头像获取。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
from collections.abc import Generator, Iterator, Mapping
|
||||
from pathlib import Path
|
||||
from tempfile import NamedTemporaryFile
|
||||
from typing import Protocol, TypeAlias, cast
|
||||
|
||||
import aiohttp
|
||||
from aiohttp import ClientTimeout
|
||||
|
||||
from ....domain.value_objects.platform_capabilities import (
|
||||
LARK_CAPABILITIES,
|
||||
PlatformCapabilities,
|
||||
)
|
||||
from ....domain.value_objects.unified_group import UnifiedGroup, UnifiedMember
|
||||
from ....domain.value_objects.unified_message import (
|
||||
MessageContent,
|
||||
MessageContentType,
|
||||
UnifiedMessage,
|
||||
)
|
||||
from ....utils.logger import logger
|
||||
from ..base import PlatformAdapter
|
||||
|
||||
|
||||
class _SDKNode(Protocol):
|
||||
def __getattr__(self, name: str) -> _SDKNode: ...
|
||||
|
||||
def __call__(self, *args: object, **kwargs: object) -> _SDKNode: ...
|
||||
|
||||
def __await__(self) -> Generator[object, None, _SDKNode]: ...
|
||||
|
||||
def __iter__(self) -> Iterator[_SDKNode]: ...
|
||||
|
||||
def __bool__(self) -> bool: ...
|
||||
|
||||
def __int__(self) -> int: ...
|
||||
|
||||
|
||||
class _BuilderRequest(Protocol):
|
||||
@classmethod
|
||||
def builder(cls) -> object: ...
|
||||
|
||||
|
||||
CreateFileRequest: type[_BuilderRequest] | None = None
|
||||
CreateFileRequestBody: type[_BuilderRequest] | None = None
|
||||
CreateImageRequest: type[_BuilderRequest] | None = None
|
||||
CreateImageRequestBody: type[_BuilderRequest] | None = None
|
||||
CreateMessageRequest: type[_BuilderRequest] | None = None
|
||||
CreateMessageRequestBody: type[_BuilderRequest] | None = None
|
||||
GetChatMembersRequest: type[_BuilderRequest] | None = None
|
||||
GetChatRequest: type[_BuilderRequest] | None = None
|
||||
GetUserRequest: type[_BuilderRequest] | None = None
|
||||
ListMessageRequest: type[_BuilderRequest] | None = None
|
||||
ReplyMessageRequest: type[_BuilderRequest] | None = None
|
||||
ReplyMessageRequestBody: type[_BuilderRequest] | None = None
|
||||
|
||||
JSONPrimitive: TypeAlias = str | int | float | bool | None
|
||||
JSONValue: TypeAlias = JSONPrimitive | dict[str, "JSONValue"] | list["JSONValue"]
|
||||
|
||||
try:
|
||||
from lark_oapi.api.contact.v3 import GetUserRequest as _GetUserRequest
|
||||
from lark_oapi.api.im.v1 import (
|
||||
CreateFileRequest as _CreateFileRequest,
|
||||
)
|
||||
from lark_oapi.api.im.v1 import (
|
||||
CreateFileRequestBody as _CreateFileRequestBody,
|
||||
)
|
||||
from lark_oapi.api.im.v1 import (
|
||||
CreateImageRequest as _CreateImageRequest,
|
||||
)
|
||||
from lark_oapi.api.im.v1 import (
|
||||
CreateImageRequestBody as _CreateImageRequestBody,
|
||||
)
|
||||
from lark_oapi.api.im.v1 import (
|
||||
CreateMessageRequest as _CreateMessageRequest,
|
||||
)
|
||||
from lark_oapi.api.im.v1 import (
|
||||
CreateMessageRequestBody as _CreateMessageRequestBody,
|
||||
)
|
||||
from lark_oapi.api.im.v1 import (
|
||||
GetChatMembersRequest as _GetChatMembersRequest,
|
||||
)
|
||||
from lark_oapi.api.im.v1 import (
|
||||
GetChatRequest as _GetChatRequest,
|
||||
)
|
||||
from lark_oapi.api.im.v1 import (
|
||||
ListMessageRequest as _ListMessageRequest,
|
||||
)
|
||||
from lark_oapi.api.im.v1 import (
|
||||
ReplyMessageRequest as _ReplyMessageRequest,
|
||||
)
|
||||
from lark_oapi.api.im.v1 import (
|
||||
ReplyMessageRequestBody as _ReplyMessageRequestBody,
|
||||
)
|
||||
|
||||
CreateFileRequest = _CreateFileRequest
|
||||
CreateFileRequestBody = _CreateFileRequestBody
|
||||
CreateImageRequest = _CreateImageRequest
|
||||
CreateImageRequestBody = _CreateImageRequestBody
|
||||
CreateMessageRequest = _CreateMessageRequest
|
||||
CreateMessageRequestBody = _CreateMessageRequestBody
|
||||
GetChatMembersRequest = _GetChatMembersRequest
|
||||
GetChatRequest = _GetChatRequest
|
||||
GetUserRequest = _GetUserRequest
|
||||
ListMessageRequest = _ListMessageRequest
|
||||
ReplyMessageRequest = _ReplyMessageRequest
|
||||
ReplyMessageRequestBody = _ReplyMessageRequestBody
|
||||
|
||||
LARK_AVAILABLE = True
|
||||
except Exception: # pragma: no cover - 兼容缺依赖环境
|
||||
LARK_AVAILABLE = False
|
||||
|
||||
|
||||
class LarkAdapter(PlatformAdapter):
|
||||
"""飞书平台适配器。"""
|
||||
|
||||
platform_name = "lark"
|
||||
_DEFAULT_SCOPE_HINT = (
|
||||
"Please grant these Feishu app scopes once: "
|
||||
"`im:message:readonly`, `im:chat:readonly`, and user/contact read scopes "
|
||||
"for profile avatar fields, then reinstall/re-authorize the app."
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
bot_instance: object,
|
||||
config: Mapping[str, object] | None = None,
|
||||
):
|
||||
normalized_config = dict(config) if config is not None else None
|
||||
super().__init__(bot_instance, normalized_config)
|
||||
self._lark_client: _SDKNode | None = self._resolve_lark_client(bot_instance)
|
||||
self._avatar_url_cache: dict[str, str] = {}
|
||||
self._member_name_cache: dict[tuple[str, str], str] = {}
|
||||
self._member_avatar_cache: dict[tuple[str, str], str] = {}
|
||||
self._permission_checked_groups: set[str] = set()
|
||||
self._permission_error: str | None = None
|
||||
|
||||
@staticmethod
|
||||
def _request_class_or_throw(
|
||||
request_cls: type[_BuilderRequest] | None, name: str
|
||||
) -> type[_BuilderRequest]:
|
||||
if request_cls is None:
|
||||
raise RuntimeError(f"{name} unavailable; install lark_oapi")
|
||||
return request_cls
|
||||
|
||||
@staticmethod
|
||||
def _builder(request_cls: type[_BuilderRequest]) -> _SDKNode:
|
||||
return cast(_SDKNode, request_cls.builder())
|
||||
|
||||
def _init_capabilities(self) -> PlatformCapabilities:
|
||||
return LARK_CAPABILITIES
|
||||
|
||||
@staticmethod
|
||||
def _resolve_lark_client(bot_instance: object) -> _SDKNode | None:
|
||||
if bot_instance is None:
|
||||
return None
|
||||
# 直接是 lark.Client
|
||||
if hasattr(bot_instance, "im") and hasattr(bot_instance, "contact"):
|
||||
return cast(_SDKNode, bot_instance)
|
||||
# 平台实例上暴露 lark_api
|
||||
if hasattr(bot_instance, "lark_api"):
|
||||
api = getattr(bot_instance, "lark_api")
|
||||
if hasattr(api, "im"):
|
||||
return cast(_SDKNode, api)
|
||||
# 常见包装层
|
||||
for attr in ("client", "_client", "bot"):
|
||||
if hasattr(bot_instance, attr):
|
||||
client = getattr(bot_instance, attr)
|
||||
if hasattr(client, "im"):
|
||||
return cast(_SDKNode, client)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _to_seconds(ts: int | None) -> int:
|
||||
if not ts:
|
||||
return 0
|
||||
try:
|
||||
ts_int = int(ts)
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
return ts_int // 1000 if ts_int > 10**11 else ts_int
|
||||
|
||||
@staticmethod
|
||||
def _safe_json_loads(raw: str | None) -> dict[str, JSONValue]:
|
||||
if not raw:
|
||||
return {}
|
||||
try:
|
||||
parsed = json.loads(raw)
|
||||
return parsed if isinstance(parsed, dict) else {}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
@staticmethod
|
||||
def _is_permission_error(code: int, msg: str) -> bool:
|
||||
msg_l = (msg or "").lower()
|
||||
return code in {99991663, 99991664, 230001, 20013} or (
|
||||
"permission" in msg_l
|
||||
or "scope" in msg_l
|
||||
or "forbidden" in msg_l
|
||||
or "无权限" in msg_l
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _pick_avatar_from_user(user_obj: object, size: int) -> str | None:
|
||||
avatar = getattr(user_obj, "avatar", None)
|
||||
if avatar is None:
|
||||
return None
|
||||
if size <= 72 and getattr(avatar, "avatar_72", None):
|
||||
return avatar.avatar_72
|
||||
if size <= 240 and getattr(avatar, "avatar_240", None):
|
||||
return avatar.avatar_240
|
||||
if size <= 640 and getattr(avatar, "avatar_640", None):
|
||||
return avatar.avatar_640
|
||||
return getattr(avatar, "avatar_origin", None) or getattr(
|
||||
avatar, "avatar_640", None
|
||||
)
|
||||
|
||||
async def prepare_group_member_cache(
|
||||
self, group_id: str
|
||||
) -> tuple[bool, str | None]:
|
||||
"""
|
||||
预热群成员缓存并完成权限探测。
|
||||
该方法用于在分析前一次性确认“成员信息+头像”权限是否齐备。
|
||||
"""
|
||||
if group_id in self._permission_checked_groups:
|
||||
return self._permission_error is None, self._permission_error
|
||||
if not LARK_AVAILABLE or not self._lark_client or not self._lark_client.im:
|
||||
self._permission_error = "Lark SDK client is not initialized."
|
||||
self._permission_checked_groups.add(group_id)
|
||||
return False, self._permission_error
|
||||
|
||||
try:
|
||||
members = await self.get_member_list(group_id)
|
||||
if not members:
|
||||
self._permission_error = (
|
||||
f"Cannot list chat members. {self._DEFAULT_SCOPE_HINT}"
|
||||
)
|
||||
self._permission_checked_groups.add(group_id)
|
||||
return False, self._permission_error
|
||||
|
||||
# 只预热近期活跃用户常见数量,避免在超大群上引入不必要延迟
|
||||
target_ids = [m.user_id for m in members[:100]]
|
||||
avatar_map = await self.batch_get_avatar_urls(target_ids, size=240)
|
||||
if target_ids and all(not avatar_map.get(uid) for uid in target_ids):
|
||||
self._permission_error = (
|
||||
"Fetched members but cannot read avatar URLs. "
|
||||
f"{self._DEFAULT_SCOPE_HINT}"
|
||||
)
|
||||
self._permission_checked_groups.add(group_id)
|
||||
return False, self._permission_error
|
||||
|
||||
self._permission_error = None
|
||||
self._permission_checked_groups.add(group_id)
|
||||
return True, None
|
||||
except Exception as e:
|
||||
self._permission_error = (
|
||||
f"Failed to warm up Lark member cache: {e}. {self._DEFAULT_SCOPE_HINT}"
|
||||
)
|
||||
self._permission_checked_groups.add(group_id)
|
||||
return False, self._permission_error
|
||||
|
||||
async def fetch_messages(
|
||||
self,
|
||||
group_id: str,
|
||||
days: int = 1,
|
||||
max_count: int = 1000,
|
||||
before_id: str | None = None,
|
||||
since_ts: int | None = None,
|
||||
) -> list[UnifiedMessage]:
|
||||
if not LARK_AVAILABLE or not self._lark_client or not self._lark_client.im:
|
||||
return []
|
||||
now_seconds = int(__import__("time").time())
|
||||
start_seconds = (
|
||||
int(since_ts) if since_ts and since_ts > 0 else now_seconds - (days * 86400)
|
||||
)
|
||||
|
||||
messages: list[UnifiedMessage] = []
|
||||
page_token: str | None = None
|
||||
page_size = min(max(max_count, 1), 200)
|
||||
seen_ids: set[str] = set()
|
||||
|
||||
while len(messages) < max_count:
|
||||
ListMessageRequestClass = self._request_class_or_throw(
|
||||
ListMessageRequest, "ListMessageRequest"
|
||||
)
|
||||
request = (
|
||||
self._builder(ListMessageRequestClass)
|
||||
.container_id_type("chat")
|
||||
.container_id(group_id)
|
||||
.start_time(str(start_seconds * 1000))
|
||||
.end_time(str(now_seconds * 1000))
|
||||
.page_size(min(page_size, max_count - len(messages)))
|
||||
.build()
|
||||
)
|
||||
if page_token:
|
||||
request = (
|
||||
self._builder(ListMessageRequestClass)
|
||||
.container_id_type("chat")
|
||||
.container_id(group_id)
|
||||
.start_time(str(start_seconds * 1000))
|
||||
.end_time(str(now_seconds * 1000))
|
||||
.page_size(min(page_size, max_count - len(messages)))
|
||||
.page_token(page_token)
|
||||
.build()
|
||||
)
|
||||
|
||||
response = await self._lark_client.im.v1.message.alist(request)
|
||||
if not response.success():
|
||||
logger.warning(
|
||||
"Lark fetch_messages failed: code=%s, msg=%s",
|
||||
response.code,
|
||||
response.msg,
|
||||
)
|
||||
break
|
||||
|
||||
items = (response.data.items if response.data else None) or []
|
||||
if not items:
|
||||
break
|
||||
|
||||
for item in items:
|
||||
msg = self._convert_message(item, group_id)
|
||||
if not msg or not msg.message_id or msg.message_id in seen_ids:
|
||||
continue
|
||||
if before_id and msg.message_id >= before_id:
|
||||
continue
|
||||
seen_ids.add(msg.message_id)
|
||||
messages.append(msg)
|
||||
if len(messages) >= max_count:
|
||||
break
|
||||
|
||||
has_more = bool(response.data and response.data.has_more)
|
||||
page_token_raw = (
|
||||
getattr(response.data, "page_token", None) if response.data else None
|
||||
)
|
||||
page_token = str(page_token_raw) if page_token_raw else None
|
||||
if not has_more or not page_token:
|
||||
break
|
||||
|
||||
messages.sort(key=lambda m: m.timestamp)
|
||||
return messages
|
||||
|
||||
def _convert_message(self, item: object, group_id: str) -> UnifiedMessage | None:
|
||||
try:
|
||||
message_id = str(getattr(item, "message_id", "") or "")
|
||||
sender = getattr(item, "sender", None)
|
||||
sender_id = str(getattr(sender, "id", "") or "")
|
||||
sender_name = (
|
||||
self._member_name_cache.get((group_id, sender_id))
|
||||
or sender_id[:8]
|
||||
or "Unknown"
|
||||
)
|
||||
body = getattr(item, "body", None)
|
||||
raw_content = str(getattr(body, "content", "") or "")
|
||||
msg_type = str(getattr(item, "msg_type", "") or "text")
|
||||
content = self._safe_json_loads(raw_content)
|
||||
|
||||
contents: list[MessageContent] = []
|
||||
text_parts: list[str] = []
|
||||
|
||||
if msg_type == "text":
|
||||
text = str(content.get("text", "")).strip()
|
||||
if text:
|
||||
text_parts.append(text)
|
||||
contents.append(
|
||||
MessageContent(type=MessageContentType.TEXT, text=text)
|
||||
)
|
||||
elif msg_type in {"post", "image"}:
|
||||
post_content = content.get("content", [])
|
||||
if isinstance(post_content, dict):
|
||||
# 富文本消息常见结构:{"zh_cn":{"title":"","content":[...]}}
|
||||
zh_cn = post_content.get("zh_cn", {})
|
||||
if isinstance(zh_cn, dict):
|
||||
post_content = zh_cn.get("content", [])
|
||||
if isinstance(post_content, list):
|
||||
for row in post_content:
|
||||
if not isinstance(row, list):
|
||||
continue
|
||||
for seg in row:
|
||||
if not isinstance(seg, dict):
|
||||
continue
|
||||
tag = seg.get("tag", "")
|
||||
if tag == "text":
|
||||
text = str(seg.get("text", "")).strip()
|
||||
if text:
|
||||
text_parts.append(text)
|
||||
contents.append(
|
||||
MessageContent(
|
||||
type=MessageContentType.TEXT, text=text
|
||||
)
|
||||
)
|
||||
elif tag == "at":
|
||||
at_uid = str(seg.get("user_id", "")).strip()
|
||||
contents.append(
|
||||
MessageContent(
|
||||
type=MessageContentType.AT, at_user_id=at_uid
|
||||
)
|
||||
)
|
||||
elif tag == "img":
|
||||
image_key = str(seg.get("image_key", "")).strip()
|
||||
if image_key:
|
||||
contents.append(
|
||||
MessageContent(
|
||||
type=MessageContentType.IMAGE,
|
||||
raw_data={"image_key": image_key},
|
||||
)
|
||||
)
|
||||
else:
|
||||
if raw_content:
|
||||
contents.append(
|
||||
MessageContent(
|
||||
type=MessageContentType.UNKNOWN,
|
||||
raw_data={"msg_type": msg_type, "content": raw_content},
|
||||
)
|
||||
)
|
||||
|
||||
if not contents:
|
||||
contents.append(
|
||||
MessageContent(
|
||||
type=MessageContentType.TEXT,
|
||||
text="".join(text_parts),
|
||||
)
|
||||
)
|
||||
|
||||
return UnifiedMessage(
|
||||
message_id=message_id,
|
||||
sender_id=sender_id,
|
||||
sender_name=sender_name,
|
||||
sender_card=None,
|
||||
group_id=group_id,
|
||||
text_content=" ".join(text_parts).strip(),
|
||||
contents=tuple(contents),
|
||||
timestamp=self._to_seconds(getattr(item, "create_time", 0)),
|
||||
platform="lark",
|
||||
reply_to_id=(
|
||||
str(parent_id)
|
||||
if (parent_id := getattr(item, "parent_id", None))
|
||||
else None
|
||||
),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(f"Lark convert message error: {e}")
|
||||
return None
|
||||
|
||||
def convert_to_raw_format(self, messages: list[UnifiedMessage]) -> list[dict]:
|
||||
result: list[dict] = []
|
||||
for msg in messages:
|
||||
chain: list[dict[str, object]] = []
|
||||
for content in msg.contents:
|
||||
if content.type == MessageContentType.TEXT:
|
||||
chain.append({"type": "text", "data": {"text": content.text}})
|
||||
elif content.type == MessageContentType.AT:
|
||||
chain.append({"type": "at", "data": {"qq": content.at_user_id}})
|
||||
elif content.type == MessageContentType.IMAGE:
|
||||
chain.append(
|
||||
{
|
||||
"type": "image",
|
||||
"data": {
|
||||
"url": content.url or "",
|
||||
"image_key": (
|
||||
content.raw_data.get("image_key", "")
|
||||
if isinstance(content.raw_data, dict)
|
||||
else ""
|
||||
),
|
||||
},
|
||||
}
|
||||
)
|
||||
result.append(
|
||||
{
|
||||
"message_id": msg.message_id,
|
||||
"group_id": msg.group_id,
|
||||
"time": msg.timestamp,
|
||||
"sender": {"user_id": msg.sender_id, "nickname": msg.sender_name},
|
||||
"message": chain,
|
||||
"user_id": msg.sender_id,
|
||||
}
|
||||
)
|
||||
return result
|
||||
|
||||
async def send_text(
|
||||
self, group_id: str, text: str, reply_to: str | None = None
|
||||
) -> bool:
|
||||
if not self._lark_client or not self._lark_client.im:
|
||||
return False
|
||||
try:
|
||||
ReplyMessageRequestClass = self._request_class_or_throw(
|
||||
ReplyMessageRequest, "ReplyMessageRequest"
|
||||
)
|
||||
ReplyMessageRequestBodyClass = self._request_class_or_throw(
|
||||
ReplyMessageRequestBody, "ReplyMessageRequestBody"
|
||||
)
|
||||
CreateMessageRequestClass = self._request_class_or_throw(
|
||||
CreateMessageRequest, "CreateMessageRequest"
|
||||
)
|
||||
CreateMessageRequestBodyClass = self._request_class_or_throw(
|
||||
CreateMessageRequestBody, "CreateMessageRequestBody"
|
||||
)
|
||||
|
||||
if reply_to:
|
||||
request = (
|
||||
self._builder(ReplyMessageRequestClass)
|
||||
.message_id(reply_to)
|
||||
.request_body(
|
||||
self._builder(ReplyMessageRequestBodyClass)
|
||||
.content(json.dumps({"text": text}, ensure_ascii=False))
|
||||
.msg_type("text")
|
||||
.build()
|
||||
)
|
||||
.build()
|
||||
)
|
||||
response = await self._lark_client.im.v1.message.areply(request)
|
||||
else:
|
||||
request = (
|
||||
self._builder(CreateMessageRequestClass)
|
||||
.receive_id_type("chat_id")
|
||||
.request_body(
|
||||
self._builder(CreateMessageRequestBodyClass)
|
||||
.receive_id(group_id)
|
||||
.msg_type("text")
|
||||
.content(json.dumps({"text": text}, ensure_ascii=False))
|
||||
.build()
|
||||
)
|
||||
.build()
|
||||
)
|
||||
response = await self._lark_client.im.v1.message.acreate(request)
|
||||
return bool(response.success())
|
||||
except Exception as e:
|
||||
logger.error(f"Lark send text failed: {e}")
|
||||
return False
|
||||
|
||||
async def send_image(
|
||||
self, group_id: str, image_path: str, caption: str = ""
|
||||
) -> bool:
|
||||
if not self._lark_client or not self._lark_client.im:
|
||||
return False
|
||||
temp_path: Path | None = None
|
||||
try:
|
||||
local_path: Path | None = None
|
||||
if image_path.startswith("base64://"):
|
||||
data = base64.b64decode(image_path.removeprefix("base64://"))
|
||||
with NamedTemporaryFile(delete=False, suffix=".png") as f:
|
||||
f.write(data)
|
||||
temp_path = Path(f.name)
|
||||
local_path = temp_path
|
||||
elif image_path.startswith("data:"):
|
||||
parts = image_path.split(",", 1)
|
||||
if len(parts) == 2:
|
||||
data = base64.b64decode(parts[1])
|
||||
with NamedTemporaryFile(delete=False, suffix=".png") as f:
|
||||
f.write(data)
|
||||
temp_path = Path(f.name)
|
||||
local_path = temp_path
|
||||
elif image_path.startswith(("http://", "https://")):
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(
|
||||
image_path, timeout=ClientTimeout(total=20)
|
||||
) as resp:
|
||||
if resp.status != 200:
|
||||
return False
|
||||
data = await resp.read()
|
||||
with NamedTemporaryFile(delete=False, suffix=".png") as f:
|
||||
f.write(data)
|
||||
temp_path = Path(f.name)
|
||||
local_path = temp_path
|
||||
else:
|
||||
p = Path(image_path)
|
||||
if p.exists():
|
||||
local_path = p
|
||||
|
||||
if not local_path:
|
||||
return False
|
||||
|
||||
CreateImageRequestClass = self._request_class_or_throw(
|
||||
CreateImageRequest, "CreateImageRequest"
|
||||
)
|
||||
CreateImageRequestBodyClass = self._request_class_or_throw(
|
||||
CreateImageRequestBody, "CreateImageRequestBody"
|
||||
)
|
||||
|
||||
with local_path.open("rb") as img_file:
|
||||
image_req = (
|
||||
self._builder(CreateImageRequestClass)
|
||||
.request_body(
|
||||
self._builder(CreateImageRequestBodyClass)
|
||||
.image_type("message")
|
||||
.image(img_file)
|
||||
.build()
|
||||
)
|
||||
.build()
|
||||
)
|
||||
image_resp = await self._lark_client.im.v1.image.acreate(image_req)
|
||||
if not image_resp.success() or not image_resp.data:
|
||||
return False
|
||||
image_key = image_resp.data.image_key
|
||||
|
||||
CreateMessageRequestClass = self._request_class_or_throw(
|
||||
CreateMessageRequest, "CreateMessageRequest"
|
||||
)
|
||||
CreateMessageRequestBodyClass = self._request_class_or_throw(
|
||||
CreateMessageRequestBody, "CreateMessageRequestBody"
|
||||
)
|
||||
|
||||
send_req = (
|
||||
self._builder(CreateMessageRequestClass)
|
||||
.receive_id_type("chat_id")
|
||||
.request_body(
|
||||
self._builder(CreateMessageRequestBodyClass)
|
||||
.receive_id(group_id)
|
||||
.msg_type("image")
|
||||
.content(json.dumps({"image_key": image_key}, ensure_ascii=False))
|
||||
.build()
|
||||
)
|
||||
.build()
|
||||
)
|
||||
send_resp = await self._lark_client.im.v1.message.acreate(send_req)
|
||||
if caption:
|
||||
await self.send_text(group_id, caption)
|
||||
return bool(send_resp.success())
|
||||
except Exception as e:
|
||||
logger.error(f"Lark send image failed: {e}")
|
||||
return False
|
||||
finally:
|
||||
if temp_path and temp_path.exists():
|
||||
try:
|
||||
temp_path.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
async def send_file(
|
||||
self, group_id: str, file_path: str, filename: str | None = None
|
||||
) -> bool:
|
||||
if not self._lark_client or not self._lark_client.im:
|
||||
return False
|
||||
try:
|
||||
p = Path(file_path)
|
||||
if not p.exists():
|
||||
return False
|
||||
with p.open("rb") as file_obj:
|
||||
CreateFileRequestClass = self._request_class_or_throw(
|
||||
CreateFileRequest, "CreateFileRequest"
|
||||
)
|
||||
CreateFileRequestBodyClass = self._request_class_or_throw(
|
||||
CreateFileRequestBody, "CreateFileRequestBody"
|
||||
)
|
||||
|
||||
file_req = (
|
||||
self._builder(CreateFileRequestClass)
|
||||
.request_body(
|
||||
self._builder(CreateFileRequestBodyClass)
|
||||
.file_type("stream")
|
||||
.file_name(filename or p.name)
|
||||
.file(file_obj)
|
||||
.build()
|
||||
)
|
||||
.build()
|
||||
)
|
||||
file_resp = await self._lark_client.im.v1.file.acreate(file_req)
|
||||
if not file_resp.success() or not file_resp.data:
|
||||
return False
|
||||
file_key = file_resp.data.file_key
|
||||
|
||||
CreateMessageRequestClass = self._request_class_or_throw(
|
||||
CreateMessageRequest, "CreateMessageRequest"
|
||||
)
|
||||
CreateMessageRequestBodyClass = self._request_class_or_throw(
|
||||
CreateMessageRequestBody, "CreateMessageRequestBody"
|
||||
)
|
||||
|
||||
msg_req = (
|
||||
self._builder(CreateMessageRequestClass)
|
||||
.receive_id_type("chat_id")
|
||||
.request_body(
|
||||
self._builder(CreateMessageRequestBodyClass)
|
||||
.receive_id(group_id)
|
||||
.msg_type("file")
|
||||
.content(json.dumps({"file_key": file_key}, ensure_ascii=False))
|
||||
.build()
|
||||
)
|
||||
.build()
|
||||
)
|
||||
msg_resp = await self._lark_client.im.v1.message.acreate(msg_req)
|
||||
return bool(msg_resp.success())
|
||||
except Exception as e:
|
||||
logger.error(f"Lark send file failed: {e}")
|
||||
return False
|
||||
|
||||
async def send_forward_msg(self, group_id: str, nodes: list[dict]) -> bool:
|
||||
if not nodes:
|
||||
return True
|
||||
chunks: list[str] = ["📊 群分析报告摘要"]
|
||||
for node in nodes:
|
||||
data = node.get("data", node)
|
||||
name = str(data.get("name", "AstrBot"))
|
||||
content = data.get("content", "")
|
||||
if isinstance(content, list):
|
||||
text_parts = []
|
||||
for seg in content:
|
||||
if isinstance(seg, dict) and seg.get("type") == "text":
|
||||
text_parts.append(str(seg.get("data", {}).get("text", "")))
|
||||
content = "".join(text_parts)
|
||||
chunks.append(f"[{name}] {content}")
|
||||
return await self.send_text(group_id, "\n".join(chunks))
|
||||
|
||||
async def get_group_info(self, group_id: str) -> UnifiedGroup | None:
|
||||
if not self._lark_client or not self._lark_client.im:
|
||||
return None
|
||||
try:
|
||||
GetChatRequestClass = self._request_class_or_throw(
|
||||
GetChatRequest, "GetChatRequest"
|
||||
)
|
||||
request = self._builder(GetChatRequestClass).chat_id(group_id).build()
|
||||
response = await self._lark_client.im.v1.chat.aget(request)
|
||||
if not response.success() or not response.data:
|
||||
return None
|
||||
group_name_raw = getattr(response.data, "name", None)
|
||||
owner_id_raw = getattr(response.data, "owner_id", None)
|
||||
description_raw = getattr(response.data, "description", None)
|
||||
return UnifiedGroup(
|
||||
group_id=group_id,
|
||||
group_name=str(group_name_raw) if group_name_raw else group_id,
|
||||
member_count=int(getattr(response.data, "user_count", 0) or 0),
|
||||
owner_id=str(owner_id_raw) if owner_id_raw else None,
|
||||
description=str(description_raw) if description_raw else None,
|
||||
platform="lark",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(f"Lark get group info failed: {e}")
|
||||
return None
|
||||
|
||||
async def get_group_list(self) -> list[str]:
|
||||
# 飞书服务端 API 不提供简单“机器人可见群列表”枚举能力
|
||||
return []
|
||||
|
||||
async def get_member_list(self, group_id: str) -> list[UnifiedMember]:
|
||||
if not self._lark_client or not self._lark_client.im:
|
||||
return []
|
||||
members: list[UnifiedMember] = []
|
||||
page_token: str | None = None
|
||||
while True:
|
||||
GetChatMembersRequestClass = self._request_class_or_throw(
|
||||
GetChatMembersRequest, "GetChatMembersRequest"
|
||||
)
|
||||
builder = (
|
||||
self._builder(GetChatMembersRequestClass)
|
||||
.chat_id(group_id)
|
||||
.member_id_type("open_id")
|
||||
.page_size(200)
|
||||
)
|
||||
if page_token:
|
||||
builder = builder.page_token(page_token)
|
||||
request = builder.build()
|
||||
response = await self._lark_client.im.v1.chat_members.aget(request)
|
||||
if not response.success():
|
||||
if self._is_permission_error(
|
||||
int(getattr(response, "code", 0) or 0),
|
||||
str(getattr(response, "msg", "") or ""),
|
||||
):
|
||||
logger.warning(
|
||||
"Lark get member list permission denied: code=%s, msg=%s",
|
||||
response.code,
|
||||
response.msg,
|
||||
)
|
||||
break
|
||||
items = (response.data.items if response.data else None) or []
|
||||
if not items:
|
||||
break
|
||||
for item in items:
|
||||
uid = str(item.member_id or "")
|
||||
name = str(item.name or uid)
|
||||
self._member_name_cache[(group_id, uid)] = name
|
||||
members.append(
|
||||
UnifiedMember(
|
||||
user_id=uid,
|
||||
nickname=name,
|
||||
role="member",
|
||||
)
|
||||
)
|
||||
if not (
|
||||
response.data and response.data.has_more and response.data.page_token
|
||||
):
|
||||
break
|
||||
page_token_raw = getattr(response.data, "page_token", None)
|
||||
page_token = str(page_token_raw) if page_token_raw else None
|
||||
return members
|
||||
|
||||
async def _get_user_profile(self, user_id: str) -> _SDKNode | None:
|
||||
if not self._lark_client or not self._lark_client.contact:
|
||||
return None
|
||||
try:
|
||||
GetUserRequestClass = self._request_class_or_throw(
|
||||
GetUserRequest, "GetUserRequest"
|
||||
)
|
||||
request = (
|
||||
self._builder(GetUserRequestClass)
|
||||
.user_id_type("open_id")
|
||||
.user_id(user_id)
|
||||
.build()
|
||||
)
|
||||
response = await self._lark_client.contact.v3.user.aget(request)
|
||||
if not response.success():
|
||||
if self._is_permission_error(
|
||||
int(getattr(response, "code", 0) or 0),
|
||||
str(getattr(response, "msg", "") or ""),
|
||||
):
|
||||
logger.warning(
|
||||
"Lark get user profile permission denied: code=%s, msg=%s",
|
||||
response.code,
|
||||
response.msg,
|
||||
)
|
||||
return None
|
||||
return response.data
|
||||
except Exception as e:
|
||||
logger.debug(f"Lark get user profile failed: {e}")
|
||||
return None
|
||||
|
||||
async def get_member_info(
|
||||
self, group_id: str, user_id: str
|
||||
) -> UnifiedMember | None:
|
||||
profile = await self._get_user_profile(user_id)
|
||||
if profile and profile.user:
|
||||
user = profile.user
|
||||
name = str(user.name or user.nickname or user_id)
|
||||
avatar = self._pick_avatar_from_user(user, 240)
|
||||
if avatar:
|
||||
self._member_avatar_cache[(group_id, user_id)] = avatar
|
||||
self._avatar_url_cache[user_id] = avatar
|
||||
self._member_name_cache[(group_id, user_id)] = name
|
||||
return UnifiedMember(
|
||||
user_id=str(user.open_id or user_id),
|
||||
nickname=name,
|
||||
card=str(user.nickname or "") or None,
|
||||
role="member",
|
||||
avatar_url=avatar,
|
||||
)
|
||||
|
||||
cached_name = self._member_name_cache.get((group_id, user_id)) or user_id
|
||||
return UnifiedMember(
|
||||
user_id=user_id,
|
||||
nickname=cached_name,
|
||||
role="member",
|
||||
avatar_url=self._member_avatar_cache.get((group_id, user_id)),
|
||||
)
|
||||
|
||||
async def get_user_avatar_url(self, user_id: str, size: int = 100) -> str | None:
|
||||
if user_id in self._avatar_url_cache:
|
||||
return self._avatar_url_cache[user_id]
|
||||
profile = await self._get_user_profile(user_id)
|
||||
if profile and profile.user:
|
||||
avatar_url = self._pick_avatar_from_user(profile.user, size)
|
||||
if avatar_url:
|
||||
self._avatar_url_cache[user_id] = avatar_url
|
||||
return avatar_url
|
||||
return None
|
||||
|
||||
async def get_user_avatar_data(self, user_id: str, size: int = 100) -> str | None:
|
||||
avatar_url = await self.get_user_avatar_url(user_id, size)
|
||||
if not avatar_url:
|
||||
return None
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(
|
||||
avatar_url, timeout=ClientTimeout(total=10)
|
||||
) as resp:
|
||||
if resp.status != 200:
|
||||
return None
|
||||
body = await resp.read()
|
||||
mime = resp.headers.get("Content-Type", "image/png")
|
||||
return (
|
||||
f"data:{mime};base64,{base64.b64encode(body).decode('utf-8')}"
|
||||
)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
async def get_group_avatar_url(self, group_id: str, size: int = 100) -> str | None:
|
||||
group = await self.get_group_info(group_id)
|
||||
if not group or not self._lark_client:
|
||||
return None
|
||||
try:
|
||||
GetChatRequestClass = self._request_class_or_throw(
|
||||
GetChatRequest, "GetChatRequest"
|
||||
)
|
||||
req = self._builder(GetChatRequestClass).chat_id(group_id).build()
|
||||
rsp = await self._lark_client.im.v1.chat.aget(req)
|
||||
if rsp.success() and rsp.data and rsp.data.avatar:
|
||||
return str(rsp.data.avatar)
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
async def batch_get_avatar_urls(
|
||||
self, user_ids: list[str], size: int = 100
|
||||
) -> dict[str, str | None]:
|
||||
if not user_ids:
|
||||
return {}
|
||||
semaphore = asyncio.Semaphore(8)
|
||||
|
||||
async def _fetch(uid: str) -> tuple[str, str | None]:
|
||||
async with semaphore:
|
||||
return uid, await self.get_user_avatar_url(uid, size)
|
||||
|
||||
pairs = await asyncio.gather(*(_fetch(uid) for uid in user_ids))
|
||||
return dict(pairs)
|
||||
|
||||
async def set_reaction(
|
||||
self, group_id: str, message_id: str, emoji: str | int, is_add: bool = True
|
||||
) -> bool:
|
||||
# 当前插件分析流程不依赖飞书 reaction,这里返回 False 以保持兼容。
|
||||
return False
|
||||
@@ -3,7 +3,7 @@
|
||||
"""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any
|
||||
from collections.abc import Mapping
|
||||
|
||||
from ...domain.repositories.avatar_repository import IAvatarRepository
|
||||
from ...domain.repositories.message_repository import (
|
||||
@@ -25,22 +25,34 @@ class PlatformAdapter(
|
||||
充当领域层与具体聊天平台(如 OneBot, Discord)之间的中转站。
|
||||
|
||||
Attributes:
|
||||
bot (Any): 平台对应的机器人 SDK 实例
|
||||
bot (object): 平台对应的机器人 SDK 实例
|
||||
config (dict): 针对该平台的特定配置
|
||||
"""
|
||||
|
||||
def __init__(self, bot_instance: Any, config: dict | None = None):
|
||||
def __init__(
|
||||
self,
|
||||
bot_instance: object,
|
||||
config: Mapping[str, object] | None = None,
|
||||
):
|
||||
"""
|
||||
初始化平台适配器。
|
||||
|
||||
Args:
|
||||
bot_instance (Any): 后端机器人实例
|
||||
bot_instance (object): 后端机器人实例
|
||||
config (dict, optional): 平台特定配置项
|
||||
"""
|
||||
self.bot = bot_instance
|
||||
self.config = config or {}
|
||||
self.config: dict[str, object] = dict(config) if config is not None else {}
|
||||
self.bot_self_ids: list[str] = []
|
||||
self._capabilities: PlatformCapabilities | None = None
|
||||
|
||||
def set_context(self, context: object) -> None:
|
||||
"""
|
||||
可选的上下文注入钩子,供需要访问插件核心服务的适配器使用。
|
||||
"""
|
||||
# 具体适配器可覆盖此方法
|
||||
pass
|
||||
|
||||
@property
|
||||
def capabilities(self) -> PlatformCapabilities:
|
||||
"""
|
||||
|
||||
@@ -3,7 +3,9 @@ Bot实例管理模块 - 基础设施层
|
||||
统一管理bot实例的获取、设置和使用
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
|
||||
from ...utils.logger import logger
|
||||
from . import PlatformAdapter, PlatformAdapterFactory
|
||||
@@ -19,14 +21,16 @@ class BotManager:
|
||||
|
||||
def __init__(self, config_manager):
|
||||
self.config_manager = config_manager
|
||||
self._bot_instances = {} # {platform_id: bot_instance}
|
||||
self._adapters = {} # {platform_id: PlatformAdapter} - DDD 集成
|
||||
self._platforms = {} # 存储平台对象以访问配置
|
||||
self._bot_self_ids = [] # 支持多个机器人账号 ID (原 _bot_qq_ids)
|
||||
self._context = None
|
||||
self._bot_instances: dict[str, object] = {} # {platform_id: bot_instance}
|
||||
self._adapters: dict[
|
||||
str, PlatformAdapter
|
||||
] = {} # {platform_id: PlatformAdapter} - DDD 集成
|
||||
self._platforms: dict[str, object] = {} # 存储平台对象以访问配置
|
||||
self._bot_self_ids: list[str] = [] # 支持多个机器人账号 ID (原 _bot_qq_ids)
|
||||
self._context: object | None = None
|
||||
self._is_initialized = False
|
||||
self._default_platform = "default" # 默认平台
|
||||
self._plugin_instance = None # 插件实例引用,用于适配器回调
|
||||
self._plugin_instance: object | None = None # 插件实例引用,用于适配器回调
|
||||
|
||||
def set_context(self, context):
|
||||
"""设置AstrBot上下文,并传递给所有支持的适配器"""
|
||||
@@ -37,7 +41,7 @@ class BotManager:
|
||||
if hasattr(adapter, "set_context"):
|
||||
adapter.set_context(context)
|
||||
|
||||
def set_plugin_instance(self, plugin_instance: Any):
|
||||
def set_plugin_instance(self, plugin_instance: object):
|
||||
"""设置插件实例引用"""
|
||||
self._plugin_instance = plugin_instance
|
||||
|
||||
@@ -68,7 +72,7 @@ class BotManager:
|
||||
)
|
||||
if adapter:
|
||||
# 如果有 context,传递给适配器
|
||||
if self._context and hasattr(adapter, "set_context"):
|
||||
if self._context is not None:
|
||||
adapter.set_context(self._context)
|
||||
self._adapters[platform_id] = adapter
|
||||
logger.debug(
|
||||
@@ -126,16 +130,19 @@ class BotManager:
|
||||
"""尝试从已存储的平台对象中刷新 bot 实例 (Lazy Load)"""
|
||||
for platform_id, platform in self._platforms.items():
|
||||
bot_client = None
|
||||
# Lark 平台优先使用 API client,避免拿到仅支持长连接的 ws client
|
||||
bot_client = getattr(platform, "lark_api", None)
|
||||
# 优先尝试 get_client()
|
||||
if hasattr(platform, "get_client"):
|
||||
bot_client = platform.get_client()
|
||||
get_client = getattr(platform, "get_client", None)
|
||||
if not bot_client and callable(get_client):
|
||||
bot_client = get_client()
|
||||
|
||||
# 如果 get_client() 返回 None,尝试直接访问属性
|
||||
if not bot_client and hasattr(platform, "bot"):
|
||||
bot_client = platform.bot
|
||||
if not bot_client and hasattr(platform, "client"):
|
||||
if not bot_client:
|
||||
bot_client = getattr(platform, "bot", None)
|
||||
if not bot_client:
|
||||
# AstrBot v4.14.4 DiscordPlatformAdapter 使用 'client' 属性
|
||||
bot_client = platform.client
|
||||
bot_client = getattr(platform, "client", None)
|
||||
|
||||
if bot_client:
|
||||
# 检查是否已存在且是否发生变化(防止重复创建适配器)
|
||||
@@ -146,12 +153,16 @@ class BotManager:
|
||||
continue
|
||||
|
||||
platform_name = None
|
||||
if hasattr(platform, "metadata"):
|
||||
metadata_obj = getattr(platform, "metadata", None)
|
||||
if metadata_obj is not None:
|
||||
# 优先使用 type
|
||||
if hasattr(platform.metadata, "type"):
|
||||
platform_name = platform.metadata.type
|
||||
elif hasattr(platform.metadata, "name"):
|
||||
platform_name = platform.metadata.name
|
||||
type_val = getattr(metadata_obj, "type", None)
|
||||
if isinstance(type_val, str):
|
||||
platform_name = type_val
|
||||
else:
|
||||
name_val = getattr(metadata_obj, "name", None)
|
||||
if isinstance(name_val, str):
|
||||
platform_name = name_val
|
||||
|
||||
# 兼容不同版本的元数据获取
|
||||
if not platform_name:
|
||||
@@ -254,7 +265,7 @@ class BotManager:
|
||||
|
||||
# ==================== DDD 集成方法 ====================
|
||||
|
||||
def get_adapter(self, platform_id: str = None) -> PlatformAdapter | None:
|
||||
def get_adapter(self, platform_id: str | None = None) -> PlatformAdapter | None:
|
||||
"""
|
||||
获取指定平台的 PlatformAdapter。
|
||||
|
||||
@@ -290,13 +301,13 @@ class BotManager:
|
||||
"""获取所有 PlatformAdapter 实例 {platform_id: adapter}"""
|
||||
return self._adapters.copy()
|
||||
|
||||
def has_adapter(self, platform_id: str = None) -> bool:
|
||||
def has_adapter(self, platform_id: str | None = None) -> bool:
|
||||
"""检查指定平台是否有适配器"""
|
||||
if platform_id:
|
||||
return platform_id in self._adapters
|
||||
return bool(self._adapters)
|
||||
|
||||
def can_analyze(self, platform_id: str = None) -> bool:
|
||||
def can_analyze(self, platform_id: str | None = None) -> bool:
|
||||
"""使用 DDD 能力检查平台是否支持分析"""
|
||||
adapter = self.get_adapter(platform_id)
|
||||
if adapter:
|
||||
@@ -309,11 +320,22 @@ class BotManager:
|
||||
|
||||
同时为每个发现的 bot 创建对应的 PlatformAdapter。
|
||||
"""
|
||||
if not self._context or not hasattr(self._context, "platform_manager"):
|
||||
platform_manager = getattr(self._context, "platform_manager", None)
|
||||
get_insts = getattr(platform_manager, "get_insts", None)
|
||||
if self._context is None or not callable(get_insts):
|
||||
return {}
|
||||
|
||||
# 使用新版 API 获取所有平台实例
|
||||
platforms = self._context.platform_manager.get_insts()
|
||||
raw_platforms = get_insts()
|
||||
if isinstance(raw_platforms, list):
|
||||
platforms: list[object] = raw_platforms
|
||||
elif isinstance(raw_platforms, tuple):
|
||||
platforms = list(raw_platforms)
|
||||
else:
|
||||
logger.warning(
|
||||
"auto_discover_bot_instances: get_insts() returned non-iterable value."
|
||||
)
|
||||
return {}
|
||||
discovered = {}
|
||||
|
||||
logger.info(
|
||||
@@ -323,47 +345,61 @@ class BotManager:
|
||||
for platform in platforms:
|
||||
# 获取bot实例
|
||||
bot_client = None
|
||||
if hasattr(platform, "get_client"):
|
||||
bot_client = platform.get_client()
|
||||
bot_client = getattr(platform, "lark_api", None)
|
||||
platform_get_client = getattr(platform, "get_client", None)
|
||||
if not bot_client and callable(platform_get_client):
|
||||
bot_client = platform_get_client()
|
||||
|
||||
if not bot_client and hasattr(platform, "bot"):
|
||||
bot_client = platform.bot
|
||||
if not bot_client and hasattr(platform, "client"):
|
||||
bot_client = platform.client
|
||||
if not bot_client:
|
||||
bot_client = getattr(platform, "bot", None)
|
||||
if not bot_client:
|
||||
bot_client = getattr(platform, "client", None)
|
||||
|
||||
# 健壮地获取元数据
|
||||
metadata = getattr(platform, "metadata", None)
|
||||
if not metadata and hasattr(platform, "meta"):
|
||||
platform_meta_method = getattr(platform, "meta", None)
|
||||
if not metadata and callable(platform_meta_method):
|
||||
try:
|
||||
metadata = platform.meta()
|
||||
metadata = platform_meta_method()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 检查是否有有效的元数据和ID
|
||||
platform_id = None
|
||||
if metadata:
|
||||
if hasattr(metadata, "id"):
|
||||
platform_id = metadata.id
|
||||
elif isinstance(metadata, dict):
|
||||
metadata_id = getattr(metadata, "id", None)
|
||||
if metadata_id is not None:
|
||||
platform_id = metadata_id
|
||||
elif isinstance(metadata, Mapping):
|
||||
platform_id = metadata.get("id")
|
||||
|
||||
if platform_id:
|
||||
# 确保平台 ID 是 str
|
||||
platform_id = str(platform_id)
|
||||
|
||||
# 知识点发现: 记录元数据以调试自定义 ID
|
||||
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')}"
|
||||
f"[群分析插件 BotManager]: Log metadata for debugging custom IDs ,Platform: {platform_id}, Metadata Type: {getattr(metadata, 'type', 'N/A') if not isinstance(metadata, Mapping) else metadata.get('type', 'N/A')}, Metadata Name: {getattr(metadata, 'name', 'N/A') if not isinstance(metadata, Mapping) else metadata.get('name', 'N/A')}"
|
||||
)
|
||||
|
||||
# 从元数据检测平台名称
|
||||
platform_name = None
|
||||
# 优先使用 type
|
||||
if hasattr(metadata, "type"):
|
||||
platform_name = metadata.type
|
||||
elif isinstance(metadata, dict) and "type" in metadata:
|
||||
platform_name = metadata["type"]
|
||||
elif hasattr(metadata, "name"):
|
||||
platform_name = metadata.name
|
||||
elif isinstance(metadata, dict) and "name" in metadata:
|
||||
platform_name = metadata["name"]
|
||||
type_val = getattr(metadata, "type", None)
|
||||
if isinstance(type_val, str):
|
||||
platform_name = type_val
|
||||
elif isinstance(metadata, Mapping):
|
||||
dict_type = metadata.get("type")
|
||||
if isinstance(dict_type, str):
|
||||
platform_name = dict_type
|
||||
if not platform_name:
|
||||
name_val = getattr(metadata, "name", None)
|
||||
if isinstance(name_val, str):
|
||||
platform_name = name_val
|
||||
elif isinstance(metadata, Mapping):
|
||||
dict_name = metadata.get("name")
|
||||
if isinstance(dict_name, str):
|
||||
platform_name = dict_name
|
||||
|
||||
# 验证此平台名称是否受支持,如果不支持,尝试从bot实例检测(如果可用)
|
||||
if (
|
||||
@@ -414,7 +450,7 @@ class BotManager:
|
||||
|
||||
return discovered
|
||||
|
||||
def get_status_info(self) -> dict[str, Any]:
|
||||
def get_status_info(self) -> dict[str, object]:
|
||||
"""获取bot管理器状态信息"""
|
||||
adapter_info = {}
|
||||
for pid, adapter in self._adapters.items():
|
||||
@@ -501,10 +537,11 @@ class BotManager:
|
||||
return True
|
||||
|
||||
platform = self._platforms[platform_id]
|
||||
if not hasattr(platform, "config") or not isinstance(platform.config, dict):
|
||||
platform_config = getattr(platform, "config", None)
|
||||
if not isinstance(platform_config, dict):
|
||||
return True
|
||||
|
||||
plugin_set = platform.config.get("plugin_set", ["*"])
|
||||
plugin_set = platform_config.get("plugin_set", ["*"])
|
||||
|
||||
if plugin_set is None:
|
||||
return False
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
平台适配器工厂
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
|
||||
from ...utils.logger import logger
|
||||
from .base import PlatformAdapter
|
||||
@@ -27,8 +29,8 @@ class PlatformAdapterFactory:
|
||||
def create(
|
||||
cls,
|
||||
platform_name: str,
|
||||
bot_instance: Any,
|
||||
config: dict = None,
|
||||
bot_instance: object,
|
||||
config: Mapping[str, object] | None = None,
|
||||
) -> PlatformAdapter | None:
|
||||
"""
|
||||
创建平台适配器
|
||||
@@ -89,5 +91,13 @@ def _register_adapters():
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
try:
|
||||
from .adapters.lark_adapter import LarkAdapter
|
||||
|
||||
PlatformAdapterFactory.register("lark", LarkAdapter)
|
||||
PlatformAdapterFactory.register("feishu", LarkAdapter)
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
|
||||
_register_adapters()
|
||||
|
||||
Reference in New Issue
Block a user