From 4bd7f5f8fa6e1b135c7b0af2fae6636ab826d043 Mon Sep 17 00:00:00 2001 From: RockChinQ Date: Sun, 22 Mar 2026 22:32:00 +0800 Subject: [PATCH 1/4] feat: EBA unified event system, entities, and adapter base class - Add EBAEvent hierarchy with all unified events (message.*, group.*, friend.*, bot.*, platform.specific) - Add User, UserGroup, UserGroupMember, ChatType, MemberRole entities - Add AbstractPlatformAdapter with optional APIs and capability declaration - Add NotSupportedError for unimplemented optional APIs - All additions are backward-compatible; existing classes unchanged --- .../definition/abstract/platform/adapter.py | 282 +++++++++++--- .../api/entities/builtin/platform/entities.py | 147 ++++++- .../api/entities/builtin/platform/errors.py | 6 + .../api/entities/builtin/platform/events.py | 365 ++++++++++++++++-- 4 files changed, 706 insertions(+), 94 deletions(-) create mode 100644 src/langbot_plugin/api/entities/builtin/platform/errors.py diff --git a/src/langbot_plugin/api/definition/abstract/platform/adapter.py b/src/langbot_plugin/api/definition/abstract/platform/adapter.py index 70c868ea..87e94c6f 100644 --- a/src/langbot_plugin/api/definition/abstract/platform/adapter.py +++ b/src/langbot_plugin/api/definition/abstract/platform/adapter.py @@ -1,20 +1,22 @@ from __future__ import annotations -# MessageSource的适配器 +# Platform adapter abstract base classes import typing import abc import pydantic import langbot_plugin.api.entities.builtin.platform.message as platform_message import langbot_plugin.api.entities.builtin.platform.events as platform_events +import langbot_plugin.api.entities.builtin.platform.entities as platform_entities import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger +from langbot_plugin.api.entities.builtin.platform.errors import NotSupportedError class AbstractMessagePlatformAdapter(pydantic.BaseModel, metaclass=abc.ABCMeta): - """消息平台适配器基类""" + """Message platform adapter base class.""" bot_account_id: str = pydantic.Field(default="") - """机器人账号ID,需要在初始化时设置""" + """Bot account ID, should be set during initialization.""" config: dict @@ -30,12 +32,12 @@ def __init__(self, *args, **kwargs): async def send_message( self, target_type: str, target_id: str, message: platform_message.MessageChain ): - """主动发送消息 + """Send a message proactively. Args: - target_type (str): 目标类型,`person`或`group` - target_id (str): 目标ID - message (platform.types.MessageChain): 消息链 + target_type: Target type, 'person' or 'group'. + target_id: Target ID. + message: Message chain to send. """ raise NotImplementedError @@ -46,12 +48,12 @@ async def reply_message( message: platform_message.MessageChain, quote_origin: bool = False, ): - """回复消息 + """Reply to a message. Args: - message_source (platform.types.MessageEvent): 消息源事件 - message (platform.types.MessageChain): 消息链 - quote_origin (bool, optional): 是否引用原消息. Defaults to False. + message_source: The source message event to reply to. + message: Message chain to send. + quote_origin: Whether to quote the original message. Defaults to False. """ raise NotImplementedError @@ -63,28 +65,30 @@ async def reply_message_chunk( quote_origin: bool = False, is_final: bool = False, ): - """回复消息(流式输出) + """Reply to a message (streaming output). + Args: - message_source (platform.types.MessageEvent): 消息源事件 - message_id (int): 消息ID - message (platform.types.MessageChain): 消息链 - quote_origin (bool, optional): 是否引用原消息. Defaults to False. - is_final (bool, optional): 流式是否结束. Defaults to False. + message_source: The source message event. + bot_message: Bot message context. + message: Message chain to send. + quote_origin: Whether to quote the original message. Defaults to False. + is_final: Whether this is the final chunk. Defaults to False. """ raise NotImplementedError async def create_message_card( self, message_id: typing.Type[str, int], event: platform_events.MessageEvent ) -> bool: - """创建卡片消息 + """Create a card message placeholder for streaming. + Args: - message_id (str): 消息ID - event (platform_events.MessageEvent): 消息源事件 + message_id: Message ID. + event: The source message event. """ return False async def is_muted(self, group_id: int) -> bool: - """获取账号是否在指定群被禁言""" + """Check if the bot is muted in the specified group.""" return False @abc.abstractmethod @@ -95,11 +99,11 @@ def register_listener( [platform_events.Event, AbstractMessagePlatformAdapter], None ], ): - """注册事件监听器 + """Register an event listener. Args: - event_type (typing.Type[platform.types.Event]): 事件类型 - callback (typing.Callable[[platform.types.Event], None]): 回调函数,接收一个参数,为事件 + event_type: The event type to listen for. + callback: Callback function that receives the event and adapter. """ raise NotImplementedError @@ -111,84 +115,272 @@ def unregister_listener( [platform_events.Event, AbstractMessagePlatformAdapter], None ], ): - """注销事件监听器 + """Unregister an event listener. Args: - event_type (typing.Type[platform.types.Event]): 事件类型 - callback (typing.Callable[[platform.types.Event], None]): 回调函数,接收一个参数,为事件 + event_type: The event type to stop listening for. + callback: The callback to remove. """ raise NotImplementedError @abc.abstractmethod async def run_async(self): - """异步运行""" + """Start the adapter asynchronously.""" raise NotImplementedError async def is_stream_output_supported(self) -> bool: - """是否支持流式输出""" + """Check if streaming output is supported.""" return False @abc.abstractmethod async def kill(self) -> bool: - """关闭适配器 + """Shut down the adapter. Returns: - bool: 是否成功关闭,热重载时若此函数返回False则不会重载MessageSource底层 + True if shutdown succeeded. On hot-reload, returning False + prevents the underlying MessageSource from being reloaded. """ raise NotImplementedError +class AbstractPlatformAdapter(AbstractMessagePlatformAdapter): + """Platform adapter base class (EBA version). + + Compared to the legacy AbstractMessagePlatformAdapter: + - Adds universal API methods (edit_message, delete_message, get_group_info, etc.) + - Adds pass-through API (call_platform_api) + - Adds capability declaration (get_supported_events, get_supported_apis) + - Event listeners support all event types, not just message events + """ + + # ---- Capability Declaration ---- + + def get_supported_events(self) -> list[str]: + """Return the list of event types supported by this adapter.""" + return ["message.received"] + + def get_supported_apis(self) -> list[str]: + """Return the list of APIs supported by this adapter.""" + return ["send_message", "reply_message"] + + # ---- Optional Message Methods ---- + + async def edit_message( + self, + chat_type: str, + chat_id: typing.Union[int, str], + message_id: typing.Union[int, str], + new_content: platform_message.MessageChain, + ) -> None: + """Edit a previously sent message.""" + raise NotSupportedError("edit_message") + + async def delete_message( + self, + chat_type: str, + chat_id: typing.Union[int, str], + message_id: typing.Union[int, str], + ) -> None: + """Delete / recall a message.""" + raise NotSupportedError("delete_message") + + async def forward_message( + self, + from_chat_type: str, + from_chat_id: typing.Union[int, str], + message_id: typing.Union[int, str], + to_chat_type: str, + to_chat_id: typing.Union[int, str], + ) -> platform_events.MessageResult: + """Forward a message.""" + raise NotSupportedError("forward_message") + + async def get_message( + self, + chat_type: str, + chat_id: typing.Union[int, str], + message_id: typing.Union[int, str], + ) -> platform_events.MessageReceivedEvent: + """Retrieve a specific message.""" + raise NotSupportedError("get_message") + + # ---- Optional Group Methods ---- + + async def get_group_info( + self, + group_id: typing.Union[int, str], + ) -> platform_entities.UserGroup: + """Get group information.""" + raise NotSupportedError("get_group_info") + + async def get_group_list(self) -> list[platform_entities.UserGroup]: + """Get the list of groups the bot has joined.""" + raise NotSupportedError("get_group_list") + + async def get_group_member_list( + self, + group_id: typing.Union[int, str], + ) -> list[platform_entities.UserGroupMember]: + """Get the member list of a group.""" + raise NotSupportedError("get_group_member_list") + + async def get_group_member_info( + self, + group_id: typing.Union[int, str], + user_id: typing.Union[int, str], + ) -> platform_entities.UserGroupMember: + """Get information about a specific group member.""" + raise NotSupportedError("get_group_member_info") + + async def set_group_name( + self, + group_id: typing.Union[int, str], + name: str, + ) -> None: + """Set the group name.""" + raise NotSupportedError("set_group_name") + + async def mute_member( + self, + group_id: typing.Union[int, str], + user_id: typing.Union[int, str], + duration: int = 0, + ) -> None: + """Mute a group member.""" + raise NotSupportedError("mute_member") + + async def unmute_member( + self, + group_id: typing.Union[int, str], + user_id: typing.Union[int, str], + ) -> None: + """Unmute a group member.""" + raise NotSupportedError("unmute_member") + + async def kick_member( + self, + group_id: typing.Union[int, str], + user_id: typing.Union[int, str], + ) -> None: + """Kick a member from the group.""" + raise NotSupportedError("kick_member") + + async def leave_group( + self, + group_id: typing.Union[int, str], + ) -> None: + """Make the bot leave a group.""" + raise NotSupportedError("leave_group") + + # ---- Optional User Methods ---- + + async def get_user_info( + self, + user_id: typing.Union[int, str], + ) -> platform_entities.User: + """Get user information.""" + raise NotSupportedError("get_user_info") + + async def get_friend_list(self) -> list[platform_entities.User]: + """Get the bot's friend list.""" + raise NotSupportedError("get_friend_list") + + async def approve_friend_request( + self, + request_id: typing.Union[int, str], + approve: bool = True, + remark: typing.Optional[str] = None, + ) -> None: + """Handle a friend request.""" + raise NotSupportedError("approve_friend_request") + + async def approve_group_invite( + self, + request_id: typing.Union[int, str], + approve: bool = True, + ) -> None: + """Handle a group invitation.""" + raise NotSupportedError("approve_group_invite") + + # ---- Optional Media Methods ---- + + async def upload_file( + self, + file_data: bytes, + filename: str, + ) -> str: + """Upload a file. Returns file ID or URL.""" + raise NotSupportedError("upload_file") + + async def get_file_url( + self, + file_id: str, + ) -> str: + """Get a file download URL.""" + raise NotSupportedError("get_file_url") + + # ---- Pass-through API ---- + + async def call_platform_api( + self, + action: str, + params: dict = {}, + ) -> dict: + """Call an adapter-specific platform API.""" + raise NotSupportedError("call_platform_api") + + class AbstractMessageConverter: - """消息链转换器基类""" + """Message chain converter base class.""" @staticmethod def yiri2target(message_chain: platform_message.MessageChain): - """将源平台消息链转换为目标平台消息链 + """Convert internal message chain to platform-specific format. Args: - message_chain (platform.types.MessageChain): 源平台消息链 + message_chain: Internal message chain. Returns: - typing.Any: 目标平台消息链 + Platform-specific message representation. """ raise NotImplementedError @staticmethod def target2yiri(message_chain: typing.Any) -> platform_message.MessageChain: - """将目标平台消息链转换为源平台消息链 + """Convert platform-specific message to internal message chain. Args: - message_chain (typing.Any): 目标平台消息链 + message_chain: Platform-specific message. Returns: - platform.types.MessageChain: 源平台消息链 + Internal message chain. """ raise NotImplementedError class AbstractEventConverter: - """事件转换器基类""" + """Event converter base class.""" @staticmethod def yiri2target(event: typing.Type[platform_events.Event]): - """将源平台事件转换为目标平台事件 + """Convert internal event to platform-specific event. Args: - event (typing.Type[platform.types.Event]): 源平台事件 + event: Internal event. Returns: - typing.Any: 目标平台事件 + Platform-specific event. """ raise NotImplementedError @staticmethod def target2yiri(event: typing.Any) -> platform_events.Event: - """将目标平台事件的调用参数转换为源平台的事件参数对象 + """Convert platform-specific event to internal event. Args: - event (typing.Any): 目标平台事件 + event: Platform-specific event. Returns: - typing.Type[platform.types.Event]: 源平台事件 + Internal event. """ raise NotImplementedError diff --git a/src/langbot_plugin/api/entities/builtin/platform/entities.py b/src/langbot_plugin/api/entities/builtin/platform/entities.py index 3f0fc29d..b3cae3e9 100644 --- a/src/langbot_plugin/api/entities/builtin/platform/entities.py +++ b/src/langbot_plugin/api/entities/builtin/platform/entities.py @@ -1,3 +1,8 @@ +# -*- coding: utf-8 -*- +""" +Platform entity models. +""" + import abc from enum import Enum import typing @@ -6,71 +11,171 @@ class Entity(pydantic.BaseModel): - """实体,表示一个用户或群。""" + """Base entity representing a user or group.""" id: typing.Union[int, str] - """ID。""" + """Entity ID.""" @abc.abstractmethod def get_name(self) -> str: - """名称。""" + """Get display name.""" + + +############################### +# EBA entities (backward-compatible additions) +############################### + +class ChatType(str, Enum): + """Chat/session type.""" + + PRIVATE = "private" + """Private (direct) chat.""" + GROUP = "group" + """Group chat.""" + + +class MemberRole(str, Enum): + """Group member role.""" + + OWNER = "owner" + """Group owner.""" + ADMIN = "admin" + """Administrator.""" + MEMBER = "member" + """Regular member.""" + + +class User(pydantic.BaseModel): + """Unified user entity. + + Provides a common representation for Friend / GroupMember basics. + """ + + id: typing.Union[int, str] + """User ID.""" + + nickname: str = "" + """Display name / nickname.""" + + avatar_url: typing.Optional[str] = None + """Avatar URL.""" + + is_bot: bool = False + """Whether this user is a bot.""" + + username: typing.Optional[str] = None + """Platform username (e.g. Telegram @username).""" + + remark: typing.Optional[str] = None + """Remark / alias set by the current user.""" + + +class UserGroup(pydantic.BaseModel): + """Group entity (EBA version). + + Coexists with the legacy Group class; named UserGroup to avoid conflicts. + """ + + id: typing.Union[int, str] + """Group ID.""" + + name: str = "" + """Group name.""" + + description: typing.Optional[str] = None + """Group description.""" + + member_count: typing.Optional[int] = None + """Number of members.""" + + avatar_url: typing.Optional[str] = None + """Group avatar URL.""" + + owner_id: typing.Optional[typing.Union[int, str]] = None + """Owner's user ID.""" + + +class UserGroupMember(pydantic.BaseModel): + """Group member entity (EBA version).""" + + user: User + """User information.""" + + group_id: typing.Union[int, str] + """ID of the group this member belongs to.""" + + role: MemberRole = MemberRole.MEMBER + """Role within the group.""" + + display_name: typing.Optional[str] = None + """Display name within the group.""" + + joined_at: typing.Optional[float] = None + """Timestamp when the user joined the group.""" + + title: typing.Optional[str] = None + """Special title / badge within the group.""" + +############################### +# Legacy entities (unchanged) +############################### class Friend(Entity): - """私聊对象。""" + """Friend (direct-chat peer).""" id: typing.Union[int, str] - """ID。""" + """ID.""" nickname: typing.Optional[str] - """昵称。""" + """Nickname.""" remark: typing.Optional[str] - """备注。""" + """Remark.""" def get_name(self) -> str: return self.nickname or self.remark or "" class Permission(str, Enum): - """群成员身份权限。""" + """Group member permission level.""" Member = "MEMBER" - """成员。""" + """Regular member.""" Administrator = "ADMINISTRATOR" - """管理员。""" + """Administrator.""" Owner = "OWNER" - """群主。""" + """Group owner.""" def __repr__(self) -> str: return repr(self.value) class Group(Entity): - """群。""" + """Group.""" id: typing.Union[int, str] - """群号。""" + """Group ID.""" name: str - """群名称。""" + """Group name.""" permission: Permission - """Bot 在群中的权限。""" + """Bot's permission level in this group.""" def get_name(self) -> str: return self.name class GroupMember(Entity): - """群成员。""" + """Group member.""" id: typing.Union[int, str] - """群员 ID。""" + """Member ID.""" member_name: str - """群员名称。""" + """Member display name.""" permission: Permission - """在群中的权限。""" + """Permission level in the group.""" group: Group - """群。""" + """The group this member belongs to.""" special_title: str = "" - """群头衔。""" + """Special title within the group.""" def get_name(self) -> str: return self.member_name diff --git a/src/langbot_plugin/api/entities/builtin/platform/errors.py b/src/langbot_plugin/api/entities/builtin/platform/errors.py new file mode 100644 index 00000000..d7b7e990 --- /dev/null +++ b/src/langbot_plugin/api/entities/builtin/platform/errors.py @@ -0,0 +1,6 @@ +from __future__ import annotations + +class NotSupportedError(Exception): + def __init__(self, api_name: str, *args): + super().__init__(f"API '{api_name}' is not supported by this adapter", *args) + self.api_name = api_name diff --git a/src/langbot_plugin/api/entities/builtin/platform/events.py b/src/langbot_plugin/api/entities/builtin/platform/events.py index c51de039..7dbd34bd 100644 --- a/src/langbot_plugin/api/entities/builtin/platform/events.py +++ b/src/langbot_plugin/api/entities/builtin/platform/events.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- """ -此模块提供事件模型。 +Platform event models. """ import typing @@ -12,14 +12,14 @@ class Event(pydantic.BaseModel): - """事件基类。 + """Base event class. Args: - type: 事件名。 + type: Event type name. """ type: str - """事件名。""" + """Event type name.""" def __repr__(self): return ( @@ -51,44 +51,46 @@ def get_subtype(cls, name: str) -> typing.Type["Event"]: ############################### -# Message Event +# Legacy Message Events (unchanged) +############################### + class MessageEvent(Event): - """消息事件。 + """Message event. Args: - type: 事件名。 - message_chain: 消息内容。 + type: Event type name. + message_chain: Message content. """ type: str - """事件名。""" + """Event type name.""" message_chain: platform_message.MessageChain - """消息内容。""" + """Message content.""" time: float | None = None - """消息发送时间戳。""" + """Message timestamp.""" source_platform_object: typing.Optional[typing.Any] = None - """原消息平台对象。 - 供消息平台适配器开发者使用,如果回复用户时需要使用原消息事件对象的信息, - 那么可以将其存到这个字段以供之后取出使用。""" + """Original platform event object. + For adapter developers: store the raw platform event here so it can be + retrieved later when replying to the user.""" class FriendMessage(MessageEvent): - """私聊消息。 + """Private (direct) message. Args: - type: 事件名。 - sender: 发送消息的好友。 - message_chain: 消息内容。 + type: Event type name. + sender: The friend who sent the message. + message_chain: Message content. """ type: str = "FriendMessage" - """事件名。""" + """Event type name.""" sender: platform_entities.Friend - """发送消息的好友。""" + """Message sender.""" message_chain: platform_message.MessageChain - """消息内容。""" + """Message content.""" def model_dump(self, **kwargs): return { @@ -100,20 +102,20 @@ def model_dump(self, **kwargs): class GroupMessage(MessageEvent): - """群消息。 + """Group message. Args: - type: 事件名。 - sender: 发送消息的群成员。 - message_chain: 消息内容。 + type: Event type name. + sender: The group member who sent the message. + message_chain: Message content. """ type: str = "GroupMessage" - """事件名。""" + """Event type name.""" sender: platform_entities.GroupMember - """发送消息的群成员。""" + """Message sender.""" message_chain: platform_message.MessageChain - """消息内容。""" + """Message content.""" @property def group(self) -> platform_entities.Group: @@ -130,6 +132,8 @@ def model_dump(self, **kwargs): ############################### # Feedback Event +############################### + class FeedbackEvent(Event): """User feedback event (like/dislike). @@ -181,3 +185,308 @@ class FeedbackEvent(Event): source_platform_object: typing.Optional[typing.Any] = None """Raw platform event object.""" + + +############################### +# EBA Unified Event System (new) +############################### + +class EBAEvent(Event): + """EBA event base class. + + All unified EBA events inherit from this class. + Coexists with the legacy MessageEvent hierarchy. + """ + + type: str + """Event type identifier, e.g. 'message.received'.""" + + timestamp: float = 0.0 + """Event timestamp.""" + + bot_uuid: str = "" + """UUID of the bot that received this event.""" + + adapter_name: str = "" + """Name of the adapter that produced this event.""" + + source_platform_object: typing.Optional[typing.Any] = None + """Raw platform event object for internal adapter use.""" + + +# ---- Message Events ---- + +class MessageReceivedEvent(EBAEvent): + """New message received. Replaces legacy FriendMessage / GroupMessage.""" + + type: str = "message.received" + + message_id: typing.Union[int, str] = "" + """Message ID.""" + + message_chain: platform_message.MessageChain = platform_message.MessageChain([]) + """Message content.""" + + sender: platform_entities.User = pydantic.Field(default_factory=lambda: platform_entities.User(id="")) + """Message sender.""" + + chat_type: platform_entities.ChatType = platform_entities.ChatType.PRIVATE + """Chat type.""" + + chat_id: typing.Union[int, str] = "" + """Chat ID (user ID for private chats, group ID for group chats).""" + + group: typing.Optional[platform_entities.UserGroup] = None + """Group info (only present in group chats).""" + + def to_legacy_event(self) -> typing.Union[FriendMessage, GroupMessage]: + """Convert this EBA event to a legacy-format event (compatibility layer).""" + if self.chat_type == platform_entities.ChatType.PRIVATE: + return FriendMessage( + sender=platform_entities.Friend( + id=self.sender.id, + nickname=self.sender.nickname, + remark=self.sender.remark, + ), + message_chain=self.message_chain, + time=self.timestamp, + source_platform_object=self.source_platform_object, + ) + else: + group = platform_entities.Group( + id=self.group.id if self.group else self.chat_id, + name=self.group.name if self.group else "", + permission=platform_entities.Permission.Member, + ) + return GroupMessage( + sender=platform_entities.GroupMember( + id=self.sender.id, + member_name=self.sender.nickname, + permission=platform_entities.Permission.Member, + group=group, + ), + message_chain=self.message_chain, + time=self.timestamp, + source_platform_object=self.source_platform_object, + ) + + +class MessageEditedEvent(EBAEvent): + """Message was edited.""" + + type: str = "message.edited" + + message_id: typing.Union[int, str] = "" + """ID of the edited message.""" + + new_content: platform_message.MessageChain = platform_message.MessageChain([]) + """New content after editing.""" + + editor: platform_entities.User = pydantic.Field(default_factory=lambda: platform_entities.User(id="")) + """User who edited the message.""" + + chat_type: platform_entities.ChatType = platform_entities.ChatType.PRIVATE + chat_id: typing.Union[int, str] = "" + group: typing.Optional[platform_entities.UserGroup] = None + + +class MessageDeletedEvent(EBAEvent): + """Message was deleted / recalled.""" + + type: str = "message.deleted" + + message_id: typing.Union[int, str] = "" + """ID of the deleted message.""" + + operator: typing.Optional[platform_entities.User] = None + """User who deleted the message.""" + + chat_type: platform_entities.ChatType = platform_entities.ChatType.PRIVATE + chat_id: typing.Union[int, str] = "" + group: typing.Optional[platform_entities.UserGroup] = None + + +class MessageReactionEvent(EBAEvent): + """Message received an emoji reaction.""" + + type: str = "message.reaction" + + message_id: typing.Union[int, str] = "" + """ID of the reacted message.""" + + user: platform_entities.User = pydantic.Field(default_factory=lambda: platform_entities.User(id="")) + """User who reacted.""" + + reaction: str = "" + """Reaction emoji identifier.""" + + is_add: bool = True + """True if reaction was added, False if removed.""" + + chat_type: platform_entities.ChatType = platform_entities.ChatType.PRIVATE + chat_id: typing.Union[int, str] = "" + group: typing.Optional[platform_entities.UserGroup] = None + + +# ---- Group Events ---- + +class MemberJoinedEvent(EBAEvent): + """New member joined a group.""" + + type: str = "group.member_joined" + + group: platform_entities.UserGroup = pydantic.Field(default_factory=lambda: platform_entities.UserGroup(id="")) + """The group.""" + + member: platform_entities.User = pydantic.Field(default_factory=lambda: platform_entities.User(id="")) + """The member who joined.""" + + inviter: typing.Optional[platform_entities.User] = None + """Inviter (if applicable).""" + + join_type: typing.Optional[str] = None + """How the member joined: 'invite' / 'request' / 'direct' / None.""" + + +class MemberLeftEvent(EBAEvent): + """Member left a group.""" + + type: str = "group.member_left" + + group: platform_entities.UserGroup = pydantic.Field(default_factory=lambda: platform_entities.UserGroup(id="")) + member: platform_entities.User = pydantic.Field(default_factory=lambda: platform_entities.User(id="")) + + is_kicked: bool = False + """Whether the member was kicked.""" + + operator: typing.Optional[platform_entities.User] = None + """Operator (the admin who kicked, if applicable).""" + + +class MemberBannedEvent(EBAEvent): + """Member was muted / restricted.""" + + type: str = "group.member_banned" + + group: platform_entities.UserGroup = pydantic.Field(default_factory=lambda: platform_entities.UserGroup(id="")) + member: platform_entities.User = pydantic.Field(default_factory=lambda: platform_entities.User(id="")) + operator: typing.Optional[platform_entities.User] = None + duration: typing.Optional[int] = None + """Mute duration in seconds. None means permanent.""" + + +class GroupInfoUpdatedEvent(EBAEvent): + """Group info was updated.""" + + type: str = "group.info_updated" + + group: platform_entities.UserGroup = pydantic.Field(default_factory=lambda: platform_entities.UserGroup(id="")) + """Updated group info.""" + + operator: typing.Optional[platform_entities.User] = None + changed_fields: list[str] = [] + """List of field names that changed.""" + + +# ---- Friend Events ---- + +class FriendRequestReceivedEvent(EBAEvent): + """Friend request received.""" + + type: str = "friend.request_received" + + request_id: typing.Union[int, str] = "" + """Request ID.""" + + user: platform_entities.User = pydantic.Field(default_factory=lambda: platform_entities.User(id="")) + """The user who sent the request.""" + + message: typing.Optional[str] = None + """Verification message.""" + + +class FriendAddedEvent(EBAEvent): + """Friend successfully added.""" + + type: str = "friend.added" + + user: platform_entities.User = pydantic.Field(default_factory=lambda: platform_entities.User(id="")) + + +class FriendRemovedEvent(EBAEvent): + """Friend was removed.""" + + type: str = "friend.removed" + + user: platform_entities.User = pydantic.Field(default_factory=lambda: platform_entities.User(id="")) + + +# ---- Bot Status Events ---- + +class BotInvitedToGroupEvent(EBAEvent): + """Bot was invited to join a group.""" + + type: str = "bot.invited_to_group" + + group: platform_entities.UserGroup = pydantic.Field(default_factory=lambda: platform_entities.UserGroup(id="")) + inviter: typing.Optional[platform_entities.User] = None + + request_id: typing.Optional[typing.Union[int, str]] = None + """Invitation request ID.""" + + +class BotRemovedFromGroupEvent(EBAEvent): + """Bot was removed from a group.""" + + type: str = "bot.removed_from_group" + + group: platform_entities.UserGroup = pydantic.Field(default_factory=lambda: platform_entities.UserGroup(id="")) + operator: typing.Optional[platform_entities.User] = None + + +class BotMutedEvent(EBAEvent): + """Bot was muted in a group.""" + + type: str = "bot.muted" + + group: platform_entities.UserGroup = pydantic.Field(default_factory=lambda: platform_entities.UserGroup(id="")) + operator: typing.Optional[platform_entities.User] = None + duration: typing.Optional[int] = None + + +class BotUnmutedEvent(EBAEvent): + """Bot was unmuted in a group.""" + + type: str = "bot.unmuted" + + group: platform_entities.UserGroup = pydantic.Field(default_factory=lambda: platform_entities.UserGroup(id="")) + operator: typing.Optional[platform_entities.User] = None + + +# ---- Platform-Specific Events ---- + +class PlatformSpecificEvent(EBAEvent): + """Platform-specific event. + + Used when the adapter cannot map an event to a standard type. + """ + + type: str = "platform.specific" + + action: str = "" + """Platform-specific action identifier.""" + + data: dict = {} + """Event data; structure defined by each adapter.""" + + +# ---- Message Send Result ---- + +class MessageResult(pydantic.BaseModel): + """Result of a message send operation.""" + + message_id: typing.Optional[typing.Union[int, str]] = None + """Message ID after successful send.""" + + raw: typing.Optional[dict] = None + """Raw platform response data.""" From 1be4ce7f9e299f2e8d2a2cd42fbd667e63473e84 Mon Sep 17 00:00:00 2001 From: Junyan Qin Date: Thu, 7 May 2026 16:25:31 +0800 Subject: [PATCH 2/4] feat: add eba feedback event --- .../api/entities/builtin/platform/events.py | 46 +++++++++++++++++++ tests/api/entities/test_events.py | 35 ++++++++++++++ 2 files changed, 81 insertions(+) diff --git a/src/langbot_plugin/api/entities/builtin/platform/events.py b/src/langbot_plugin/api/entities/builtin/platform/events.py index 7dbd34bd..13cd7916 100644 --- a/src/langbot_plugin/api/entities/builtin/platform/events.py +++ b/src/langbot_plugin/api/entities/builtin/platform/events.py @@ -328,6 +328,52 @@ class MessageReactionEvent(EBAEvent): group: typing.Optional[platform_entities.UserGroup] = None +# ---- Feedback Events ---- + +class FeedbackReceivedEvent(EBAEvent): + """User feedback received for a bot response.""" + + type: str = "feedback.received" + + feedback_id: str + """Unique feedback identifier from the platform.""" + + feedback_type: int + """1 = like, 2 = dislike, 3 = cancel/remove feedback.""" + + feedback_content: typing.Optional[str] = None + """Free-form user feedback text.""" + + inaccurate_reasons: typing.Optional[typing.List[str]] = None + """Predefined inaccuracy reasons (for dislikes).""" + + user_id: typing.Optional[str] = None + """ID of the user who submitted the feedback.""" + + session_id: typing.Optional[str] = None + """Session / conversation ID.""" + + message_id: typing.Optional[str] = None + """ID of the original message being rated.""" + + stream_id: typing.Optional[str] = None + """Stream message ID (for streaming responses).""" + + def to_legacy_event(self) -> FeedbackEvent: + """Convert this EBA event to the legacy FeedbackEvent format.""" + return FeedbackEvent( + feedback_id=self.feedback_id, + feedback_type=self.feedback_type, + feedback_content=self.feedback_content, + inaccurate_reasons=self.inaccurate_reasons, + user_id=self.user_id, + session_id=self.session_id, + message_id=self.message_id, + stream_id=self.stream_id, + source_platform_object=self.source_platform_object, + ) + + # ---- Group Events ---- class MemberJoinedEvent(EBAEvent): diff --git a/tests/api/entities/test_events.py b/tests/api/entities/test_events.py index 2a3873ab..2d7a0691 100644 --- a/tests/api/entities/test_events.py +++ b/tests/api/entities/test_events.py @@ -19,6 +19,8 @@ from langbot_plugin.api.entities.builtin.provider.message import Message from langbot_plugin.api.entities.builtin.pipeline.query import Query from langbot_plugin.api.entities.builtin.platform.events import ( + FeedbackEvent, + FeedbackReceivedEvent, FriendMessage, GroupMessage, ) @@ -316,3 +318,36 @@ def test_validation_errors(): sender_id="789012", message_chain=MessageChain([Plain(text="Hello")]).model_dump(), ) + + +def test_feedback_received_event_serialization_and_legacy_mapping(): + event = FeedbackReceivedEvent( + feedback_id="feedback-1", + feedback_type=2, + feedback_content="not accurate", + inaccurate_reasons=["wrong_answer"], + user_id="user-1", + session_id="person_user-1", + message_id="msg-1", + stream_id="stream-1", + timestamp=123.0, + bot_uuid="bot-1", + adapter_name="wecom", + ) + + serialized = event.model_dump() + assert serialized["type"] == "feedback.received" + assert serialized["feedback_type"] == 2 + assert serialized["inaccurate_reasons"] == ["wrong_answer"] + + legacy_event = event.to_legacy_event() + assert isinstance(legacy_event, FeedbackEvent) + assert legacy_event.type == "FeedbackEvent" + assert legacy_event.feedback_id == event.feedback_id + assert legacy_event.feedback_type == event.feedback_type + assert legacy_event.feedback_content == event.feedback_content + assert legacy_event.inaccurate_reasons == event.inaccurate_reasons + assert legacy_event.user_id == event.user_id + assert legacy_event.session_id == event.session_id + assert legacy_event.message_id == event.message_id + assert legacy_event.stream_id == event.stream_id From 6dcec3a17439305d6c453dcf359ae06f77251179 Mon Sep 17 00:00:00 2001 From: Junyan Qin Date: Thu, 7 May 2026 17:00:27 +0800 Subject: [PATCH 3/4] feat: expose eba events to plugins --- src/langbot_plugin/api/entities/context.py | 4 +- src/langbot_plugin/api/entities/events.py | 311 ++++++++++++++++++ tests/api/entities/test_events.py | 136 ++++++++ tests/e2e/standalone_runtime_eba_probe.py | 196 +++++++++++ .../eba_event_probe_plugin/.gitignore | 1 + .../eba_event_probe_plugin/assets/icon.svg | 4 + .../components/event_listener/default.py | 41 +++ .../components/event_listener/default.yaml | 12 + tests/fixtures/eba_event_probe_plugin/main.py | 8 + .../eba_event_probe_plugin/manifest.yaml | 24 ++ 10 files changed, 735 insertions(+), 2 deletions(-) create mode 100644 tests/e2e/standalone_runtime_eba_probe.py create mode 100644 tests/fixtures/eba_event_probe_plugin/.gitignore create mode 100644 tests/fixtures/eba_event_probe_plugin/assets/icon.svg create mode 100644 tests/fixtures/eba_event_probe_plugin/components/event_listener/default.py create mode 100644 tests/fixtures/eba_event_probe_plugin/components/event_listener/default.yaml create mode 100644 tests/fixtures/eba_event_probe_plugin/main.py create mode 100644 tests/fixtures/eba_event_probe_plugin/manifest.yaml diff --git a/src/langbot_plugin/api/entities/context.py b/src/langbot_plugin/api/entities/context.py index 5b153ed2..395aa221 100644 --- a/src/langbot_plugin/api/entities/context.py +++ b/src/langbot_plugin/api/entities/context.py @@ -17,7 +17,7 @@ class EventContext(pydantic.BaseModel): """事件上下文, 保存此次事件运行的信息""" - query_id: int + query_id: int = 0 """请求ID""" eid: int = 0 @@ -84,7 +84,7 @@ def is_prevented_postorder(self): @classmethod def from_event(cls, event: BaseEventModel) -> EventContext: global global_eid_index - query_id = event.query.query_id + query_id = event.query.query_id if event.query else 0 eid = global_eid_index event = event event_name = event.__class__.__name__ diff --git a/src/langbot_plugin/api/entities/events.py b/src/langbot_plugin/api/entities/events.py index b9e66c0b..ac52aaea 100644 --- a/src/langbot_plugin/api/entities/events.py +++ b/src/langbot_plugin/api/entities/events.py @@ -6,6 +6,7 @@ from langbot_plugin.api.entities.builtin.platform import message as platform_message from langbot_plugin.api.entities.builtin.platform import events as platform_events +from langbot_plugin.api.entities.builtin.platform import entities as platform_entities from langbot_plugin.api.entities.builtin.provider import message as provider_message from langbot_plugin.api.entities.builtin.provider import session as provider_session from langbot_plugin.api.entities.builtin.pipeline import query as pipeline_query @@ -24,6 +25,316 @@ class Config: arbitrary_types_allowed = True +class MessageReceived(BaseEventModel): + """An EBA platform message was received.""" + + event_name: str = "MessageReceived" + + message_id: typing.Union[int, str] = "" + message_chain: platform_message.MessageChain = pydantic.Field( + default_factory=platform_message.MessageChain, + serialization_alias="message_chain", + ) + sender: platform_entities.User = pydantic.Field(default_factory=lambda: platform_entities.User(id="")) + chat_type: platform_entities.ChatType = platform_entities.ChatType.PRIVATE + chat_id: typing.Union[int, str] = "" + group: typing.Optional[platform_entities.UserGroup] = None + platform_event: typing.Optional[platform_events.MessageReceivedEvent] = pydantic.Field(default=None, exclude=True) + + @pydantic.field_serializer("message_chain") + def serialize_message_chain(self, v, _info): + return v.model_dump() + + @pydantic.field_validator("message_chain", mode="before") + def validate_message_chain(cls, v): + return platform_message.MessageChain.model_validate(v) + + @classmethod + def from_platform_event(cls, event: platform_events.MessageReceivedEvent) -> "MessageReceived": + return cls( + message_id=event.message_id, + message_chain=event.message_chain, + sender=event.sender, + chat_type=event.chat_type, + chat_id=event.chat_id, + group=event.group, + platform_event=event, + ) + + +class MessageEdited(BaseEventModel): + """An EBA platform message was edited.""" + + event_name: str = "MessageEdited" + + message_id: typing.Union[int, str] = "" + new_content: platform_message.MessageChain = pydantic.Field( + default_factory=platform_message.MessageChain, + serialization_alias="new_content", + ) + editor: platform_entities.User = pydantic.Field(default_factory=lambda: platform_entities.User(id="")) + chat_type: platform_entities.ChatType = platform_entities.ChatType.PRIVATE + chat_id: typing.Union[int, str] = "" + group: typing.Optional[platform_entities.UserGroup] = None + platform_event: typing.Optional[platform_events.MessageEditedEvent] = pydantic.Field(default=None, exclude=True) + + @pydantic.field_serializer("new_content") + def serialize_new_content(self, v, _info): + return v.model_dump() + + @pydantic.field_validator("new_content", mode="before") + def validate_new_content(cls, v): + return platform_message.MessageChain.model_validate(v) + + @classmethod + def from_platform_event(cls, event: platform_events.MessageEditedEvent) -> "MessageEdited": + return cls( + message_id=event.message_id, + new_content=event.new_content, + editor=event.editor, + chat_type=event.chat_type, + chat_id=event.chat_id, + group=event.group, + platform_event=event, + ) + + +class MessageDeleted(BaseEventModel): + """An EBA platform message was deleted.""" + + event_name: str = "MessageDeleted" + + message_id: typing.Union[int, str] = "" + operator: typing.Optional[platform_entities.User] = None + chat_type: platform_entities.ChatType = platform_entities.ChatType.PRIVATE + chat_id: typing.Union[int, str] = "" + group: typing.Optional[platform_entities.UserGroup] = None + platform_event: typing.Optional[platform_events.MessageDeletedEvent] = pydantic.Field(default=None, exclude=True) + + @classmethod + def from_platform_event(cls, event: platform_events.MessageDeletedEvent) -> "MessageDeleted": + return cls( + message_id=event.message_id, + operator=event.operator, + chat_type=event.chat_type, + chat_id=event.chat_id, + group=event.group, + platform_event=event, + ) + + +class MessageReactionReceived(BaseEventModel): + """An EBA platform message reaction was received.""" + + event_name: str = "MessageReactionReceived" + + message_id: typing.Union[int, str] = "" + user: platform_entities.User = pydantic.Field(default_factory=lambda: platform_entities.User(id="")) + reaction: str = "" + is_add: bool = True + chat_type: platform_entities.ChatType = platform_entities.ChatType.PRIVATE + chat_id: typing.Union[int, str] = "" + group: typing.Optional[platform_entities.UserGroup] = None + platform_event: typing.Optional[platform_events.MessageReactionEvent] = pydantic.Field(default=None, exclude=True) + + @classmethod + def from_platform_event(cls, event: platform_events.MessageReactionEvent) -> "MessageReactionReceived": + return cls( + message_id=event.message_id, + user=event.user, + reaction=event.reaction, + is_add=event.is_add, + chat_type=event.chat_type, + chat_id=event.chat_id, + group=event.group, + platform_event=event, + ) + + +class FeedbackReceived(BaseEventModel): + """User feedback was received for a bot response.""" + + event_name: str = "FeedbackReceived" + + feedback_id: str + feedback_type: int + feedback_content: typing.Optional[str] = None + inaccurate_reasons: typing.Optional[list[str]] = None + user_id: typing.Optional[str] = None + session_id: typing.Optional[str] = None + message_id: typing.Optional[str] = None + stream_id: typing.Optional[str] = None + platform_event: typing.Optional[platform_events.FeedbackReceivedEvent] = pydantic.Field(default=None, exclude=True) + + @classmethod + def from_platform_event(cls, event: platform_events.FeedbackReceivedEvent) -> "FeedbackReceived": + return cls( + feedback_id=event.feedback_id, + feedback_type=event.feedback_type, + feedback_content=event.feedback_content, + inaccurate_reasons=event.inaccurate_reasons, + user_id=event.user_id, + session_id=event.session_id, + message_id=event.message_id, + stream_id=event.stream_id, + platform_event=event, + ) + + +class GroupMemberJoined(BaseEventModel): + """A member joined a group.""" + + event_name: str = "GroupMemberJoined" + + group: platform_entities.UserGroup = pydantic.Field(default_factory=lambda: platform_entities.UserGroup(id="")) + member: platform_entities.User = pydantic.Field(default_factory=lambda: platform_entities.User(id="")) + inviter: typing.Optional[platform_entities.User] = None + join_type: typing.Optional[str] = None + platform_event: typing.Optional[platform_events.MemberJoinedEvent] = pydantic.Field(default=None, exclude=True) + + @classmethod + def from_platform_event(cls, event: platform_events.MemberJoinedEvent) -> "GroupMemberJoined": + return cls( + group=event.group, + member=event.member, + inviter=event.inviter, + join_type=event.join_type, + platform_event=event, + ) + + +class GroupMemberLeft(BaseEventModel): + """A member left a group.""" + + event_name: str = "GroupMemberLeft" + + group: platform_entities.UserGroup = pydantic.Field(default_factory=lambda: platform_entities.UserGroup(id="")) + member: platform_entities.User = pydantic.Field(default_factory=lambda: platform_entities.User(id="")) + is_kicked: bool = False + operator: typing.Optional[platform_entities.User] = None + platform_event: typing.Optional[platform_events.MemberLeftEvent] = pydantic.Field(default=None, exclude=True) + + @classmethod + def from_platform_event(cls, event: platform_events.MemberLeftEvent) -> "GroupMemberLeft": + return cls( + group=event.group, + member=event.member, + is_kicked=event.is_kicked, + operator=event.operator, + platform_event=event, + ) + + +class GroupMemberBanned(BaseEventModel): + """A member was muted or restricted in a group.""" + + event_name: str = "GroupMemberBanned" + + group: platform_entities.UserGroup = pydantic.Field(default_factory=lambda: platform_entities.UserGroup(id="")) + member: platform_entities.User = pydantic.Field(default_factory=lambda: platform_entities.User(id="")) + operator: typing.Optional[platform_entities.User] = None + duration: typing.Optional[int] = None + platform_event: typing.Optional[platform_events.MemberBannedEvent] = pydantic.Field(default=None, exclude=True) + + @classmethod + def from_platform_event(cls, event: platform_events.MemberBannedEvent) -> "GroupMemberBanned": + return cls( + group=event.group, + member=event.member, + operator=event.operator, + duration=event.duration, + platform_event=event, + ) + + +class BotInvitedToGroup(BaseEventModel): + """The bot was invited to a group.""" + + event_name: str = "BotInvitedToGroup" + + group: platform_entities.UserGroup = pydantic.Field(default_factory=lambda: platform_entities.UserGroup(id="")) + inviter: typing.Optional[platform_entities.User] = None + request_id: typing.Optional[typing.Union[int, str]] = None + platform_event: typing.Optional[platform_events.BotInvitedToGroupEvent] = pydantic.Field(default=None, exclude=True) + + @classmethod + def from_platform_event(cls, event: platform_events.BotInvitedToGroupEvent) -> "BotInvitedToGroup": + return cls( + group=event.group, + inviter=event.inviter, + request_id=event.request_id, + platform_event=event, + ) + + +class BotRemovedFromGroup(BaseEventModel): + """The bot was removed from a group.""" + + event_name: str = "BotRemovedFromGroup" + + group: platform_entities.UserGroup = pydantic.Field(default_factory=lambda: platform_entities.UserGroup(id="")) + operator: typing.Optional[platform_entities.User] = None + platform_event: typing.Optional[platform_events.BotRemovedFromGroupEvent] = pydantic.Field(default=None, exclude=True) + + @classmethod + def from_platform_event(cls, event: platform_events.BotRemovedFromGroupEvent) -> "BotRemovedFromGroup": + return cls(group=event.group, operator=event.operator, platform_event=event) + + +class BotMuted(BaseEventModel): + """The bot was muted in a group.""" + + event_name: str = "BotMuted" + + group: platform_entities.UserGroup = pydantic.Field(default_factory=lambda: platform_entities.UserGroup(id="")) + operator: typing.Optional[platform_entities.User] = None + duration: typing.Optional[int] = None + platform_event: typing.Optional[platform_events.BotMutedEvent] = pydantic.Field(default=None, exclude=True) + + @classmethod + def from_platform_event(cls, event: platform_events.BotMutedEvent) -> "BotMuted": + return cls( + group=event.group, + operator=event.operator, + duration=event.duration, + platform_event=event, + ) + + +class BotUnmuted(BaseEventModel): + """The bot was unmuted in a group.""" + + event_name: str = "BotUnmuted" + + group: platform_entities.UserGroup = pydantic.Field(default_factory=lambda: platform_entities.UserGroup(id="")) + operator: typing.Optional[platform_entities.User] = None + platform_event: typing.Optional[platform_events.BotUnmutedEvent] = pydantic.Field(default=None, exclude=True) + + @classmethod + def from_platform_event(cls, event: platform_events.BotUnmutedEvent) -> "BotUnmuted": + return cls(group=event.group, operator=event.operator, platform_event=event) + + +class PlatformSpecificEventReceived(BaseEventModel): + """A platform-specific EBA event was received.""" + + event_name: str = "PlatformSpecificEventReceived" + + adapter_name: str = "" + action: str = "" + data: dict = pydantic.Field(default_factory=dict) + platform_event: typing.Optional[platform_events.PlatformSpecificEvent] = pydantic.Field(default=None, exclude=True) + + @classmethod + def from_platform_event(cls, event: platform_events.PlatformSpecificEvent) -> "PlatformSpecificEventReceived": + return cls( + adapter_name=event.adapter_name, + action=event.action, + data=event.data, + platform_event=event, + ) + + class PersonMessageReceived(BaseEventModel): """收到任何私聊消息时""" diff --git a/tests/api/entities/test_events.py b/tests/api/entities/test_events.py index 2d7a0691..6b830f82 100644 --- a/tests/api/entities/test_events.py +++ b/tests/api/entities/test_events.py @@ -1,6 +1,16 @@ import pytest +import asyncio from langbot_plugin.api.entities.events import ( BaseEventModel, + BotInvitedToGroup, + FeedbackReceived, + GroupMemberBanned, + GroupMemberJoined, + GroupMemberLeft, + MessageEdited, + MessageReactionReceived, + MessageReceived, + PlatformSpecificEventReceived, PersonMessageReceived, GroupMessageReceived, PersonNormalMessageReceived, @@ -19,17 +29,30 @@ from langbot_plugin.api.entities.builtin.provider.message import Message from langbot_plugin.api.entities.builtin.pipeline.query import Query from langbot_plugin.api.entities.builtin.platform.events import ( + BotInvitedToGroupEvent, FeedbackEvent, FeedbackReceivedEvent, FriendMessage, GroupMessage, + MemberBannedEvent, + MemberJoinedEvent, + MemberLeftEvent, + MessageEditedEvent, + MessageReactionEvent, + MessageReceivedEvent, + PlatformSpecificEvent, ) from langbot_plugin.api.entities.builtin.platform.entities import ( + ChatType, Friend, Group, GroupMember, Permission, + User, + UserGroup, ) +from langbot_plugin.api.entities.context import EventContext +from langbot_plugin.api.definition.components.common.event_listener import EventListener from langbot_plugin.api.definition.abstract.platform.adapter import ( AbstractMessagePlatformAdapter, ) @@ -351,3 +374,116 @@ def test_feedback_received_event_serialization_and_legacy_mapping(): assert legacy_event.session_id == event.session_id assert legacy_event.message_id == event.message_id assert legacy_event.stream_id == event.stream_id + + +def test_eba_plugin_event_models_convert_from_platform_events(): + group = UserGroup(id="group-1", name="Test Group") + user = User(id="user-1", nickname="Tester") + message_chain = MessageChain([Plain(text="hello eba")]) + + cases = [ + ( + MessageReceived, + MessageReceivedEvent( + message_id="msg-1", + message_chain=message_chain, + sender=user, + chat_type=ChatType.GROUP, + chat_id="group-1", + group=group, + ), + {"message_id": "msg-1", "chat_id": "group-1"}, + ), + ( + MessageEdited, + MessageEditedEvent( + message_id="msg-2", + new_content=message_chain, + editor=user, + chat_type=ChatType.PRIVATE, + chat_id="user-1", + ), + {"message_id": "msg-2", "chat_id": "user-1"}, + ), + ( + MessageReactionReceived, + MessageReactionEvent(message_id="msg-3", user=user, reaction="👍", chat_id="group-1", group=group), + {"message_id": "msg-3", "reaction": "👍"}, + ), + ( + FeedbackReceived, + FeedbackReceivedEvent(feedback_id="fb-1", feedback_type=2, feedback_content="bad"), + {"feedback_id": "fb-1", "feedback_type": 2}, + ), + ( + GroupMemberJoined, + MemberJoinedEvent(group=group, member=user, inviter=user, join_type="invite"), + {"join_type": "invite"}, + ), + ( + GroupMemberLeft, + MemberLeftEvent(group=group, member=user, is_kicked=True, operator=user), + {"is_kicked": True}, + ), + ( + GroupMemberBanned, + MemberBannedEvent(group=group, member=user, operator=user, duration=60), + {"duration": 60}, + ), + ( + BotInvitedToGroup, + BotInvitedToGroupEvent(group=group, inviter=user, request_id="req-1"), + {"request_id": "req-1"}, + ), + ( + PlatformSpecificEventReceived, + PlatformSpecificEvent(adapter_name="telegram", action="callback_query", data={"data": "ok"}), + {"adapter_name": "telegram", "action": "callback_query"}, + ), + ] + + for plugin_event_type, platform_event, expected_fields in cases: + plugin_event = plugin_event_type.from_platform_event(platform_event) + serialized = plugin_event.model_dump() + assert serialized["event_name"] == plugin_event_type.__name__ + assert "query" not in serialized + assert "platform_event" not in serialized + for field, value in expected_fields.items(): + assert serialized[field] == value + + event_context = EventContext.from_event(plugin_event) + assert event_context.query_id == 0 + assert event_context.event_name == plugin_event_type.__name__ + + +def test_event_listener_can_handle_eba_plugin_events(): + listener = EventListener() + calls: list[tuple[str, str]] = [] + + @listener.handler(MessageReactionReceived) + async def on_reaction(ctx: EventContext): + calls.append((ctx.event_name, ctx.event.reaction)) + + @listener.handler(PlatformSpecificEventReceived) + async def on_platform_specific(ctx: EventContext): + calls.append((ctx.event_name, ctx.event.action)) + + reaction_ctx = EventContext.from_event( + MessageReactionReceived.from_platform_event( + MessageReactionEvent(message_id="msg-1", user=User(id="u1"), reaction="👍") + ) + ) + platform_ctx = EventContext.from_event( + PlatformSpecificEventReceived.from_platform_event( + PlatformSpecificEvent(adapter_name="telegram", action="callback_query", data={"data": "button"}) + ) + ) + + for ctx in (reaction_ctx, platform_ctx): + for handler in listener.registered_handlers[ctx.event.__class__]: + asyncio.run(handler(ctx)) + + assert calls == [ + ("MessageReactionReceived", "👍"), + ("PlatformSpecificEventReceived", "callback_query"), + ] diff --git a/tests/e2e/standalone_runtime_eba_probe.py b/tests/e2e/standalone_runtime_eba_probe.py new file mode 100644 index 00000000..44cf2114 --- /dev/null +++ b/tests/e2e/standalone_runtime_eba_probe.py @@ -0,0 +1,196 @@ +from __future__ import annotations + +import asyncio +import json +import sys +from pathlib import Path +from typing import Any + +import websockets + + +CONTROL_URL = "ws://127.0.0.1:5410/control/ws" +PLUGIN_ID = ("Codex", "EBAEventProbe") + + +class RuntimeControlClient: + def __init__(self, websocket): + self.websocket = websocket + self.seq = 0 + self.waiters: dict[int, asyncio.Future[dict[str, Any]]] = {} + + async def start_reader(self): + async for message in self.websocket: + payload = json.loads(message) + if "action" in payload: + await self._handle_runtime_action(payload) + elif "code" in payload: + seq_id = payload["seq_id"] + waiter = self.waiters.pop(seq_id, None) + if waiter and not waiter.done(): + waiter.set_result(payload) + + async def _handle_runtime_action(self, payload: dict[str, Any]): + action = payload["action"] + + if action == "initialize_plugin_settings": + response_data: dict[str, Any] = {} + elif action == "get_plugin_settings": + response_data = { + "enabled": True, + "priority": 0, + "plugin_config": {}, + "install_source": "debug", + "install_info": {}, + } + else: + response_data = {} + + await self.websocket.send(json.dumps({ + "seq_id": payload["seq_id"], + "code": 0, + "message": "success", + "data": response_data, + "chunk_status": "continue", + })) + + async def call(self, action: str, data: dict[str, Any], timeout: float = 10.0) -> dict[str, Any]: + self.seq += 1 + seq_id = self.seq + waiter: asyncio.Future[dict[str, Any]] = asyncio.get_running_loop().create_future() + self.waiters[seq_id] = waiter + await self.websocket.send(json.dumps({"seq_id": seq_id, "action": action, "data": data})) + response = await asyncio.wait_for(waiter, timeout) + if response["code"] != 0: + raise RuntimeError(response["message"]) + return response["data"] + + +def event_context(event_name: str, event: dict[str, Any], eid: int) -> dict[str, Any]: + return { + "query_id": 0, + "eid": eid, + "event_name": event_name, + "event": {"event_name": event_name, **event}, + "is_prevent_default": False, + "is_prevent_postorder": False, + } + + +def probe_events() -> list[dict[str, Any]]: + group = {"id": "group-1", "name": "Probe Group"} + user = {"id": "user-1", "nickname": "Probe User", "is_bot": False} + return [ + event_context( + "MessageReceived", + { + "message_id": "msg-1", + "message_chain": [{"type": "Plain", "text": "hello"}], + "sender": user, + "chat_type": "private", + "chat_id": "user-1", + "group": None, + }, + 1, + ), + event_context( + "MessageEdited", + { + "message_id": "msg-2", + "new_content": [{"type": "Plain", "text": "edited"}], + "editor": user, + "chat_type": "private", + "chat_id": "user-1", + "group": None, + }, + 2, + ), + event_context( + "MessageReactionReceived", + { + "message_id": "msg-3", + "user": user, + "reaction": "👍", + "is_add": True, + "chat_type": "group", + "chat_id": "group-1", + "group": group, + }, + 3, + ), + event_context( + "FeedbackReceived", + { + "feedback_id": "fb-1", + "feedback_type": 2, + "feedback_content": "not accurate", + "inaccurate_reasons": ["wrong_answer"], + "user_id": "user-1", + "session_id": "person_user-1", + "message_id": "msg-4", + "stream_id": "stream-1", + }, + 4, + ), + event_context("GroupMemberJoined", {"group": group, "member": user, "inviter": user, "join_type": "invite"}, 5), + event_context("GroupMemberLeft", {"group": group, "member": user, "is_kicked": True, "operator": user}, 6), + event_context("GroupMemberBanned", {"group": group, "member": user, "operator": user, "duration": 60}, 7), + event_context("BotInvitedToGroup", {"group": group, "inviter": user, "request_id": "req-1"}, 8), + event_context("BotRemovedFromGroup", {"group": group, "operator": user}, 9), + event_context("BotMuted", {"group": group, "operator": user, "duration": 60}, 10), + event_context("BotUnmuted", {"group": group, "operator": user}, 11), + event_context("PlatformSpecificEventReceived", {"adapter_name": "telegram", "action": "callback_query", "data": {"data": "button"}}, 12), + ] + + +async def wait_for_probe_plugin(client: RuntimeControlClient): + for _ in range(30): + plugins = await client.call("list_plugins", {}) + for plugin in plugins["plugins"]: + metadata = plugin["manifest"]["manifest"]["metadata"] + if (metadata["author"], metadata["name"]) == PLUGIN_ID: + return + await asyncio.sleep(1) + raise TimeoutError("EBAEventProbe did not register with standalone runtime") + + +async def main() -> int: + log_path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path("eba_event_probe.jsonl") + if log_path.exists(): + log_path.unlink() + + async with websockets.connect(CONTROL_URL, open_timeout=10) as websocket: + client = RuntimeControlClient(websocket) + reader_task = asyncio.create_task(client.start_reader()) + try: + await wait_for_probe_plugin(client) + for event_ctx in probe_events(): + result = await client.call( + "emit_event", + { + "event_context": event_ctx, + "include_plugins": ["Codex/EBAEventProbe"], + }, + timeout=20, + ) + if not result["emitted_plugins"]: + raise RuntimeError(f"Event was not emitted: {event_ctx['event_name']}") + + for _ in range(20): + if log_path.exists(): + lines = [json.loads(line) for line in log_path.read_text(encoding="utf-8").splitlines()] + if len(lines) >= len(probe_events()): + seen = [line["event_name"] for line in lines] + expected = [event["event_name"] for event in probe_events()] + if seen[-len(expected):] == expected: + print(json.dumps({"ok": True, "events": seen[-len(expected):]}, ensure_ascii=False)) + return 0 + await asyncio.sleep(0.5) + + raise TimeoutError("Probe plugin did not write all expected events") + finally: + reader_task.cancel() + + +if __name__ == "__main__": + raise SystemExit(asyncio.run(main())) diff --git a/tests/fixtures/eba_event_probe_plugin/.gitignore b/tests/fixtures/eba_event_probe_plugin/.gitignore new file mode 100644 index 00000000..8cb7b5a9 --- /dev/null +++ b/tests/fixtures/eba_event_probe_plugin/.gitignore @@ -0,0 +1 @@ +eba_event_probe.jsonl diff --git a/tests/fixtures/eba_event_probe_plugin/assets/icon.svg b/tests/fixtures/eba_event_probe_plugin/assets/icon.svg new file mode 100644 index 00000000..3784313e --- /dev/null +++ b/tests/fixtures/eba_event_probe_plugin/assets/icon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/tests/fixtures/eba_event_probe_plugin/components/event_listener/default.py b/tests/fixtures/eba_event_probe_plugin/components/event_listener/default.py new file mode 100644 index 00000000..0ac72899 --- /dev/null +++ b/tests/fixtures/eba_event_probe_plugin/components/event_listener/default.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +import json +import os +from pathlib import Path + +from langbot_plugin.api.definition.components.common.event_listener import EventListener +from langbot_plugin.api.entities import context, events + + +class EBAEventProbeListener(EventListener): + def __init__(self): + super().__init__() + self.log_path = Path(os.getenv("EBA_PROBE_LOG", "eba_event_probe.jsonl")) + + for event_type in ( + events.MessageReceived, + events.MessageEdited, + events.MessageReactionReceived, + events.FeedbackReceived, + events.GroupMemberJoined, + events.GroupMemberLeft, + events.GroupMemberBanned, + events.BotInvitedToGroup, + events.BotRemovedFromGroup, + events.BotMuted, + events.BotUnmuted, + events.PlatformSpecificEventReceived, + ): + self.handler(event_type)(self._record) + + async def _record(self, event_context: context.EventContext): + event_data = event_context.event.model_dump() + record = { + "event_name": event_context.event_name, + "query_id": event_context.query_id, + "event": event_data, + } + with self.log_path.open("a", encoding="utf-8") as fp: + fp.write(json.dumps(record, ensure_ascii=False) + "\n") + print(f"EBA_PROBE_EVENT {event_context.event_name}") diff --git a/tests/fixtures/eba_event_probe_plugin/components/event_listener/default.yaml b/tests/fixtures/eba_event_probe_plugin/components/event_listener/default.yaml new file mode 100644 index 00000000..de2bda94 --- /dev/null +++ b/tests/fixtures/eba_event_probe_plugin/components/event_listener/default.yaml @@ -0,0 +1,12 @@ +apiVersion: v1 +kind: EventListener +metadata: + name: default + label: + en_US: EBAEventProbeListener + zh_Hans: EBAEventProbeListener +spec: +execution: + python: + path: default.py + attr: EBAEventProbeListener diff --git a/tests/fixtures/eba_event_probe_plugin/main.py b/tests/fixtures/eba_event_probe_plugin/main.py new file mode 100644 index 00000000..bcccedc7 --- /dev/null +++ b/tests/fixtures/eba_event_probe_plugin/main.py @@ -0,0 +1,8 @@ +from __future__ import annotations + +from langbot_plugin.api.definition.plugin import BasePlugin + + +class EBAEventProbePlugin(BasePlugin): + async def initialize(self) -> None: + return None diff --git a/tests/fixtures/eba_event_probe_plugin/manifest.yaml b/tests/fixtures/eba_event_probe_plugin/manifest.yaml new file mode 100644 index 00000000..1ebd4893 --- /dev/null +++ b/tests/fixtures/eba_event_probe_plugin/manifest.yaml @@ -0,0 +1,24 @@ +apiVersion: v1 +kind: Plugin +metadata: + author: Codex + name: EBAEventProbe + repository: https://github.com/langbot-app/langbot-plugin-sdk + version: 0.1.0 + description: + en_US: Probe plugin used to verify EBA EventListener delivery. + zh_Hans: 用于验证 EBA EventListener 分发的探针插件。 + label: + en_US: EBA Event Probe + zh_Hans: EBA 事件探针 + icon: assets/icon.svg +spec: + config: [] + components: + EventListener: + fromDirs: + - path: components/event_listener/ +execution: + python: + path: main.py + attr: EBAEventProbePlugin From d3976fd98db3bf730a2fbc00cf1a00ce88ad622b Mon Sep 17 00:00:00 2001 From: Junyan Qin Date: Sun, 10 May 2026 18:58:18 +0800 Subject: [PATCH 4/4] feat: expose platform api calls to plugins --- .../api/entities/builtin/platform/message.py | 1 + src/langbot_plugin/api/entities/events.py | 4 ++ src/langbot_plugin/api/proxies/langbot_api.py | 46 +++++++++++++++---- .../entities/io/actions/enums.py | 1 + .../runtime/io/handlers/plugin.py | 10 ++++ tests/api/entities/test_events.py | 4 +- tests/test_message.py | 19 +++++--- 7 files changed, 67 insertions(+), 18 deletions(-) diff --git a/src/langbot_plugin/api/entities/builtin/platform/message.py b/src/langbot_plugin/api/entities/builtin/platform/message.py index 59592116..5fa51f1b 100644 --- a/src/langbot_plugin/api/entities/builtin/platform/message.py +++ b/src/langbot_plugin/api/entities/builtin/platform/message.py @@ -45,6 +45,7 @@ def _get_component_types(cls) -> dict[str, type[MessageComponent]]: "Voice": Voice, "Forward": Forward, "File": File, + "Face": Face, "WeChatMiniPrograms": WeChatMiniPrograms, "WeChatForwardMiniPrograms": WeChatForwardMiniPrograms, "WeChatEmoji": WeChatEmoji, diff --git a/src/langbot_plugin/api/entities/events.py b/src/langbot_plugin/api/entities/events.py index ac52aaea..9478abd9 100644 --- a/src/langbot_plugin/api/entities/events.py +++ b/src/langbot_plugin/api/entities/events.py @@ -30,6 +30,8 @@ class MessageReceived(BaseEventModel): event_name: str = "MessageReceived" + bot_uuid: str = "" + adapter_name: str = "" message_id: typing.Union[int, str] = "" message_chain: platform_message.MessageChain = pydantic.Field( default_factory=platform_message.MessageChain, @@ -52,6 +54,8 @@ def validate_message_chain(cls, v): @classmethod def from_platform_event(cls, event: platform_events.MessageReceivedEvent) -> "MessageReceived": return cls( + bot_uuid=event.bot_uuid, + adapter_name=event.adapter_name, message_id=event.message_id, message_chain=event.message_chain, sender=event.sender, diff --git a/src/langbot_plugin/api/proxies/langbot_api.py b/src/langbot_plugin/api/proxies/langbot_api.py index 572a1e45..5863120c 100644 --- a/src/langbot_plugin/api/proxies/langbot_api.py +++ b/src/langbot_plugin/api/proxies/langbot_api.py @@ -48,7 +48,7 @@ async def send_message( target_type: str, target_id: str, message_chain: platform_message.MessageChain, - ) -> None: + ) -> Any: """Send a message to a bot Args: @@ -57,15 +57,41 @@ async def send_message( target_id: The ID of the target message_chain: The message chain to send """ - await self.plugin_runtime_handler.call_action( - PluginToRuntimeAction.SEND_MESSAGE, - { - "bot_uuid": bot_uuid, - "target_type": target_type, - "target_id": target_id, - "message_chain": message_chain.model_dump(), - }, - ) + return ( + await self.plugin_runtime_handler.call_action( + PluginToRuntimeAction.SEND_MESSAGE, + { + "bot_uuid": bot_uuid, + "target_type": target_type, + "target_id": target_id, + "message_chain": message_chain.model_dump(), + }, + ) + ).get("result") + + async def call_platform_api( + self, + bot_uuid: str, + action: str, + params: dict[str, Any] | None = None, + ) -> Any: + """Call a bot adapter API. + + Args: + bot_uuid: The UUID of the bot + action: Adapter API name, such as "get_group_info" or "call_platform_api" + params: Parameters passed to the adapter API + """ + return ( + await self.plugin_runtime_handler.call_action( + PluginToRuntimeAction.CALL_PLATFORM_API, + { + "bot_uuid": bot_uuid, + "action": action, + "params": params or {}, + }, + ) + )["result"] async def get_llm_models(self) -> list[str]: """Get all LLM models""" diff --git a/src/langbot_plugin/entities/io/actions/enums.py b/src/langbot_plugin/entities/io/actions/enums.py index d34c0a68..5470777c 100644 --- a/src/langbot_plugin/entities/io/actions/enums.py +++ b/src/langbot_plugin/entities/io/actions/enums.py @@ -36,6 +36,7 @@ class PluginToRuntimeAction(ActionType): GET_BOTS = "get_bots" GET_BOT_INFO = "get_bot_info" SEND_MESSAGE = "send_message" + CALL_PLATFORM_API = "call_platform_api" GET_LLM_MODELS = "get_llm_models" # GET_LLM_MODEL_INFO = "get_llm_model_info" diff --git a/src/langbot_plugin/runtime/io/handlers/plugin.py b/src/langbot_plugin/runtime/io/handlers/plugin.py index fbcce3a8..b6930e9b 100644 --- a/src/langbot_plugin/runtime/io/handlers/plugin.py +++ b/src/langbot_plugin/runtime/io/handlers/plugin.py @@ -186,6 +186,16 @@ async def send_message(data: dict[str, Any]) -> handler.ActionResponse: ) return handler.ActionResponse.success(result) + @self.action(PluginToRuntimeAction.CALL_PLATFORM_API) + async def call_platform_api(data: dict[str, Any]) -> handler.ActionResponse: + result = await self.context.control_handler.call_action( + PluginToRuntimeAction.CALL_PLATFORM_API, + { + **data, + }, + ) + return handler.ActionResponse.success(result) + @self.action(PluginToRuntimeAction.GET_LLM_MODELS) async def get_llm_models(data: dict[str, Any]) -> handler.ActionResponse: result = await self.context.control_handler.call_action( diff --git a/tests/api/entities/test_events.py b/tests/api/entities/test_events.py index 6b830f82..416638b8 100644 --- a/tests/api/entities/test_events.py +++ b/tests/api/entities/test_events.py @@ -385,6 +385,8 @@ def test_eba_plugin_event_models_convert_from_platform_events(): ( MessageReceived, MessageReceivedEvent( + bot_uuid="bot-1", + adapter_name="discord", message_id="msg-1", message_chain=message_chain, sender=user, @@ -392,7 +394,7 @@ def test_eba_plugin_event_models_convert_from_platform_events(): chat_id="group-1", group=group, ), - {"message_id": "msg-1", "chat_id": "group-1"}, + {"message_id": "msg-1", "chat_id": "group-1", "bot_uuid": "bot-1", "adapter_name": "discord"}, ), ( MessageEdited, diff --git a/tests/test_message.py b/tests/test_message.py index 5f00b365..47e56e1d 100644 --- a/tests/test_message.py +++ b/tests/test_message.py @@ -6,6 +6,7 @@ At, AtAll, Image, + Face, Source, Quote, Forward, @@ -119,6 +120,7 @@ def test_message_chain_serialization(): At(target=123456), AtAll(), Image(image_id="test_image_id", url="http://example.com/image.jpg"), + Face(face_id=14, face_name="smile"), Quote( id=12345, group_id=67890, @@ -155,13 +157,16 @@ def test_message_chain_serialization(): assert deserialized_chain[4].image_id == "test_image_id" assert str(deserialized_chain[4].url) == "http://example.com/image.jpg" - assert isinstance(deserialized_chain[5], Quote) - assert deserialized_chain[5].id == 12345 - assert deserialized_chain[5].group_id == 67890 - assert deserialized_chain[5].sender_id == 11111 - assert deserialized_chain[5].target_id == 22222 - assert isinstance(deserialized_chain[5].origin, MessageChain) - assert deserialized_chain[5].origin[0].text == "Original message" + assert isinstance(deserialized_chain[5], Face) + assert deserialized_chain[5].face_id == 14 + + assert isinstance(deserialized_chain[6], Quote) + assert deserialized_chain[6].id == 12345 + assert deserialized_chain[6].group_id == 67890 + assert deserialized_chain[6].sender_id == 11111 + assert deserialized_chain[6].target_id == 22222 + assert isinstance(deserialized_chain[6].origin, MessageChain) + assert deserialized_chain[6].origin[0].text == "Original message" def test_message_chain_contains():