diff --git a/astrbot/core/platform/astr_message_event.py b/astrbot/core/platform/astr_message_event.py index 0fbddf35f0..bb8102a849 100644 --- a/astrbot/core/platform/astr_message_event.py +++ b/astrbot/core/platform/astr_message_event.py @@ -504,9 +504,27 @@ async def react(self, emoji: str) -> None: await self.send(MessageChain([Plain(emoji)])) async def get_group(self, group_id: str | None = None, **kwargs) -> Group | None: - """获取一个群聊的数据, 如果不填写 group_id: 如果是私聊消息,返回 None。如果是群聊消息,返回当前群聊的数据。 + """Get group information. - 适配情况: + Platform event subclasses can enrich the result through their APIs. The + default implementation returns inbound group data, or an ID-only object + when an explicit group is queried. - - aiocqhttp(OneBotv11) + Args: + group_id: Group ID to query. Defaults to the current message group. + **kwargs: Extra platform-specific query options. + + Returns: + Group information, or ``None`` for a private message without an + explicit group ID. """ + resolved_group_id = group_id or self.get_group_id() + if not resolved_group_id: + return None + resolved_group_id = str(resolved_group_id) + if ( + self.message_obj.group + and self.message_obj.group.group_id == resolved_group_id + ): + return self.message_obj.group + return Group(group_id=resolved_group_id) diff --git a/astrbot/core/platform/astrbot_message.py b/astrbot/core/platform/astrbot_message.py index 3db53fd484..ed3eb3ef6a 100644 --- a/astrbot/core/platform/astrbot_message.py +++ b/astrbot/core/platform/astrbot_message.py @@ -33,6 +33,8 @@ class Group: """群管理员 id""" members: list[MessageMember] | None = None """所有群成员""" + member_count: int | None = None + """Total members, available even when the member list is incomplete.""" def __str__(self) -> str: # 使用 f-string 来构建返回的字符串表示形式 @@ -42,6 +44,7 @@ def __str__(self) -> str: f"Avatar: {self.group_avatar if self.group_avatar else 'N/A'}\n" f"Owner ID: {self.group_owner if self.group_owner else 'N/A'}\n" f"Admin IDs: {self.group_admins if self.group_admins else 'N/A'}\n" + f"Member Count: {self.member_count if self.member_count is not None else 'N/A'}\n" f"Members Len: {len(self.members) if self.members else 0}\n" f"First Member: {self.members[0] if self.members else 'N/A'}\n" ) diff --git a/astrbot/core/platform/sources/aiocqhttp/aiocqhttp_message_event.py b/astrbot/core/platform/sources/aiocqhttp/aiocqhttp_message_event.py index 91a7444f38..8e2b008eb6 100644 --- a/astrbot/core/platform/sources/aiocqhttp/aiocqhttp_message_event.py +++ b/astrbot/core/platform/sources/aiocqhttp/aiocqhttp_message_event.py @@ -4,6 +4,7 @@ from aiocqhttp import CQHttp, Event +from astrbot.api import logger from astrbot.api.event import AstrMessageEvent, MessageChain from astrbot.api.message_components import ( At, @@ -234,50 +235,94 @@ async def send_streaming( return await super().send_streaming(generator, use_fallback) async def get_group(self, group_id=None, **kwargs): - if isinstance(group_id, str) and group_id.isdigit(): - group_id = int(group_id) - elif self.get_group_id(): - group_id = int(self.get_group_id()) - else: + """Get OneBot group details while preserving inbound data on failures. + + Args: + group_id: Optional OneBot group identifier. + **kwargs: Reserved compatibility arguments. + + Returns: + Enriched group information, or a basic group when an API is unavailable. + """ + resolved_group_id = group_id or self.get_group_id() + if not resolved_group_id: return None + resolved_group_id = str(resolved_group_id) + api_group_id = ( + int(resolved_group_id) if resolved_group_id.isdigit() else resolved_group_id + ) + + current_group = self.message_obj.group + group = ( + current_group + if current_group and current_group.group_id == resolved_group_id + else Group(group_id=resolved_group_id) + ) routing_params = {} if getattr(self.message_obj, "self_id", None): routing_params["self_id"] = self.message_obj.self_id - info: dict = await self.bot.call_action( - "get_group_info", - group_id=group_id, - **routing_params, - ) + try: + info = await self.bot.call_action( + "get_group_info", + group_id=api_group_id, + **routing_params, + ) + if isinstance(info, dict): + group.group_name = info.get("group_name") or group.group_name + member_count = info.get("member_count") + if member_count is not None: + try: + group.member_count = int(member_count) + except (TypeError, ValueError): + logger.warning( + "[aiocqhttp] Invalid member_count for group %s", + resolved_group_id, + ) + except Exception as exc: + logger.warning( + "[aiocqhttp] Failed to get group information for %s: %s", + resolved_group_id, + exc, + ) - members: list[dict] = await self.bot.call_action( - "get_group_member_list", - group_id=group_id, - **routing_params, - ) + try: + members = await self.bot.call_action( + "get_group_member_list", + group_id=api_group_id, + **routing_params, + ) + except Exception as exc: + logger.warning( + "[aiocqhttp] Failed to get members for group %s: %s", + resolved_group_id, + exc, + ) + return group + if not isinstance(members, list): + return group owner_id = None - admin_ids = [] + admin_ids: list[str] = [] for member in members: - if member["role"] == "owner": - owner_id = member["user_id"] - if member["role"] == "admin": - admin_ids.append(member["user_id"]) - - group = Group( - group_id=str(group_id), - group_name=info.get("group_name"), - group_avatar="", - group_admins=admin_ids, - group_owner=str(owner_id), - members=[ - MessageMember( - user_id=member["user_id"], - nickname=member.get("nickname") or member.get("card"), - ) - for member in members - ], - ) + if not isinstance(member, dict) or member.get("user_id") is None: + continue + if member.get("role") == "owner": + owner_id = str(member["user_id"]) + if member.get("role") == "admin": + admin_ids.append(str(member["user_id"])) + group.group_admins = admin_ids + group.group_owner = owner_id + group.members = [ + MessageMember( + user_id=str(member["user_id"]), + nickname=member.get("nickname") or member.get("card"), + ) + for member in members + if isinstance(member, dict) and member.get("user_id") is not None + ] + if group.member_count is None: + group.member_count = len(group.members) return group diff --git a/astrbot/core/platform/sources/aiocqhttp/aiocqhttp_platform_adapter.py b/astrbot/core/platform/sources/aiocqhttp/aiocqhttp_platform_adapter.py index a69cb915f5..a351063f07 100644 --- a/astrbot/core/platform/sources/aiocqhttp/aiocqhttp_platform_adapter.py +++ b/astrbot/core/platform/sources/aiocqhttp/aiocqhttp_platform_adapter.py @@ -216,7 +216,7 @@ async def _convert_handle_message_event( abm.type = MessageType.GROUP_MESSAGE abm.group_id = str(event.group_id) abm.group = Group(str(event.group_id)) - abm.group.group_name = event.get("group_name", "N/A") + abm.group.group_name = event.get("group_name") elif event["message_type"] == "private": abm.type = MessageType.FRIEND_MESSAGE abm.session_id = ( diff --git a/astrbot/core/platform/sources/dingtalk/dingtalk_adapter.py b/astrbot/core/platform/sources/dingtalk/dingtalk_adapter.py index bae98a8076..97d9609f46 100644 --- a/astrbot/core/platform/sources/dingtalk/dingtalk_adapter.py +++ b/astrbot/core/platform/sources/dingtalk/dingtalk_adapter.py @@ -192,6 +192,8 @@ async def convert_msg( if index == 0 and id == abm.self_id: leading_at_is_self = True abm.group_id = message.conversation_id + if abm.group: + abm.group.group_name = message.conversation_title abm.session_id = abm.group_id else: abm.session_id = abm.sender.user_id diff --git a/astrbot/core/platform/sources/discord/discord_platform_adapter.py b/astrbot/core/platform/sources/discord/discord_platform_adapter.py index 7c6e009254..f8205017ee 100644 --- a/astrbot/core/platform/sources/discord/discord_platform_adapter.py +++ b/astrbot/core/platform/sources/discord/discord_platform_adapter.py @@ -84,6 +84,13 @@ async def send_by_session( if channel: message_obj.type = self._get_message_type(channel) message_obj.group_id = self._get_channel_id(channel) + group_name = self._get_group_name(channel) + if ( + message_obj.type == MessageType.GROUP_MESSAGE + and message_obj.group + and group_name + ): + message_obj.group.group_name = group_name else: logger.warning( f"[Discord] Can't get channel info for {channel_id_str}, will guess message type.", @@ -189,6 +196,27 @@ def _get_channel_id( """根据 channel 对象获取ID""" return str(getattr(channel, "id", None)) + @staticmethod + def _get_group_name( + channel: Messageable | GuildChannel | PrivateChannel, + ) -> str | None: + """Build the AstrBot group name for a Discord guild channel. + + Args: + channel: Discord channel or thread associated with the message. + + Returns: + ``-`` when both are available, otherwise the + available name, or ``None`` when neither has a name. + """ + channel_name = getattr(channel, "name", None) + guild_name = getattr(getattr(channel, "guild", None), "name", None) + if isinstance(guild_name, str) and isinstance(channel_name, str): + return f"{guild_name}-{channel_name}" + if isinstance(channel_name, str): + return channel_name + return guild_name if isinstance(guild_name, str) else None + def _convert_message_to_abm(self, data: dict) -> AstrBotMessage: """将普通消息转换为 AstrBotMessage""" message = data["message"] @@ -226,6 +254,9 @@ def _convert_message_to_abm(self, data: dict) -> AstrBotMessage: abm = AstrBotMessage() abm.type = self._get_message_type(message.channel) abm.group_id = self._get_channel_id(message.channel) + group_name = self._get_group_name(message.channel) + if abm.type == MessageType.GROUP_MESSAGE and abm.group and group_name: + abm.group.group_name = group_name abm.message_str = content abm.sender = MessageMember( user_id=str(message.author.id), @@ -512,6 +543,9 @@ async def dynamic_callback( if channel is not None: abm.type = self._get_message_type(channel, ctx.guild_id) abm.group_id = self._get_channel_id(channel) + group_name = self._get_group_name(channel) + if abm.type == MessageType.GROUP_MESSAGE and abm.group and group_name: + abm.group.group_name = group_name else: # 防守式兜底:channel 取不到时,仍能根据 guild_id/channel_id 推断会话信息 abm.type = ( diff --git a/astrbot/core/platform/sources/discord/discord_platform_event.py b/astrbot/core/platform/sources/discord/discord_platform_event.py index ff085dafd6..d622b724d8 100644 --- a/astrbot/core/platform/sources/discord/discord_platform_event.py +++ b/astrbot/core/platform/sources/discord/discord_platform_event.py @@ -17,7 +17,14 @@ Record, Reply, ) -from astrbot.api.platform import AstrBotMessage, At, PlatformMetadata +from astrbot.api.platform import ( + AstrBotMessage, + At, + Group, + MessageMember, + MessageType, + PlatformMetadata, +) from astrbot.core.utils.media_utils import ( MEDIA_MIME_EXTENSIONS, MediaResolver, @@ -129,6 +136,151 @@ async def _get_channel( logger.error(f"[Discord] 无法获取频道 {self.session_id}") return None + async def get_group( + self, group_id: str | None = None, **kwargs: object + ) -> Group | None: + """Get Discord channel and guild metadata without fetching all members. + + AstrBot treats a Discord channel or thread as the group. Guild metadata is + attached for context, while members are exposed only when the local cache is + known to be complete. + + Args: + group_id: Discord channel or thread ID. Defaults to the current group. + **kwargs: Reserved for compatibility with the platform event interface. + + Returns: + Enriched group metadata, or ``None`` when no group ID is available. + """ + if group_id is None and self.message_obj.type != MessageType.GROUP_MESSAGE: + return None + + requested_group_id = str(group_id or self.get_group_id()) + if not requested_group_id: + return None + + current_group = self.message_obj.group + group = Group( + group_id=requested_group_id, + group_name=( + current_group.group_name + if current_group and current_group.group_id == requested_group_id + else None + ), + ) + try: + channel_id = int(requested_group_id) + except ValueError: + logger.warning(f"[Discord] Invalid group channel ID: {requested_group_id}") + return group + + channel = self.client.get_channel(channel_id) + if channel is None: + try: + channel = await self.client.fetch_channel(channel_id) + except Exception as exc: + logger.warning( + f"[Discord] Failed to get group channel {requested_group_id}: {exc}" + ) + return group + + channel_name = getattr(channel, "name", None) + if isinstance(channel_name, str): + group.group_name = channel_name + + guild = getattr(channel, "guild", None) + guild_name = getattr(guild, "name", None) + if not isinstance(guild_name, str): + guild_id = getattr(channel, "guild_id", None) or getattr(guild, "id", None) + try: + resolved_guild_id = int(guild_id) if guild_id is not None else None + except (TypeError, ValueError): + resolved_guild_id = None + if resolved_guild_id is not None: + get_guild = getattr(self.client, "get_guild", None) + cached_guild = ( + get_guild(resolved_guild_id) if callable(get_guild) else None + ) + if cached_guild is not None: + guild = cached_guild + else: + fetch_guild = getattr(self.client, "fetch_guild", None) + if callable(fetch_guild): + try: + guild = await fetch_guild(resolved_guild_id) + except Exception as exc: + logger.warning( + f"[Discord] Failed to get guild {resolved_guild_id}: {exc}" + ) + guild_name = getattr(guild, "name", None) + + if guild is None: + return group + + if isinstance(guild_name, str) and isinstance(channel_name, str): + group.group_name = f"{guild_name}-{channel_name}" + elif isinstance(guild_name, str): + group.group_name = guild_name + + icon = getattr(guild, "icon", None) + icon_url = getattr(icon, "url", None) if icon else None + if icon_url: + group.group_avatar = str(icon_url) + + owner_id = getattr(guild, "owner_id", None) + if owner_id is not None: + group.group_owner = str(owner_id) + + member_count = getattr(guild, "member_count", None) + if isinstance(member_count, int): + group.member_count = member_count + + cached_members = getattr(guild, "members", None) + members_intent = bool( + getattr(getattr(self.client, "intents", None), "members", False) + ) + cache_complete = bool( + members_intent + and cached_members is not None + and ( + getattr(guild, "chunked", False) + or ( + group.member_count is not None + and len(cached_members) >= group.member_count + ) + ) + ) + if not cache_complete: + return group + + group.group_admins = [] + group.members = None if isinstance(channel, discord.Thread) else [] + for member in cached_members: + member_id = getattr(member, "id", None) + if member_id is None: + continue + guild_permissions = getattr(member, "guild_permissions", None) + if ( + getattr(guild_permissions, "administrator", False) + and str(member_id) != group.group_owner + ): + group.group_admins.append(str(member_id)) + if isinstance(channel, discord.Thread): + continue + try: + if not channel.permissions_for(member).view_channel: + continue + except Exception: + continue + group.members.append( + MessageMember( + user_id=str(member_id), + nickname=getattr(member, "display_name", None), + ) + ) + + return group + async def _parse_to_discord( self, message: MessageChain, diff --git a/astrbot/core/platform/sources/kook/kook_adapter.py b/astrbot/core/platform/sources/kook/kook_adapter.py index e23f781912..a0897b650a 100644 --- a/astrbot/core/platform/sources/kook/kook_adapter.py +++ b/astrbot/core/platform/sources/kook/kook_adapter.py @@ -7,6 +7,7 @@ from astrbot.api.message_components import At, AtAll, Image, Plain from astrbot.api.platform import ( AstrBotMessage, + Group, MessageMember, MessageType, Platform, @@ -451,7 +452,10 @@ async def convert_message(self, data: KookMessageEventData) -> AstrBotMessage: case KookChannelType.GROUP: session_id = data.target_id or "unknown" abm.type = MessageType.GROUP_MESSAGE - abm.group_id = session_id + abm.group = Group( + group_id=session_id, + group_name=data.extra.channel_name or None, + ) abm.session_id = session_id case KookChannelType.PERSON: abm.type = MessageType.FRIEND_MESSAGE diff --git a/astrbot/core/platform/sources/kook/kook_client.py b/astrbot/core/platform/sources/kook/kook_client.py index 14ef17e885..691f765629 100644 --- a/astrbot/core/platform/sources/kook/kook_client.py +++ b/astrbot/core/platform/sources/kook/kook_client.py @@ -3,6 +3,7 @@ import time import traceback import zlib +from typing import Any import aiohttp import pydantic @@ -69,6 +70,119 @@ def bot_username(self): def http_client(self): return self._http_client + async def _get_api_data( + self, + url: str, + params: dict[str, str | int], + ) -> dict[str, Any]: + """Gets and validates an object payload from a KOOK API endpoint. + + Args: + url: Absolute KOOK API endpoint URL. + params: Query parameters for the request. + + Returns: + The response's data object. + + Raises: + RuntimeError: If the HTTP or KOOK API response is unsuccessful. + """ + async with self._http_client.get(url, params=params) as resp: + if resp.status != 200: + raise RuntimeError( + f"KOOK GET {url} failed: {resp.status} {await resp.text()}" + ) + payload = await resp.json() + if not isinstance(payload, dict) or payload.get("code") != 0: + raise RuntimeError(f"KOOK GET {url} returned an error: {payload}") + data = payload.get("data") + if not isinstance(data, dict): + raise RuntimeError(f"KOOK GET {url} returned invalid data: {payload}") + return data + + async def get_channel(self, channel_id: str) -> dict[str, Any]: + """Gets a KOOK channel. + + Args: + channel_id: KOOK channel identifier. + + Returns: + KOOK channel data. + """ + return await self._get_api_data( + KookApiPaths.CHANNEL_VIEW, + {"target_id": channel_id}, + ) + + async def get_guild(self, guild_id: str) -> dict[str, Any]: + """Gets a KOOK guild. + + Args: + guild_id: KOOK guild identifier. + + Returns: + KOOK guild data. + """ + return await self._get_api_data( + KookApiPaths.GUILD_VIEW, + {"guild_id": guild_id}, + ) + + async def get_guild_users( + self, + guild_id: str, + *, + channel_id: str, + page: int, + page_size: int, + ) -> dict[str, Any]: + """Gets one page of KOOK guild members. + + Args: + guild_id: KOOK guild identifier. + channel_id: KOOK channel used to filter visible members. + page: One-based page index. + page_size: Maximum members requested per page. + + Returns: + Guild member items and pagination metadata. + """ + return await self._get_api_data( + KookApiPaths.GUILD_USER_LIST, + { + "guild_id": guild_id, + "channel_id": channel_id, + "page": page, + "page_size": page_size, + }, + ) + + async def get_guild_roles( + self, + guild_id: str, + *, + page: int, + page_size: int, + ) -> dict[str, Any]: + """Gets one page of KOOK guild roles. + + Args: + guild_id: KOOK guild identifier. + page: One-based page index. + page_size: Maximum roles requested per page. + + Returns: + Guild role items and pagination metadata. + """ + return await self._get_api_data( + KookApiPaths.GUILD_ROLE_LIST, + { + "guild_id": guild_id, + "page": page, + "page_size": page_size, + }, + ) + async def get_bot_info(self) -> None: """获取机器人账号信息""" url = KookApiPaths.USER_ME diff --git a/astrbot/core/platform/sources/kook/kook_event.py b/astrbot/core/platform/sources/kook/kook_event.py index 884d066d8d..52f29bf8e1 100644 --- a/astrbot/core/platform/sources/kook/kook_event.py +++ b/astrbot/core/platform/sources/kook/kook_event.py @@ -6,7 +6,7 @@ from astrbot import logger from astrbot.api.event import AstrMessageEvent, MessageChain -from astrbot.api.platform import AstrBotMessage, PlatformMetadata +from astrbot.api.platform import AstrBotMessage, Group, MessageMember, PlatformMetadata from astrbot.core.message.components import ( At, AtAll, @@ -208,3 +208,159 @@ async def send(self, message: MessageChain): logger.error(f"[kook] {err_msg}") await super().send(message) + + async def get_group(self, group_id=None, **kwargs): + """Gets KOOK channel and guild member information. + + Args: + group_id: Optional KOOK channel identifier. + **kwargs: Reserved compatibility arguments. + + Returns: + Enriched channel information, or a basic group if lookup fails. + """ + channel_id = group_id or self.get_group_id() + if not channel_id: + return None + + current_group = self.message_obj.group + group = Group( + group_id=channel_id, + group_name=( + current_group.group_name + if current_group and current_group.group_id == channel_id + else None + ), + ) + + try: + channel = await self.client.get_channel(channel_id) + group.group_name = channel.get("name") or group.group_name + except Exception as exc: + logger.debug("KOOK channel lookup failed for %s: %s", channel_id, exc) + return group + + guild_id = str(channel.get("guild_id") or "") + if ( + not guild_id + and channel_id == self.get_group_id() + and isinstance(self.message_obj.raw_message, dict) + ): + extra = self.message_obj.raw_message.get("extra") or {} + if isinstance(extra, dict): + guild_id = str(extra.get("guild_id") or "") + if not guild_id: + return group + + guild_roles: list[dict] = [] + try: + guild = await self.client.get_guild(guild_id) + group.group_avatar = guild.get("icon") or None + group.group_owner = str(guild.get("user_id") or "") or None + guild_roles.extend( + role for role in (guild.get("roles") or []) if isinstance(role, dict) + ) + except Exception as exc: + logger.debug("KOOK guild lookup failed for %s: %s", guild_id, exc) + + if not guild_roles: + role_page_number = 1 + role_page_size = 50 + try: + while True: + role_page = await self.client.get_guild_roles( + guild_id, + page=role_page_number, + page_size=role_page_size, + ) + role_items = role_page.get("items") or [] + guild_roles.extend( + role for role in role_items if isinstance(role, dict) + ) + role_meta = role_page.get("meta") or {} + page_total = ( + role_meta.get("page_total") + if isinstance(role_meta, dict) + else None + ) + if isinstance(page_total, int): + if role_page_number >= page_total: + break + elif len(role_items) < role_page_size: + break + role_page_number += 1 + except Exception as exc: + logger.debug("KOOK guild role lookup failed for %s: %s", guild_id, exc) + + admin_role_ids: set[int] = set() + for role in guild_roles: + role_id = role.get("role_id") or role.get("id") + permissions = role.get("permissions") + if ( + role_id is None + or not str(role_id).isdigit() + or not isinstance(permissions, int) + ): + continue + if permissions & 1: + admin_role_ids.add(int(role_id)) + + member_items: list[dict] = [] + total: int | None = None + page = 1 + page_size = 50 + try: + while True: + member_page = await self.client.get_guild_users( + guild_id, + channel_id=channel_id, + page=page, + page_size=page_size, + ) + items = member_page.get("items") or [] + member_items.extend(item for item in items if isinstance(item, dict)) + meta = member_page.get("meta") or {} + if isinstance(meta, dict) and isinstance(meta.get("total"), int): + total = meta["total"] + page_total = meta.get("page_total") if isinstance(meta, dict) else None + if isinstance(page_total, int): + if page >= page_total: + break + elif len(items) < page_size: + break + if total is not None and len(member_items) >= total: + break + page += 1 + except Exception as exc: + logger.debug("KOOK guild member lookup failed for %s: %s", guild_id, exc) + return group + + unique_members: dict[str, dict] = {} + for member in member_items: + user_id = str(member.get("id") or "") + if user_id: + unique_members[user_id] = member + + members: list[MessageMember] = [] + admins: list[str] = [] + for user_id, member in unique_members.items(): + members.append( + MessageMember( + user_id=user_id, + nickname=( + member.get("nickname") or member.get("username") or user_id + ), + ), + ) + member_role_ids = { + int(role_id) + for role_id in (member.get("roles") or []) + if isinstance(role_id, (int, str)) and str(role_id).isdigit() + } + if admin_role_ids.intersection(member_role_ids): + admins.append(user_id) + + group.members = members + group.group_admins = admins + group.member_count = total if total is not None else len(members) + return group diff --git a/astrbot/core/platform/sources/kook/kook_types.py b/astrbot/core/platform/sources/kook/kook_types.py index 281458f86c..f263ac711c 100644 --- a/astrbot/core/platform/sources/kook/kook_types.py +++ b/astrbot/core/platform/sources/kook/kook_types.py @@ -16,6 +16,12 @@ class KookApiPaths: USER_VIEW = f"{BASE_URL}{API_VERSION_PATH}/user/view" GATEWAY_INDEX = f"{BASE_URL}{API_VERSION_PATH}/gateway/index" + # Channel and guild information + CHANNEL_VIEW = f"{BASE_URL}{API_VERSION_PATH}/channel/view" + GUILD_VIEW = f"{BASE_URL}{API_VERSION_PATH}/guild/view" + GUILD_USER_LIST = f"{BASE_URL}{API_VERSION_PATH}/guild/user-list" + GUILD_ROLE_LIST = f"{BASE_URL}{API_VERSION_PATH}/guild-role/list" + # 消息相关 ASSET_CREATE = f"{BASE_URL}{API_VERSION_PATH}/asset/create" ## 频道消息 diff --git a/astrbot/core/platform/sources/lark/lark_adapter.py b/astrbot/core/platform/sources/lark/lark_adapter.py index c03ed48cb9..756ec49e49 100644 --- a/astrbot/core/platform/sources/lark/lark_adapter.py +++ b/astrbot/core/platform/sources/lark/lark_adapter.py @@ -8,6 +8,7 @@ from uuid import uuid4 import lark_oapi as lark +from lark_oapi.api.contact.v3 import GetUserRequest from lark_oapi.api.im.v1 import ( GetMessageRequest, GetMessageResourceRequest, @@ -34,6 +35,11 @@ from .lark_event import LarkMessageEvent from .server import LarkWebhookServer +USER_NAME_CACHE_TTL_SECONDS = 1800 +USER_NAME_FAILURE_CACHE_TTL_SECONDS = 60 +USER_NAME_CACHE_MAX_SIZE = 1000 +USER_NAME_LOOKUP_TIMEOUT_SECONDS = 5 + @register_platform_adapter( "lark", "飞书机器人官方 API 适配器", support_streaming_message=True @@ -94,6 +100,7 @@ def do_v2_msg_event(event: lark.im.v1.P2ImMessageReceiveV1) -> None: self.webhook_server.set_callback(self.handle_webhook_event) self.event_id_timestamps: dict[str, float] = {} + self._user_name_cache: dict[str, tuple[str, float]] = {} async def _download_message_resource( self, @@ -582,10 +589,57 @@ async def convert_msg(self, event: lark.im.v1.P2ImMessageReceiveV1) -> None: abm.message_id = message.message_id abm.raw_message = message - abm.sender = MessageMember( - user_id=event.event.sender.sender_id.open_id, - nickname=event.event.sender.sender_id.open_id[:8], - ) + sender_open_id = event.event.sender.sender_id.open_id + sender_name = sender_open_id[:8] + if ( + abm.type == MessageType.FRIEND_MESSAGE + and getattr(event.event.sender, "sender_type", "user") == "user" + ): + cached_name = self._user_name_cache.get(sender_open_id) + if cached_name and time.time() <= cached_name[1]: + sender_name = cached_name[0] + else: + self._user_name_cache.pop(sender_open_id, None) + sender_name = "" + if not sender_name: + name_cache_ttl = USER_NAME_FAILURE_CACHE_TTL_SECONDS + try: + request = ( + GetUserRequest.builder() + .user_id(sender_open_id) + .user_id_type("open_id") + .build() + ) + response = await asyncio.wait_for( + self.lark_api.contact.v3.user.aget(request), + timeout=USER_NAME_LOOKUP_TIMEOUT_SECONDS, + ) + if response.success() and response.data and response.data.user: + sender_name = str(response.data.user.name or "").strip() + if sender_name: + name_cache_ttl = USER_NAME_CACHE_TTL_SECONDS + else: + logger.debug( + "[Lark] Sender name lookup failed for %s: code=%s, msg=%s", + sender_open_id, + getattr(response, "code", None), + getattr(response, "msg", None), + ) + except Exception as exc: + logger.debug( + "[Lark] Sender name lookup failed for %s: %s", + sender_open_id, + exc, + ) + sender_name = sender_name or sender_open_id[:8] + self._user_name_cache[sender_open_id] = ( + sender_name, + time.time() + name_cache_ttl, + ) + if len(self._user_name_cache) > USER_NAME_CACHE_MAX_SIZE: + self._user_name_cache.pop(next(iter(self._user_name_cache))) + + abm.sender = MessageMember(user_id=sender_open_id, nickname=sender_name) if abm.type == MessageType.GROUP_MESSAGE: abm.session_id = abm.group_id else: diff --git a/astrbot/core/platform/sources/lark/lark_event.py b/astrbot/core/platform/sources/lark/lark_event.py index ccc90455f2..d9bff6e310 100644 --- a/astrbot/core/platform/sources/lark/lark_event.py +++ b/astrbot/core/platform/sources/lark/lark_event.py @@ -20,6 +20,8 @@ CreateMessageReactionRequest, CreateMessageReactionRequestBody, Emoji, + GetChatMembersRequest, + GetChatRequest, ReplyMessageRequest, ReplyMessageRequestBody, ) @@ -28,6 +30,7 @@ from astrbot.api.event import AstrMessageEvent, MessageChain from astrbot.api.message_components import At, File, Json, Plain, Record, Video from astrbot.api.message_components import Image as AstrBotImage +from astrbot.api.platform import Group, MessageMember from astrbot.core.utils.media_utils import ( MediaResolver, convert_audio_to_opus, @@ -49,6 +52,141 @@ def __init__( super().__init__(message_str, message_obj, platform_meta, session_id) self.bot = bot + async def get_group( + self, + group_id: str | None = None, + **kwargs, + ) -> Group | None: + """Get Lark chat details and members. + + Args: + group_id: Chat ID to query. Defaults to the current chat ID. + **kwargs: Reserved for platform-compatible query options. + + Returns: + Enriched group details, a basic group when the API is unavailable, or + ``None`` when no chat ID can be resolved. + """ + resolved_group_id = str(group_id or self.get_group_id()) + if not resolved_group_id: + return None + + basic_group = Group(group_id=resolved_group_id) + if ( + self.message_obj.group + and self.message_obj.group.group_id == resolved_group_id + ): + basic_group = self.message_obj.group + + if self.bot.im is None: + logger.warning("[Lark] IM API is unavailable while getting chat details") + return basic_group + + try: + request = ( + GetChatRequest.builder() + .chat_id(resolved_group_id) + .user_id_type("open_id") + .build() + ) + response = await self.bot.im.v1.chat.aget(request) + except Exception as exc: + logger.warning( + "[Lark] Failed to get chat details for %s: %s", + resolved_group_id, + exc, + ) + return basic_group + + if not response.success() or response.data is None: + logger.warning( + "[Lark] Failed to get chat details for %s (%s): %s", + resolved_group_id, + response.code, + response.msg, + ) + return basic_group + + chat_data = response.data + member_count = None + if chat_data.user_count is not None: + try: + member_count = int(chat_data.user_count) + except (TypeError, ValueError): + logger.warning( + "[Lark] Chat %s returned an invalid user count: %r", + resolved_group_id, + chat_data.user_count, + ) + + group = Group( + group_id=resolved_group_id, + group_name=chat_data.name or basic_group.group_name, + group_avatar=chat_data.avatar or basic_group.group_avatar, + group_owner=chat_data.owner_id or basic_group.group_owner, + group_admins=list(chat_data.user_manager_id_list or []), + member_count=member_count, + ) + + members: list[MessageMember] = [] + members_complete = False + page_token: str | None = None + try: + while True: + request_builder = ( + GetChatMembersRequest.builder() + .chat_id(resolved_group_id) + .member_id_type("open_id") + .page_size(100) + ) + if page_token: + request_builder.page_token(page_token) + members_response = await self.bot.im.v1.chat_members.aget( + request_builder.build(), + ) + if not members_response.success() or members_response.data is None: + logger.warning( + "[Lark] Failed to get members for chat %s (%s): %s", + resolved_group_id, + members_response.code, + members_response.msg, + ) + break + + members_data = members_response.data + for member in members_data.items or []: + if member.member_id: + members.append( + MessageMember( + user_id=member.member_id, + nickname=member.name, + ), + ) + if group.member_count is None and members_data.member_total is not None: + group.member_count = members_data.member_total + + if getattr(members_data, "trigger_security_conf_limit", False): + logger.warning( + "[Lark] Member list for chat %s was truncated by its security policy", + resolved_group_id, + ) + break + + page_token = members_data.page_token + if not members_data.has_more or not page_token: + members_complete = True + break + except Exception as exc: + logger.warning( + "[Lark] Failed to get members for chat %s: %s", + resolved_group_id, + exc, + ) + + if members_complete: + group.members = members + return group + @staticmethod async def _send_im_message( lark_client: lark.Client, diff --git a/astrbot/core/platform/sources/line/line_adapter.py b/astrbot/core/platform/sources/line/line_adapter.py index fb1980b3fd..51fdfd2b59 100644 --- a/astrbot/core/platform/sources/line/line_adapter.py +++ b/astrbot/core/platform/sources/line/line_adapter.py @@ -214,7 +214,21 @@ async def convert_message(self, event: dict[str, Any]) -> AstrBotMessage | None: if source_type in {"group", "room"}: abm.type = MessageType.GROUP_MESSAGE container_id = group_id or room_id - abm.group = Group(group_id=container_id, group_name=container_id) + group_name = str( + source.get("groupName") + or source.get("roomName") + or event.get("groupName") + or event.get("roomName") + or "" + ).strip() + group_avatar = str( + source.get("pictureUrl") or event.get("pictureUrl") or "" + ).strip() + abm.group = Group( + group_id=container_id, + group_name=group_name or None, + group_avatar=group_avatar or None, + ) abm.session_id = container_id sender_id = user_id or container_id elif source_type == "user": diff --git a/astrbot/core/platform/sources/line/line_api.py b/astrbot/core/platform/sources/line/line_api.py index 32204bd6ee..61f33001c7 100644 --- a/astrbot/core/platform/sources/line/line_api.py +++ b/astrbot/core/platform/sources/line/line_api.py @@ -112,6 +112,150 @@ async def _post_json( logger.error("[LINE] %s message request failed: %s", op_name, e) return False + async def _get_json( + self, + url: str, + *, + op_name: str, + params: dict[str, str] | None = None, + ) -> dict[str, Any] | None: + """Fetch a JSON object from a LINE Messaging API endpoint. + + Args: + url: Fully qualified LINE API endpoint. + op_name: Short operation name used in logs. + params: Optional query parameters. + + Returns: + Parsed JSON object, or ``None`` when the request fails. + """ + session = await self._get_session() + try: + async with session.get( + url, + headers=self._auth_headers, + params=params, + ) as resp: + if resp.status != 200: + body = await resp.text() + logger.debug( + "[LINE] %s failed: status=%s body=%s", + op_name, + resp.status, + body, + ) + return None + data = await resp.json() + if isinstance(data, dict): + return data + logger.debug("[LINE] %s returned a non-object response", op_name) + return None + except Exception as e: + logger.debug("[LINE] %s request failed: %s", op_name, e) + return None + + async def get_group_summary(self, group_id: str) -> dict[str, Any] | None: + """Get a LINE group chat's name and icon. + + Args: + group_id: LINE group chat ID. + + Returns: + Group summary, or ``None`` when unavailable. + """ + return await self._get_json( + f"https://api.line.me/v2/bot/group/{group_id}/summary", + op_name="get group summary", + ) + + async def get_chat_member_count( + self, + chat_type: str, + chat_id: str, + ) -> int | None: + """Get the user count for a LINE group or multi-person chat. + + Args: + chat_type: LINE source type, either ``group`` or ``room``. + chat_id: LINE group or room ID. + + Returns: + Member count excluding the bot, or ``None`` when unavailable. + """ + data = await self._get_json( + f"https://api.line.me/v2/bot/{chat_type}/{chat_id}/members/count", + op_name=f"get {chat_type} member count", + ) + if not data: + return None + count = data.get("count") + return count if isinstance(count, int) and count >= 0 else None + + async def get_chat_member_ids( + self, + chat_type: str, + chat_id: str, + ) -> list[str] | None: + """Get all accessible member IDs for a LINE chat. + + LINE returns at most 100 IDs per response. This follows continuation + tokens until all pages are consumed. The endpoint is restricted to + verified or premium LINE Official Accounts. + + Args: + chat_type: LINE source type, either ``group`` or ``room``. + chat_id: LINE group or room ID. + + Returns: + Member IDs, or ``None`` when the endpoint is unavailable. + """ + member_ids: list[str] = [] + start = "" + seen_tokens: set[str] = set() + while True: + data = await self._get_json( + f"https://api.line.me/v2/bot/{chat_type}/{chat_id}/members/ids", + op_name=f"get {chat_type} member IDs", + params={"start": start} if start else None, + ) + if data is None: + return None + + page_member_ids = data.get("memberIds") + if isinstance(page_member_ids, list): + member_ids.extend( + member_id + for item in page_member_ids + if (member_id := str(item).strip()) + ) + + next_token = str(data.get("next", "")).strip() + if not next_token or next_token in seen_tokens: + return list(dict.fromkeys(member_ids)) + seen_tokens.add(next_token) + start = next_token + + async def get_chat_member_profile( + self, + chat_type: str, + chat_id: str, + user_id: str, + ) -> dict[str, Any] | None: + """Get a member profile within a LINE chat. + + Args: + chat_type: LINE source type, either ``group`` or ``room``. + chat_id: LINE group or room ID. + user_id: LINE member user ID. + + Returns: + Member profile, or ``None`` when unavailable. + """ + return await self._get_json( + f"https://api.line.me/v2/bot/{chat_type}/{chat_id}/member/{user_id}", + op_name=f"get {chat_type} member profile", + ) + async def get_message_content( self, message_id: str, diff --git a/astrbot/core/platform/sources/line/line_event.py b/astrbot/core/platform/sources/line/line_event.py index 8b82ad1820..c107207451 100644 --- a/astrbot/core/platform/sources/line/line_event.py +++ b/astrbot/core/platform/sources/line/line_event.py @@ -16,6 +16,7 @@ Record, Video, ) +from astrbot.api.platform import Group, MessageMember from astrbot.core.utils.astrbot_path import get_astrbot_temp_path from astrbot.core.utils.media_utils import get_media_duration @@ -281,3 +282,104 @@ async def send_streaming( if buffer.strip(): await self.send(MessageChain([Plain(buffer)])) return await super().send_streaming(generator, use_fallback) + + async def get_group( + self, + group_id: str | None = None, + **kwargs, + ) -> Group | None: + """Get LINE group or multi-person chat information. + + Group summaries provide a name and icon. LINE doesn't expose a room + summary endpoint, so room enrichment is limited to its member count and + accessible members. Member enumeration is unavailable to unverified + accounts; in that case the basic group object is still returned. + + Args: + group_id: Group or room ID. Defaults to the current message chat. + **kwargs: Optional ``chat_type`` override (``group`` or ``room``). + + Returns: + Enriched group information, a basic group object when LINE denies + enrichment, or ``None`` for a private event without a group ID. + """ + resolved_group_id = str(group_id or self.get_group_id()).strip() + if not resolved_group_id: + return None + + current_group = self.message_obj.group + if current_group and current_group.group_id == resolved_group_id: + result = Group( + group_id=resolved_group_id, + group_name=current_group.group_name, + group_avatar=current_group.group_avatar, + group_owner=current_group.group_owner, + group_admins=current_group.group_admins, + members=current_group.members, + member_count=current_group.member_count, + ) + else: + result = Group(group_id=resolved_group_id) + + chat_type = str(kwargs.get("chat_type", "")).strip().lower() + raw = self.message_obj.raw_message + if chat_type not in {"group", "room"} and isinstance(raw, dict): + source = raw.get("source") + if isinstance(source, dict): + source_type = str(source.get("type", "")).strip().lower() + source_id = str( + source.get("groupId") or source.get("roomId") or "" + ).strip() + if source_id == resolved_group_id and source_type in {"group", "room"}: + chat_type = source_type + if chat_type not in {"group", "room"}: + chat_type = "room" if resolved_group_id.startswith("R") else "group" + + calls = [] + if chat_type == "group": + calls.append(self.line_api.get_group_summary(resolved_group_id)) + calls.extend( + [ + self.line_api.get_chat_member_count(chat_type, resolved_group_id), + self.line_api.get_chat_member_ids(chat_type, resolved_group_id), + ] + ) + responses = await asyncio.gather(*calls, return_exceptions=True) + + if chat_type == "group": + summary, member_count, member_ids = responses + if isinstance(summary, dict): + group_name = str(summary.get("groupName", "")).strip() + group_avatar = str(summary.get("pictureUrl", "")).strip() + if group_name: + result.group_name = group_name + if group_avatar: + result.group_avatar = group_avatar + else: + member_count, member_ids = responses + + if isinstance(member_count, int) and member_count >= 0: + result.member_count = member_count + + if isinstance(member_ids, list): + profile_responses = await asyncio.gather( + *( + self.line_api.get_chat_member_profile( + chat_type, + resolved_group_id, + member_id, + ) + for member_id in member_ids + ), + return_exceptions=True, + ) + result.members = [] + for member_id, profile in zip(member_ids, profile_responses): + nickname = None + if isinstance(profile, dict): + nickname = str(profile.get("displayName", "")).strip() or None + result.members.append( + MessageMember(user_id=member_id, nickname=nickname), + ) + + return result diff --git a/astrbot/core/platform/sources/mattermost/client.py b/astrbot/core/platform/sources/mattermost/client.py index c35b893873..e4bf5a60aa 100644 --- a/astrbot/core/platform/sources/mattermost/client.py +++ b/astrbot/core/platform/sources/mattermost/client.py @@ -73,6 +73,84 @@ async def get_me(self) -> dict[str, Any]: async def get_channel(self, channel_id: str) -> dict[str, Any]: return await self.get_json(f"channels/{channel_id}") + async def get_channel_stats(self, channel_id: str) -> dict[str, Any]: + """Gets aggregate statistics for a channel. + + Args: + channel_id: Mattermost channel identifier. + + Returns: + Channel statistics returned by Mattermost. + """ + return await self.get_json(f"channels/{channel_id}/stats") + + async def get_channel_members( + self, + channel_id: str, + *, + page: int, + per_page: int, + ) -> list[dict[str, Any]]: + """Gets one page of channel membership records. + + Args: + channel_id: Mattermost channel identifier. + page: Zero-based page index. + per_page: Maximum records requested per page. + + Returns: + Channel membership records for the requested page. + + Raises: + RuntimeError: If Mattermost rejects the request or returns invalid JSON. + """ + session = await self.ensure_session() + path = f"channels/{channel_id}/members" + url = f"{self.base_url}/api/v4/{path}" + async with session.get( + url, + headers=self._headers(), + params={"page": page, "per_page": per_page}, + ) as resp: + if resp.status >= 400: + body = await resp.text() + raise RuntimeError( + f"Mattermost GET {path} failed: {resp.status} {body}" + ) + data = await resp.json() + if not isinstance(data, list): + raise RuntimeError(f"Mattermost GET {path} returned non-list JSON") + return [item for item in data if isinstance(item, dict)] + + async def get_users_by_ids( + self, + user_ids: list[str], + ) -> list[dict[str, Any]]: + """Gets user profiles in one Mattermost batch request. + + Args: + user_ids: Mattermost user identifiers to resolve. + + Returns: + User profiles visible to the bot. + + Raises: + RuntimeError: If Mattermost rejects the request or returns invalid JSON. + """ + session = await self.ensure_session() + path = "users/ids" + url = f"{self.base_url}/api/v4/{path}" + async with session.post(url, headers=self._headers(), json=user_ids) as resp: + if resp.status >= 400: + body = await resp.text() + raise RuntimeError( + f"Mattermost POST {path} failed: {resp.status} {body}" + ) + data = await resp.json() + if not isinstance(data, list): + raise RuntimeError(f"Mattermost POST {path} returned non-list JSON") + return [item for item in data if isinstance(item, dict)] + async def get_file_info(self, file_id: str) -> dict[str, Any]: return await self.get_json(f"files/{file_id}/info") diff --git a/astrbot/core/platform/sources/mattermost/mattermost_adapter.py b/astrbot/core/platform/sources/mattermost/mattermost_adapter.py index 622edadeeb..7703c501fc 100644 --- a/astrbot/core/platform/sources/mattermost/mattermost_adapter.py +++ b/astrbot/core/platform/sources/mattermost/mattermost_adapter.py @@ -12,6 +12,7 @@ from astrbot.api.message_components import At, Plain from astrbot.api.platform import ( AstrBotMessage, + Group, MessageMember, MessageType, Platform, @@ -221,7 +222,12 @@ async def convert_message( abm.type = MessageType.FRIEND_MESSAGE else: abm.type = MessageType.GROUP_MESSAGE - abm.group_id = channel_id + abm.group = Group( + group_id=channel_id, + group_name=( + data.get("channel_display_name") or data.get("channel_name") or None + ), + ) if file_ids: ( diff --git a/astrbot/core/platform/sources/mattermost/mattermost_event.py b/astrbot/core/platform/sources/mattermost/mattermost_event.py index 5faaf71345..22c445a6fb 100644 --- a/astrbot/core/platform/sources/mattermost/mattermost_event.py +++ b/astrbot/core/platform/sources/mattermost/mattermost_event.py @@ -2,6 +2,7 @@ import re from collections.abc import AsyncGenerator +from astrbot.api import logger from astrbot.api.event import AstrMessageEvent, MessageChain from astrbot.api.message_components import Plain from astrbot.api.platform import Group, MessageMember @@ -70,19 +71,117 @@ async def send_streaming( return None async def get_group(self, group_id=None, **kwargs): + """Gets Mattermost channel information and all visible members. + + Args: + group_id: Optional Mattermost channel identifier. + **kwargs: Reserved compatibility arguments. + + Returns: + Enriched channel information, or a basic group if lookup fails. + """ channel_id = group_id or self.get_group_id() if not channel_id: return None - channel = await self.client.get_channel(channel_id) - return Group( + + current_group = self.message_obj.group + group = Group( group_id=channel_id, - group_name=channel.get("display_name") or channel.get("name") or channel_id, - group_owner="", - group_admins=[], - members=[ - MessageMember( - user_id=self.get_sender_id(), - nickname=self.get_sender_name(), - ) - ], + group_name=( + current_group.group_name + if current_group and current_group.group_id == channel_id + else None + ), ) + + try: + channel = await self.client.get_channel(channel_id) + group.group_name = ( + channel.get("display_name") or channel.get("name") or group.group_name + ) + except Exception as exc: + logger.debug( + "Mattermost channel lookup failed for %s: %s", + channel_id, + exc, + ) + return group + + try: + stats = await self.client.get_channel_stats(channel_id) + group.member_count = stats.get("member_count") + except Exception as exc: + logger.debug( + "Mattermost channel stats lookup failed for %s: %s", + channel_id, + exc, + ) + + memberships: list[dict] = [] + page = 0 + per_page = 200 + try: + while True: + membership_page = await self.client.get_channel_members( + channel_id, + page=page, + per_page=per_page, + ) + memberships.extend(membership_page) + if len(membership_page) < per_page: + break + if group.member_count and len(memberships) >= group.member_count: + break + page += 1 + except Exception as exc: + logger.debug( + "Mattermost channel member lookup failed for %s: %s", + channel_id, + exc, + ) + return group + + unique_memberships: dict[str, dict] = {} + for membership in memberships: + user_id = str(membership.get("user_id") or "") + if user_id: + unique_memberships[user_id] = membership + + user_ids = list(unique_memberships) + users_by_id: dict[str, dict] = {} + for offset in range(0, len(user_ids), 100): + user_id_batch = user_ids[offset : offset + 100] + try: + users = await self.client.get_users_by_ids(user_id_batch) + except Exception as exc: + logger.debug( + "Mattermost user batch lookup failed for %s: %s", + channel_id, + exc, + ) + continue + for user in users: + user_id = str(user.get("id") or "") + if user_id: + users_by_id[user_id] = user + + members: list[MessageMember] = [] + admins: list[str] = [] + for user_id, membership in unique_memberships.items(): + user = users_by_id.get(user_id, {}) + members.append( + MessageMember( + user_id=user_id, + nickname=(user.get("nickname") or user.get("username") or user_id), + ), + ) + if ( + "channel_admin" in str(membership.get("roles") or "").split() + or membership.get("scheme_admin") is True + ): + admins.append(user_id) + + group.members = members + group.group_admins = admins + group.member_count = group.member_count or len(members) + return group diff --git a/astrbot/core/platform/sources/misskey/misskey_adapter.py b/astrbot/core/platform/sources/misskey/misskey_adapter.py index c488007442..4f22456059 100644 --- a/astrbot/core/platform/sources/misskey/misskey_adapter.py +++ b/astrbot/core/platform/sources/misskey/misskey_adapter.py @@ -8,6 +8,7 @@ from astrbot.api.event import MessageChain from astrbot.api.platform import ( AstrBotMessage, + Group, Platform, PlatformMetadata, register_platform_adapter, @@ -720,6 +721,13 @@ async def convert_room_message(self, raw_data: dict[str, Any]) -> AstrBotMessage is_chat=False, room_id=room_id, ) + room_data = raw_data.get("toRoom") + if message.group and isinstance(room_data, dict): + message.group = Group( + group_id=message.group.group_id, + group_name=room_data.get("name") or None, + group_owner=str(room_data.get("ownerId") or "") or None, + ) cache_user_info( self._user_cache, diff --git a/astrbot/core/platform/sources/misskey/misskey_event.py b/astrbot/core/platform/sources/misskey/misskey_event.py index 5c33fdea67..025d291cdf 100644 --- a/astrbot/core/platform/sources/misskey/misskey_event.py +++ b/astrbot/core/platform/sources/misskey/misskey_event.py @@ -5,7 +5,7 @@ from astrbot.api import logger from astrbot.api.event import AstrMessageEvent, MessageChain from astrbot.api.message_components import Plain -from astrbot.api.platform import AstrBotMessage, PlatformMetadata +from astrbot.api.platform import AstrBotMessage, Group, MessageMember, PlatformMetadata from .misskey_utils import ( add_at_mention_if_needed, @@ -161,3 +161,119 @@ async def send_streaming( if buffer.strip(): await self.send(MessageChain([Plain(buffer)])) return await super().send_streaming(generator, use_fallback) + + async def get_group( + self, + group_id: str | None = None, + **kwargs, + ) -> Group | None: + """Retrieve Misskey chat room information and all room members. + + Args: + group_id: Room ID to query. Defaults to the current room ID. + **kwargs: Reserved for compatibility with the platform event interface. + + Returns: + Room information, or ``None`` when no room ID is available. If the + instance does not support the room APIs, returns the basic room + information already present on the incoming message. + """ + del kwargs + room_id = str(group_id or self.get_group_id() or "") + if not room_id: + return None + + current_group = self.message_obj.group + cached_room = getattr(self.client, "_user_cache", {}).get( + f"room:{room_id}", + {}, + ) + fallback_group = Group( + group_id=room_id, + group_name=( + current_group.group_name + if current_group and current_group.group_id == room_id + else cached_room.get("room_name") or None + ), + group_owner=( + current_group.group_owner + if current_group and current_group.group_id == room_id + else str(cached_room.get("owner_id") or "") or None + ), + ) + + api = getattr(self.client, "api", None) + if api is None or not hasattr(api, "_make_request"): + return fallback_group + + try: + room = await api._make_request( + "chat/rooms/show", + {"roomId": room_id}, + ) + if not isinstance(room, dict): + raise TypeError("Misskey room response must be an object") + + memberships: list[dict] = [] + until_id = None + while True: + payload = {"roomId": room_id, "limit": 100} + if until_id: + payload["untilId"] = until_id + page = await api._make_request("chat/rooms/members", payload) + if not isinstance(page, list): + raise TypeError("Misskey room members response must be a list") + memberships.extend( + membership for membership in page if isinstance(membership, dict) + ) + if len(page) < 100: + break + next_until_id = page[-1].get("id") + if not next_until_id or next_until_id == until_id: + raise ValueError( + "Misskey room members pagination cursor is missing" + ) + until_id = next_until_id + except Exception as exc: + logger.warning( + f"[MisskeyEvent] Failed to retrieve room information for {room_id}: {exc}", + ) + return fallback_group + + owner_id = str(room.get("ownerId") or fallback_group.group_owner or "") + members = [] + member_ids = set() + for membership in memberships: + user = membership.get("user") + user = user if isinstance(user, dict) else {} + user_id = str(membership.get("userId") or user.get("id") or "") + if not user_id or user_id in member_ids: + continue + member_ids.add(user_id) + members.append( + MessageMember( + user_id=user_id, + nickname=user.get("name") or user.get("username") or None, + ), + ) + + # Misskey does not create a membership record for the room owner. + if owner_id and owner_id not in member_ids: + owner = room.get("owner") + owner = owner if isinstance(owner, dict) else {} + members.append( + MessageMember( + user_id=owner_id, + nickname=owner.get("name") or owner.get("username") or None, + ), + ) + + group = Group( + group_id=room_id, + group_name=room.get("name") or fallback_group.group_name, + group_owner=owner_id or None, + group_admins=[], + members=members, + member_count=len(members), + ) + return group diff --git a/astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py b/astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py index 49f7a9e8fe..98780230a8 100644 --- a/astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py +++ b/astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py @@ -28,7 +28,7 @@ from astrbot.api import logger from astrbot.api.event import AstrMessageEvent, MessageChain from astrbot.api.message_components import File, Image, Plain, Record, Video -from astrbot.api.platform import AstrBotMessage, PlatformMetadata +from astrbot.api.platform import AstrBotMessage, Group, PlatformMetadata from astrbot.core.platform.sources.qqofficial.qqofficial_chunked_upload import ( QQOFFICIAL_CHUNKED_UPLOAD_THRESHOLD, QQOfficialChunkedUploader, @@ -108,6 +108,110 @@ def __init__( self.bot = bot self.send_buffer = None + async def get_group(self, group_id: str | None = None, **kwargs) -> Group | None: + """Get QQ group or guild-channel information for this event. + + QQ group metadata is restricted to allowlisted bots. When the API is + unavailable, the basic group object attached to the incoming message is + returned so callers can still rely on the group identifier. + + Args: + group_id: Optional QQ group OpenID or guild channel ID. Defaults to + the current message group identifier. + **kwargs: Reserved for compatibility with the base event API. + + Returns: + Available group information, or ``None`` for a private event without + an explicit group identifier. + """ + del kwargs + target_id = group_id or self.message_obj.group_id + if not target_id: + return None + + current_group = self.message_obj.group + group = ( + current_group + if current_group and current_group.group_id == target_id + else Group(group_id=target_id) + ) + source = self.message_obj.raw_message + + if isinstance(source, botpy.message.GroupMessage): + try: + route = Route( + "GET", + "/v2/groups/{group_openid}/info", + group_openid=target_id, + ) + payload = await self.bot.api._http.request(route) + if not isinstance(payload, dict): + logger.warning( + "[QQOfficial] Group info API returned an invalid response for %s", + target_id, + ) + return group + + group.group_name = payload.get("group_name") or group.group_name + member_count = payload.get( + "group_member_num", + payload.get("member_count"), + ) + if member_count is not None: + try: + group.member_count = int(member_count) + except (TypeError, ValueError): + logger.warning( + "[QQOfficial] Group info API returned an invalid member_count for %s", + target_id, + ) + except Exception as exc: + logger.warning( + "[QQOfficial] Failed to get group info for %s: %s", + target_id, + exc, + ) + return group + + if isinstance(source, botpy.message.Message): + try: + channel = await self.bot.api.get_channel(target_id) + if not isinstance(channel, dict): + logger.warning( + "[QQOfficial] Channel API returned an invalid response for %s", + target_id, + ) + return group + + group.group_name = channel.get("name") or group.group_name + guild_id = channel.get("guild_id") or getattr(source, "guild_id", None) + if guild_id: + guild = await self.bot.api.get_guild(str(guild_id)) + if isinstance(guild, dict): + # QQ subchannels have no independent avatar or member roster; + # these fields describe their parent guild while the ID and + # name above continue to identify the current subchannel. + group.group_avatar = guild.get("icon") or group.group_avatar + group.group_owner = guild.get("owner_id") or group.group_owner + member_count = guild.get("member_count") + if member_count is not None: + try: + group.member_count = int(member_count) + except (TypeError, ValueError): + logger.warning( + "[QQOfficial] Guild API returned an invalid member_count for %s", + guild_id, + ) + except Exception as exc: + logger.warning( + "[QQOfficial] Failed to get channel info for %s: %s", + target_id, + exc, + ) + return group + + return group + async def send(self, message: MessageChain) -> None: self.send_buffer = message await self._post_send() diff --git a/astrbot/core/platform/sources/qqofficial/qqofficial_platform_adapter.py b/astrbot/core/platform/sources/qqofficial/qqofficial_platform_adapter.py index a9f7f8b942..c8b6230dbb 100644 --- a/astrbot/core/platform/sources/qqofficial/qqofficial_platform_adapter.py +++ b/astrbot/core/platform/sources/qqofficial/qqofficial_platform_adapter.py @@ -20,6 +20,7 @@ from astrbot.api.message_components import At, File, Image, Plain, Record, Reply, Video from astrbot.api.platform import ( AstrBotMessage, + Group, MessageMember, MessageType, Platform, @@ -786,7 +787,14 @@ async def _parse_from_qqofficial( message.author.member_openid, getattr(message.author, "username", "") or "", ) - abm.group_id = message.group_openid + raw_data = getattr(message, "raw_data", {}) + group_name = getattr(message, "group_name", None) + if not group_name and isinstance(raw_data, dict): + group_name = raw_data.get("group_name") + abm.group = Group( + group_id=message.group_openid, + group_name=str(group_name) if group_name else None, + ) bot_mentions = [ mention for mention in (getattr(message, "mentions", None) or []) @@ -858,7 +866,14 @@ async def _parse_from_qqofficial( msg.append(Plain(plain_content)) if isinstance(message, botpy.message.Message): - abm.group_id = message.channel_id + raw_data = getattr(message, "raw_data", {}) + channel_name = getattr(message, "channel_name", None) + if not channel_name and isinstance(raw_data, dict): + channel_name = raw_data.get("channel_name") + abm.group = Group( + group_id=message.channel_id, + group_name=str(channel_name) if channel_name else None, + ) else: raise ValueError(f"Unknown message type: {message_type}") if not abm.self_id: diff --git a/astrbot/core/platform/sources/satori/satori_adapter.py b/astrbot/core/platform/sources/satori/satori_adapter.py index 8748996fb4..bf06f4c3ae 100644 --- a/astrbot/core/platform/sources/satori/satori_adapter.py +++ b/astrbot/core/platform/sources/satori/satori_adapter.py @@ -20,6 +20,7 @@ ) from astrbot.api.platform import ( AstrBotMessage, + Group, MessageMember, MessageType, Platform, @@ -334,7 +335,11 @@ async def convert_satori_message( if guild and guild.get("id"): abm.type = MessageType.GROUP_MESSAGE - abm.group_id = guild.get("id", "") + abm.group = Group( + group_id=str(guild["id"]), + group_name=guild.get("name"), + group_avatar=guild.get("avatar"), + ) abm.session_id = channel.get("id", "") else: abm.type = MessageType.FRIEND_MESSAGE diff --git a/astrbot/core/platform/sources/satori/satori_event.py b/astrbot/core/platform/sources/satori/satori_event.py index 7e2e92eb40..9d53c31bd0 100644 --- a/astrbot/core/platform/sources/satori/satori_event.py +++ b/astrbot/core/platform/sources/satori/satori_event.py @@ -14,7 +14,7 @@ Reply, Video, ) -from astrbot.api.platform import AstrBotMessage, PlatformMetadata +from astrbot.api.platform import AstrBotMessage, Group, MessageMember, PlatformMetadata from astrbot.core.utils.media_utils import resolve_media_ref_to_base64_data if TYPE_CHECKING: @@ -53,6 +53,125 @@ def __init__( user = login.get("user", {}) self.user_id = user.get("id") if user else None + async def get_group( + self, + group_id: str | None = None, + **kwargs, + ) -> Group | None: + """Get Satori guild information and all available members. + + Args: + group_id: Guild ID to query. Defaults to the current message guild. + **kwargs: Reserved for compatibility with the common event interface. + + Returns: + Enriched guild information, a basic guild when APIs are unavailable, + or None when no guild ID is available. + """ + del kwargs + target_id = str(group_id or self.get_group_id()) + if not target_id: + return None + + current_group = self.message_obj.group + if current_group and current_group.group_id == target_id: + group = Group( + group_id=target_id, + group_name=current_group.group_name, + group_avatar=current_group.group_avatar, + group_owner=current_group.group_owner, + group_admins=current_group.group_admins, + members=current_group.members, + member_count=current_group.member_count, + ) + else: + group = Group(group_id=target_id) + + features = None + for login in getattr(self.adapter, "logins", []): + login_user = login.get("user") or {} + if ( + login.get("platform") == self.platform + and login_user.get("id") == self.user_id + ): + features = login.get("features") + break + + if features is None or "guild.get" in features: + try: + guild = await self.adapter.send_http_request( + "POST", + "/guild.get", + {"guild_id": target_id}, + self.platform, + self.user_id, + ) + except Exception as exc: + logger.warning( + "[Satori] Failed to get guild %s: %s", + target_id, + exc, + ) + guild = {} + if guild: + group.group_name = guild.get("name") or group.group_name + group.group_avatar = guild.get("avatar") or group.group_avatar + + if features is not None and "guild.member.list" not in features: + return group + + members: list[MessageMember] = [] + member_count = 0 + next_token = None + seen_tokens: set[str] = set() + while True: + data = {"guild_id": target_id} + if next_token: + data["next"] = next_token + try: + response = await self.adapter.send_http_request( + "POST", + "/guild.member.list", + data, + self.platform, + self.user_id, + ) + except Exception as exc: + logger.warning( + "[Satori] Failed to get members for guild %s: %s", + target_id, + exc, + ) + break + if not response or not isinstance(response.get("data"), list): + break + + member_count += len(response["data"]) + for member in response["data"]: + user = member.get("user") or {} + user_id = user.get("id") + if not user_id: + continue + members.append( + MessageMember( + user_id=str(user_id), + nickname=member.get("nick") + or user.get("nick") + or user.get("name"), + ), + ) + + next_token = response.get("next") + if not next_token: + group.members = members + group.member_count = member_count + break + if next_token in seen_tokens: + break + seen_tokens.add(next_token) + + return group + @staticmethod async def _image_to_data_url(component: Image) -> str | None: """Resolve an image component to a MIME-aware data URL. diff --git a/astrbot/core/platform/sources/slack/slack_adapter.py b/astrbot/core/platform/sources/slack/slack_adapter.py index 191df9e136..8f6bf866f6 100644 --- a/astrbot/core/platform/sources/slack/slack_adapter.py +++ b/astrbot/core/platform/sources/slack/slack_adapter.py @@ -14,6 +14,7 @@ from astrbot.api.message_components import * from astrbot.api.platform import ( AstrBotMessage, + Group, MessageMember, MessageType, Platform, @@ -133,13 +134,17 @@ async def convert_message(self, event: dict) -> AstrBotMessage: channel_id = event.get("channel", "") try: channel_info = await self.web_client.conversations_info(channel=channel_id) - is_im = cast(dict, channel_info["channel"])["is_im"] + channel_data = cast(dict, channel_info["channel"]) + is_im = channel_data["is_im"] if is_im: abm.type = MessageType.FRIEND_MESSAGE else: abm.type = MessageType.GROUP_MESSAGE - abm.group_id = channel_id + abm.group = Group( + group_id=channel_id, + group_name=channel_data.get("name") or None, + ) except Exception: # 默认作为群组消息处理 abm.type = MessageType.GROUP_MESSAGE diff --git a/astrbot/core/platform/sources/slack/slack_event.py b/astrbot/core/platform/sources/slack/slack_event.py index 5fb26d22f0..77b21e814e 100644 --- a/astrbot/core/platform/sources/slack/slack_event.py +++ b/astrbot/core/platform/sources/slack/slack_event.py @@ -1,6 +1,6 @@ import asyncio import re -from collections.abc import AsyncGenerator, Iterable +from collections.abc import AsyncGenerator from pathlib import Path from typing import cast @@ -210,46 +210,98 @@ async def send_streaming( return await super().send_streaming(generator, use_fallback) async def get_group(self, group_id=None, **kwargs): - if group_id: - channel_id = group_id - elif self.get_group_id(): - channel_id = self.get_group_id() - else: + """Gets Slack channel information and all visible members. + + Args: + group_id: Optional Slack channel identifier. + **kwargs: Reserved compatibility arguments. + + Returns: + Enriched channel information, or a basic group if lookup fails. + """ + channel_id = group_id or self.get_group_id() + if not channel_id: return None - try: - # 获取频道信息 - channel_info = await self.web_client.conversations_info(channel=channel_id) + current_group = self.message_obj.group + group = Group( + group_id=channel_id, + group_name=( + current_group.group_name + if current_group and current_group.group_id == channel_id + else None + ), + ) - # 获取频道成员 - members_response = await self.web_client.conversations_members( + try: + channel_info = await self.web_client.conversations_info( channel=channel_id, + include_num_members=True, + ) + channel_data = cast(dict, channel_info["channel"]) + group.group_name = channel_data.get("name") or group.group_name + group.member_count = channel_data.get("num_members") + except Exception as exc: + logger.debug("Slack channel info lookup failed for %s: %s", channel_id, exc) + return group + + member_ids: list[str] = [] + cursor: str | None = None + try: + while True: + request: dict[str, str | int] = { + "channel": channel_id, + "limit": 200, + } + if cursor: + request["cursor"] = cursor + members_response = await self.web_client.conversations_members( + **request, + ) + member_ids.extend( + str(member_id) for member_id in members_response["members"] + ) + response_metadata = members_response.get("response_metadata") or {} + cursor = str(response_metadata.get("next_cursor") or "") + if not cursor: + break + except Exception as exc: + logger.debug( + "Slack channel member lookup failed for %s: %s", + channel_id, + exc, ) + return group - members = [] - for member_id in cast(Iterable, members_response["members"]): + unique_member_ids = list(dict.fromkeys(member_ids)) + members: list[MessageMember] = [] + for offset in range(0, len(unique_member_ids), 20): + member_id_batch = unique_member_ids[offset : offset + 20] + user_responses = await asyncio.gather( + *( + self.web_client.users_info(user=member_id) + for member_id in member_id_batch + ), + return_exceptions=True, + ) + for member_id, user_response in zip(member_id_batch, user_responses): + if isinstance(user_response, BaseException): + members.append(MessageMember(user_id=member_id, nickname=member_id)) + continue try: - user_info = await self.web_client.users_info(user=member_id) - user_data = cast(dict, user_info["user"]) - members.append( - MessageMember( - user_id=member_id, - nickname=user_data.get("real_name") - or user_data.get("name", member_id), - ), + user_data = cast(dict, user_response["user"]) + nickname = ( + user_data.get("real_name") or user_data.get("name") or member_id ) - except Exception: - # 如果获取用户信息失败,使用默认信息 - members.append(MessageMember(user_id=member_id, nickname=member_id)) + except (KeyError, TypeError, AttributeError): + nickname = member_id + members.append( + MessageMember( + user_id=member_id, + nickname=nickname, + ), + ) - channel_data = cast(dict, channel_info["channel"]) - return Group( - group_id=channel_id, - group_name=channel_data.get("name", ""), - group_avatar="", - group_admins=[], # Slack 的管理员信息需要特殊权限获取 - group_owner=channel_data.get("creator", ""), - members=members, - ) - except Exception: - return None + group.members = members + group.member_count = group.member_count or len(members) + return group diff --git a/astrbot/core/platform/sources/telegram/tg_adapter.py b/astrbot/core/platform/sources/telegram/tg_adapter.py index b8c3a7a079..636e5d9f4c 100644 --- a/astrbot/core/platform/sources/telegram/tg_adapter.py +++ b/astrbot/core/platform/sources/telegram/tg_adapter.py @@ -19,6 +19,7 @@ from astrbot.api.event import MessageChain from astrbot.api.platform import ( AstrBotMessage, + Group, MessageMember, MessageType, Platform, @@ -44,6 +45,8 @@ @register_platform_adapter("telegram", "telegram 适配器") class TelegramPlatformAdapter(Platform): + _FORUM_TOPIC_NAME_CACHE_MAX_SIZE = 1000 + def __init__( self, platform_config: dict, @@ -117,6 +120,7 @@ def __init__( self._polling_recovery_threshold = 3 self._polling_failure_window = 60.0 self._application_started = False + self._forum_topic_names: dict[tuple[str, int | None], str] = {} self._build_application() # Media group handling @@ -474,11 +478,69 @@ def _apply_caption() -> None: message.type = MessageType.FRIEND_MESSAGE else: message.type = MessageType.GROUP_MESSAGE - message.group_id = str(update.message.chat.id) - if update.message.is_topic_message and update.message.message_thread_id: + chat_id = str(update.message.chat.id) + group_id = chat_id + is_forum = getattr(update.message.chat, "is_forum", False) is True + raw_thread_id = ( + update.message.message_thread_id + if update.message.is_topic_message + else None + ) + thread_id = ( + raw_thread_id + if raw_thread_id and not (is_forum and raw_thread_id == 1) + else None + ) + if thread_id is not None: # Telegram Topic Group: include thread id to isolate per-topic sessions. - message.group_id += "#" + str(update.message.message_thread_id) - message.session_id = message.group_id + group_id += "#" + str(thread_id) + message.session_id = group_id + + chat_title = getattr(update.message.chat, "title", None) + group_name = chat_title if isinstance(chat_title, str) else None + topic_name = None + topic_created = getattr(update.message, "forum_topic_created", None) + topic_edited = getattr(update.message, "forum_topic_edited", None) + discovered_topic_name = getattr(topic_created, "name", None) + if not isinstance(discovered_topic_name, str): + discovered_topic_name = getattr(topic_edited, "name", None) + if not isinstance(discovered_topic_name, str): + reply_message = update.message.reply_to_message + reply_topic_created = getattr( + reply_message, "forum_topic_created", None + ) + discovered_topic_name = getattr(reply_topic_created, "name", None) + + topic_key = None + if thread_id is not None: + topic_key = (chat_id, thread_id) + elif is_forum: + topic_key = (chat_id, None) + + if topic_key is not None: + cached_topic_name = self._forum_topic_names.pop(topic_key, None) + if ( + isinstance(discovered_topic_name, str) + and discovered_topic_name.strip() + ): + cached_topic_name = discovered_topic_name.strip() + if cached_topic_name: + self._forum_topic_names[topic_key] = cached_topic_name + if ( + len(self._forum_topic_names) + > self._FORUM_TOPIC_NAME_CACHE_MAX_SIZE + ): + oldest_topic_key = next(iter(self._forum_topic_names)) + del self._forum_topic_names[oldest_topic_key] + topic_name = cached_topic_name + + if group_name and topic_name: + group_name = f"{group_name}-{topic_name}" + message.group = Group( + group_id=group_id, + group_name=group_name, + ) + message._telegram_topic_name = topic_name message.message_id = str(update.message.message_id) _from_user = update.message.from_user if not _from_user: diff --git a/astrbot/core/platform/sources/telegram/tg_event.py b/astrbot/core/platform/sources/telegram/tg_event.py index 8445a8ea1e..c1eaaf135b 100644 --- a/astrbot/core/platform/sources/telegram/tg_event.py +++ b/astrbot/core/platform/sources/telegram/tg_event.py @@ -21,7 +21,7 @@ Reply, Video, ) -from astrbot.api.platform import AstrBotMessage, MessageType, PlatformMetadata +from astrbot.api.platform import AstrBotMessage, Group, MessageType, PlatformMetadata from astrbot.core.utils.metrics import Metric @@ -347,6 +347,102 @@ async def send(self, message: MessageChain) -> None: await self.send_with_client(self.client, message, self.get_sender_id()) await super().send(message) + async def get_group( + self, group_id: str | None = None, **kwargs: Any + ) -> Group | None: + """Get Telegram group metadata available to the bot. + + Telegram topics use ``#`` inside AstrBot. The Bot API + calls target the parent chat while the returned group keeps the topic-aware ID. + + Args: + group_id: AstrBot group ID to query. Defaults to the current group. + **kwargs: Reserved for compatibility with the platform event interface. + + Returns: + Enriched group metadata, or ``None`` when no group ID is available. + """ + requested_group_id = str(group_id or self.get_group_id()) + if not requested_group_id: + return None + + current_group = self.message_obj.group + group = Group( + group_id=requested_group_id, + group_name=( + current_group.group_name + if current_group and current_group.group_id == requested_group_id + else None + ), + ) + topic_name = ( + getattr(self.message_obj, "_telegram_topic_name", None) + if current_group and current_group.group_id == requested_group_id + else None + ) + chat_id = requested_group_id.split("#", 1)[0] + api_chat_id: str | int = ( + int(chat_id) if chat_id.lstrip("-").isdigit() else chat_id + ) + + try: + chat = await self.client.get_chat(chat_id=api_chat_id) + title = getattr(chat, "title", None) + if isinstance(title, str): + group.group_name = ( + f"{title}-{topic_name}" + if isinstance(topic_name, str) and topic_name + else title + ) + + photo = getattr(chat, "photo", None) + file_id = getattr(photo, "big_file_id", None) if photo else None + if file_id: + try: + photo_file = await self.client.get_file(file_id=file_id) + file_path = getattr(photo_file, "file_path", None) + if file_path: + group.group_avatar = str(file_path) + except Exception as exc: + logger.warning( + f"[Telegram] Failed to get group photo for {chat_id}: {exc}" + ) + except Exception as exc: + logger.warning( + f"[Telegram] Failed to get group information for {chat_id}: {exc}" + ) + + try: + group.member_count = await self.client.get_chat_member_count( + chat_id=api_chat_id + ) + except Exception as exc: + logger.warning( + f"[Telegram] Failed to get group member count for {chat_id}: {exc}" + ) + + try: + administrators = await self.client.get_chat_administrators( + chat_id=api_chat_id + ) + group.group_admins = [] + for administrator in administrators: + status = getattr(administrator, "status", None) + user = getattr(administrator, "user", None) + user_id = getattr(user, "id", None) + if user_id is None: + continue + if status == "creator": + group.group_owner = str(user_id) + elif status == "administrator": + group.group_admins.append(str(user_id)) + except Exception as exc: + logger.warning( + f"[Telegram] Failed to get group administrators for {chat_id}: {exc}" + ) + + return group + async def react(self, emoji: str | None, big: bool = False) -> None: """给原消息添加 Telegram 反应: - 普通 emoji:传入 '👍'、'😂' 等 diff --git a/astrbot/core/platform/sources/wecom_ai_bot/wecomai_adapter.py b/astrbot/core/platform/sources/wecom_ai_bot/wecomai_adapter.py index 1c0ee96c0d..8238318567 100644 --- a/astrbot/core/platform/sources/wecom_ai_bot/wecomai_adapter.py +++ b/astrbot/core/platform/sources/wecom_ai_bot/wecomai_adapter.py @@ -544,6 +544,8 @@ async def convert_message(self, payload: dict) -> AstrBotMessage: if message_data.get("chattype") == "group" else MessageType.FRIEND_MESSAGE ) + if abm.type == MessageType.GROUP_MESSAGE and message_data.get("chatid"): + abm.group_id = str(message_data["chatid"]) abm.session_id = session_id # 消息内容 diff --git a/docs/en/platform/lark.md b/docs/en/platform/lark.md index 173d0d8edb..293608939f 100644 --- a/docs/en/platform/lark.md +++ b/docs/en/platform/lark.md @@ -38,7 +38,7 @@ Under `Creation Method`, select `One-click QR Creation`, choose the China or int > To find the App ID, go back to AstrBot's `Bots` page, find the Lark bot you just created, click `Edit`, and check the dialog that opens. > > ```text -> https://open.feishu.cn/app//auth?q=contact:contact.base:readonly,im:message.p2p_msg:readonly,im:message.group_at_msg:readonly,im:message:send,im:message,im:message:send_as_bot,im:resource:upload,im:resource,cardkit:card:write,im:message.group_at_msg:readonly,im:message.group_msg&op_from=openapi&token_type=tenant +> https://open.feishu.cn/app//auth?q=contact:contact.base:readonly,contact:user.base:readonly,im:message.p2p_msg:readonly,im:message.group_at_msg:readonly,im:message:send,im:message,im:message:send_as_bot,im:resource:upload,im:resource,cardkit:card:write,im:message.group_at_msg:readonly,im:message.group_msg&op_from=openapi&token_type=tenant > ``` After QR creation succeeds, continue checking the event subscription, permissions, version release, and group installation steps below. @@ -117,6 +117,8 @@ Next, click on "Permission Management," click "Enable Permissions," and enter `i Enter `im:resource:upload,im:resource` again to enable image upload permissions. +Enable `contact:contact.base:readonly` and `contact:user.base:readonly` so private-message senders can be shown by their display names. + If you want to use the bot in group chats, additionally enable `im:message.group_at_msg:readonly` and `im:message.group_msg`. > [!TIP] diff --git a/docs/zh/platform/lark.md b/docs/zh/platform/lark.md index 782b1a83cf..a788a80e7f 100644 --- a/docs/zh/platform/lark.md +++ b/docs/zh/platform/lark.md @@ -38,7 +38,7 @@ > App ID 获取方式:回到 AstrBot 的 `机器人` 页,找到刚刚创建的飞书机器人,点击 `编辑`,弹出的对话框中可以看到 App ID。 > > ```text -> https://open.feishu.cn/app//auth?q=contact:contact.base:readonly,im:message.p2p_msg:readonly,im:message.group_at_msg:readonly,im:message:send,im:message,im:message:send_as_bot,im:resource:upload,im:resource,cardkit:card:write,im:message.group_at_msg:readonly,im:message.group_msg&op_from=openapi&token_type=tenant +> https://open.feishu.cn/app//auth?q=contact:contact.base:readonly,contact:user.base:readonly,im:message.p2p_msg:readonly,im:message.group_at_msg:readonly,im:message:send,im:message,im:message:send_as_bot,im:resource:upload,im:resource,cardkit:card:write,im:message.group_at_msg:readonly,im:message.group_msg&op_from=openapi&token_type=tenant > ``` 扫码创建完成后,建议继续检查后文的事件订阅、权限、版本发布和拉入群组步骤。 @@ -117,6 +117,8 @@ 再次输入 `im:resource:upload,im:resource` 开通上传图片相关的权限。 +开通 `contact:contact.base:readonly` 和 `contact:user.base:readonly`,用于读取私聊发送者的显示名。 + 如果需要在群聊里使用,请额外开通 `im:message.group_at_msg:readonly` 和 `im:message.group_msg` 权限。 > [!TIP] diff --git a/tests/test_dingtalk_adapter.py b/tests/test_dingtalk_adapter.py index 818ebd1156..2aa7106079 100644 --- a/tests/test_dingtalk_adapter.py +++ b/tests/test_dingtalk_adapter.py @@ -6,6 +6,7 @@ from astrbot.api.message_components import At, Plain from astrbot.core.message.message_event_result import MessageChain +from astrbot.core.platform.platform_metadata import PlatformMetadata from astrbot.core.platform.sources.dingtalk import dingtalk_adapter from astrbot.core.platform.sources.dingtalk.dingtalk_adapter import ( DINGTALK_RECONNECT_INITIAL_DELAY, @@ -13,6 +14,9 @@ DingtalkPlatformAdapter, _dingtalk_reconnect_delay, ) +from astrbot.core.platform.sources.dingtalk.dingtalk_event import ( + DingtalkMessageEvent, +) def _dingtalk_group_message(**payload) -> dingtalk_stream.ChatbotMessage: @@ -167,6 +171,32 @@ async def test_dingtalk_self_mention_produces_consistent_command_text(payload): assert result.message[1].text == "/server" +@pytest.mark.asyncio +async def test_dingtalk_group_message_includes_available_group_details(): + adapter = DingtalkPlatformAdapter.__new__(DingtalkPlatformAdapter) + message = _dingtalk_group_message( + conversationTitle="AstrBot Group", + isAdmin=True, + msgtype="text", + text={"content": "hello"}, + ) + + result = await adapter.convert_msg(message) + + assert result.group is not None + assert result.group.group_id == "conversation" + assert result.group.group_name == "AstrBot Group" + assert result.group.group_admins is None + + event = DingtalkMessageEvent( + result.message_str, + result, + PlatformMetadata(name="dingtalk", description="DingTalk", id="dingtalk"), + result.session_id, + ) + assert await event.get_group() is result.group + + @pytest.mark.asyncio async def test_dingtalk_rich_text_preserves_non_self_mention_text(): adapter = DingtalkPlatformAdapter.__new__(DingtalkPlatformAdapter) diff --git a/tests/test_discord_adapter.py b/tests/test_discord_adapter.py index 0d0e281a8d..0d4bdfd66f 100644 --- a/tests/test_discord_adapter.py +++ b/tests/test_discord_adapter.py @@ -1,10 +1,12 @@ import base64 from io import BytesIO from types import SimpleNamespace +from unittest.mock import AsyncMock import pytest from astrbot.api.message_components import Image, Record +from astrbot.api.platform import Group, MessageType from astrbot.core.message.message_event_result import MessageChain from astrbot.core.platform.sources.discord import ( discord_platform_adapter, @@ -24,6 +26,231 @@ _WAV_PATH = "/tmp/discord_voice.wav" +@pytest.mark.asyncio +async def test_discord_group_message_includes_guild_and_channel_name(): + adapter = DiscordPlatformAdapter.__new__(DiscordPlatformAdapter) + adapter.bot_self_id = "1" + adapter.client = SimpleNamespace(user=SimpleNamespace(id=1)) + guild = SimpleNamespace(name="AstrBot", get_member=lambda member_id: None) + message = SimpleNamespace( + id=42, + content="hello", + channel=SimpleNamespace(id=123, name="general", guild=guild), + author=SimpleNamespace(id=2, display_name="tester"), + attachments=[], + guild=guild, + role_mentions=[], + ) + + abm = await adapter.convert_message({"message": message}) + + assert abm.group is not None + assert abm.group.group_id == "123" + assert abm.group.group_name == "AstrBot-general" + + +@pytest.mark.asyncio +async def test_discord_private_message_does_not_get_group_name(): + adapter = DiscordPlatformAdapter.__new__(DiscordPlatformAdapter) + adapter.bot_self_id = "1" + adapter.client = SimpleNamespace(user=SimpleNamespace(id=1)) + message = SimpleNamespace( + id=42, + content="hello", + channel=SimpleNamespace(id=123, name="direct-message", guild=None), + author=SimpleNamespace(id=2, display_name="tester"), + attachments=[], + guild=None, + role_mentions=[], + ) + + abm = await adapter.convert_message({"message": message}) + + assert abm.type == MessageType.FRIEND_MESSAGE + assert abm.group is not None + assert abm.group.group_name is None + + +def test_discord_group_name_falls_back_when_one_name_is_missing(): + assert ( + DiscordPlatformAdapter._get_group_name( + SimpleNamespace(name="general", guild=SimpleNamespace(name=None)) + ) + == "general" + ) + assert ( + DiscordPlatformAdapter._get_group_name( + SimpleNamespace(name=None, guild=SimpleNamespace(name="AstrBot")) + ) + == "AstrBot" + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("guild_name", "channel_name", "expected_name"), + [(None, "general", "general"), ("AstrBot", None, "AstrBot")], +) +async def test_discord_get_group_name_falls_back_when_one_name_is_missing( + guild_name, channel_name, expected_name +): + guild = SimpleNamespace( + name=guild_name, + icon=None, + owner_id=None, + member_count=None, + members=[], + chunked=False, + ) + channel = SimpleNamespace(id=123, name=channel_name, guild=guild) + event = DiscordPlatformEvent.__new__(DiscordPlatformEvent) + event.message_obj = SimpleNamespace( + type=MessageType.GROUP_MESSAGE, + group=Group(group_id="123", group_name="cached"), + group_id="123", + ) + event.client = SimpleNamespace( + get_channel=lambda channel_id: channel, + intents=SimpleNamespace(members=False), + ) + + group = await event.get_group() + + assert group is not None + assert group.group_name == expected_name + + +@pytest.mark.asyncio +async def test_discord_get_group_fetches_uncached_guild_name(): + channel = SimpleNamespace( + id=123, + name="general", + guild=SimpleNamespace(id=456), + ) + guild = SimpleNamespace( + id=456, + name="AstrBot", + icon=None, + owner_id=None, + member_count=None, + members=[], + chunked=False, + ) + client = SimpleNamespace( + get_channel=lambda channel_id: None, + fetch_channel=AsyncMock(return_value=channel), + get_guild=lambda guild_id: None, + fetch_guild=AsyncMock(return_value=guild), + intents=SimpleNamespace(members=False), + ) + event = DiscordPlatformEvent.__new__(DiscordPlatformEvent) + event.message_obj = SimpleNamespace( + type=MessageType.GROUP_MESSAGE, + group=Group(group_id="123"), + group_id="123", + ) + event.client = client + + group = await event.get_group() + + assert group is not None + assert group.group_name == "AstrBot-general" + client.fetch_channel.assert_awaited_once_with(123) + client.fetch_guild.assert_awaited_once_with(456) + + +@pytest.mark.asyncio +async def test_discord_get_group_enriches_guild_metadata_from_complete_cache(): + members = [ + SimpleNamespace( + id=1, + display_name="owner", + guild_permissions=SimpleNamespace(administrator=True), + ), + SimpleNamespace( + id=2, + display_name="admin", + guild_permissions=SimpleNamespace(administrator=True), + ), + SimpleNamespace( + id=3, + display_name="member", + guild_permissions=SimpleNamespace(administrator=False), + ), + ] + guild = SimpleNamespace( + name="AstrBot", + icon=SimpleNamespace(url="https://cdn.discordapp.com/guild.png"), + owner_id=1, + member_count=3, + members=members, + chunked=True, + ) + channel = SimpleNamespace( + id=123, + name="general", + guild=guild, + permissions_for=lambda member: SimpleNamespace(view_channel=True), + ) + client = SimpleNamespace( + get_channel=lambda channel_id: channel, + fetch_channel=AsyncMock(), + intents=SimpleNamespace(members=True), + ) + event = DiscordPlatformEvent.__new__(DiscordPlatformEvent) + event.message_obj = SimpleNamespace( + type=MessageType.GROUP_MESSAGE, + group=Group(group_id="123", group_name="general"), + group_id="123", + ) + event.client = client + + group = await event.get_group() + + assert group is not None + assert group.group_id == "123" + assert group.group_name == "AstrBot-general" + assert group.group_avatar == "https://cdn.discordapp.com/guild.png" + assert group.group_owner == "1" + assert group.member_count == 3 + assert group.group_admins == ["2"] + assert group.members is not None + assert [member.user_id for member in group.members] == ["1", "2", "3"] + client.fetch_channel.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_discord_get_group_returns_none_for_private_message(): + event = DiscordPlatformEvent.__new__(DiscordPlatformEvent) + event.message_obj = SimpleNamespace( + type=MessageType.FRIEND_MESSAGE, + group=None, + group_id="123", + ) + event.client = SimpleNamespace() + + assert await event.get_group() is None + + +@pytest.mark.asyncio +async def test_discord_get_group_keeps_basic_metadata_when_channel_fetch_fails(): + client = SimpleNamespace( + get_channel=lambda channel_id: None, + fetch_channel=AsyncMock(side_effect=RuntimeError("channel unavailable")), + ) + event = DiscordPlatformEvent.__new__(DiscordPlatformEvent) + event.message_obj = SimpleNamespace( + type=MessageType.GROUP_MESSAGE, + group=Group(group_id="123", group_name="general"), + group_id="123", + ) + event.client = client + + group = await event.get_group() + + assert group == Group(group_id="123", group_name="general") + + @pytest.mark.asyncio async def test_discord_audio_attachment_resolves_to_wav_record(monkeypatch): class FakeMediaResolver: diff --git a/tests/test_kook/test_kook_client.py b/tests/test_kook/test_kook_client.py index c8bbeacf65..1d7c3ce102 100644 --- a/tests/test_kook/test_kook_client.py +++ b/tests/test_kook/test_kook_client.py @@ -216,6 +216,8 @@ async def test_kook_event_warp_message( assert astrbotMessage.raw_message == raw_event["d"] assert astrbotMessage.message_id == raw_event["d"]["msg_id"] assert astrbotMessage.message == expected_message_components + if event.data.channel_type.value == "GROUP": + assert astrbotMessage.group.group_name == event.data.extra.channel_name if isinstance(expected_message_str, str): assert astrbotMessage.message_str == expected_message_str else: diff --git a/tests/test_kook/test_kook_event.py b/tests/test_kook/test_kook_event.py index dc81ebafad..31e35bdaa9 100644 --- a/tests/test_kook/test_kook_event.py +++ b/tests/test_kook/test_kook_event.py @@ -1,8 +1,15 @@ import json +from unittest.mock import AsyncMock import pytest -from astrbot.api.platform import PlatformMetadata, Unknown +from astrbot.api.platform import ( + AstrBotMessage, + Group, + MessageType, + PlatformMetadata, + Unknown, +) from astrbot.core.message.components import ( At, AtAll, @@ -172,3 +179,109 @@ async def test_kook_event_warp_message( assert result.index == expected_output.index assert result.type == expected_output.type assert result.reply_id == expected_output.reply_id + + +@pytest.mark.asyncio +async def test_kook_get_group_enriches_channel_with_guild_members(): + client = mock_kook_client("", "") + client.get_channel = AsyncMock( + return_value={"id": "channel-1", "name": "general", "guild_id": "guild-1"}, + ) + client.get_guild = AsyncMock( + return_value={ + "id": "guild-1", + "icon": "https://example.com/icon.png", + "user_id": "owner-1", + }, + ) + client.get_guild_roles = AsyncMock( + return_value={ + "items": [ + {"role_id": 1, "permissions": 1}, + {"role_id": 2, "permissions": 0}, + ], + "meta": {"page_total": 1, "total": 2}, + }, + ) + client.get_guild_users = AsyncMock( + side_effect=[ + { + "items": [ + {"id": "owner-1", "nickname": "Owner", "roles": [1]}, + {"id": "user-1", "username": "Alice", "roles": [2]}, + ], + "meta": {"page_total": 2, "total": 3}, + "user_count": 99, + }, + { + "items": [{"id": "user-2", "username": "Bob", "roles": [1]}], + "meta": {"page_total": 2, "total": 3}, + "user_count": 99, + }, + ], + ) + message = AstrBotMessage() + message.type = MessageType.GROUP_MESSAGE + message.group = Group(group_id="channel-1", group_name="cached-channel") + message.session_id = "channel-1" + message.message_id = "message-1" + message.message = [] + message.message_str = "hello" + message.raw_message = {"extra": {"guild_id": "guild-1"}} + event = KookEvent( + "hello", + message, + PlatformMetadata(name="kook", id="kook", description="KOOK"), + "channel-1", + client, + ) + + group = await event.get_group() + + assert group.group_name == "general" + assert group.group_avatar == "https://example.com/icon.png" + assert group.group_owner == "owner-1" + assert group.member_count == 3 + assert [member.user_id for member in group.members] == [ + "owner-1", + "user-1", + "user-2", + ] + assert group.group_admins == ["owner-1", "user-2"] + assert client.get_guild_users.await_count == 2 + client.get_guild_users.assert_any_await( + "guild-1", + channel_id="channel-1", + page=1, + page_size=50, + ) + client.get_guild_roles.assert_awaited_once_with( + "guild-1", + page=1, + page_size=50, + ) + + +@pytest.mark.asyncio +async def test_kook_get_group_returns_basic_group_when_lookup_fails(): + client = mock_kook_client("", "") + client.get_channel = AsyncMock(side_effect=RuntimeError("forbidden")) + message = AstrBotMessage() + message.type = MessageType.GROUP_MESSAGE + message.group = Group(group_id="channel-1", group_name="cached-channel") + message.session_id = "channel-1" + message.message_id = "message-1" + message.message = [] + message.message_str = "hello" + message.raw_message = {} + event = KookEvent( + "hello", + message, + PlatformMetadata(name="kook", id="kook", description="KOOK"), + "channel-1", + client, + ) + + group = await event.get_group() + + assert group == Group(group_id="channel-1", group_name="cached-channel") diff --git a/tests/test_lark_group_info.py b/tests/test_lark_group_info.py new file mode 100644 index 0000000000..5bdc71bc68 --- /dev/null +++ b/tests/test_lark_group_info.py @@ -0,0 +1,186 @@ +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +from astrbot.core.platform.astrbot_message import AstrBotMessage, Group, MessageMember +from astrbot.core.platform.message_type import MessageType +from astrbot.core.platform.platform_metadata import PlatformMetadata +from astrbot.core.platform.sources.lark.lark_event import LarkMessageEvent + + +def _lark_event(bot) -> LarkMessageEvent: + """Build a group event with the provided Lark client. + + Args: + bot: Lark client or a compatible test double. + + Returns: + Group message event for tests. + """ + message = AstrBotMessage() + message.type = MessageType.GROUP_MESSAGE + message.self_id = "bot" + message.session_id = "chat-1" + message.message_id = "message-1" + message.group = Group(group_id="chat-1") + message.sender = MessageMember(user_id="sender", nickname="Sender") + message.message = [] + message.message_str = "hello" + message.raw_message = None + return LarkMessageEvent( + message_str=message.message_str, + message_obj=message, + platform_meta=PlatformMetadata( + name="lark", + description="Lark", + id="lark-account", + ), + session_id=message.session_id, + bot=bot, + ) + + +@pytest.mark.asyncio +async def test_lark_get_group_fetches_details_and_all_member_pages(): + chat_api = SimpleNamespace( + aget=AsyncMock( + return_value=SimpleNamespace( + success=lambda: True, + data=SimpleNamespace( + name="AstrBot Group", + avatar="https://example.com/avatar.png", + owner_id="owner", + user_manager_id_list=["admin"], + user_count="3", + ), + ), + ), + ) + members_api = SimpleNamespace( + aget=AsyncMock( + side_effect=[ + SimpleNamespace( + success=lambda: True, + data=SimpleNamespace( + items=[ + SimpleNamespace(member_id="owner", name="Owner"), + SimpleNamespace(member_id="admin", name="Admin"), + ], + member_total=3, + page_token="next-page", + has_more=True, + ), + ), + SimpleNamespace( + success=lambda: True, + data=SimpleNamespace( + items=[SimpleNamespace(member_id="member", name="Member")], + member_total=3, + page_token=None, + has_more=False, + ), + ), + ], + ), + ) + bot = SimpleNamespace( + im=SimpleNamespace( + v1=SimpleNamespace(chat=chat_api, chat_members=members_api), + ), + ) + + group = await _lark_event(bot).get_group() + + assert group is not None + assert group.group_id == "chat-1" + assert group.group_name == "AstrBot Group" + assert group.group_avatar == "https://example.com/avatar.png" + assert group.group_owner == "owner" + assert group.group_admins == ["admin"] + assert group.member_count == 3 + assert [(member.user_id, member.nickname) for member in group.members or []] == [ + ("owner", "Owner"), + ("admin", "Admin"), + ("member", "Member"), + ] + + chat_request = chat_api.aget.await_args.args[0] + assert chat_request.chat_id == "chat-1" + assert chat_request.user_id_type == "open_id" + member_requests = [call.args[0] for call in members_api.aget.await_args_list] + assert member_requests[0].member_id_type == "open_id" + assert member_requests[0].page_size == 100 + assert member_requests[0].page_token is None + assert member_requests[1].page_token == "next-page" + + +@pytest.mark.asyncio +async def test_lark_get_group_falls_back_to_incoming_group_on_api_failure(): + chat_api = SimpleNamespace( + aget=AsyncMock( + return_value=SimpleNamespace( + success=lambda: False, + data=None, + code=999, + msg="permission denied", + ), + ), + ) + bot = SimpleNamespace( + im=SimpleNamespace( + v1=SimpleNamespace( + chat=chat_api, + chat_members=SimpleNamespace(aget=AsyncMock()), + ), + ), + ) + event = _lark_event(bot) + + group = await event.get_group() + + assert group is event.message_obj.group + assert group.group_id == "chat-1" + + +@pytest.mark.asyncio +async def test_lark_get_group_does_not_publish_a_truncated_member_list(): + chat_api = SimpleNamespace( + aget=AsyncMock( + return_value=SimpleNamespace( + success=lambda: True, + data=SimpleNamespace( + name="AstrBot Group", + avatar=None, + owner_id=None, + user_manager_id_list=None, + user_count="10", + ), + ), + ), + ) + members_api = SimpleNamespace( + aget=AsyncMock( + return_value=SimpleNamespace( + success=lambda: True, + data=SimpleNamespace( + items=[SimpleNamespace(member_id="member", name="Member")], + member_total=10, + page_token=None, + has_more=False, + trigger_security_conf_limit=True, + ), + ), + ), + ) + bot = SimpleNamespace( + im=SimpleNamespace( + v1=SimpleNamespace(chat=chat_api, chat_members=members_api), + ), + ) + + group = await _lark_event(bot).get_group() + + assert group is not None + assert group.member_count == 10 + assert group.members is None diff --git a/tests/test_lark_sender_name.py b/tests/test_lark_sender_name.py new file mode 100644 index 0000000000..376a8ab673 --- /dev/null +++ b/tests/test_lark_sender_name.py @@ -0,0 +1,124 @@ +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +from astrbot.core.platform.sources.lark.lark_adapter import LarkPlatformAdapter + + +def _private_message_event(message_id: str = "message-1") -> SimpleNamespace: + """Builds a Lark private-message event. + + Args: + message_id: Unique message identifier. + + Returns: + Lark-compatible event object. + """ + return SimpleNamespace( + event=SimpleNamespace( + sender=SimpleNamespace( + sender_id=SimpleNamespace(open_id="ou_sender"), + sender_type="user", + ), + message=SimpleNamespace( + create_time="1700000000000", + chat_type="p2p", + chat_id="oc_private", + parent_id=None, + mentions=None, + content='{"text":"hello"}', + message_id=message_id, + message_type="text", + ), + ), + ) + + +def _adapter(user_response: SimpleNamespace) -> LarkPlatformAdapter: + """Builds an adapter with a mocked Contact API. + + Args: + user_response: Response returned by the Contact API. + + Returns: + Lark adapter test double. + """ + adapter = LarkPlatformAdapter.__new__(LarkPlatformAdapter) + adapter.bot_open_id = "ou_bot" + adapter.bot_name = "AstrBot" + adapter._user_name_cache = {} + adapter.handle_msg = AsyncMock() + adapter.lark_api = SimpleNamespace( + contact=SimpleNamespace( + v3=SimpleNamespace( + user=SimpleNamespace(aget=AsyncMock(return_value=user_response)), + ), + ), + ) + return adapter + + +@pytest.mark.asyncio +async def test_lark_private_sender_uses_contact_display_name_and_cache(): + adapter = _adapter( + SimpleNamespace( + success=lambda: True, + data=SimpleNamespace(user=SimpleNamespace(name="Alice Zhang")), + ), + ) + + await adapter.convert_msg(_private_message_event("message-1")) + await adapter.convert_msg(_private_message_event("message-2")) + + first_message = adapter.handle_msg.await_args_list[0].args[0] + second_message = adapter.handle_msg.await_args_list[1].args[0] + assert first_message.sender.user_id == "ou_sender" + assert first_message.sender.nickname == "Alice Zhang" + assert second_message.sender.nickname == "Alice Zhang" + adapter.lark_api.contact.v3.user.aget.assert_awaited_once() + request = adapter.lark_api.contact.v3.user.aget.await_args.args[0] + assert request.user_id == "ou_sender" + assert request.user_id_type == "open_id" + + +@pytest.mark.asyncio +async def test_lark_private_sender_falls_back_when_contact_lookup_fails(): + adapter = _adapter( + SimpleNamespace( + success=lambda: False, + data=None, + code=999, + msg="permission denied", + ), + ) + + await adapter.convert_msg(_private_message_event()) + + message = adapter.handle_msg.await_args.args[0] + assert message.sender.user_id == "ou_sender" + assert message.sender.nickname == "ou_sende" + + +@pytest.mark.asyncio +async def test_lark_private_sender_retries_after_failure_cache_expires(): + adapter = _adapter( + SimpleNamespace( + success=lambda: False, + data=None, + code=999, + msg="permission denied", + ), + ) + + await adapter.convert_msg(_private_message_event("message-1")) + adapter._user_name_cache["ou_sender"] = ("ou_sende", 0) + adapter.lark_api.contact.v3.user.aget.return_value = SimpleNamespace( + success=lambda: True, + data=SimpleNamespace(user=SimpleNamespace(name="Alice Zhang")), + ) + await adapter.convert_msg(_private_message_event("message-2")) + + message = adapter.handle_msg.await_args_list[1].args[0] + assert message.sender.nickname == "Alice Zhang" + assert adapter.lark_api.contact.v3.user.aget.await_count == 2 diff --git a/tests/test_line_adapter.py b/tests/test_line_adapter.py new file mode 100644 index 0000000000..650f0c47e6 --- /dev/null +++ b/tests/test_line_adapter.py @@ -0,0 +1,229 @@ +import asyncio +from unittest.mock import AsyncMock + +import pytest + +from astrbot.core.platform.sources.line.line_adapter import LinePlatformAdapter +from astrbot.core.platform.sources.line.line_api import LineAPIClient +from tests.fixtures.helpers import make_platform_config + + +def _build_adapter() -> LinePlatformAdapter: + """Build a LINE adapter without making network requests. + + Returns: + Configured LINE platform adapter. + """ + return LinePlatformAdapter( + make_platform_config( + "line", + channel_access_token="test-token", + channel_secret="test-secret", + ), + {}, + asyncio.Queue(), + ) + + +def _group_message(source: dict) -> dict: + """Build a minimal LINE text message webhook event. + + Args: + source: LINE webhook source object. + + Returns: + LINE webhook message event. + """ + return { + "type": "message", + "mode": "active", + "timestamp": 1_700_000_000_000, + "webhookEventId": "event-1", + "source": source, + "message": {"id": "message-1", "type": "text", "text": "hello"}, + } + + +@pytest.mark.asyncio +async def test_line_group_message_carries_available_basic_group_information(): + adapter = _build_adapter() + + result = await adapter.convert_message( + _group_message( + { + "type": "group", + "groupId": "C-group", + "userId": "U-sender", + "groupName": "Webhook group", + "pictureUrl": "https://example.com/group.png", + } + ) + ) + + assert result is not None + assert result.group is not None + assert result.group.group_id == "C-group" + assert result.group.group_name == "Webhook group" + assert result.group.group_avatar == "https://example.com/group.png" + + +@pytest.mark.asyncio +async def test_line_group_message_does_not_use_group_id_as_name(): + adapter = _build_adapter() + + result = await adapter.convert_message( + _group_message( + { + "type": "group", + "groupId": "C-group", + "userId": "U-sender", + } + ) + ) + + assert result is not None + assert result.group is not None + assert result.group.group_id == "C-group" + assert result.group.group_name is None + + +@pytest.mark.asyncio +async def test_line_get_group_enriches_summary_count_and_members(): + adapter = _build_adapter() + message = await adapter.convert_message( + _group_message( + { + "type": "group", + "groupId": "C-group", + "userId": "U-sender", + } + ) + ) + assert message is not None + + adapter.line_api.get_group_summary = AsyncMock( + return_value={ + "groupId": "C-group", + "groupName": "LINE group", + "pictureUrl": "https://example.com/group.png", + } + ) + adapter.line_api.get_chat_member_count = AsyncMock(return_value=2) + adapter.line_api.get_chat_member_ids = AsyncMock(return_value=["U-one", "U-two"]) + adapter.line_api.get_chat_member_profile = AsyncMock( + side_effect=[ + {"userId": "U-one", "displayName": "Alice"}, + None, + ] + ) + + result = await adapter.create_event(message).get_group() + + assert result is not None + assert result.group_id == "C-group" + assert result.group_name == "LINE group" + assert result.group_avatar == "https://example.com/group.png" + assert result.member_count == 2 + assert result.members is not None + assert [(member.user_id, member.nickname) for member in result.members] == [ + ("U-one", "Alice"), + ("U-two", None), + ] + adapter.line_api.get_chat_member_count.assert_awaited_once_with("group", "C-group") + + +@pytest.mark.asyncio +async def test_line_get_room_uses_room_endpoints_without_summary(): + adapter = _build_adapter() + message = await adapter.convert_message( + _group_message( + { + "type": "room", + "roomId": "R-room", + "userId": "U-sender", + } + ) + ) + assert message is not None + + adapter.line_api.get_group_summary = AsyncMock() + adapter.line_api.get_chat_member_count = AsyncMock(return_value=3) + adapter.line_api.get_chat_member_ids = AsyncMock(return_value=None) + adapter.line_api.get_chat_member_profile = AsyncMock() + + result = await adapter.create_event(message).get_group() + + assert result is not None + assert result.group_id == "R-room" + assert result.group_name is None + assert result.member_count == 3 + assert result.members is None + adapter.line_api.get_group_summary.assert_not_awaited() + adapter.line_api.get_chat_member_count.assert_awaited_once_with("room", "R-room") + adapter.line_api.get_chat_member_profile.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_line_get_group_returns_basic_information_when_api_calls_fail(): + adapter = _build_adapter() + message = await adapter.convert_message( + _group_message( + { + "type": "group", + "groupId": "C-group", + "userId": "U-sender", + "groupName": "Webhook group", + } + ) + ) + assert message is not None + + adapter.line_api.get_group_summary = AsyncMock(side_effect=RuntimeError("denied")) + adapter.line_api.get_chat_member_count = AsyncMock( + side_effect=RuntimeError("denied") + ) + adapter.line_api.get_chat_member_ids = AsyncMock(side_effect=RuntimeError("denied")) + + result = await adapter.create_event(message).get_group() + + assert result is not None + assert result.group_id == "C-group" + assert result.group_name == "Webhook group" + assert result.member_count is None + assert result.members is None + + +@pytest.mark.asyncio +async def test_line_member_id_api_follows_pagination_tokens(): + client = LineAPIClient( + channel_access_token="test-token", + channel_secret="test-secret", + ) + client._get_json = AsyncMock( + side_effect=[ + {"memberIds": ["U-one"], "next": "next-page"}, + {"memberIds": ["U-two", "U-one"]}, + ] + ) + + result = await client.get_chat_member_ids("group", "C-group") + + assert result == ["U-one", "U-two"] + assert client._get_json.await_count == 2 + assert client._get_json.await_args_list[0].kwargs["params"] is None + assert client._get_json.await_args_list[1].kwargs["params"] == { + "start": "next-page" + } + + +@pytest.mark.asyncio +async def test_line_member_id_api_returns_none_when_restricted(): + client = LineAPIClient( + channel_access_token="test-token", + channel_secret="test-secret", + ) + client._get_json = AsyncMock(return_value=None) + + result = await client.get_chat_member_ids("room", "R-room") + + assert result is None diff --git a/tests/test_mattermost_adapter.py b/tests/test_mattermost_adapter.py index 153bbc6d65..1f414fd298 100644 --- a/tests/test_mattermost_adapter.py +++ b/tests/test_mattermost_adapter.py @@ -5,6 +5,7 @@ import pytest import astrbot.api.message_components as Comp +from astrbot.api.platform import Group from astrbot.core.platform.sources.mattermost.client import MattermostClient from astrbot.core.platform.sources.mattermost.mattermost_adapter import ( MattermostPlatformAdapter, @@ -45,11 +46,13 @@ async def test_mattermost_convert_message_strips_leading_self_mention(): }, data={ "channel_type": "O", + "channel_display_name": "Town Square", "sender_name": "alice", }, ) assert result is not None + assert result.group == Group(group_id="channel-1", group_name="Town Square") assert result.message_str == "/help now" assert isinstance(result.message[0], Comp.At) assert result.message[0].qq == "bot-id" @@ -111,3 +114,73 @@ async def to_path(self, **kwargs) -> str: path = Path(temp_path) assert path.exists() assert path.name.endswith(Path(expected_name).suffix) + + +@pytest.mark.asyncio +async def test_mattermost_get_group_returns_members_and_channel_admins(): + adapter = _build_adapter() + adapter.client.get_channel = AsyncMock( + return_value={"id": "channel-1", "display_name": "Town Square"}, + ) + adapter.client.get_channel_stats = AsyncMock(return_value={"member_count": 2}) + adapter.client.get_channel_members = AsyncMock( + return_value=[ + {"user_id": "user-1", "roles": "channel_user channel_admin"}, + {"user_id": "user-2", "roles": "channel_user", "scheme_admin": True}, + ], + ) + adapter.client.get_users_by_ids = AsyncMock( + return_value=[ + {"id": "user-1", "username": "alice", "nickname": "Alice"}, + {"id": "user-2", "username": "bob"}, + ], + ) + message = await adapter.convert_message( + post={ + "id": "post-1", + "channel_id": "channel-1", + "user_id": "user-1", + "message": "hello", + "create_at": 1_700_000_000_000, + "file_ids": [], + }, + data={ + "channel_type": "O", + "channel_display_name": "Cached Name", + "sender_name": "alice", + }, + ) + event = adapter.create_event(message) + + group = await event.get_group() + + assert group.group_name == "Town Square" + assert group.group_owner is None + assert group.member_count == 2 + assert [member.nickname for member in group.members] == ["Alice", "bob"] + assert group.group_admins == ["user-1", "user-2"] + + +@pytest.mark.asyncio +async def test_mattermost_get_group_returns_cached_name_when_lookup_fails(): + adapter = _build_adapter() + adapter.client.get_channel = AsyncMock(side_effect=RuntimeError("forbidden")) + message = await adapter.convert_message( + post={ + "id": "post-1", + "channel_id": "channel-1", + "user_id": "user-1", + "message": "hello", + "create_at": 1_700_000_000_000, + "file_ids": [], + }, + data={ + "channel_type": "O", + "channel_display_name": "Cached Name", + "sender_name": "alice", + }, + ) + + group = await adapter.create_event(message).get_group() + + assert group == Group(group_id="channel-1", group_name="Cached Name") diff --git a/tests/test_qqofficial_group_message_create.py b/tests/test_qqofficial_group_message_create.py index 14c8791429..338a92af0d 100644 --- a/tests/test_qqofficial_group_message_create.py +++ b/tests/test_qqofficial_group_message_create.py @@ -19,16 +19,17 @@ from astrbot.core.pipeline.result_decorate.stage import ResultDecorateStage from astrbot.core.platform.message_session import MessageSession from astrbot.core.platform.message_type import MessageType +from astrbot.core.platform.sources.qqofficial.qqofficial_message_event import ( + QQOfficialMessageEvent, +) from astrbot.core.platform.sources.qqofficial.qqofficial_platform_adapter import ( + PatchedMessage, QQOfficialPlatformAdapter, _ensure_group_message_create_parser, ) from astrbot.core.platform.sources.qqofficial.qqofficial_platform_adapter import ( botClient as QQOfficialBotClient, ) -from astrbot.core.platform.sources.qqofficial.qqofficial_message_event import ( - QQOfficialMessageEvent, -) from astrbot.core.platform.sources.qqofficial_webhook.qo_webhook_adapter import ( QQOfficialWebhookPlatformAdapter, ) @@ -44,6 +45,7 @@ def _make_group_payload( message_type: int | None = None, msg_elements: list[dict] | None = None, message_reference: dict | None = None, + group_name: str | None = None, ) -> dict: data = { "id": f"event-{message_id}", @@ -62,6 +64,8 @@ def _make_group_payload( data["d"]["msg_elements"] = msg_elements if message_reference is not None: data["d"]["message_reference"] = message_reference + if group_name is not None: + data["d"]["group_name"] = group_name return data @@ -103,7 +107,10 @@ async def test_group_message_create_parser_is_registered_and_dispatches_group_me @pytest.mark.asyncio async def test_parse_group_message_create_plain_message_has_no_at_component(): _, message = _dispatch_group_message( - _make_group_payload(content="plain group message") + _make_group_payload( + content="plain group message", + group_name="Incoming Group", + ) ) abm = await QQOfficialPlatformAdapter._parse_from_qqofficial( @@ -114,6 +121,8 @@ async def test_parse_group_message_create_plain_message_has_no_at_component(): assert abm.type == MessageType.GROUP_MESSAGE assert abm.sender.user_id == "member-1" assert abm.group_id == "group-1" + assert abm.group is not None + assert abm.group.group_name == "Incoming Group" assert abm.message_str == "plain group message" assert not any(isinstance(component, At) for component in abm.message) assert [ @@ -244,6 +253,154 @@ def commit_event(self, event) -> None: assert committed[0].session_id == "group-1" +@pytest.mark.asyncio +@pytest.mark.parametrize("use_webhook", [False, True]) +async def test_get_group_uses_authenticated_client_for_ws_and_webhook(use_webhook): + _, message = _dispatch_group_message( + _make_group_payload(group_name="Incoming Group") + ) + abm = await QQOfficialPlatformAdapter._parse_from_qqofficial( + message, + MessageType.GROUP_MESSAGE, + ) + abm.session_id = abm.group_id + + if use_webhook: + adapter = QQOfficialWebhookPlatformAdapter( + { + "id": "qq-official-webhook-test", + "appid": "123", + "secret": "secret", + }, + {}, + asyncio.Queue(), + ) + else: + adapter = QQOfficialPlatformAdapter( + { + "id": "qq-official-test", + "appid": "123", + "secret": "secret", + "enable_group_c2c": True, + "enable_guild_direct_message": False, + }, + {}, + asyncio.Queue(), + ) + + request = AsyncMock( + return_value={ + "group_openid": "group-1", + "group_name": "API Group", + "group_member_num": 42, + } + ) + adapter.client.api = SimpleNamespace(_http=SimpleNamespace(request=request)) + event = adapter.create_event(abm) + + group = await event.get_group() + + assert group is abm.group + assert group.group_id == "group-1" + assert group.group_name == "API Group" + assert group.member_count == 42 + route = request.await_args.args[0] + assert route.method == "GET" + assert route.path == "/v2/groups/{group_openid}/info" + assert route.parameters == {"group_openid": "group-1"} + + +@pytest.mark.asyncio +async def test_get_group_keeps_incoming_metadata_when_group_info_api_fails(): + _, message = _dispatch_group_message( + _make_group_payload(group_name="Incoming Group") + ) + abm = await QQOfficialPlatformAdapter._parse_from_qqofficial( + message, + MessageType.GROUP_MESSAGE, + ) + abm.session_id = abm.group_id + bot = SimpleNamespace( + api=SimpleNamespace( + _http=SimpleNamespace(request=AsyncMock(side_effect=PermissionError)) + ) + ) + event = QQOfficialMessageEvent( + abm.message_str, + abm, + SimpleNamespace(name="qq_official", id="qq-official-test"), + abm.session_id, + cast(Any, bot), + ) + + group = await event.get_group() + + assert group is abm.group + assert group.group_id == "group-1" + assert group.group_name == "Incoming Group" + assert group.member_count is None + + +@pytest.mark.asyncio +async def test_get_group_loads_channel_and_parent_guild_metadata(): + message = PatchedMessage( + None, + "event-channel-1", + { + "id": "channel-message-1", + "content": "<@!bot-1> hello channel", + "author": {"id": "user-1", "username": "Alice"}, + "channel_id": "channel-1", + "channel_name": "Incoming Channel", + "guild_id": "guild-1", + "mentions": [{"id": "bot-1", "is_you": True}], + "attachments": [], + }, + ) + abm = await QQOfficialPlatformAdapter._parse_from_qqofficial( + message, + MessageType.GROUP_MESSAGE, + ) + abm.session_id = abm.group_id + api = SimpleNamespace( + get_channel=AsyncMock( + return_value={ + "id": "channel-1", + "guild_id": "guild-1", + "name": "API Channel", + } + ), + get_guild=AsyncMock( + return_value={ + "id": "guild-1", + "icon": "https://example.com/guild.png", + "owner_id": "owner-1", + "member_count": "128", + } + ), + ) + event = QQOfficialMessageEvent( + abm.message_str, + abm, + SimpleNamespace(name="qq_official", id="qq-official-test"), + abm.session_id, + cast(Any, SimpleNamespace(api=api)), + ) + + assert abm.group is not None + assert abm.group.group_name == "Incoming Channel" + + group = await event.get_group() + + assert group.group_id == "channel-1" + assert group.group_name == "API Channel" + assert group.group_avatar == "https://example.com/guild.png" + assert group.group_owner == "owner-1" + assert group.member_count == 128 + api.get_channel.assert_awaited_once_with("channel-1") + api.get_guild.assert_awaited_once_with("guild-1") + + @pytest.mark.asyncio async def test_ws_group_send_by_session_without_cached_msg_id_omits_msg_id(): adapter = QQOfficialPlatformAdapter( @@ -317,9 +474,7 @@ async def test_media_upload_propagates_qq_api_error(monkeypatch): side_effect=botpy.errors.ServerError("413 Request Entity Too Large") ) send_helper = SimpleNamespace( - bot=SimpleNamespace( - api=SimpleNamespace(_http=SimpleNamespace(request=request)) - ) + bot=SimpleNamespace(api=SimpleNamespace(_http=SimpleNamespace(request=request))) ) monkeypatch.setattr( "astrbot.core.platform.sources.qqofficial.qqofficial_message_event._qqofficial_retry", diff --git a/tests/test_satori_group_info.py b/tests/test_satori_group_info.py new file mode 100644 index 0000000000..5b70bfa963 --- /dev/null +++ b/tests/test_satori_group_info.py @@ -0,0 +1,178 @@ +import asyncio +from unittest.mock import AsyncMock, call + +import pytest + +from astrbot.core.platform.message_type import MessageType +from astrbot.core.platform.sources.satori.satori_adapter import ( + SatoriPlatformAdapter, +) + + +async def _make_group_event(guild: dict): + adapter = SatoriPlatformAdapter( + {"id": "satori-test"}, + {}, + asyncio.Queue(), + ) + login = { + "platform": "discord", + "user": {"id": "bot-1", "name": "AstrBot"}, + } + message = await adapter.convert_satori_message( + {"id": "message-1", "content": "hello"}, + {"id": "user-1", "name": "Alice"}, + {"id": "channel-1", "name": "general"}, + guild, + login, + ) + assert message is not None + return adapter, message, adapter.create_event(message) + + +@pytest.mark.asyncio +async def test_satori_group_message_maps_event_guild_metadata(): + _, message, _ = await _make_group_event( + { + "id": "guild-1", + "name": "AstrBot Users", + "avatar": "https://example.com/guild.png", + }, + ) + + assert message.type == MessageType.GROUP_MESSAGE + assert message.group is not None + assert message.group.group_id == "guild-1" + assert message.group.group_name == "AstrBot Users" + assert message.group.group_avatar == "https://example.com/guild.png" + + +@pytest.mark.asyncio +async def test_satori_get_group_enriches_metadata_and_paginates_members(): + adapter, _, event = await _make_group_event( + {"id": "guild-1", "name": "Event Name"}, + ) + adapter.logins = [ + { + "platform": "discord", + "user": {"id": "bot-1"}, + "features": ["guild.get", "guild.member.list"], + }, + ] + adapter.send_http_request = AsyncMock( + side_effect=[ + { + "id": "guild-1", + "name": "Fetched Name", + "avatar": "https://example.com/fetched.png", + }, + { + "data": [ + { + "nick": "Alice in Guild", + "user": {"id": "user-1", "name": "Alice"}, + }, + ], + "next": "page-2", + }, + { + "data": [ + {"user": {"id": "user-2", "name": "Bob"}}, + ], + }, + ], + ) + + group = await event.get_group() + + assert group is not None + assert group.group_id == "guild-1" + assert group.group_name == "Fetched Name" + assert group.group_avatar == "https://example.com/fetched.png" + assert group.member_count == 2 + assert [(member.user_id, member.nickname) for member in group.members or []] == [ + ("user-1", "Alice in Guild"), + ("user-2", "Bob"), + ] + assert adapter.send_http_request.await_args_list == [ + call( + "POST", + "/guild.get", + {"guild_id": "guild-1"}, + "discord", + "bot-1", + ), + call( + "POST", + "/guild.member.list", + {"guild_id": "guild-1"}, + "discord", + "bot-1", + ), + call( + "POST", + "/guild.member.list", + {"guild_id": "guild-1", "next": "page-2"}, + "discord", + "bot-1", + ), + ] + + +@pytest.mark.asyncio +async def test_satori_get_group_falls_back_when_apis_are_unavailable(): + adapter, _, event = await _make_group_event( + { + "id": "guild-1", + "name": "Event Name", + "avatar": "https://example.com/event.png", + }, + ) + adapter.send_http_request = AsyncMock(side_effect=[{}, {}]) + + group = await event.get_group() + + assert group is not None + assert group.group_id == "guild-1" + assert group.group_name == "Event Name" + assert group.group_avatar == "https://example.com/event.png" + assert group.members is None + assert group.member_count is None + + +@pytest.mark.asyncio +async def test_satori_get_group_falls_back_when_api_calls_raise(): + adapter, _, event = await _make_group_event( + {"id": "guild-1", "name": "Event Name"}, + ) + adapter.send_http_request = AsyncMock( + side_effect=RuntimeError("HTTP session unavailable") + ) + + group = await event.get_group() + + assert group is not None + assert group.group_name == "Event Name" + assert group.members is None + assert group.member_count is None + + +@pytest.mark.asyncio +async def test_satori_get_group_respects_declared_unsupported_features(): + adapter, _, event = await _make_group_event( + {"id": "guild-1", "name": "Event Name"}, + ) + adapter.logins = [ + { + "platform": "discord", + "user": {"id": "bot-1"}, + "features": [], + }, + ] + adapter.send_http_request = AsyncMock() + + group = await event.get_group() + + assert group is not None + assert group.group_name == "Event Name" + adapter.send_http_request.assert_not_awaited() diff --git a/tests/test_slack_group_info.py b/tests/test_slack_group_info.py new file mode 100644 index 0000000000..a72b15cce3 --- /dev/null +++ b/tests/test_slack_group_info.py @@ -0,0 +1,143 @@ +import asyncio +from unittest.mock import AsyncMock + +import pytest + +from astrbot.api.platform import ( + AstrBotMessage, + Group, + MessageMember, + MessageType, + PlatformMetadata, +) +from astrbot.core.platform.sources.slack.slack_adapter import SlackAdapter +from astrbot.core.platform.sources.slack.slack_event import SlackMessageEvent +from tests.fixtures.helpers import make_platform_config + + +def _build_message() -> AstrBotMessage: + message = AstrBotMessage() + message.type = MessageType.GROUP_MESSAGE + message.group = Group(group_id="C123", group_name="cached-channel") + message.session_id = "C123" + message.sender = MessageMember(user_id="U1", nickname="Alice") + message.message_id = "message-1" + message.message = [] + message.message_str = "hello" + message.raw_message = {} + return message + + +def _platform_metadata() -> PlatformMetadata: + return PlatformMetadata(name="slack", id="slack", description="Slack") + + +@pytest.mark.asyncio +async def test_slack_convert_message_includes_channel_name(): + adapter = SlackAdapter( + make_platform_config( + "slack", + id="test_slack", + bot_token="xoxb-test", + app_token="xapp-test", + ), + {}, + asyncio.Queue(), + ) + adapter.bot_self_id = "UBOT" + adapter.web_client.users_info = AsyncMock( + return_value={"user": {"id": "U1", "real_name": "Alice"}}, + ) + adapter.web_client.conversations_info = AsyncMock( + return_value={"channel": {"id": "C123", "is_im": False, "name": "general"}}, + ) + + message = await adapter.convert_message( + {"user": "U1", "channel": "C123", "text": "hello", "ts": "1700000000"}, + ) + + assert message.group == Group(group_id="C123", group_name="general") + + +@pytest.mark.asyncio +async def test_slack_convert_message_keeps_group_id_when_channel_lookup_fails(): + adapter = SlackAdapter( + make_platform_config( + "slack", + id="test_slack", + bot_token="xoxb-test", + app_token="xapp-test", + ), + {}, + asyncio.Queue(), + ) + adapter.bot_self_id = "UBOT" + adapter.web_client.users_info = AsyncMock( + return_value={"user": {"id": "U1", "real_name": "Alice"}}, + ) + adapter.web_client.conversations_info = AsyncMock( + side_effect=RuntimeError("missing scope"), + ) + + message = await adapter.convert_message( + {"user": "U1", "channel": "C123", "text": "hello", "ts": "1700000000"}, + ) + + assert message.group == Group(group_id="C123") + assert message.session_id == "C123" + + +@pytest.mark.asyncio +async def test_slack_get_group_paginates_members_and_does_not_infer_owner(): + web_client = AsyncMock() + web_client.conversations_info.return_value = { + "channel": { + "id": "C123", + "name": "general", + "creator": "U0", + "num_members": 3, + }, + } + web_client.conversations_members.side_effect = [ + { + "members": ["U1", "U2"], + "response_metadata": {"next_cursor": "next"}, + }, + {"members": ["U3"], "response_metadata": {"next_cursor": ""}}, + ] + web_client.users_info.side_effect = lambda user: { + "user": {"id": user, "real_name": f"Name {user}"}, + } + event = SlackMessageEvent( + "hello", + _build_message(), + platform_meta=_platform_metadata(), + session_id="C123", + web_client=web_client, + ) + + group = await event.get_group() + + assert group.group_name == "general" + assert group.group_owner is None + assert group.group_avatar is None + assert group.member_count == 3 + assert [member.user_id for member in group.members] == ["U1", "U2", "U3"] + assert web_client.conversations_members.await_count == 2 + + +@pytest.mark.asyncio +async def test_slack_get_group_returns_basic_group_when_lookup_fails(): + web_client = AsyncMock() + web_client.conversations_info.side_effect = RuntimeError("missing scope") + event = SlackMessageEvent( + "hello", + _build_message(), + platform_meta=_platform_metadata(), + session_id="C123", + web_client=web_client, + ) + + group = await event.get_group() + + assert group == Group(group_id="C123", group_name="cached-channel") diff --git a/tests/test_telegram_adapter.py b/tests/test_telegram_adapter.py index 47e2c16e3c..3001d5a217 100644 --- a/tests/test_telegram_adapter.py +++ b/tests/test_telegram_adapter.py @@ -1,11 +1,13 @@ import asyncio import importlib import sys +from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock, patch import pytest import astrbot.api.message_components as Comp +from astrbot.api.platform import Group from astrbot.core.platform.register import unregister_platform_adapters_by_module from tests.fixtures.helpers import ( NoopAwaitable, @@ -82,6 +84,344 @@ def _build_context() -> MagicMock: return context +@pytest.mark.asyncio +async def test_telegram_topic_with_missing_name_falls_back_to_group_name(): + TelegramPlatformAdapter = _load_telegram_adapter() + adapter = TelegramPlatformAdapter( + make_platform_config("telegram"), + {}, + asyncio.Queue(), + ) + update = create_mock_update( + chat_type="supergroup", + chat_id=-100123, + message_thread_id=42, + is_topic_message=True, + ) + update.message.chat.title = "Engineering" + + result = await adapter.convert_message(update, _build_context()) + + assert result is not None + assert result.group is not None + assert result.group.group_id == "-100123#42" + assert result.group.group_name == "Engineering" + + +@pytest.mark.asyncio +async def test_telegram_regular_supergroup_message_uses_group_name(): + TelegramPlatformAdapter = _load_telegram_adapter() + adapter = TelegramPlatformAdapter( + make_platform_config("telegram"), + {}, + asyncio.Queue(), + ) + update = create_mock_update(chat_type="supergroup", chat_id=-100123) + update.message.chat.title = "Engineering" + update.message.chat.is_forum = False + + result = await adapter.convert_message(update, _build_context()) + + assert result is not None + assert result.group is not None + assert result.group.group_id == "-100123" + assert result.group.group_name == "Engineering" + + +@pytest.mark.asyncio +async def test_telegram_forum_topic_name_is_learned_and_updated_from_events(): + TelegramPlatformAdapter = _load_telegram_adapter() + adapter = TelegramPlatformAdapter( + make_platform_config("telegram"), + {}, + asyncio.Queue(), + ) + created_update = create_mock_update( + chat_type="supergroup", + chat_id=-100123, + message_thread_id=42, + is_topic_message=True, + ) + created_update.message.chat.title = "Engineering" + created_update.message.chat.is_forum = True + created_update.message.forum_topic_created = SimpleNamespace(name="Backend") + + created = await adapter.convert_message(created_update, _build_context()) + + assert created is not None + assert created.group is not None + assert created.group.group_name == "Engineering-Backend" + + regular_update = create_mock_update( + chat_type="supergroup", + chat_id=-100123, + message_thread_id=42, + is_topic_message=True, + ) + regular_update.message.chat.title = "Engineering" + regular_update.message.chat.is_forum = True + + regular = await adapter.convert_message(regular_update, _build_context()) + + assert regular is not None + assert regular.group is not None + assert regular.group.group_name == "Engineering-Backend" + + empty_edit_update = create_mock_update( + chat_type="supergroup", + chat_id=-100123, + message_thread_id=42, + is_topic_message=True, + ) + empty_edit_update.message.chat.title = "Engineering" + empty_edit_update.message.chat.is_forum = True + empty_edit_update.message.forum_topic_edited = SimpleNamespace(name=" ") + + empty_edit = await adapter.convert_message(empty_edit_update, _build_context()) + + assert empty_edit is not None + assert empty_edit.group is not None + assert empty_edit.group.group_name == "Engineering-Backend" + + edited_update = create_mock_update( + chat_type="supergroup", + chat_id=-100123, + message_thread_id=42, + is_topic_message=True, + ) + edited_update.message.chat.title = "Engineering" + edited_update.message.chat.is_forum = True + edited_update.message.forum_topic_edited = SimpleNamespace(name="Platform") + + edited = await adapter.convert_message(edited_update, _build_context()) + + assert edited is not None + assert edited.group is not None + assert edited.group.group_name == "Engineering-Platform" + + +@pytest.mark.asyncio +async def test_telegram_forum_topic_cache_evicts_oldest_entry(): + TelegramPlatformAdapter = _load_telegram_adapter() + assert TelegramPlatformAdapter._FORUM_TOPIC_NAME_CACHE_MAX_SIZE == 1000 + adapter = TelegramPlatformAdapter( + make_platform_config("telegram"), + {}, + asyncio.Queue(), + ) + adapter._FORUM_TOPIC_NAME_CACHE_MAX_SIZE = 2 + + for thread_id, topic_name in [(41, "One"), (42, "Two"), (43, "Three")]: + update = create_mock_update( + chat_type="supergroup", + chat_id=-100123, + message_thread_id=thread_id, + is_topic_message=True, + ) + update.message.chat.title = "Engineering" + update.message.chat.is_forum = True + update.message.forum_topic_created = SimpleNamespace(name=topic_name) + await adapter.convert_message(update, _build_context()) + + assert list(adapter._forum_topic_names) == [("-100123", 42), ("-100123", 43)] + + +@pytest.mark.asyncio +async def test_telegram_forum_topic_name_is_read_from_topic_root_reply(): + TelegramPlatformAdapter = _load_telegram_adapter() + adapter = TelegramPlatformAdapter( + make_platform_config("telegram"), + {}, + asyncio.Queue(), + ) + topic_root = create_mock_update( + chat_type="supergroup", + chat_id=-100123, + message_id=42, + ).message + topic_root.forum_topic_created = SimpleNamespace(name="Backend") + update = create_mock_update( + chat_type="supergroup", + chat_id=-100123, + message_thread_id=42, + is_topic_message=True, + reply_to_message=topic_root, + ) + update.message.chat.title = "Engineering" + update.message.chat.is_forum = True + + result = await adapter.convert_message(update, _build_context()) + + assert result is not None + assert result.group is not None + assert result.group.group_name == "Engineering-Backend" + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("message_thread_id", "is_topic_message"), + [(None, False), (1, True)], +) +async def test_telegram_general_forum_topic_without_known_name_uses_group_name( + message_thread_id, is_topic_message +): + TelegramPlatformAdapter = _load_telegram_adapter() + adapter = TelegramPlatformAdapter( + make_platform_config("telegram"), + {}, + asyncio.Queue(), + ) + update = create_mock_update( + chat_type="supergroup", + chat_id=-100123, + message_thread_id=message_thread_id, + is_topic_message=is_topic_message, + ) + update.message.chat.title = "Engineering" + update.message.chat.is_forum = True + + result = await adapter.convert_message(update, _build_context()) + + assert result is not None + assert result.group is not None + assert result.group.group_id == "-100123" + assert result.group.group_name == "Engineering" + + +@pytest.mark.asyncio +async def test_telegram_general_forum_topic_uses_observed_custom_name(): + TelegramPlatformAdapter = _load_telegram_adapter() + adapter = TelegramPlatformAdapter( + make_platform_config("telegram"), + {}, + asyncio.Queue(), + ) + edited_update = create_mock_update(chat_type="supergroup", chat_id=-100123) + edited_update.message.chat.title = "Engineering" + edited_update.message.chat.is_forum = True + edited_update.message.forum_topic_edited = SimpleNamespace(name="Lobby") + + edited = await adapter.convert_message(edited_update, _build_context()) + + assert edited is not None + assert edited.group is not None + assert edited.group.group_name == "Engineering-Lobby" + + regular_update = create_mock_update(chat_type="supergroup", chat_id=-100123) + regular_update.message.chat.title = "Engineering" + regular_update.message.chat.is_forum = True + + regular = await adapter.convert_message(regular_update, _build_context()) + + assert regular is not None + assert regular.group is not None + assert regular.group.group_name == "Engineering-Lobby" + + +@pytest.mark.asyncio +async def test_telegram_get_group_keeps_forum_topic_name(): + TelegramPlatformAdapter = _load_telegram_adapter() + TelegramPlatformEvent = _load_telegram_platform_event() + adapter = TelegramPlatformAdapter( + make_platform_config("telegram"), + {}, + asyncio.Queue(), + ) + update = create_mock_update( + chat_type="supergroup", + chat_id=-100123, + message_thread_id=42, + is_topic_message=True, + ) + update.message.chat.title = "Engineering" + update.message.chat.is_forum = True + update.message.forum_topic_created = SimpleNamespace(name="Backend") + message = await adapter.convert_message(update, _build_context()) + assert message is not None + + event = TelegramPlatformEvent.__new__(TelegramPlatformEvent) + event.message_obj = message + event.client = SimpleNamespace( + get_chat=AsyncMock( + return_value=SimpleNamespace(title="Engineering 2", photo=None) + ), + get_chat_member_count=AsyncMock(return_value=24), + get_chat_administrators=AsyncMock(return_value=[]), + ) + + group = await event.get_group() + + assert group is not None + assert group.group_name == "Engineering 2-Backend" + + +@pytest.mark.asyncio +async def test_telegram_get_group_enriches_available_metadata(): + TelegramPlatformEvent = _load_telegram_platform_event() + client = SimpleNamespace( + get_chat=AsyncMock( + return_value=SimpleNamespace( + title="Engineering", + photo=SimpleNamespace(big_file_id="photo-1"), + ) + ), + get_file=AsyncMock( + return_value=SimpleNamespace( + file_path="https://api.telegram.org/file/group.jpg" + ) + ), + get_chat_member_count=AsyncMock(return_value=24), + get_chat_administrators=AsyncMock( + return_value=[ + SimpleNamespace(status="creator", user=SimpleNamespace(id=1)), + SimpleNamespace(status="administrator", user=SimpleNamespace(id=2)), + ] + ), + ) + event = TelegramPlatformEvent.__new__(TelegramPlatformEvent) + event.message_obj = SimpleNamespace( + group=Group(group_id="-100123#42", group_name="Cached title"), + group_id="-100123#42", + ) + event.client = client + + group = await event.get_group() + + assert group is not None + assert group.group_id == "-100123#42" + assert group.group_name == "Engineering" + assert group.group_avatar == "https://api.telegram.org/file/group.jpg" + assert group.member_count == 24 + assert group.group_owner == "1" + assert group.group_admins == ["2"] + assert group.members is None + client.get_chat.assert_awaited_once_with(chat_id=-100123) + client.get_chat_member_count.assert_awaited_once_with(chat_id=-100123) + client.get_chat_administrators.assert_awaited_once_with(chat_id=-100123) + + +@pytest.mark.asyncio +async def test_telegram_get_group_keeps_basic_metadata_when_apis_fail(): + TelegramPlatformEvent = _load_telegram_platform_event() + client = SimpleNamespace( + get_chat=AsyncMock(side_effect=RuntimeError("chat unavailable")), + get_chat_member_count=AsyncMock(side_effect=RuntimeError("count unavailable")), + get_chat_administrators=AsyncMock( + side_effect=RuntimeError("administrators unavailable") + ), + ) + event = TelegramPlatformEvent.__new__(TelegramPlatformEvent) + event.message_obj = SimpleNamespace( + group=Group(group_id="-100123#42", group_name="Cached title"), + group_id="-100123#42", + ) + event.client = client + + group = await event.get_group() + + assert group == Group(group_id="-100123#42", group_name="Cached title") + + @pytest.mark.asyncio async def test_telegram_partial_quote_uses_exact_quote_text(): TelegramPlatformAdapter = _load_telegram_adapter() diff --git a/tests/test_wecomai_group_info.py b/tests/test_wecomai_group_info.py new file mode 100644 index 0000000000..11ec7a1188 --- /dev/null +++ b/tests/test_wecomai_group_info.py @@ -0,0 +1,48 @@ +import pytest + +from astrbot.core.platform.platform_metadata import PlatformMetadata +from astrbot.core.platform.sources.wecom_ai_bot.wecomai_adapter import ( + WecomAIBotAdapter, +) +from astrbot.core.platform.sources.wecom_ai_bot.wecomai_event import ( + WecomAIBotMessageEvent, +) +from astrbot.core.platform.sources.wecom_ai_bot.wecomai_queue_mgr import ( + WecomAIQueueMgr, +) + + +@pytest.mark.asyncio +async def test_wecomai_group_message_includes_chat_id(): + adapter = WecomAIBotAdapter.__new__(WecomAIBotAdapter) + adapter.bot_name = "AstrBot" + adapter.encoding_aes_key = "" + payload = { + "message_data": { + "chattype": "group", + "chatid": "group-chat-1", + "from": {"userid": "sender"}, + "msgtype": "text", + "text": {"content": "hello"}, + }, + "session_id": "wecomai:group-chat-1", + } + + message = await adapter.convert_message(payload) + + assert message.group is not None + assert message.group.group_id == "group-chat-1" + + event = WecomAIBotMessageEvent( + message_str=message.message_str, + message_obj=message, + platform_meta=PlatformMetadata( + name="wecom_ai_bot", + description="WeCom AI Bot", + id="wecom-ai-bot", + ), + session_id=message.session_id, + api_client=None, + queue_mgr=WecomAIQueueMgr(), + ) + assert await event.get_group() is message.group diff --git a/tests/unit/test_aiocqhttp_group_info.py b/tests/unit/test_aiocqhttp_group_info.py new file mode 100644 index 0000000000..12b210c4d7 --- /dev/null +++ b/tests/unit/test_aiocqhttp_group_info.py @@ -0,0 +1,133 @@ +from types import SimpleNamespace +from unittest.mock import AsyncMock, call + +import pytest + +from astrbot.api.platform import Group +from astrbot.core.platform.sources.aiocqhttp.aiocqhttp_message_event import ( + AiocqhttpMessageEvent, +) + + +@pytest.mark.asyncio +async def test_aiocqhttp_get_group_enriches_inbound_group(): + event = AiocqhttpMessageEvent.__new__(AiocqhttpMessageEvent) + event.message_obj = SimpleNamespace( + group=Group(group_id="123", group_name="Inbound name"), + group_id="123", + self_id="bot-1", + ) + event.bot = SimpleNamespace( + call_action=AsyncMock( + side_effect=[ + {"group_name": "Fetched name", "member_count": 2}, + [ + {"user_id": 1, "role": "owner", "nickname": "Owner"}, + {"user_id": 2, "role": "admin", "nickname": "Admin"}, + ], + ], + ) + ) + + group = await event.get_group() + + assert group.group_name == "Fetched name" + assert group.group_owner == "1" + assert group.group_admins == ["2"] + assert group.member_count == 2 + assert [member.user_id for member in group.members] == ["1", "2"] + + +@pytest.mark.asyncio +async def test_aiocqhttp_get_group_keeps_partial_info_when_members_fail(): + event = AiocqhttpMessageEvent.__new__(AiocqhttpMessageEvent) + event.message_obj = SimpleNamespace( + group=Group(group_id="123", group_name="Inbound name"), + group_id="123", + self_id="bot-1", + ) + event.bot = SimpleNamespace( + call_action=AsyncMock( + side_effect=[ + {"group_name": "Fetched name", "member_count": 8}, + RuntimeError("member API unavailable"), + ], + ) + ) + + group = await event.get_group() + + assert group.group_name == "Fetched name" + assert group.member_count == 8 + assert group.members is None + + +@pytest.mark.asyncio +async def test_aiocqhttp_get_group_keeps_inbound_info_when_group_info_fails(): + event = AiocqhttpMessageEvent.__new__(AiocqhttpMessageEvent) + event.message_obj = SimpleNamespace( + group=Group(group_id="123", group_name="Inbound name"), + group_id="123", + self_id="bot-1", + ) + event.bot = SimpleNamespace( + call_action=AsyncMock( + side_effect=[ + RuntimeError("group API unavailable"), + [], + ], + ) + ) + + group = await event.get_group() + + assert group is event.message_obj.group + assert group.group_name == "Inbound name" + assert group.member_count == 0 + assert group.members == [] + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("group_id", "expected_api_group_id"), + [ + (456, 456), + ("room-alpha", "room-alpha"), + ], +) +async def test_aiocqhttp_get_group_honors_explicit_group_id( + group_id, + expected_api_group_id, +): + event = AiocqhttpMessageEvent.__new__(AiocqhttpMessageEvent) + event.message_obj = SimpleNamespace( + group=Group(group_id="123", group_name="Current group"), + group_id="123", + self_id="bot-1", + ) + event.bot = SimpleNamespace( + call_action=AsyncMock( + side_effect=[ + {"group_name": "Explicit group", "member_count": 0}, + [], + ], + ) + ) + + group = await event.get_group(group_id=group_id) + + assert group.group_id == str(group_id) + assert group.group_name == "Explicit group" + assert group is not event.message_obj.group + assert event.bot.call_action.await_args_list == [ + call( + "get_group_info", + group_id=expected_api_group_id, + self_id="bot-1", + ), + call( + "get_group_member_list", + group_id=expected_api_group_id, + self_id="bot-1", + ), + ] diff --git a/tests/unit/test_astr_message_event.py b/tests/unit/test_astr_message_event.py index 89087d1cab..2ff1f67e85 100644 --- a/tests/unit/test_astr_message_event.py +++ b/tests/unit/test_astr_message_event.py @@ -16,7 +16,7 @@ ) from astrbot.core.message.message_event_result import MessageEventResult from astrbot.core.platform.astr_message_event import AstrMessageEvent -from astrbot.core.platform.astrbot_message import AstrBotMessage, MessageMember +from astrbot.core.platform.astrbot_message import AstrBotMessage, Group, MessageMember from astrbot.core.platform.message_type import MessageType from astrbot.core.platform.platform_metadata import PlatformMetadata @@ -691,9 +691,20 @@ async def test_get_group_returns_none_for_private(self, astr_message_event): @pytest.mark.asyncio async def test_get_group_with_group_id_param(self, astr_message_event): """Test get_group with group_id parameter.""" - # Default implementation returns None result = await astr_message_event.get_group(group_id="group123") - assert result is None + assert result == Group(group_id="group123") + + @pytest.mark.asyncio + async def test_get_group_returns_message_group(self, astr_message_event): + """Test get_group returns group data already attached to the message.""" + astr_message_event.message_obj.group = Group( + group_id="group123", + group_name="Test Group", + ) + + result = await astr_message_event.get_group() + + assert result is astr_message_event.message_obj.group class TestMessageTypeHandling: diff --git a/tests/unit/test_astrbot_message.py b/tests/unit/test_astrbot_message.py index 508a2727b8..902fbbcec8 100644 --- a/tests/unit/test_astrbot_message.py +++ b/tests/unit/test_astrbot_message.py @@ -55,6 +55,7 @@ def test_group_creation_basic(self): assert group.group_owner is None assert group.group_admins is None assert group.members is None + assert group.member_count is None def test_group_creation_with_all_fields(self): """Test creating a Group with all fields.""" @@ -66,6 +67,7 @@ def test_group_creation_with_all_fields(self): group_owner="owner123", group_admins=["admin1", "admin2"], members=members, + member_count=2, ) assert group.group_id == "group123" @@ -74,6 +76,7 @@ def test_group_creation_with_all_fields(self): assert group.group_owner == "owner123" assert group.group_admins == ["admin1", "admin2"] assert group.members == members + assert group.member_count == 2 def test_group_str_with_all_fields(self): """Test __str__ method with all fields.""" @@ -85,6 +88,7 @@ def test_group_str_with_all_fields(self): group_owner="owner123", group_admins=["admin1"], members=members, + member_count=1, ) result = str(group) @@ -93,6 +97,7 @@ def test_group_str_with_all_fields(self): assert "Avatar: http://example.com/avatar.jpg" in result assert "Owner ID: owner123" in result assert "Admin IDs: ['admin1']" in result + assert "Member Count: 1" in result assert "Members Len: 1" in result def test_group_str_with_minimal_fields(self): @@ -105,6 +110,7 @@ def test_group_str_with_minimal_fields(self): assert "Avatar: N/A" in result assert "Owner ID: N/A" in result assert "Admin IDs: N/A" in result + assert "Member Count: N/A" in result assert "Members Len: 0" in result assert "First Member: N/A" in result diff --git a/tests/unit/test_misskey_group_info.py b/tests/unit/test_misskey_group_info.py new file mode 100644 index 0000000000..2c83bbd414 --- /dev/null +++ b/tests/unit/test_misskey_group_info.py @@ -0,0 +1,135 @@ +import asyncio +from unittest.mock import AsyncMock + +import pytest + +from astrbot.core.platform.sources.misskey.misskey_adapter import ( + MisskeyPlatformAdapter, +) + + +def make_adapter() -> MisskeyPlatformAdapter: + """Create a Misskey adapter suitable for message conversion tests. + + Returns: + Adapter with an in-memory event queue and no network client. + """ + adapter = MisskeyPlatformAdapter( + {"id": "misskey-test"}, + {}, + asyncio.Queue(), + ) + adapter.bot_self_id = "bot-id" + return adapter + + +@pytest.mark.asyncio +async def test_room_message_maps_embedded_room_information() -> None: + adapter = make_adapter() + + message = await adapter.convert_room_message( + { + "id": "message-1", + "text": "hello", + "fromUserId": "sender-id", + "fromUser": {"id": "sender-id", "username": "sender"}, + "toRoomId": "room-id", + "toRoom": { + "id": "room-id", + "name": "AstrBot room", + "ownerId": "owner-id", + }, + }, + ) + + assert message.group is not None + assert message.group.group_id == "room-id" + assert message.group.group_name == "AstrBot room" + assert message.group.group_owner == "owner-id" + + +@pytest.mark.asyncio +async def test_get_group_paginates_members_and_adds_owner() -> None: + adapter = make_adapter() + message = await adapter.convert_room_message( + { + "id": "message-1", + "text": "hello", + "fromUserId": "sender-id", + "fromUser": {"id": "sender-id", "username": "sender"}, + "toRoomId": "room-id", + "toRoom": {"name": "Cached room", "ownerId": "owner-id"}, + }, + ) + first_page = [ + { + "id": f"membership-{index}", + "userId": f"user-{index}", + "user": {"id": f"user-{index}", "username": f"user{index}"}, + } + for index in range(100) + ] + second_page = [ + { + "id": "membership-100", + "userId": "user-100", + "user": {"id": "user-100", "name": "Last member"}, + }, + ] + adapter.api = AsyncMock() + adapter.api._make_request = AsyncMock( + side_effect=[ + { + "id": "room-id", + "name": "Current room", + "ownerId": "owner-id", + "owner": {"id": "owner-id", "name": "Room owner"}, + }, + first_page, + second_page, + ], + ) + + group = await adapter.create_event(message).get_group() + + assert group is not None + assert group.group_name == "Current room" + assert group.group_owner == "owner-id" + assert group.group_admins == [] + assert group.member_count == 102 + assert group.members is not None + assert group.members[-1].user_id == "owner-id" + assert group.members[-1].nickname == "Room owner" + assert adapter.api._make_request.await_args_list[2].args == ( + "chat/rooms/members", + { + "roomId": "room-id", + "limit": 100, + "untilId": "membership-99", + }, + ) + + +@pytest.mark.asyncio +async def test_get_group_falls_back_when_room_api_is_unavailable() -> None: + adapter = make_adapter() + message = await adapter.convert_room_message( + { + "id": "message-1", + "text": "hello", + "fromUserId": "sender-id", + "fromUser": {"id": "sender-id", "username": "sender"}, + "toRoomId": "room-id", + "toRoom": {"name": "Cached room", "ownerId": "owner-id"}, + }, + ) + adapter.api = AsyncMock() + adapter.api._make_request = AsyncMock(side_effect=RuntimeError("not supported")) + + group = await adapter.create_event(message).get_group() + + assert group is not None + assert group.group_id == "room-id" + assert group.group_name == "Cached room" + assert group.group_owner == "owner-id" + assert group.members is None