fix(platform): align eba event declarations

This commit is contained in:
wangcham
2026-06-28 17:31:48 +08:00
committed by huanghuoguoguo
parent 801ffbf8fe
commit 66c900fec4
14 changed files with 438 additions and 25 deletions
@@ -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:
@@ -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
@@ -92,6 +92,7 @@ spec:
supported_events:
- message.received
- feedback.received
- platform.specific
supported_apis:
@@ -1,3 +1,3 @@
from __future__ import annotations
ADAPTER_NAME = 'dingtalk'
ADAPTER_NAME = 'dingtalk-eba'
@@ -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 [
@@ -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(
@@ -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()
@@ -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: