From a1538b233275808ac0a55c27596d94a24c3f2aed Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Mon, 14 Sep 2026 10:27:10 -0700 Subject: [PATCH 1/8] Porting methods to check feature support across channels --- .../microsoft_agents/activity/channels.py | 246 ++++++++++++++---- tests/activity/test_channels.py | 126 +++++++++ 2 files changed, 315 insertions(+), 57 deletions(-) create mode 100644 tests/activity/test_channels.py diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/channels.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/channels.py index f94e7fe12..164d17885 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/channels.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/channels.py @@ -1,17 +1,24 @@ # 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): """ Ids of channels supported by ABS. """ - """Agents channel.""" agents = "agents" + """Agents channel.""" + + alexa = "alexa" + """Alexa channel.""" console = "console" """Console channel.""" @@ -43,7 +50,7 @@ class Channels(str, Enum): line = "line" """Line channel.""" - ms_teams = "msteams" + msteams = "msteams" """MS Teams channel.""" skype = "skype" @@ -58,6 +65,9 @@ class Channels(str, Enum): sms = "sms" """SMS (Twilio) channel.""" + twilio = "twilio-sms" + """Twilio channel.""" + telegram = "telegram" """Telegram channel.""" @@ -70,33 +80,50 @@ 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. + """ + if isinstance(channel_id, Channels): + channel_id = channel_id.value + 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 checks 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.ms_teams.value: + return conversation_type == "personal" and button_cnt <= 3 + max_actions = { # https://developers.facebook.com/docs/messenger-platform/send-messages/quick-replies - Channels.facebook: 10, - Channels.skype: 10, + Channels.facebook.value: 10, + Channels.skype.value: 10, # https://developers.line.biz/en/reference/messaging-api/#items-object - Channels.line: 13, + Channels.line.value: 13, # https://dev.kik.com/#/docs/messaging#text-response-object - Channels.kik: 20, - Channels.telegram: 100, - Channels.emulator: 100, - Channels.direct_line: 100, - Channels.direct_line_speech: 100, - Channels.webchat: 100, + 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] @@ -105,60 +132,165 @@ 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.line: 99, - Channels.slack: 100, - Channels.telegram: 100, - Channels.emulator: 100, - Channels.direct_line: 100, - Channels.direct_line_speech: 100, - Channels.webchat: 100, + Channels.facebook.value: 3, + Channels.skype.value: 3, + Channels.ms_teams.value: 50, + 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, + Channels.cortana.value: 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.value, + Channels.msteams.value, + Channels.twilio.value, ) @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.value, + Channels.groupme.value, + Channels.msteams.value, + Channels.twilio.value, + ) - 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.value, + Channels.msteams.value, + Channels.twilio.value, + ) + + @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.value, + Channels.msteams.value, + Channels.twilio.value, + Channels.email.value, + Channels.groupme.value, + Channels.line.value, + Channels.slack.value, + Channels.telegram.value, + ) @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.value, + Channels.msteams.value, + Channels.email, + Channels.groupme, + Channels.twilio.value, + ) - 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.value + @staticmethod + def max_action_title_length(channel_id: str) -> int: + """Maximum length allowed for Action Titles. + :param _: 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.value, + Channels.facebook.value, + Channels.groupme.value, + Channels.kik.value, + Channels.line.value, + Channels.msteams.value, + Channels.slack.value, + Channels.sms.value, + Channels.telegram.value, + ) + + @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.value, + Channels.slack.value, + Channels.telegram.value, + ) diff --git a/tests/activity/test_channels.py b/tests/activity/test_channels.py new file mode 100644 index 000000000..9de9470e4 --- /dev/null +++ b/tests/activity/test_channels.py @@ -0,0 +1,126 @@ +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. + """ + forms = [ + base, + f"{base}:sub", + ChannelId(base), + ChannelId(f"{base}:sub"), + ] + member = next((c for c in Channels if c.value == base), None) + if member is not None: + forms.append(member) + 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") + [Channels.ms_teams]) + 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") + [Channels.ms_teams]) + 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") + [Channels.ms_teams]) + 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") + [Channels.ms_teams]) + 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") + [Channels.ms_teams]) + 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") + [Channels.ms_teams]) + 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") + [Channels.ms_teams]) + 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") + [Channels.ms_teams]) + 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") + [Channels.ms_teams]) + 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("msteams") + [Channels.ms_teams]) + def test_supports_thumbnail_card_returns_false_for_msteams(self, channel_id): + assert Channels.supports_thumbnail_card(channel_id) is False + + @pytest.mark.parametrize("channel_id", _forms("webchat")) + def test_supports_thumbnail_card_returns_true_for_webchat(self, channel_id): + assert Channels.supports_thumbnail_card(channel_id) is True + + @pytest.mark.parametrize("channel_id", _forms("msteams") + [Channels.ms_teams]) + 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") + [Channels.ms_teams]) + 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 From 060808ae7f0d9ced71b197e060fa3aad27efbb60 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Mon, 14 Sep 2026 10:29:42 -0700 Subject: [PATCH 2/8] Updating changelog --- changelog.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/changelog.md b/changelog.md index dace20920..d3393a0df 100644 --- a/changelog.md +++ b/changelog.md @@ -7,6 +7,12 @@ - **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**: Adding `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 + +- **Typing Indicator on Update/Delete**: Fixed the typing indicator re-appearing in Teams clients after the finalized streaming activity was updated or deleted post-`end_stream()`. +- **`Channels.msteams` Back-Compat**: Fixed a broken `Channels` module (invalid syntax) and restored the deprecated `Channels.ms_teams` name as an alias of `Channels.msteams` (#580) ## Bug Fixes From 99d96baa923333c748b827f9aecb7241868255c2 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Mon, 14 Sep 2026 10:31:50 -0700 Subject: [PATCH 3/8] Update --- changelog.md | 1 - 1 file changed, 1 deletion(-) diff --git a/changelog.md b/changelog.md index d3393a0df..83458b348 100644 --- a/changelog.md +++ b/changelog.md @@ -12,7 +12,6 @@ ## Bug Fixes - **Typing Indicator on Update/Delete**: Fixed the typing indicator re-appearing in Teams clients after the finalized streaming activity was updated or deleted post-`end_stream()`. -- **`Channels.msteams` Back-Compat**: Fixed a broken `Channels` module (invalid syntax) and restored the deprecated `Channels.ms_teams` name as an alias of `Channels.msteams` (#580) ## Bug Fixes From dbd0e79bd6f8e2d5dcd33912b6a89aad9413de4c Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Mon, 14 Sep 2026 10:33:28 -0700 Subject: [PATCH 4/8] Updating docstring --- .../microsoft_agents/activity/channels.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/channels.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/channels.py index 164d17885..57d99353d 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/channels.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/channels.py @@ -4,7 +4,6 @@ from __future__ import annotations from enum import Enum -from typing_extensions import Self from .channel_id import ChannelId @@ -246,7 +245,7 @@ def has_message_feed(channel_id: str) -> bool: @staticmethod def max_action_title_length(channel_id: str) -> int: """Maximum length allowed for Action Titles. - :param _: The Channel to check for maximum Action Title length. + :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 From 417f57c61ac496e7d657fba7b8cdf6c6667c4e7a Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Mon, 14 Sep 2026 10:49:16 -0700 Subject: [PATCH 5/8] Fixing supports_thumbnail_card deny list --- .../microsoft_agents/activity/channels.py | 8 +- tests/activity/test_channels.py | 94 ++++++++++++++----- 2 files changed, 78 insertions(+), 24 deletions(-) diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/channels.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/channels.py index 57d99353d..39814e4e8 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/channels.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/channels.py @@ -107,7 +107,7 @@ def supports_suggested_actions( channel_id = Channels._normalize_channel_id(channel_id) - if channel_id == Channels.ms_teams.value: + if channel_id == Channels.msteams.value: return conversation_type == "personal" and button_cnt <= 3 max_actions = { @@ -143,7 +143,7 @@ def supports_card_actions(channel_id: str, button_cnt: int = 100) -> bool: max_actions = { Channels.facebook.value: 3, Channels.skype.value: 3, - Channels.ms_teams.value: 50, + Channels.msteams.value: 50, Channels.line.value: 99, Channels.slack.value: 100, Channels.telegram.value: 100, @@ -194,7 +194,9 @@ def supports_thumbnail_card(channel_id: str) -> bool: channel = Channels._normalize_channel_id(channel_id) return channel not in ( Channels.alexa.value, - Channels.msteams.value, + Channels.groupme.value, + Channels.line.value, + Channels.slack.value, Channels.twilio.value, ) diff --git a/tests/activity/test_channels.py b/tests/activity/test_channels.py index 9de9470e4..6bd2c9c9e 100644 --- a/tests/activity/test_channels.py +++ b/tests/activity/test_channels.py @@ -8,17 +8,19 @@ def _forms(base: str) -> list: 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. + 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 = [ - base, - f"{base}:sub", - ChannelId(base), - ChannelId(f"{base}:sub"), + 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(member) + forms.append(pytest.param(member, id=f"{base}-channels-enum")) return forms @@ -31,7 +33,11 @@ def test_ms_teams_is_alias_of_msteams(self): # aliases are not included twice in iteration assert list(Channels).count(Channels.msteams) == 1 - @pytest.mark.parametrize("channel_id", _forms("msteams") + [Channels.ms_teams]) + @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 @@ -45,7 +51,11 @@ def test_supports_card_actions_returns_false_over_limit(self, channel_id): 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") + [Channels.ms_teams]) + @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 @@ -57,7 +67,11 @@ def test_supports_suggested_actions_returns_bool(self, channel_id): 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") + [Channels.ms_teams]) + @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 @@ -65,11 +79,19 @@ def test_has_message_feed_returns_true_for_msteams(self, channel_id): 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") + [Channels.ms_teams]) + @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") + [Channels.ms_teams]) + @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 @@ -77,7 +99,11 @@ def test_supports_video_card_returns_false_for_msteams(self, channel_id): 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") + [Channels.ms_teams]) + @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 @@ -85,15 +111,27 @@ def test_supports_create_conversation_returns_true_for_msteams(self, channel_id) 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") + [Channels.ms_teams]) + @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") + [Channels.ms_teams]) + @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") + [Channels.ms_teams]) + @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 @@ -101,15 +139,25 @@ def test_supports_receipt_card_returns_false_for_msteams(self, channel_id): 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("msteams") + [Channels.ms_teams]) - def test_supports_thumbnail_card_returns_false_for_msteams(self, channel_id): + @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")) - def test_supports_thumbnail_card_returns_true_for_webchat(self, channel_id): + @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") + [Channels.ms_teams]) + @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 @@ -117,7 +165,11 @@ def test_supports_audio_card_returns_false_for_msteams(self, channel_id): 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") + [Channels.ms_teams]) + @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 From 93e272388179bed3731b207da2a39ca14eb165bc Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Mon, 14 Sep 2026 11:59:30 -0700 Subject: [PATCH 6/8] Modifying dialogs to use core's Channels helper methods --- changelog.md | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/changelog.md b/changelog.md index 83458b348..cf2128694 100644 --- a/changelog.md +++ b/changelog.md @@ -7,11 +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**: Adding `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 - -- **Typing Indicator on Update/Delete**: Fixed the typing indicator re-appearing in Teams clients after the finalized streaming activity was updated or deleted post-`end_stream()`. +- **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 From 635e2601db7015be988d9fc98c745e56e5eb0711 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Mon, 14 Sep 2026 12:07:17 -0700 Subject: [PATCH 7/8] updating dialogs Channel class to point to behavior in Channels --- .../microsoft_agents/activity/channels.py | 2 +- .../hosting/dialogs/choices/channel.py | 54 +++---------------- 2 files changed, 7 insertions(+), 49 deletions(-) diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/channels.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/channels.py index 39814e4e8..b2fa704a0 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/channels.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/channels.py @@ -99,7 +99,7 @@ def supports_suggested_actions( ) -> bool: """Determine if a number of Suggested Actions are supported by a Channel. - :param channel_id: The ID of the channel to checks for support of Suggested Actions. + :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. 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) From c8ebdade5e09301a4c89de08667837fadabd6107 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Mon, 14 Sep 2026 12:13:57 -0700 Subject: [PATCH 8/8] Simplifying enum usage --- .../microsoft_agents/activity/channels.py | 116 +++++++++--------- tests/hosting_dialogs/choices/test_channel.py | 4 +- 2 files changed, 59 insertions(+), 61 deletions(-) diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/channels.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/channels.py index b2fa704a0..3896e1547 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/channels.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/channels.py @@ -89,8 +89,6 @@ def _normalize_channel_id(channel_id: str | Channels | ChannelId) -> str: :param channel_id: The channel ID to normalize. :returns: The canonical string representation of the channel ID. """ - if isinstance(channel_id, Channels): - channel_id = channel_id.value return ChannelId(channel_id).channel @staticmethod @@ -107,22 +105,22 @@ def supports_suggested_actions( channel_id = Channels._normalize_channel_id(channel_id) - if channel_id == Channels.msteams.value: + 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.value: 10, - Channels.skype.value: 10, + Channels.facebook: 10, + Channels.skype: 10, # https://developers.line.biz/en/reference/messaging-api/#items-object - Channels.line.value: 13, + Channels.line: 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, + Channels.kik: 20, + Channels.telegram: 100, + Channels.emulator: 100, + Channels.direct_line: 100, + Channels.direct_line_speech: 100, + Channels.webchat: 100, } return ( button_cnt <= max_actions[channel_id] @@ -141,17 +139,17 @@ def supports_card_actions(channel_id: str, button_cnt: int = 100) -> bool: channel = Channels._normalize_channel_id(channel_id) max_actions = { - Channels.facebook.value: 3, - Channels.skype.value: 3, - Channels.msteams.value: 50, - 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, - Channels.cortana.value: 100, + Channels.facebook: 3, + Channels.skype: 3, + Channels.msteams: 50, + Channels.line: 99, + Channels.slack: 100, + Channels.telegram: 100, + Channels.emulator: 100, + Channels.direct_line: 100, + Channels.direct_line_speech: 100, + Channels.webchat: 100, + Channels.cortana: 100, } return button_cnt <= max_actions[channel] if channel in max_actions else False @@ -164,9 +162,9 @@ def supports_video_card(channel_id: str) -> bool: """ channel = Channels._normalize_channel_id(channel_id) return channel not in ( - Channels.alexa.value, - Channels.msteams.value, - Channels.twilio.value, + Channels.alexa, + Channels.msteams, + Channels.twilio, ) @staticmethod @@ -178,10 +176,10 @@ def supports_receipt_card(channel_id: str) -> bool: """ channel = Channels._normalize_channel_id(channel_id) return channel not in ( - Channels.alexa.value, - Channels.groupme.value, - Channels.msteams.value, - Channels.twilio.value, + Channels.alexa, + Channels.groupme, + Channels.msteams, + Channels.twilio, ) @staticmethod @@ -193,11 +191,11 @@ def supports_thumbnail_card(channel_id: str) -> bool: """ channel = Channels._normalize_channel_id(channel_id) return channel not in ( - Channels.alexa.value, - Channels.groupme.value, - Channels.line.value, - Channels.slack.value, - Channels.twilio.value, + Channels.alexa, + Channels.groupme, + Channels.line, + Channels.slack, + Channels.twilio, ) @staticmethod @@ -209,14 +207,14 @@ def supports_audio_card(channel_id: str) -> bool: """ channel = Channels._normalize_channel_id(channel_id) return channel not in ( - Channels.alexa.value, - Channels.msteams.value, - Channels.twilio.value, - Channels.email.value, - Channels.groupme.value, - Channels.line.value, - Channels.slack.value, - Channels.telegram.value, + Channels.alexa, + Channels.msteams, + Channels.twilio, + Channels.email, + Channels.groupme, + Channels.line, + Channels.slack, + Channels.telegram, ) @staticmethod @@ -228,11 +226,11 @@ def supports_animation_card(channel_id: str) -> bool: """ channel = Channels._normalize_channel_id(channel_id) return channel not in ( - Channels.alexa.value, - Channels.msteams.value, + Channels.alexa, + Channels.msteams, Channels.email, Channels.groupme, - Channels.twilio.value, + Channels.twilio, ) @staticmethod @@ -242,7 +240,7 @@ def has_message_feed(channel_id: str) -> bool: :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.value + return channel != Channels.cortana @staticmethod def max_action_title_length(channel_id: str) -> int: @@ -261,15 +259,15 @@ def supports_create_conversation(channel_id: str) -> bool: """ channel = Channels._normalize_channel_id(channel_id) return channel in ( - Channels.email.value, - Channels.facebook.value, - Channels.groupme.value, - Channels.kik.value, - Channels.line.value, - Channels.msteams.value, - Channels.slack.value, - Channels.sms.value, - Channels.telegram.value, + Channels.email, + Channels.facebook, + Channels.groupme, + Channels.kik, + Channels.line, + Channels.msteams, + Channels.slack, + Channels.sms, + Channels.telegram, ) @staticmethod @@ -291,7 +289,7 @@ def supports_delete_activity(channel_id: str) -> bool: """ channel = Channels._normalize_channel_id(channel_id) return channel in ( - Channels.msteams.value, - Channels.slack.value, - Channels.telegram.value, + Channels.msteams, + Channels.slack, + Channels.telegram, ) 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)