diff --git a/changelog.md b/changelog.md index dace20920..cf2128694 100644 --- a/changelog.md +++ b/changelog.md @@ -7,6 +7,7 @@ - **MSAL Token Credential**: Added `MsalTokenCredential`, an Azure Core-compatible asynchronous token credential backed by MSAL, for authenticating Azure SDK clients that accept an `AsyncTokenCredential`. - **Authorization Token Credentials**: Added `AsyncTokenCredential` access through `AccessTokenProviderBase` and `Authorization`, including user-token refresh and exchange flows. +- **Channel Feature Support Helpers**: Added `Channels` helper methods (e.g. `supports_card_actions`, `supports_suggested_actions`, `supports_video_card`, `supports_receipt_card`, `supports_thumbnail_card`, `supports_audio_card`, `supports_animation_card`, `supports_create_conversation`, `supports_update_activity`, `supports_delete_activity`) for checking per-channel feature support, accepting `Channels`, `ChannelId`, and plain string inputs (#580) ## Bug Fixes diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/channels.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/channels.py index f94e7fe12..3896e1547 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/channels.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/channels.py @@ -1,8 +1,11 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. +from __future__ import annotations + from enum import Enum -from typing_extensions import Self + +from .channel_id import ChannelId class Channels(str, Enum): @@ -10,8 +13,11 @@ class Channels(str, Enum): Ids of channels supported by ABS. """ - """Agents channel.""" agents = "agents" + """Agents channel.""" + + alexa = "alexa" + """Alexa channel.""" console = "console" """Console channel.""" @@ -43,7 +49,7 @@ class Channels(str, Enum): line = "line" """Line channel.""" - ms_teams = "msteams" + msteams = "msteams" """MS Teams channel.""" skype = "skype" @@ -58,6 +64,9 @@ class Channels(str, Enum): sms = "sms" """SMS (Twilio) channel.""" + twilio = "twilio-sms" + """Twilio channel.""" + telegram = "telegram" """Telegram channel.""" @@ -70,20 +79,35 @@ class Channels(str, Enum): copilot_studio = "pva-studio" """Microsoft Copilot Studio channel.""" - # TODO: validate the need of Self annotations in the following methods + ms_teams = "msteams" + """Deprecated alias for :attr:`msteams`. Kept for backwards compatibility.""" + @staticmethod - def supports_suggested_actions(channel_id: Self, button_cnt: int = 100) -> bool: - """Determine if a number of Suggested Actions are supported by a Channel. + def _normalize_channel_id(channel_id: str | Channels | ChannelId) -> str: + """Normalize a channel ID to obtain the parent channel. - Args: - channel_id (str): The Channel to check the if Suggested Actions are supported in. - button_cnt (int, Optional): Defaults to 100. The number of Suggested Actions to check for the Channel. + :param channel_id: The channel ID to normalize. + :returns: The canonical string representation of the channel ID. + """ + return ChannelId(channel_id).channel - Returns: - bool: True if the Channel supports the button_cnt total Suggested Actions, False if the Channel does not - support that number of Suggested Actions. + @staticmethod + def supports_suggested_actions( + channel_id: str, button_cnt: int = 100, conversation_type: str | None = None + ) -> bool: + """Determine if a number of Suggested Actions are supported by a Channel. + + :param channel_id: The ID of the channel to check for support of Suggested Actions. + :param button_cnt: The number of Suggested Actions to check for the Channel. + :param conversation_type: The type of conversation, if applicable. + :returns: True if the Channel supports the button_cnt total Suggested Actions, False if the Channel does not support that number of Suggested Actions. """ + channel_id = Channels._normalize_channel_id(channel_id) + + if channel_id == Channels.msteams: + return conversation_type == "personal" and button_cnt <= 3 + max_actions = { # https://developers.facebook.com/docs/messenger-platform/send-messages/quick-replies Channels.facebook: 10, @@ -105,22 +129,19 @@ def supports_suggested_actions(channel_id: Self, button_cnt: int = 100) -> bool: ) @staticmethod - def supports_card_actions(channel_id: Self, button_cnt: int = 100) -> bool: + def supports_card_actions(channel_id: str, button_cnt: int = 100) -> bool: """Determine if a number of Card Actions are supported by a Channel. - Args: - channel_id (str): The Channel to check if the Card Actions are supported in. - button_cnt (int, Optional): Defaults to 100. The number of Card Actions to check for the Channel. - - Returns: - bool: True if the Channel supports the button_cnt total Card Actions, False if the Channel does not support - that number of Card Actions. + :param button_cnt: The number of Card Actions to check for the Channel. + :returns: True if the Channel supports the button_cnt total Card Actions, False if the Channel does not support that number of Card Actions. """ + channel = Channels._normalize_channel_id(channel_id) + max_actions = { Channels.facebook: 3, Channels.skype: 3, - Channels.ms_teams: 3, + Channels.msteams: 50, Channels.line: 99, Channels.slack: 100, Channels.telegram: 100, @@ -128,37 +149,147 @@ def supports_card_actions(channel_id: Self, button_cnt: int = 100) -> bool: Channels.direct_line: 100, Channels.direct_line_speech: 100, Channels.webchat: 100, + Channels.cortana: 100, } - return ( - button_cnt <= max_actions[channel_id] - if channel_id in max_actions - else False + return button_cnt <= max_actions[channel] if channel in max_actions else False + + @staticmethod + def supports_video_card(channel_id: str) -> bool: + """Determine if a Channel supports Video Cards. + + :param channel_id: The Channel to check for Video Card support. + :returns: True if the Channel supports Video Cards, False if it does not. + """ + channel = Channels._normalize_channel_id(channel_id) + return channel not in ( + Channels.alexa, + Channels.msteams, + Channels.twilio, ) @staticmethod - def has_message_feed(_: str) -> bool: - """Determine if a Channel has a Message Feed. + def supports_receipt_card(channel_id: str) -> bool: + """Determine if a Channel supports Receipt Cards. + + :param channel_id: The Channel to check for Receipt Card support. + :returns: True if the Channel supports Receipt Cards, False if it does not. + """ + channel = Channels._normalize_channel_id(channel_id) + return channel not in ( + Channels.alexa, + Channels.groupme, + Channels.msteams, + Channels.twilio, + ) - Args: - channel_id (str): The Channel to check for Message Feed. + @staticmethod + def supports_thumbnail_card(channel_id: str) -> bool: + """Determine if a Channel supports Thumbnail Cards. - Returns: - bool: True if the Channel has a Message Feed, False if it does not. + :param channel_id: The Channel to check for Thumbnail Card support. + :returns: True if the Channel supports Thumbnail Cards, False if it does not. """ + channel = Channels._normalize_channel_id(channel_id) + return channel not in ( + Channels.alexa, + Channels.groupme, + Channels.line, + Channels.slack, + Channels.twilio, + ) + + @staticmethod + def supports_audio_card(channel_id: str) -> bool: + """Determine if a Channel supports Audio Cards. - return True + :param channel_id: The Channel to check for Audio Card support. + :returns: True if the Channel supports Audio Cards, False if it does not. + """ + channel = Channels._normalize_channel_id(channel_id) + return channel not in ( + Channels.alexa, + Channels.msteams, + Channels.twilio, + Channels.email, + Channels.groupme, + Channels.line, + Channels.slack, + Channels.telegram, + ) @staticmethod - def max_action_title_length( # pylint: disable=unused-argument - channel_id: Self, - ) -> int: - """Maximum length allowed for Action Titles. + def supports_animation_card(channel_id: str) -> bool: + """Determine if a Channel supports Animation Cards. - Args: - channel_id (str): The Channel to determine Maximum Action Title Length. + :param channel_id: The Channel to check for Animation Card support. + :returns: True if the Channel supports Animation Cards, False if it does not. + """ + channel = Channels._normalize_channel_id(channel_id) + return channel not in ( + Channels.alexa, + Channels.msteams, + Channels.email, + Channels.groupme, + Channels.twilio, + ) - Returns: - int: The total number of characters allowed for an Action Title on a specific Channel. + @staticmethod + def has_message_feed(channel_id: str) -> bool: + """Determine if a Channel has a Message Feed. + :param channel_id: The Channel to check for Message Feed support. + :returns: True if the Channel has a Message Feed, False if it does not. """ + channel = Channels._normalize_channel_id(channel_id) + return channel != Channels.cortana + @staticmethod + def max_action_title_length(channel_id: str) -> int: + """Maximum length allowed for Action Titles. + :param channel_id: The Channel to check for maximum Action Title length. + :returns: The maximum number of characters allowed for an Action Title. + """ return 20 + + @staticmethod + def supports_create_conversation(channel_id: str) -> bool: + """Determine if a Channel supports creating a conversation. + + :param channel_id: The Channel to check for create conversation support. + :returns: True if the Channel supports creating a conversation, False if it does not. + """ + channel = Channels._normalize_channel_id(channel_id) + return channel in ( + Channels.email, + Channels.facebook, + Channels.groupme, + Channels.kik, + Channels.line, + Channels.msteams, + Channels.slack, + Channels.sms, + Channels.telegram, + ) + + @staticmethod + def supports_update_activity(channel_id: str) -> bool: + """Determine if a Channel supports updating activities. + + :param channel_id: The Channel to check for update activity support. + :returns: True if the Channel supports updating activities, False if it does not. + """ + channel = Channels._normalize_channel_id(channel_id) + return channel == Channels.msteams + + @staticmethod + def supports_delete_activity(channel_id: str) -> bool: + """Determine if a Channel supports deleting activities. + + :param channel_id: The Channel to check for delete activity support. + :returns: True if the Channel supports deleting activities, False if it does not. + """ + channel = Channels._normalize_channel_id(channel_id) + return channel in ( + Channels.msteams, + Channels.slack, + Channels.telegram, + ) diff --git a/libraries/microsoft-agents-hosting-dialogs/microsoft_agents/hosting/dialogs/choices/channel.py b/libraries/microsoft-agents-hosting-dialogs/microsoft_agents/hosting/dialogs/choices/channel.py index 6aeb0b15b..02a5826f2 100644 --- a/libraries/microsoft-agents-hosting-dialogs/microsoft_agents/hosting/dialogs/choices/channel.py +++ b/libraries/microsoft-agents-hosting-dialogs/microsoft_agents/hosting/dialogs/choices/channel.py @@ -22,28 +22,7 @@ def supports_suggested_actions(channel_id: str, button_cnt: int = 100) -> bool: bool: True if the Channel supports the button_cnt total Suggested Actions, False if the Channel does not support that number of Suggested Actions. """ - if isinstance(channel_id, Channels): - channel_id = channel_id.value - - max_actions = { - # https://developers.facebook.com/docs/messenger-platform/send-messages/quick-replies - Channels.facebook.value: 10, - Channels.skype.value: 10, - # https://developers.line.biz/en/reference/messaging-api/#items-object - Channels.line.value: 13, - # https://dev.kik.com/#/docs/messaging#text-response-object - Channels.kik.value: 20, - Channels.telegram.value: 100, - Channels.emulator.value: 100, - Channels.direct_line.value: 100, - Channels.direct_line_speech.value: 100, - Channels.webchat.value: 100, - } - return ( - button_cnt <= max_actions[channel_id] - if channel_id in max_actions - else False - ) + return Channels.supports_suggested_actions(channel_id, button_cnt) @staticmethod def supports_card_actions(channel_id: str, button_cnt: int = 100) -> bool: @@ -57,29 +36,10 @@ def supports_card_actions(channel_id: str, button_cnt: int = 100) -> bool: bool: True if the Channel supports the button_cnt total Card Actions, False if the Channel does not support that number of Card Actions. """ - if isinstance(channel_id, Channels): - channel_id = channel_id.value - - max_actions = { - Channels.facebook.value: 3, - Channels.skype.value: 3, - Channels.ms_teams.value: 3, - Channels.line.value: 99, - Channels.slack.value: 100, - Channels.telegram.value: 100, - Channels.emulator.value: 100, - Channels.direct_line.value: 100, - Channels.direct_line_speech.value: 100, - Channels.webchat.value: 100, - } - return ( - button_cnt <= max_actions[channel_id] - if channel_id in max_actions - else False - ) + return Channels.supports_card_actions(channel_id, button_cnt) @staticmethod - def has_message_feed(_: str) -> bool: + def has_message_feed(channel_id: str) -> bool: """Determine if a Channel has a Message Feed. Args: @@ -88,8 +48,7 @@ def has_message_feed(_: str) -> bool: Returns: bool: True if the Channel has a Message Feed, False if it does not. """ - - return True + return Channels.has_message_feed(channel_id) @staticmethod def get_channel_id(turn_context: TurnContext) -> str: @@ -106,7 +65,7 @@ def get_channel_id(turn_context: TurnContext) -> str: return "" @staticmethod - def max_action_title_length( # pylint: disable=unused-argument + def max_action_title_length( channel_id: str, ) -> int: """Maximum length allowed for Action Titles. @@ -117,5 +76,4 @@ def max_action_title_length( # pylint: disable=unused-argument Returns: int: The total number of characters allowed for an Action Title on a specific Channel. """ - - return 20 + return Channels.max_action_title_length(channel_id) diff --git a/tests/activity/test_channels.py b/tests/activity/test_channels.py new file mode 100644 index 000000000..6bd2c9c9e --- /dev/null +++ b/tests/activity/test_channels.py @@ -0,0 +1,178 @@ +import pytest + +from microsoft_agents.activity import Channels, ChannelId + + +def _forms(base: str) -> list: + """Build equivalent representations of a channel id for a given base channel. + + Returns the plain string, the plain string with a sub-channel, and the + corresponding ChannelId instances (with and without a sub-channel), plus + the Channels enum member when one exists for the base value. Each form is + wrapped in a pytest.param with a descriptive id so parametrized test names + reveal which input shape is under test. + """ + forms = [ + pytest.param(base, id=f"{base}-str"), + pytest.param(f"{base}:sub", id=f"{base}-str-with-subchannel"), + pytest.param(ChannelId(base), id=f"{base}-channelid"), + pytest.param(ChannelId(f"{base}:sub"), id=f"{base}-channelid-with-subchannel"), + ] + member = next((c for c in Channels if c.value == base), None) + if member is not None: + forms.append(pytest.param(member, id=f"{base}-channels-enum")) + return forms + + +class TestChannels: + def test_ms_teams_is_alias_of_msteams(self): + # Channels.ms_teams is a deprecated back-compat alias of Channels.msteams. + assert Channels.ms_teams is Channels.msteams + assert Channels.ms_teams.value == "msteams" + assert Channels.ms_teams.name == "msteams" + # aliases are not included twice in iteration + assert list(Channels).count(Channels.msteams) == 1 + + @pytest.mark.parametrize( + "channel_id", + _forms("msteams") + + [pytest.param(Channels.ms_teams, id="msteams-ms_teams-alias")], + ) + def test_supports_card_actions_accepts_all_formats(self, channel_id): + # msteams supports up to 50 card actions regardless of how the channel is expressed. + assert Channels.supports_card_actions(channel_id, 3) is True + assert Channels.supports_card_actions(channel_id, 100) is False + + @pytest.mark.parametrize("channel_id", _forms("facebook")) + def test_supports_card_actions_returns_false_over_limit(self, channel_id): + assert Channels.supports_card_actions(channel_id, 3) is True + assert Channels.supports_card_actions(channel_id, 4) is False + + def test_supports_card_actions_unknown_channel_is_false(self): + assert Channels.supports_card_actions("some-unknown-channel", 1) is False + + @pytest.mark.parametrize( + "channel_id", + _forms("msteams") + + [pytest.param(Channels.ms_teams, id="msteams-ms_teams-alias")], + ) + def test_supports_suggested_actions_accepts_all_formats(self, channel_id): + assert Channels.supports_suggested_actions(channel_id, 3, "personal") is True + assert Channels.supports_suggested_actions(channel_id, 3) is False + + @pytest.mark.parametrize("channel_id", _forms("webchat")) + def test_supports_suggested_actions_returns_bool(self, channel_id): + assert Channels.supports_suggested_actions(channel_id, 100) is True + + def test_supports_suggested_actions_unknown_channel_is_false(self): + assert Channels.supports_suggested_actions("some-unknown-channel", 1) is False + + @pytest.mark.parametrize( + "channel_id", + _forms("msteams") + + [pytest.param(Channels.ms_teams, id="msteams-ms_teams-alias")], + ) + def test_has_message_feed_returns_true_for_msteams(self, channel_id): + assert Channels.has_message_feed(channel_id) is True + + @pytest.mark.parametrize("channel_id", _forms("cortana")) + def test_has_message_feed_returns_false_for_cortana(self, channel_id): + assert Channels.has_message_feed(channel_id) is False + + @pytest.mark.parametrize( + "channel_id", + _forms("msteams") + + [pytest.param(Channels.ms_teams, id="msteams-ms_teams-alias")], + ) + def test_max_action_title_length_returns_int(self, channel_id): + assert Channels.max_action_title_length(channel_id) == 20 + + @pytest.mark.parametrize( + "channel_id", + _forms("msteams") + + [pytest.param(Channels.ms_teams, id="msteams-ms_teams-alias")], + ) + def test_supports_video_card_returns_false_for_msteams(self, channel_id): + assert Channels.supports_video_card(channel_id) is False + + @pytest.mark.parametrize("channel_id", _forms("webchat")) + def test_supports_video_card_returns_true_for_webchat(self, channel_id): + assert Channels.supports_video_card(channel_id) is True + + @pytest.mark.parametrize( + "channel_id", + _forms("msteams") + + [pytest.param(Channels.ms_teams, id="msteams-ms_teams-alias")], + ) + def test_supports_create_conversation_returns_true_for_msteams(self, channel_id): + assert Channels.supports_create_conversation(channel_id) is True + + @pytest.mark.parametrize("channel_id", _forms("alexa")) + def test_supports_create_conversation_returns_false_for_alexa(self, channel_id): + assert Channels.supports_create_conversation(channel_id) is False + + @pytest.mark.parametrize( + "channel_id", + _forms("msteams") + + [pytest.param(Channels.ms_teams, id="msteams-ms_teams-alias")], + ) + def test_supports_update_activity_returns_true_for_msteams(self, channel_id): + assert Channels.supports_update_activity(channel_id) is True + + @pytest.mark.parametrize( + "channel_id", + _forms("msteams") + + [pytest.param(Channels.ms_teams, id="msteams-ms_teams-alias")], + ) + def test_supports_delete_activity_returns_true_for_msteams(self, channel_id): + assert Channels.supports_delete_activity(channel_id) is True + + @pytest.mark.parametrize( + "channel_id", + _forms("msteams") + + [pytest.param(Channels.ms_teams, id="msteams-ms_teams-alias")], + ) + def test_supports_receipt_card_returns_false_for_msteams(self, channel_id): + assert Channels.supports_receipt_card(channel_id) is False + + @pytest.mark.parametrize("channel_id", _forms("webchat")) + def test_supports_receipt_card_returns_true_for_webchat(self, channel_id): + assert Channels.supports_receipt_card(channel_id) is True + + @pytest.mark.parametrize("channel_id", _forms("groupme")) + def test_supports_thumbnail_card_returns_false_for_groupme(self, channel_id): + assert Channels.supports_thumbnail_card(channel_id) is False + + @pytest.mark.parametrize( + "channel_id", + _forms("webchat") + + _forms("msteams") + + [pytest.param(Channels.ms_teams, id="msteams-ms_teams-alias")], + ) + def test_supports_thumbnail_card_returns_true(self, channel_id): + # msteams supports thumbnail cards, unlike most other card types. + assert Channels.supports_thumbnail_card(channel_id) is True + + @pytest.mark.parametrize( + "channel_id", + _forms("msteams") + + [pytest.param(Channels.ms_teams, id="msteams-ms_teams-alias")], + ) + def test_supports_audio_card_returns_false_for_msteams(self, channel_id): + assert Channels.supports_audio_card(channel_id) is False + + @pytest.mark.parametrize("channel_id", _forms("webchat")) + def test_supports_audio_card_returns_true_for_webchat(self, channel_id): + assert Channels.supports_audio_card(channel_id) is True + + @pytest.mark.parametrize( + "channel_id", + _forms("msteams") + + [pytest.param(Channels.ms_teams, id="msteams-ms_teams-alias")], + ) + def test_supports_animation_card_returns_false_for_msteams(self, channel_id): + assert Channels.supports_animation_card(channel_id) is False + + @pytest.mark.parametrize("channel_id", _forms("webchat")) + def test_supports_animation_card_returns_true_for_webchat(self, channel_id): + assert Channels.supports_animation_card(channel_id) is True diff --git a/tests/hosting_dialogs/choices/test_channel.py b/tests/hosting_dialogs/choices/test_channel.py index 6b597aa26..cd6b891bb 100644 --- a/tests/hosting_dialogs/choices/test_channel.py +++ b/tests/hosting_dialogs/choices/test_channel.py @@ -62,8 +62,8 @@ def test_supports_suggested_actions_accepts_string_channel_id(self): assert not Channel.supports_suggested_actions("facebook", 11) def test_supports_card_actions_accepts_string_channel_id(self): - assert Channel.supports_card_actions("msteams", 3) - assert not Channel.supports_card_actions("msteams", 4) + assert Channel.supports_card_actions("msteams", 50) + assert not Channel.supports_card_actions("msteams", 51) def test_should_return_channel_id_from_context_activity(self): adapter = TestAdapter(channel_id=Channels.facebook)