diff --git a/docs/event-based-agents/adapters/dingtalk.md b/docs/event-based-agents/adapters/dingtalk.md index ff6ce6110..5576c783b 100644 --- a/docs/event-based-agents/adapters/dingtalk.md +++ b/docs/event-based-agents/adapters/dingtalk.md @@ -10,7 +10,7 @@ The DingTalk adapter now has an Event-Based Agents adapter package with: - `manifest.yaml` for adapter metadata, configuration, events, common APIs, and platform-specific APIs. - `adapter.py` for DingTalk client startup, native callback handling, legacy compatibility, and EBA dispatch. -- `event_converter.py` for native DingTalk events to common EBA events. +- `event_converter.py` for native DingTalk events and card callbacks to common EBA events. - `message_converter.py` for DingTalk message payloads to/from common `MessageChain` components. - `api_impl.py` for common EBA API implementations. - `platform_api.py` for DingTalk-specific `call_platform_api` actions. @@ -33,7 +33,8 @@ The legacy DingTalk HTTP client now returns successful JSON response bodies from | Event | Support | Evidence | |-------|---------|----------| | `message.received` | implemented | `plugin-e2e-ui` private text and emoji-as-text. | -| `platform.specific` | implemented | Not reproduced in the latest UI run. | +| `feedback.received` | unit covered | DingTalk card callback actions with feedback-like values (`like`, `dislike`, `cancel`, or `1`/`2`/`3`) map to `FeedbackReceivedEvent`. Other card actions remain `platform.specific`. | +| `platform.specific` | implemented | Non-feedback card callbacks and unmapped callback/message shapes are emitted as structured platform-specific events. | ## Receive Components diff --git a/docs/event-based-agents/adapters/discord.md b/docs/event-based-agents/adapters/discord.md index 7f2d106f0..a2ea4143c 100644 --- a/docs/event-based-agents/adapters/discord.md +++ b/docs/event-based-agents/adapters/discord.md @@ -44,7 +44,6 @@ Discord declares these EBA events: - `message.reaction` - `group.member_joined` - `group.member_left` -- `group.member_banned` - `bot.invited_to_group` - `bot.removed_from_group` - `platform.specific` diff --git a/docs/event-based-agents/adapters/officialaccount.md b/docs/event-based-agents/adapters/officialaccount.md index 411a5a153..92f744f12 100644 --- a/docs/event-based-agents/adapters/officialaccount.md +++ b/docs/event-based-agents/adapters/officialaccount.md @@ -24,7 +24,7 @@ Status: partial migration. Unit/API-shape coverage is present, and private text | Event | Evidence | Notes | | --- | --- | --- | | `message.received` | plugin-e2e-ui, unit | Text UI message verified through WeChat Official Account on `dev.rockchin.top`; image and voice webhook payloads are covered by unit tests. | -| `platform.specific` | unit | Subscribe/menu/etc. native events are emitted as structured `PlatformSpecificEvent`. | +| `platform.specific` | unit | Subscribe/unsubscribe/menu/etc. native events are emitted as structured `PlatformSpecificEvent`. | ## Common APIs diff --git a/docs/event-based-agents/adapters/qqofficial.md b/docs/event-based-agents/adapters/qqofficial.md index ee72efef9..66b1c0059 100644 --- a/docs/event-based-agents/adapters/qqofficial.md +++ b/docs/event-based-agents/adapters/qqofficial.md @@ -22,7 +22,12 @@ Status: partial migration. The EBA adapter structure, manifest, converters, cach | Event | Evidence | Notes | | --- | --- | --- | | `message.received` | adapter-live, unit | `C2C_MESSAGE_CREATE`, `DIRECT_MESSAGE_CREATE`, `GROUP_AT_MESSAGE_CREATE`, and `AT_MESSAGE_CREATE` map to common `MessageReceivedEvent`. A real WebSocket-mode QQ Official bot reached the LangBot pipeline on `dev.rockchin.top`; plugin JSONL evidence remains pending. | -| `platform.specific` | unit, blocked | Unmapped gateway events are emitted as structured `PlatformSpecificEvent`; live evidence is pending. | +| `message.reaction` | unit | `MESSAGE_REACTION_ADD` and `MESSAGE_REACTION_REMOVE` map to common `MessageReactionEvent`. Live gateway evidence is pending. | +| `group.member_joined` | unit | `GUILD_MEMBER_ADD` and `GROUP_MEMBER_ADD` map to common `MemberJoinedEvent` when the gateway payload carries a group/guild/channel ID and member openid. | +| `group.member_left` | unit | `GUILD_MEMBER_REMOVE` and `GROUP_MEMBER_REMOVE` map to common `MemberLeftEvent`. Live gateway evidence is pending. | +| `bot.invited_to_group` | unit | `GUILD_CREATE` and `GROUP_ADD_ROBOT` map to common `BotInvitedToGroupEvent`. | +| `bot.removed_from_group` | unit | `GUILD_DELETE` and `GROUP_DEL_ROBOT` map to common `BotRemovedFromGroupEvent`. | +| `platform.specific` | unit | Unmapped gateway events are emitted as structured `PlatformSpecificEvent`; live evidence is pending. | ## Common APIs diff --git a/src/langbot/pkg/platform/adapters/dingtalk/adapter.py b/src/langbot/pkg/platform/adapters/dingtalk/adapter.py index b07e34c5c..8993a696f 100644 --- a/src/langbot/pkg/platform/adapters/dingtalk/adapter.py +++ b/src/langbot/pkg/platform/adapters/dingtalk/adapter.py @@ -3,6 +3,7 @@ from __future__ import annotations import traceback import typing +import dingtalk_stream import pydantic from langbot.libs.dingtalk_api.api import DingTalkClient @@ -19,6 +20,32 @@ from langbot_plugin.api.entities.builtin.platform import message as platform_mes from langbot_plugin.api.entities.builtin.platform.errors import NotSupportedError +class DingTalkCardCallbackHandler(dingtalk_stream.CallbackHandler): + def __init__(self, adapter: 'DingTalkAdapter'): + super().__init__() + self.adapter = adapter + + async def process(self, message: dingtalk_stream.CallbackMessage): + callback = dingtalk_stream.CardCallbackMessage.from_dict(message.data or {}) + event = DingTalkEvent.from_payload( + { + 'conversation_type': 'CardCallback', + 'Type': 'card_callback', + 'CardCallback': { + 'extension': callback.extension, + 'corp_id': callback.corp_id, + 'user_id': callback.user_id, + 'content': callback.content, + 'space_id': callback.space_id, + 'card_instance_id': callback.card_instance_id, + }, + } + ) + if event is not None: + await self.adapter._handle_native_event(event) + return dingtalk_stream.AckMessage.STATUS_OK, 'OK' + + class DingTalkAdapter(DingTalkAPIMixin, abstract_platform_adapter.AbstractPlatformAdapter): bot: DingTalkClient = pydantic.Field(exclude=True) @@ -68,6 +95,7 @@ class DingTalkAdapter(DingTalkAPIMixin, abstract_platform_adapter.AbstractPlatfo def get_supported_events(self) -> list[str]: return [ 'message.received', + 'feedback.received', 'platform.specific', ] @@ -198,6 +226,10 @@ class DingTalkAdapter(DingTalkAPIMixin, abstract_platform_adapter.AbstractPlatfo self.bot.on_message('FriendMessage')(on_message) self.bot.on_message('GroupMessage')(on_message) + self.bot.client.register_callback_handler( + dingtalk_stream.CallbackHandler.TOPIC_CARD_CALLBACK, + DingTalkCardCallbackHandler(self), + ) async def _handle_native_event(self, event: DingTalkEvent): try: diff --git a/src/langbot/pkg/platform/adapters/dingtalk/event_converter.py b/src/langbot/pkg/platform/adapters/dingtalk/event_converter.py index 1965a5a5b..51e7db193 100644 --- a/src/langbot/pkg/platform/adapters/dingtalk/event_converter.py +++ b/src/langbot/pkg/platform/adapters/dingtalk/event_converter.py @@ -19,6 +19,11 @@ class DingTalkEventConverter(abstract_platform_adapter.AbstractEventConverter): async def target2yiri(event: DingTalkEvent, bot_name: str) -> platform_events.Event | None: if event.conversation in {'FriendMessage', 'GroupMessage'}: return await DingTalkEventConverter.message_to_eba(event, bot_name) + if event.conversation == 'CardCallback': + feedback = DingTalkEventConverter.card_callback_to_feedback(event) + if feedback is not None: + return feedback + return DingTalkEventConverter.platform_specific(event, 'card.callback') return DingTalkEventConverter.platform_specific(event, f'message.{event.conversation or "unknown"}') @staticmethod @@ -57,6 +62,54 @@ class DingTalkEventConverter(abstract_platform_adapter.AbstractEventConverter): source_platform_object=event, ) + @staticmethod + def card_callback_to_feedback(event: DingTalkEvent) -> platform_events.FeedbackReceivedEvent | None: + callback = event.get('CardCallback') or {} + content = callback.get('content') or {} + extension = callback.get('extension') or {} + action = str( + content.get('action') or content.get('actionKey') or content.get('value') or extension.get('action') or '' + ).lower() + feedback_type = DingTalkEventConverter._feedback_type( + action or content.get('feedback_type') or content.get('feedbackType') + ) + if feedback_type is None: + return None + feedback_id = str( + content.get('feedback_id') + or content.get('feedbackId') + or callback.get('card_instance_id') + or callback.get('user_id') + or '' + ) + return platform_events.FeedbackReceivedEvent( + type='feedback.received', + adapter_name=ADAPTER_NAME, + feedback_id=feedback_id, + feedback_type=feedback_type, + feedback_content=content.get('feedback_content') or content.get('feedbackContent') or content.get('reason'), + inaccurate_reasons=content.get('inaccurate_reasons') or content.get('inaccurateReasons'), + user_id=callback.get('user_id') or None, + session_id=callback.get('space_id') or None, + message_id=content.get('message_id') or content.get('messageId'), + stream_id=content.get('stream_id') or content.get('streamId'), + timestamp=0.0, + source_platform_object=event, + ) + + @staticmethod + def _feedback_type(value: typing.Any) -> int | None: + if isinstance(value, int) and value in {1, 2, 3}: + return value + normalized = str(value or '').lower() + if normalized in {'1', 'like', 'liked', 'thumb_up', 'thumbup', 'up', 'good'}: + return 1 + if normalized in {'2', 'dislike', 'disliked', 'thumb_down', 'thumbdown', 'down', 'bad'}: + return 2 + if normalized in {'3', 'cancel', 'remove', 'removed', 'clear'}: + return 3 + return None + @staticmethod def user_from_event(event: DingTalkEvent) -> platform_entities.User: incoming_message = event.incoming_message diff --git a/src/langbot/pkg/platform/adapters/dingtalk/manifest.yaml b/src/langbot/pkg/platform/adapters/dingtalk/manifest.yaml index d11af49e8..0fb160e9c 100644 --- a/src/langbot/pkg/platform/adapters/dingtalk/manifest.yaml +++ b/src/langbot/pkg/platform/adapters/dingtalk/manifest.yaml @@ -92,6 +92,7 @@ spec: supported_events: - message.received + - feedback.received - platform.specific supported_apis: diff --git a/src/langbot/pkg/platform/adapters/dingtalk/types.py b/src/langbot/pkg/platform/adapters/dingtalk/types.py index c2663b9f1..c12b72acc 100644 --- a/src/langbot/pkg/platform/adapters/dingtalk/types.py +++ b/src/langbot/pkg/platform/adapters/dingtalk/types.py @@ -1,3 +1,3 @@ from __future__ import annotations -ADAPTER_NAME = 'dingtalk' +ADAPTER_NAME = 'dingtalk-eba' diff --git a/src/langbot/pkg/platform/adapters/lark/adapter.py b/src/langbot/pkg/platform/adapters/lark/adapter.py index 03e6a5705..3f2767e48 100644 --- a/src/langbot/pkg/platform/adapters/lark/adapter.py +++ b/src/langbot/pkg/platform/adapters/lark/adapter.py @@ -161,7 +161,11 @@ class LarkAdapter(LarkAPIMixin, abstract_platform_adapter.AbstractPlatformAdapte ) def get_supported_events(self) -> list[str]: - return ['message.received', 'bot.invited_to_group', 'platform.specific'] + return [ + 'message.received', + 'bot.invited_to_group', + 'platform.specific', + ] def get_supported_apis(self) -> list[str]: return [ diff --git a/src/langbot/pkg/platform/adapters/qqofficial/adapter.py b/src/langbot/pkg/platform/adapters/qqofficial/adapter.py index 15b2ed0d2..47831a389 100644 --- a/src/langbot/pkg/platform/adapters/qqofficial/adapter.py +++ b/src/langbot/pkg/platform/adapters/qqofficial/adapter.py @@ -11,7 +11,16 @@ from langbot.libs.qq_official_api.api import QQOfficialClient from langbot.libs.qq_official_api.qqofficialevent import QQOfficialEvent from langbot.pkg.platform.adapters.qqofficial.api_impl import QQOfficialAPIMixin from langbot.pkg.platform.adapters.qqofficial.errors import NotSupportedError -from langbot.pkg.platform.adapters.qqofficial.event_converter import QQOfficialEventConverter +from langbot.pkg.platform.adapters.qqofficial.event_converter import ( + BOT_INVITED_EVENT_TYPES, + BOT_REMOVED_EVENT_TYPES, + MEMBER_JOINED_EVENT_TYPES, + MEMBER_LEFT_EVENT_TYPES, + MESSAGE_EVENT_TYPES, + REACTION_ADD_EVENT_TYPES, + REACTION_REMOVE_EVENT_TYPES, + QQOfficialEventConverter, +) from langbot.pkg.platform.adapters.qqofficial.message_converter import QQOfficialMessageConverter from langbot.pkg.platform.adapters.qqofficial.platform_api import PLATFORM_API_MAP import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter @@ -89,6 +98,11 @@ class QQOfficialAdapter(QQOfficialAPIMixin, abstract_platform_adapter.AbstractPl def get_supported_events(self) -> list[str]: return [ 'message.received', + 'message.reaction', + 'group.member_joined', + 'group.member_left', + 'bot.invited_to_group', + 'bot.removed_from_group', 'platform.specific', ] @@ -111,7 +125,9 @@ class QQOfficialAdapter(QQOfficialAPIMixin, abstract_platform_adapter.AbstractPl target_id: str, message: platform_message.MessageChain, ) -> platform_events.MessageResult: - raw = await self._send_content_list(str(target_type), str(target_id), await QQOfficialMessageConverter.yiri2target(message)) + raw = await self._send_content_list( + str(target_type), str(target_id), await QQOfficialMessageConverter.yiri2target(message) + ) return platform_events.MessageResult(raw={'results': raw}) async def reply_message( @@ -212,8 +228,14 @@ class QQOfficialAdapter(QQOfficialAPIMixin, abstract_platform_adapter.AbstractPl is_final: bool = False, ): await self._cleanup_stale_streams() - chunk_text = '\n\n'.join(component.text for component in message if isinstance(component, platform_message.Plain)) - message_id = bot_message.get('resp_message_id') if isinstance(bot_message, dict) else getattr(bot_message, 'resp_message_id', None) + chunk_text = '\n\n'.join( + component.text for component in message if isinstance(component, platform_message.Plain) + ) + message_id = ( + bot_message.get('resp_message_id') + if isinstance(bot_message, dict) + else getattr(bot_message, 'resp_message_id', None) + ) if not message_id or message_id not in self._stream_ctx: if chunk_text: self._fallback_text[message_id] = self._fallback_text.get(message_id, '') + chunk_text @@ -221,7 +243,11 @@ class QQOfficialAdapter(QQOfficialAPIMixin, abstract_platform_adapter.AbstractPl if is_final: full_text = self._fallback_text.pop(message_id, '') if full_text: - await self.reply_message(message_source, platform_message.MessageChain([platform_message.Plain(text=full_text)]), quote_origin) + await self.reply_message( + message_source, + platform_message.MessageChain([platform_message.Plain(text=full_text)]), + quote_origin, + ) return ctx = self._stream_ctx[message_id] @@ -259,7 +285,15 @@ class QQOfficialAdapter(QQOfficialAPIMixin, abstract_platform_adapter.AbstractPl self._stream_ctx_ts.pop(message_id, None) def _register_native_handlers(self): - for event_type in ('C2C_MESSAGE_CREATE', 'DIRECT_MESSAGE_CREATE', 'GROUP_AT_MESSAGE_CREATE', 'AT_MESSAGE_CREATE'): + for event_type in ( + MESSAGE_EVENT_TYPES + | REACTION_ADD_EVENT_TYPES + | REACTION_REMOVE_EVENT_TYPES + | MEMBER_JOINED_EVENT_TYPES + | MEMBER_LEFT_EVENT_TYPES + | BOT_INVITED_EVENT_TYPES + | BOT_REMOVED_EVENT_TYPES + ): self.bot.on_message(event_type)(self._handle_native_event) async def _handle_native_event(self, event: QQOfficialEvent): @@ -305,12 +339,12 @@ class QQOfficialAdapter(QQOfficialAPIMixin, abstract_platform_adapter.AbstractPl await self.logger.info('QQ Official WebSocket connected and ready') async def on_event(event_type: str, event_data: dict): - if event_type not in {'C2C_MESSAGE_CREATE', 'DIRECT_MESSAGE_CREATE', 'GROUP_AT_MESSAGE_CREATE', 'AT_MESSAGE_CREATE'}: - await self._dispatch_eba_event(QQOfficialEventConverter.platform_specific(QQOfficialEvent({'t': event_type, **(event_data or {})}), f'qqofficial.{event_type}')) - return if not isinstance(event_data, dict): await self.logger.warning(f'Event data is not dict, skipping: {event_type} -> {type(event_data)}') return + if event_type not in MESSAGE_EVENT_TYPES: + await self._handle_native_event(QQOfficialEvent({'t': event_type, **event_data})) + return payload = {'t': event_type, 'd': event_data} message_data = await self.bot.get_message(payload) if message_data: @@ -337,7 +371,9 @@ class QQOfficialAdapter(QQOfficialAPIMixin, abstract_platform_adapter.AbstractPl return 'channel_private', event.guild_id raise NotSupportedError(f'reply_message:{event.t or "unknown_event"}') - async def _send_content_list(self, target_type: str, target_id: str, content_list: list[dict], msg_id: str | None = None) -> list[dict]: + async def _send_content_list( + self, target_type: str, target_id: str, content_list: list[dict], msg_id: str | None = None + ) -> list[dict]: target_type = self._normalize_target_type(target_type) results: list[dict] = [] for content in content_list: @@ -361,10 +397,14 @@ class QQOfficialAdapter(QQOfficialAPIMixin, abstract_platform_adapter.AbstractPl raise NotSupportedError(f'send_message:{target_type}') results.append({'type': content_type, 'raw': raw}) elif content_type == 'image': - raw = await self.bot.send_image_msg(target_type, target_id, file_url=content.get('url'), file_data=content.get('base64'), msg_id=msg_id) + raw = await self.bot.send_image_msg( + target_type, target_id, file_url=content.get('url'), file_data=content.get('base64'), msg_id=msg_id + ) results.append({'type': content_type, 'raw': raw}) elif content_type == 'voice': - raw = await self.bot.send_voice_msg(target_type, target_id, file_url=content.get('url'), file_data=content.get('base64'), msg_id=msg_id) + raw = await self.bot.send_voice_msg( + target_type, target_id, file_url=content.get('url'), file_data=content.get('base64'), msg_id=msg_id + ) results.append({'type': content_type, 'raw': raw}) elif content_type == 'file': raw = await self.bot.send_file_msg( diff --git a/src/langbot/pkg/platform/adapters/qqofficial/event_converter.py b/src/langbot/pkg/platform/adapters/qqofficial/event_converter.py index 051c068e1..66b550418 100644 --- a/src/langbot/pkg/platform/adapters/qqofficial/event_converter.py +++ b/src/langbot/pkg/platform/adapters/qqofficial/event_converter.py @@ -12,12 +12,28 @@ from langbot_plugin.api.entities.builtin.platform import entities as platform_en from langbot_plugin.api.entities.builtin.platform import events as platform_events +MESSAGE_EVENT_TYPES = { + 'C2C_MESSAGE_CREATE', + 'DIRECT_MESSAGE_CREATE', + 'GROUP_AT_MESSAGE_CREATE', + 'AT_MESSAGE_CREATE', +} +REACTION_ADD_EVENT_TYPES = {'MESSAGE_REACTION_ADD'} +REACTION_REMOVE_EVENT_TYPES = {'MESSAGE_REACTION_REMOVE'} +MEMBER_JOINED_EVENT_TYPES = {'GUILD_MEMBER_ADD', 'GROUP_MEMBER_ADD'} +MEMBER_LEFT_EVENT_TYPES = {'GUILD_MEMBER_REMOVE', 'GROUP_MEMBER_REMOVE'} +BOT_INVITED_EVENT_TYPES = {'GUILD_CREATE', 'GROUP_ADD_ROBOT'} +BOT_REMOVED_EVENT_TYPES = {'GUILD_DELETE', 'GROUP_DEL_ROBOT'} + + class QQOfficialEventConverter(abstract_platform_adapter.AbstractEventConverter): @staticmethod async def yiri2target(event: platform_events.Event) -> typing.Any: return getattr(event, 'source_platform_object', None) - async def target2legacy(self, event: QQOfficialEvent) -> platform_events.FriendMessage | platform_events.GroupMessage | None: + async def target2legacy( + self, event: QQOfficialEvent + ) -> platform_events.FriendMessage | platform_events.GroupMessage | None: eba_event = await self.target2yiri(event) if not isinstance(eba_event, platform_events.MessageReceivedEvent): return None @@ -50,8 +66,18 @@ class QQOfficialEventConverter(abstract_platform_adapter.AbstractEventConverter) ) async def target2yiri(self, event: QQOfficialEvent) -> platform_events.Event: - if event.t in {'C2C_MESSAGE_CREATE', 'DIRECT_MESSAGE_CREATE', 'GROUP_AT_MESSAGE_CREATE', 'AT_MESSAGE_CREATE'}: + if event.t in MESSAGE_EVENT_TYPES: return await self.message_to_eba(event) + if event.t in REACTION_ADD_EVENT_TYPES | REACTION_REMOVE_EVENT_TYPES: + return self.reaction_to_eba(event, event.t in REACTION_ADD_EVENT_TYPES) + if event.t in MEMBER_JOINED_EVENT_TYPES: + return self.member_joined_to_eba(event) + if event.t in MEMBER_LEFT_EVENT_TYPES: + return self.member_left_to_eba(event) + if event.t in BOT_INVITED_EVENT_TYPES: + return self.bot_invited_to_eba(event) + if event.t in BOT_REMOVED_EVENT_TYPES: + return self.bot_removed_to_eba(event) return self.platform_specific(event, f'qqofficial.{event.t or "unknown"}') async def message_to_eba(self, event: QQOfficialEvent) -> platform_events.MessageReceivedEvent: @@ -82,6 +108,100 @@ class QQOfficialEventConverter(abstract_platform_adapter.AbstractEventConverter) source_platform_object=event, ) + def reaction_to_eba(self, event: QQOfficialEvent, is_add: bool) -> platform_events.MessageReactionEvent: + chat_type, chat_id, group = self._chat_from_event(event) + return platform_events.MessageReactionEvent( + type='message.reaction', + adapter_name=ADAPTER_NAME, + message_id=_event_value(event, 'message_id', 'msg_id', 'target_id', 'id'), + user=platform_entities.User( + id=_event_value(event, 'user_openid', 'member_openid', 'openid', 'user_id'), + nickname=_event_value(event, 'username', 'nick', 'nickname', 'user_openid', 'openid'), + ), + reaction=_event_value(event, 'emoji', 'emoji_id', 'reaction', 'reaction_id', 'type'), + is_add=is_add, + chat_type=chat_type, + chat_id=chat_id, + group=group, + timestamp=_timestamp_value(event.timestamp), + source_platform_object=event, + ) + + def member_joined_to_eba(self, event: QQOfficialEvent) -> platform_events.MemberJoinedEvent: + group = self._group_from_event(event) + member = self._user_from_event(event, 'openid', 'member_openid', 'user_openid', 'user_id') + inviter_id = _event_value(event, 'inviter_openid', 'operator_openid', 'op_user_id') + return platform_events.MemberJoinedEvent( + type='group.member_joined', + adapter_name=ADAPTER_NAME, + group=group, + member=member, + inviter=platform_entities.User(id=inviter_id, nickname=inviter_id) if inviter_id else None, + timestamp=_timestamp_value(event.timestamp), + source_platform_object=event, + ) + + def member_left_to_eba(self, event: QQOfficialEvent) -> platform_events.MemberLeftEvent: + group = self._group_from_event(event) + member = self._user_from_event(event, 'openid', 'member_openid', 'user_openid', 'user_id') + operator_id = _event_value(event, 'operator_openid', 'op_user_id', 'user_openid') + return platform_events.MemberLeftEvent( + type='group.member_left', + adapter_name=ADAPTER_NAME, + group=group, + member=member, + operator=platform_entities.User(id=operator_id, nickname=operator_id) if operator_id else None, + is_kicked=event.t in {'GROUP_MEMBER_REMOVE', 'GUILD_MEMBER_REMOVE'}, + timestamp=_timestamp_value(event.timestamp), + source_platform_object=event, + ) + + def bot_invited_to_eba(self, event: QQOfficialEvent) -> platform_events.BotInvitedToGroupEvent: + group = self._group_from_event(event) + inviter_id = _event_value(event, 'op_user_id', 'operator_openid', 'user_openid', 'member_openid', 'openid') + return platform_events.BotInvitedToGroupEvent( + type='bot.invited_to_group', + adapter_name=ADAPTER_NAME, + group=group, + inviter=platform_entities.User(id=inviter_id, nickname=inviter_id) if inviter_id else None, + request_id=_event_value(event, 'event_id', 'id'), + timestamp=_timestamp_value(event.timestamp), + source_platform_object=event, + ) + + def bot_removed_to_eba(self, event: QQOfficialEvent) -> platform_events.BotRemovedFromGroupEvent: + group = self._group_from_event(event) + operator_id = _event_value(event, 'op_user_id', 'operator_openid', 'user_openid', 'member_openid', 'openid') + return platform_events.BotRemovedFromGroupEvent( + type='bot.removed_from_group', + adapter_name=ADAPTER_NAME, + group=group, + operator=platform_entities.User(id=operator_id, nickname=operator_id) if operator_id else None, + timestamp=_timestamp_value(event.timestamp), + source_platform_object=event, + ) + + def _chat_from_event( + self, event: QQOfficialEvent + ) -> tuple[platform_entities.ChatType, str, platform_entities.UserGroup | None]: + group_id = _event_value(event, 'group_openid', 'guild_id', 'channel_id', 'group_id') + if group_id: + return platform_entities.ChatType.GROUP, group_id, self._group_from_event(event) + private_id = _event_value(event, 'user_openid', 'member_openid', 'openid', 'user_id') + return platform_entities.ChatType.PRIVATE, private_id, None + + @staticmethod + def _group_from_event(event: QQOfficialEvent) -> platform_entities.UserGroup: + group_id = _event_value(event, 'group_openid', 'guild_id', 'channel_id', 'group_id') + group_name = _event_value(event, 'group_name', 'guild_name', 'channel_name', 'name') or group_id + return platform_entities.UserGroup(id=group_id, name=group_name) + + @staticmethod + def _user_from_event(event: QQOfficialEvent, *keys: str) -> platform_entities.User: + user_id = _event_value(event, *keys) + nickname = _event_value(event, 'username', 'nick', 'nickname') or user_id + return platform_entities.User(id=user_id, nickname=nickname) + @staticmethod def _sender_id(event: QQOfficialEvent) -> str: member_openid = event.member_openid or event.get('member_openid', '') @@ -107,6 +227,17 @@ class QQOfficialEventConverter(abstract_platform_adapter.AbstractEventConverter) ) +def _event_value(event: QQOfficialEvent, *keys: str) -> str: + nested = event.get('d') if isinstance(event.get('d'), dict) else {} + for key in keys: + value = event.get(key) + if value in (None, '', {}): + value = nested.get(key) + if value not in (None, '', {}): + return str(value) + return '' + + def _timestamp_value(value: str) -> float: if not value: return time.time() diff --git a/src/langbot/pkg/platform/adapters/qqofficial/manifest.yaml b/src/langbot/pkg/platform/adapters/qqofficial/manifest.yaml index 09239f00e..45912a163 100644 --- a/src/langbot/pkg/platform/adapters/qqofficial/manifest.yaml +++ b/src/langbot/pkg/platform/adapters/qqofficial/manifest.yaml @@ -88,6 +88,11 @@ spec: supported_events: - message.received + - message.reaction + - group.member_joined + - group.member_left + - bot.invited_to_group + - bot.removed_from_group - platform.specific supported_apis: diff --git a/tests/unit_tests/platform/test_dingtalk_eba_adapter.py b/tests/unit_tests/platform/test_dingtalk_eba_adapter.py index 4eca7508d..2613a1897 100644 --- a/tests/unit_tests/platform/test_dingtalk_eba_adapter.py +++ b/tests/unit_tests/platform/test_dingtalk_eba_adapter.py @@ -36,6 +36,7 @@ class DummyLogger(AbstractEventLogger): class DummyDingTalkClient(DingTalkClient): def __init__(self, *args, **kwargs): self._message_handlers = {} + self.client = SimpleNamespace(register_callback_handler=AsyncMock()) self.markdown_card = kwargs.get('markdown_card', True) self.access_token = '' self.send_message = AsyncMock() @@ -115,6 +116,30 @@ def dingtalk_event(conversation='GroupMessage', **overrides) -> DingTalkEvent: return DingTalkEvent.from_payload(payload) +def dingtalk_card_callback_event(**overrides) -> DingTalkEvent: + return DingTalkEvent.from_payload( + { + 'conversation_type': 'CardCallback', + 'Type': 'card_callback', + 'CardCallback': { + 'extension': overrides.get('extension', {}), + 'corp_id': overrides.get('corp_id', 'corp-1'), + 'user_id': overrides.get('user_id', 'user-1'), + 'content': overrides.get( + 'content', + { + 'action': 'like', + 'feedback_id': 'feedback-1', + 'message_id': 'bot-msg-1', + }, + ), + 'space_id': overrides.get('space_id', 'space-1'), + 'card_instance_id': overrides.get('card_instance_id', 'card-1'), + }, + } + ) + + def test_dingtalk_supported_events_match_manifest(): assert make_adapter().get_supported_events() == manifest()['spec']['supported_events'] @@ -195,7 +220,7 @@ async def test_dingtalk_event_converter_maps_group_and_private_message(): group_event = await DingTalkEventConverter.target2yiri(dingtalk_event(), 'LangBot') assert isinstance(group_event, platform_events.MessageReceivedEvent) - assert group_event.adapter_name == 'dingtalk' + assert group_event.adapter_name == 'dingtalk-eba' assert group_event.chat_type == platform_entities.ChatType.GROUP assert group_event.chat_id == 'group-1' assert group_event.group.name == 'LangBot Team' @@ -212,6 +237,42 @@ async def test_dingtalk_event_converter_maps_group_and_private_message(): assert private_event.group is None +@pytest.mark.asyncio +async def test_dingtalk_event_converter_maps_card_feedback(): + feedback = await DingTalkEventConverter.target2yiri(dingtalk_card_callback_event(), 'LangBot') + callback = await DingTalkEventConverter.target2yiri( + dingtalk_card_callback_event(content={'action': 'open_details'}), + 'LangBot', + ) + + assert isinstance(feedback, platform_events.FeedbackReceivedEvent) + assert feedback.adapter_name == 'dingtalk-eba' + assert feedback.feedback_id == 'feedback-1' + assert feedback.feedback_type == 1 + assert feedback.user_id == 'user-1' + assert feedback.session_id == 'space-1' + assert feedback.message_id == 'bot-msg-1' + + assert isinstance(callback, platform_events.PlatformSpecificEvent) + assert callback.action == 'card.callback' + + +@pytest.mark.asyncio +async def test_dingtalk_adapter_dispatches_card_feedback_event(): + adapter = make_adapter() + calls: list[platform_events.Event] = [] + + async def listener(event, adapter): + calls.append(event) + + adapter.register_listener(platform_events.FeedbackReceivedEvent, listener) + await adapter._handle_native_event(dingtalk_card_callback_event()) + + assert len(calls) == 1 + assert isinstance(calls[0], platform_events.FeedbackReceivedEvent) + assert calls[0].feedback_type == 1 + + @pytest.mark.asyncio async def test_dingtalk_adapter_dispatches_and_caches_message_event(): adapter = make_adapter() diff --git a/tests/unit_tests/platform/test_qqofficial_eba_adapter.py b/tests/unit_tests/platform/test_qqofficial_eba_adapter.py index b423c9210..d979a033e 100644 --- a/tests/unit_tests/platform/test_qqofficial_eba_adapter.py +++ b/tests/unit_tests/platform/test_qqofficial_eba_adapter.py @@ -143,6 +143,7 @@ def qq_event(event_type: str = 'C2C_MESSAGE_CREATE', **overrides) -> QQOfficialE 'image_attachments': overrides.get('image_attachments'), 'content_type': overrides.get('content_type', 'image/png'), } + payload.update(overrides.get('extra', {})) return QQOfficialEvent(payload) @@ -194,7 +195,7 @@ async def test_qqofficial_event_converter_maps_private_group_and_platform_specif private_event = await QQOfficialEventConverter().target2yiri(qq_event('C2C_MESSAGE_CREATE')) group_event = await QQOfficialEventConverter().target2yiri(qq_event('GROUP_AT_MESSAGE_CREATE')) channel_event = await QQOfficialEventConverter().target2yiri(qq_event('AT_MESSAGE_CREATE')) - platform_event = await QQOfficialEventConverter().target2yiri(qq_event('GUILD_CREATE')) + platform_event = await QQOfficialEventConverter().target2yiri(qq_event('UNKNOWN_EVENT')) assert isinstance(private_event, platform_events.MessageReceivedEvent) assert private_event.adapter_name == 'qqofficial-eba' @@ -209,7 +210,67 @@ async def test_qqofficial_event_converter_maps_private_group_and_platform_specif assert channel_event.chat_id == 'channel-1' assert isinstance(platform_event, platform_events.PlatformSpecificEvent) - assert platform_event.action == 'qqofficial.GUILD_CREATE' + assert platform_event.action == 'qqofficial.UNKNOWN_EVENT' + + +@pytest.mark.asyncio +async def test_qqofficial_event_converter_maps_declared_lifecycle_events(): + converter = QQOfficialEventConverter() + + reaction_add = await converter.target2yiri( + qq_event( + 'MESSAGE_REACTION_ADD', + extra={ + 'message_id': 'reacted-msg', + 'emoji': 'thumbs_up', + 'group_openid': 'group-openid', + 'openid': 'reactor-openid', + }, + ) + ) + reaction_remove = await converter.target2yiri( + qq_event('MESSAGE_REACTION_REMOVE', extra={'message_id': 'reacted-msg', 'emoji_id': '1'}) + ) + member_joined = await converter.target2yiri( + qq_event('GROUP_MEMBER_ADD', extra={'openid': 'new-member', 'group_openid': 'group-openid'}) + ) + member_left = await converter.target2yiri( + qq_event('GROUP_MEMBER_REMOVE', extra={'openid': 'old-member', 'operator_openid': 'operator-openid'}) + ) + bot_invited = await converter.target2yiri( + qq_event('GROUP_ADD_ROBOT', extra={'group_openid': 'group-openid', 'op_user_id': 'inviter-openid'}) + ) + bot_removed = await converter.target2yiri( + qq_event('GROUP_DEL_ROBOT', extra={'group_openid': 'group-openid', 'op_user_id': 'operator-openid'}) + ) + + assert isinstance(reaction_add, platform_events.MessageReactionEvent) + assert reaction_add.type == 'message.reaction' + assert reaction_add.is_add is True + assert reaction_add.message_id == 'reacted-msg' + assert reaction_add.reaction == 'thumbs_up' + assert reaction_add.chat_type == platform_entities.ChatType.GROUP + + assert isinstance(reaction_remove, platform_events.MessageReactionEvent) + assert reaction_remove.is_add is False + + assert isinstance(member_joined, platform_events.MemberJoinedEvent) + assert member_joined.type == 'group.member_joined' + assert member_joined.group.id == 'group-openid' + assert member_joined.member.id == 'new-member' + + assert isinstance(member_left, platform_events.MemberLeftEvent) + assert member_left.type == 'group.member_left' + assert member_left.member.id == 'old-member' + assert member_left.operator.id == 'operator-openid' + + assert isinstance(bot_invited, platform_events.BotInvitedToGroupEvent) + assert bot_invited.type == 'bot.invited_to_group' + assert bot_invited.inviter.id == 'inviter-openid' + + assert isinstance(bot_removed, platform_events.BotRemovedFromGroupEvent) + assert bot_removed.type == 'bot.removed_from_group' + assert bot_removed.operator.id == 'operator-openid' @pytest.mark.asyncio @@ -236,6 +297,24 @@ async def test_qqofficial_adapter_dispatches_eba_and_legacy_and_caches_group_eve assert (await adapter.get_group_member_info('group-openid', 'member-openid')).user.id == 'member-openid' +@pytest.mark.asyncio +async def test_qqofficial_adapter_dispatches_non_message_eba_events(): + adapter = make_adapter() + calls: list[platform_events.Event] = [] + + async def member_listener(event, adapter): + calls.append(event) + + adapter.register_listener(platform_events.MemberJoinedEvent, member_listener) + await adapter._handle_native_event( + qq_event('GROUP_MEMBER_ADD', extra={'openid': 'new-member', 'group_openid': 'group-openid'}) + ) + + assert len(calls) == 1 + assert isinstance(calls[0], platform_events.MemberJoinedEvent) + assert calls[0].member.id == 'new-member' + + @pytest.mark.asyncio async def test_qqofficial_send_reply_stream_platform_api_and_unsupported(): adapter = make_adapter() @@ -252,7 +331,9 @@ async def test_qqofficial_send_reply_stream_platform_api_and_unsupported(): assert ('private_text', 'user-openid', 'reply', 'msg-1') in adapter.bot.sent assert any(call[0] == 'image' and call[1] == 'c2c' for call in adapter.bot.sent) - await adapter.send_message('group', 'group-openid', platform_message.MessageChain([platform_message.Plain(text='hello group')])) + await adapter.send_message( + 'group', 'group-openid', platform_message.MessageChain([platform_message.Plain(text='hello group')]) + ) assert ('group_text', 'group-openid', 'hello group', None) in adapter.bot.sent assert await adapter.call_platform_api('get_mode', {}) == {