from __future__ import annotations import pytest from yuxi.channels.message_actions import ActionDeclaration, ActionRegistry, ActionStatus, MessageAction from yuxi.channels.base import BaseChannelAdapter from yuxi.channels.capabilities import ( CAPS_FULL_FEATURED, CAPS_SIMPLE_TEXT, ChannelCapabilities, ) from yuxi.channels.models import ( ChannelMessage, ChannelResponse, ChannelType, DeliveryResult, HealthStatus, ) class _EmptyAdapter(BaseChannelAdapter): channel_id = "test_empty" channel_type = ChannelType.WEBCHAT async def connect(self) -> None: pass async def disconnect(self) -> None: pass async def send(self, response: ChannelResponse) -> DeliveryResult: return DeliveryResult(success=True) def normalize_inbound(self, raw) -> ChannelMessage: raise NotImplementedError def format_outbound(self, response: ChannelResponse): return {"content": response.content} async def health_check(self) -> HealthStatus: return HealthStatus(status="healthy") class _FullAdapter(_EmptyAdapter): channel_id = "test_full" supports_streaming = True streaming_modes = ["off", "block"] supports_markdown = True async def send_media(self, chat_id, media_type, data) -> DeliveryResult: return DeliveryResult(success=True) async def edit_message(self, chat_id, msg_id, content) -> DeliveryResult: return DeliveryResult(success=True) async def delete_message(self, chat_id, msg_id) -> DeliveryResult: return DeliveryResult(success=True) async def send_reaction(self, chat_id, msg_id, emoji) -> DeliveryResult: return DeliveryResult(success=True) class _FeishuLikeAdapter(_EmptyAdapter): channel_id = "test_feishu_like" async def send_media(self, chat_id, media_type, data) -> DeliveryResult: return DeliveryResult(success=True) async def edit_message(self, chat_id, msg_id, content) -> DeliveryResult: return DeliveryResult(success=True) async def delete_message(self, chat_id, msg_id) -> DeliveryResult: return DeliveryResult(success=True) async def send_reaction(self, chat_id, msg_id, emoji) -> DeliveryResult: return DeliveryResult(success=True) async def pin_message(self, chat_id, msg_id) -> DeliveryResult: return DeliveryResult(success=True) async def unpin_message(self, chat_id, msg_id) -> DeliveryResult: return DeliveryResult(success=True) async def list_pins(self, chat_id) -> dict: return {} class TestMessageActionEnum: def test_all_values_are_strings(self): for action in MessageAction: assert isinstance(action.value, str) def test_send_is_str_enum(self): assert MessageAction.SEND == "send" assert str(MessageAction.SEND) == "send" def test_contains_expected_actions(self): assert MessageAction.EDIT == "edit" assert MessageAction.DELETE == "delete" assert MessageAction.REACT == "react" assert MessageAction.STREAM == "stream" assert MessageAction.PIN == "pin" assert MessageAction.UNPIN == "unpin" assert MessageAction.LIST_PINS == "list_pins" def test_contains_p2_actions(self): assert MessageAction.KICK == "kick" assert MessageAction.BAN == "ban" assert MessageAction.MUTE == "mute" assert MessageAction.CREATE_POLL == "create_poll" assert MessageAction.CREATE_THREAD == "create_thread" class TestActionRegistryDeriveFromAdapter: def test_empty_adapter_derives_no_capabilities(self): ActionRegistry.clear() caps = ActionRegistry.derive_from_adapter_cls(_EmptyAdapter) assert caps.edit is False assert caps.unsend is False assert caps.reactions is False assert caps.media is False def test_full_adapter_derives_all_capabilities(self): caps = ActionRegistry.derive_from_adapter_cls(_FullAdapter) assert caps.media is True assert caps.edit is True assert caps.unsend is True assert caps.reactions is True assert caps.supports_streaming is True def test_feishu_like_derives_pin_capabilities(self): caps = ActionRegistry.derive_from_adapter_cls(_FeishuLikeAdapter) assert caps.pin is True assert caps.unpin is True assert caps.list_pins is True def test_feishu_like_has_no_send_ephemeral(self): caps = ActionRegistry.derive_from_adapter_cls(_FeishuLikeAdapter) assert caps.send_ephemeral is False class TestActionRegistryRegisterAndQuery: @pytest.fixture(autouse=True) def _clear_registry(self): ActionRegistry.clear() yield ActionRegistry.clear() def test_register_and_get(self): caps = CAPS_FULL_FEATURED ActionRegistry.register_adapter(_FullAdapter) result = ActionRegistry.get("test_full") assert result is not None assert result.edit is True def test_supports_edit(self): ActionRegistry.register_adapter(_FullAdapter) assert ActionRegistry.supports("test_full", "edit") is True def test_supports_react(self): ActionRegistry.register_adapter(_FullAdapter) assert ActionRegistry.supports("test_full", "react") is True def test_does_not_support_pin_for_full_adapter(self): ActionRegistry.register_adapter(_FullAdapter) assert ActionRegistry.supports("test_full", "pin") is False def test_unknown_channel_returns_false(self): assert ActionRegistry.supports("nonexistent", "edit") is False def test_unknown_channel_get_returns_none(self): assert ActionRegistry.get("nonexistent") is None def test_find_channels_supporting_edit(self): ActionRegistry.register_adapter(_FullAdapter) ActionRegistry.register_adapter(_EmptyAdapter) channels = ActionRegistry.find_channels_supporting("edit") assert "test_full" in channels assert "test_empty" not in channels def test_find_channels_supporting_pin(self): ActionRegistry.register_adapter(_FeishuLikeAdapter) ActionRegistry.register_adapter(_FullAdapter) channels = ActionRegistry.find_channels_supporting("pin") assert channels == ["test_feishu_like"] def test_list_all(self): ActionRegistry.register_adapter(_FullAdapter) all_caps = ActionRegistry.list_all() assert "test_full" in all_caps def test_clear(self): ActionRegistry.register_adapter(_FullAdapter) ActionRegistry.clear() assert ActionRegistry.get("test_full") is None class TestActionRegistryExplicitCapabilities: @pytest.fixture(autouse=True) def _clear_registry(self): ActionRegistry.clear() yield ActionRegistry.clear() def test_explicit_caps_take_precedence(self): caps = ChannelCapabilities( edit=True, reactions=True, ) ActionRegistry.register("test_explicit", caps) result = ActionRegistry.get("test_explicit") assert result is not None assert result.edit is True def test_register_adapters_batch(self): ActionRegistry.register_adapters([_FullAdapter, _FeishuLikeAdapter]) assert ActionRegistry.supports("test_full", "edit") is True assert ActionRegistry.supports("test_feishu_like", "pin") is True class TestChannelCapabilitiesSupportedActions: def test_empty_caps_has_no_actions(self): caps = ChannelCapabilities() assert caps.supported_actions() == [] def test_simple_text_has_no_actions(self): actions = CAPS_SIMPLE_TEXT.supported_actions() assert actions == [] def test_full_featured_has_expected_actions(self): actions = CAPS_FULL_FEATURED.supported_actions() assert "send_media" in actions assert "edit" in actions assert "delete" in actions assert "react" in actions assert "create_poll" in actions assert "create_thread" in actions assert "pin" not in actions def test_caps_with_pin(self): caps = ChannelCapabilities(pin=True, unpin=True, list_pins=True) actions = caps.supported_actions() assert "pin" in actions assert "unpin" in actions assert "list_pins" in actions def test_caps_with_group_management(self): caps = ChannelCapabilities(group_management=True) actions = caps.supported_actions() assert "kick" in actions assert "ban" in actions assert "mute" in actions def test_caps_with_send_ephemeral(self): caps = ChannelCapabilities(send_ephemeral=True) actions = caps.supported_actions() assert "send_ephemeral" in actions class TestMessageActionEnumExpanded: def test_enum_has_58_plus_actions(self): all_actions = list(MessageAction) assert len(all_actions) >= 58 def test_new_standard_actions(self): assert MessageAction.BROADCAST == "broadcast" assert MessageAction.REPLY == "reply" assert MessageAction.UNSEND == "unsend" assert MessageAction.READ == "read" assert MessageAction.LIST_REACTIONS == "list_reactions" assert MessageAction.SEND_WITH_EFFECT == "send_with_effect" assert MessageAction.RENAME_GROUP == "rename_group" assert MessageAction.SET_GROUP_ICON == "set_group_icon" assert MessageAction.ADD_PARTICIPANT == "add_participant" assert MessageAction.REMOVE_PARTICIPANT == "remove_participant" assert MessageAction.LEAVE_GROUP == "leave_group" assert MessageAction.PERMISSIONS == "permissions" def test_thread_actions(self): assert MessageAction.THREAD_CREATE == "thread_create" assert MessageAction.THREAD_LIST == "thread_list" assert MessageAction.THREAD_REPLY == "thread_reply" def test_sticker_emoji_actions(self): assert MessageAction.STICKER == "sticker" assert MessageAction.STICKER_SEARCH == "sticker_search" assert MessageAction.STICKER_UPLOAD == "sticker_upload" assert MessageAction.EMOJI_LIST == "emoji_list" assert MessageAction.EMOJI_UPLOAD == "emoji_upload" def test_member_role_actions(self): assert MessageAction.MEMBER_INFO == "member_info" assert MessageAction.ROLE_INFO == "role_info" assert MessageAction.ROLE_ADD == "role_add" assert MessageAction.ROLE_REMOVE == "role_remove" def test_channel_category_actions(self): assert MessageAction.CHANNEL_INFO == "channel_info" assert MessageAction.CHANNEL_LIST == "channel_list" assert MessageAction.CHANNEL_CREATE == "channel_create" assert MessageAction.CHANNEL_EDIT == "channel_edit" assert MessageAction.CHANNEL_DELETE == "channel_delete" assert MessageAction.CHANNEL_MOVE == "channel_move" assert MessageAction.CATEGORY_CREATE == "category_create" assert MessageAction.CATEGORY_EDIT == "category_edit" assert MessageAction.CATEGORY_DELETE == "category_delete" def test_file_profile_actions(self): assert MessageAction.DOWNLOAD_FILE == "download_file" assert MessageAction.UPLOAD_FILE == "upload_file" assert MessageAction.SEND_ATTACHMENT == "send_attachment" assert MessageAction.SET_PROFILE == "set_profile" assert MessageAction.SET_PRESENCE == "set_presence" def test_event_voice_actions(self): assert MessageAction.EVENT_LIST == "event_list" assert MessageAction.EVENT_CREATE == "event_create" assert MessageAction.VOICE_STATUS == "voice_status" assert MessageAction.SEARCH == "search" assert MessageAction.TOPIC_CREATE == "topic_create" assert MessageAction.TOPIC_EDIT == "topic_edit" def test_backward_compat_actions(self): assert MessageAction.SEND_MEDIA == "send_media" assert MessageAction.STREAM == "stream" assert MessageAction.SEND_EPHEMERAL == "send_ephemeral" assert MessageAction.CREATE_THREAD == "create_thread" assert MessageAction.REPLY_THREAD == "reply_thread" class TestActionStatus: def test_supported(self): assert ActionStatus.SUPPORTED == "supported" def test_unsupported(self): assert ActionStatus.UNSUPPORTED == "unsupported" def test_partial(self): assert ActionStatus.PARTIAL == "partial" class TestActionDeclaration: def test_create_declaration(self): decl = ActionDeclaration( action=MessageAction.SEND, status=ActionStatus.SUPPORTED, reason="", impl="adapter.send()", ) assert decl.action == MessageAction.SEND assert decl.status == ActionStatus.SUPPORTED assert decl.impl == "adapter.send()" def test_create_unsupported_declaration(self): decl = ActionDeclaration( action=MessageAction.PIN, status=ActionStatus.UNSUPPORTED, reason="WeChat 不支持置顶", ) assert decl.action == MessageAction.PIN assert decl.status == ActionStatus.UNSUPPORTED assert decl.reason == "WeChat 不支持置顶" assert decl.impl == "" class TestActionRegistryChannelActions: @pytest.fixture(autouse=True) def _clear_registry(self): ActionRegistry.clear() yield ActionRegistry.clear() def _make_channel_actions(self): declarations: dict[MessageAction, ActionDeclaration] = {} declarations[MessageAction.SEND] = ActionDeclaration( action=MessageAction.SEND, status=ActionStatus.SUPPORTED, impl="adapter.send()", ) declarations[MessageAction.EDIT] = ActionDeclaration( action=MessageAction.EDIT, status=ActionStatus.SUPPORTED, impl="adapter.edit_message()", ) declarations[MessageAction.PIN] = ActionDeclaration( action=MessageAction.PIN, status=ActionStatus.UNSUPPORTED, reason="Not supported", ) declarations[MessageAction.BROADCAST] = ActionDeclaration( action=MessageAction.BROADCAST, status=ActionStatus.PARTIAL, reason="Template only", impl="sendMessageTemplate", ) return declarations def test_register_channel_actions(self): declarations = self._make_channel_actions() ActionRegistry.register_channel_actions("test_ch", declarations) assert len(ActionRegistry.list_all_declarations("test_ch")) == 4 def test_is_supported_true(self): declarations = self._make_channel_actions() ActionRegistry.register_channel_actions("test_ch", declarations) assert ActionRegistry.is_supported("test_ch", MessageAction.SEND) is True assert ActionRegistry.is_supported("test_ch", MessageAction.EDIT) is True def test_is_supported_false(self): declarations = self._make_channel_actions() ActionRegistry.register_channel_actions("test_ch", declarations) assert ActionRegistry.is_supported("test_ch", MessageAction.PIN) is False def test_is_supported_falls_back_to_caps(self): ActionRegistry.register_adapter(_FullAdapter) assert ActionRegistry.is_supported("test_full", MessageAction.EDIT) is True assert ActionRegistry.is_supported("test_full", MessageAction.PIN) is False def test_list_supported_actions(self): declarations = self._make_channel_actions() ActionRegistry.register_channel_actions("test_ch", declarations) supported = ActionRegistry.list_supported_actions("test_ch") assert MessageAction.SEND in supported assert MessageAction.EDIT in supported assert MessageAction.PIN not in supported assert MessageAction.BROADCAST not in supported def test_list_supported_actions_falls_back_to_caps(self): ActionRegistry.register_adapter(_FullAdapter) supported = ActionRegistry.list_supported_actions("test_full") assert MessageAction.EDIT in supported assert MessageAction.REACT in supported def test_list_channels_for_action(self): declarations = self._make_channel_actions() ActionRegistry.register_channel_actions("channel_a", declarations) ActionRegistry.register_channel_actions("channel_b", { MessageAction.SEND: ActionDeclaration( action=MessageAction.SEND, status=ActionStatus.SUPPORTED, impl="send", ), MessageAction.PIN: ActionDeclaration( action=MessageAction.PIN, status=ActionStatus.SUPPORTED, impl="pin", ), }) channels = ActionRegistry.list_channels_for_action(MessageAction.PIN) assert "channel_b" in channels assert "channel_a" not in channels assert ActionRegistry.list_channels_for_action(MessageAction.SEND) == ["channel_a", "channel_b"] def test_list_channels_for_action_includes_caps_channels(self): ActionRegistry.register_adapter(_FullAdapter) declarations = self._make_channel_actions() ActionRegistry.register_channel_actions("test_ch", declarations) channels = ActionRegistry.list_channels_for_action(MessageAction.EDIT) assert "test_ch" in channels assert "test_full" in channels def test_get_action_declaration(self): declarations = self._make_channel_actions() ActionRegistry.register_channel_actions("test_ch", declarations) decl = ActionRegistry.get_action_declaration("test_ch", MessageAction.PIN) assert decl is not None assert decl.status == ActionStatus.UNSUPPORTED assert decl.reason == "Not supported" def test_get_action_declaration_nonexistent(self): assert ActionRegistry.get_action_declaration("nonexistent", MessageAction.SEND) is None def test_list_all_declarations_empty(self): assert ActionRegistry.list_all_declarations("nonexistent") == {} def test_clear_includes_channel_actions(self): declarations = self._make_channel_actions() ActionRegistry.register_channel_actions("test_ch", declarations) ActionRegistry.clear() assert ActionRegistry.list_all_declarations("test_ch") == {} class TestChannelRegistrations: @pytest.fixture(autouse=True) def _clear_registry(self): ActionRegistry.clear() yield ActionRegistry.clear() def test_wechat_registration(self): from yuxi.channels.adapters.wechat.message_actions import register_wechat_actions register_wechat_actions() declarations = ActionRegistry.list_all_declarations("wechat") assert len(declarations) > 0 send_decl = declarations.get(MessageAction.SEND) assert send_decl is not None assert send_decl.status == ActionStatus.SUPPORTED def test_telegram_registration(self): from yuxi.channels.adapters.telegram.message_actions import register_telegram_actions register_telegram_actions() declarations = ActionRegistry.list_all_declarations("telegram") assert len(declarations) > 0 supported = ActionRegistry.list_supported_actions("telegram") assert MessageAction.SEND in supported assert MessageAction.EDIT in supported assert MessageAction.REACT in supported def test_discord_registration(self): from yuxi.channels.adapters.discord.message_actions import register_discord_actions register_discord_actions() declarations = ActionRegistry.list_all_declarations("discord") assert len(declarations) > 0 supported = ActionRegistry.list_supported_actions("discord") assert MessageAction.SEND in supported assert MessageAction.EDIT in supported def test_slack_registration(self): from yuxi.channels.adapters.slack.message_actions import register_slack_actions register_slack_actions() declarations = ActionRegistry.list_all_declarations("slack") assert len(declarations) > 0 supported = ActionRegistry.list_supported_actions("slack") assert MessageAction.SEND in supported def test_whatsapp_registration(self): from yuxi.channels.adapters.whatsapp.message_actions import register_whatsapp_actions register_whatsapp_actions() declarations = ActionRegistry.list_all_declarations("whatsapp") assert len(declarations) > 0 supported = ActionRegistry.list_supported_actions("whatsapp") assert MessageAction.SEND in supported def test_feishu_registration(self): from yuxi.channels.adapters.feishu.message_actions import register_feishu_actions register_feishu_actions() declarations = ActionRegistry.list_all_declarations("feishu") assert len(declarations) > 0 supported = ActionRegistry.list_supported_actions("feishu") assert MessageAction.SEND in supported assert MessageAction.PIN in supported def test_cross_channel_query_pin(self): from yuxi.channels.adapters.discord.message_actions import register_discord_actions from yuxi.channels.adapters.feishu.message_actions import register_feishu_actions from yuxi.channels.adapters.telegram.message_actions import register_telegram_actions register_telegram_actions() register_discord_actions() register_feishu_actions() channels = ActionRegistry.list_channels_for_action(MessageAction.PIN) assert "feishu" in channels assert "telegram" in channels