From ec636c6c577838837831f5de2de1214305e6d79f Mon Sep 17 00:00:00 2001 From: kylerohn-msft Date: Tue, 21 Jul 2026 14:57:45 -0700 Subject: [PATCH 01/15] add helpers for --- .../microsoft_agents/activity/activity.py | 379 +++++++++++++++++- .../activity/errors/error_resources.py | 6 + .../hosting/core/turn_context.py | 38 +- tests/activity/test_activity_builders.py | 208 ++++++++++ 4 files changed, 607 insertions(+), 24 deletions(-) create mode 100644 tests/activity/test_activity_builders.py diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/activity.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/activity.py index 8e0b7080b..5f770e528 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/activity.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/activity.py @@ -4,6 +4,7 @@ from __future__ import annotations import logging +import re from copy import copy from datetime import datetime, timezone from typing import Optional, Any, cast, Annotated, TypeVar @@ -30,6 +31,8 @@ Entity, EntityTypes, Mention, + ActivityTreatment, + ActivityTreatmentTypes, AIEntity, ClientCitation, ProductInfo, @@ -429,6 +432,375 @@ def as_typing_activity(self): """ return self if self.__is_activity(ActivityTypes.typing) else None + def with_text(self, text: str) -> "Activity": + """ + Sets the text content of the activity. + + :param text: The text content of the message. + :returns: This activity, to allow for method chaining. + """ + self.text = text + return self + + def with_speak(self, speak: str) -> "Activity": + """ + Sets the text to speak for the activity. + + :param speak: The text to speak. + :returns: This activity, to allow for method chaining. + """ + self.speak = speak + return self + + def with_input_hint(self, input_hint: str) -> "Activity": + """ + Sets the input hint for the activity. + + :param input_hint: Indicates whether the agent is accepting, expecting, or ignoring user input. + :returns: This activity, to allow for method chaining. + """ + self.input_hint = input_hint + return self + + def with_summary(self, summary: str) -> "Activity": + """ + Sets the summary of the activity. + + :param summary: The text to display if the channel cannot render cards. + :returns: This activity, to allow for method chaining. + """ + self.summary = summary + return self + + def with_locale(self, locale: str) -> "Activity": + """ + Sets the locale of the activity. + + :param locale: A locale name for the contents of the text field. + :returns: This activity, to allow for method chaining. + """ + self.locale = locale + return self + + def with_text_format(self, text_format: str) -> "Activity": + """ + Sets the text format of the activity. + + :param text_format: Format of the text fields. Possible values include: 'markdown', 'plain', 'xml'. + :returns: This activity, to allow for method chaining. + """ + self.text_format = text_format + return self + + def with_attachment_layout(self, attachment_layout: str) -> "Activity": + """ + Sets the attachment layout hint for the activity. + + :param attachment_layout: The layout hint for multiple attachments. Possible values include: 'list', 'carousel'. + :returns: This activity, to allow for method chaining. + """ + self.attachment_layout = attachment_layout + return self + + def with_delivery_mode(self, delivery_mode: str) -> "Activity": + """ + Sets the delivery mode of the activity. + + :param delivery_mode: The delivery mode for the activity. + :returns: This activity, to allow for method chaining. + """ + self.delivery_mode = delivery_mode + return self + + def with_name(self, name: str) -> "Activity": + """ + Sets the name of the activity. + + :param name: The name of the operation associated with an invoke or event activity. + :returns: This activity, to allow for method chaining. + """ + self.name = name + return self + + def with_value(self, value: object, value_type: str | None = None) -> "Activity": + """ + Sets the value of the activity, and optionally its value type. + + :param value: A value that is associated with the activity. + :param value_type: The type of the activity's value object. Only set when provided. + :returns: This activity, to allow for method chaining. + """ + self.value = value + if value_type is not None: + self.value_type = value_type + return self + + def with_suggested_actions(self, suggested_actions: SuggestedActions) -> "Activity": + """ + Sets the suggested actions for the activity. + + :param suggested_actions: The suggested actions for the activity. + :returns: This activity, to allow for method chaining. + """ + self.suggested_actions = suggested_actions + return self + + def add_text(self, text: str) -> None: + """ + Appends text to the existing text content of the activity. + + :param text: The text to append to the activity's text. + """ + self.text = (self.text or "") + text + + def add_attachment(self, *attachments: Attachment) -> "Activity": + """ + Adds one or more attachments to the activity. + + :param attachments: The attachments to add to the activity. + :returns: This activity, to allow for method chaining. + """ + if not attachments: + return self + + self.attachments = self.attachments or [] + self.attachments.extend(attachments) + return self + + def add_entity(self, *entities: Entity) -> "Activity": + """ + Adds one or more entities to the activity. + + :param entities: The entities to add to the activity. + :returns: This activity, to allow for method chaining. + """ + if not entities: + return self + + self.entities = self.entities or [] + self.entities.extend(entities) + return self + + def add_mention( + self, + account: ChannelAccount, + text: NonEmptyString | None = None, + add_text: bool = True, + ) -> "Activity": + """ + Adds a mention of the given account to the activity. + + :param account: The account to mention. + :param text: The text of the mention. Defaults to the account's name. + :param add_text: Whether to prepend the mention markup to the activity's text. + :returns: This activity, to allow for method chaining. + """ + mention_text = text if text is not None else (account.name if account else None) + if mention_text is None: + logger.warning("Adding a mention with no text or account name.") + + markup = f"{mention_text}" + + if add_text: + self.text = markup if not self.text else f"{markup} {self.text}" + + return self.add_entity(Mention(mentioned=account, text=markup)) + + def get_account_mention(self, account_id: NonEmptyString) -> Optional[Mention]: + """ + Resolves the mention for the given account id, if any. + + :param account_id: The id of the account to find a mention for. + :returns: The matching mention; or None, if none is found. + """ + if not self.entities or account_id is None: + return None + + for mention in self.get_mentions(): + if mention.mentioned and mention.mentioned.id == account_id: + return mention + + return None + + def is_recipient_mentioned(self) -> bool: + """ + Indicates whether the recipient of this activity was mentioned. + + :returns: True if the recipient was mentioned; otherwise, False. + """ + return ( + self.recipient is not None + and self.recipient.id is not None + and self.get_account_mention(self.recipient.id) is not None + ) + + def remove_recipient_mention(self) -> NonEmptyString: + """ + Removes the recipient's mention text from the activity's text. + + :returns: The updated activity text. + + .. remarks:: + This method is defined on the :class:`microsoft_agents.activity.Activity` class, but is only intended for + use with a message activity, where the activity Activity.Type is set to ActivityTypes.Message. + """ + return self.remove_mention_text(self.recipient.id if self.recipient else None) + + def remove_mention_text(self, identifier: NonEmptyString | None) -> NonEmptyString: + """ + Removes the mention text for the given account id from the activity's text. + + For example, given the message `echoAgent Hi Agent`, this removes + `echoAgent`, leaving `Hi Agent`. + + :param identifier: The id of the account whose mention text should be removed. + :returns: The updated activity text. + + .. remarks:: + The format of a mention entity is dependent on the channel, but in all cases + the Mention.text is expected to contain the exact text for the user as it + appears in Activity.text. + """ + if not identifier: + return self.text + + for mention in self.get_mentions(): + if not mention.mentioned or mention.mentioned.id != identifier: + continue + + if mention.text is None: + pattern = f"{re.escape(mention.mentioned.name)}" + else: + pattern = re.escape(mention.text) + + self.text = re.sub( + pattern, "", self.text or "", flags=re.IGNORECASE + ).strip() + + return self.text + + def is_targeted_activity(self) -> bool: + """ + Indicates whether this activity is targeted. + + :returns: True if this activity carries a targeted treatment; otherwise, False. + """ + if not self.entities: + return False + + for entity in self.entities: + if ( + entity.type == EntityTypes.ACTIVITY_TREATMENT + and isinstance(entity, ActivityTreatment) + and entity.treatment == ActivityTreatmentTypes.TARGETED + ): + return True + + return False + + def make_targeted_activity(self, user: ChannelAccount | None = None) -> "Activity": + """ + Marks this activity as targeted, setting the recipient if provided. + + :param user: The account to target. Defaults to the current recipient. + :returns: This activity, to allow for method chaining. + :raises ValueError: If both the activity's recipient and the user argument are None. + """ + if self.is_targeted_activity(): + return self + + if self.recipient is None and user is None: + raise ValueError(str(activity_errors.InvalidTargetedActivityRecipient)) + + self.entities = self.entities or [] + self.entities.append( + ActivityTreatment(treatment=ActivityTreatmentTypes.TARGETED) + ) + + self.recipient = user if user is not None else self.recipient + + return self + + def is_message(self) -> bool: + """ + Indicates whether this activity is a message activity. + + :return: True if this activity is a message activity; otherwise, False. + """ + return self.__is_activity(ActivityTypes.message) + + def is_event(self) -> bool: + """ + Indicates whether this activity is an event activity. + + :return: True if this activity is an event activity; otherwise, False. + """ + return self.__is_activity(ActivityTypes.event) + + def is_invoke(self) -> bool: + """ + Indicates whether this activity is an invoke activity. + + :return: True if this activity is an invoke activity; otherwise, False. + """ + return self.__is_activity(ActivityTypes.invoke) + + def is_typing(self) -> bool: + """ + Indicates whether this activity is a typing activity. + + :return: True if this activity is a typing activity; otherwise, False. + """ + return self.__is_activity(ActivityTypes.typing) + + def is_conversation_update(self) -> bool: + """ + Indicates whether this activity is a conversation update activity. + + :return: True if this activity is a conversation update activity; otherwise, False. + """ + return self.__is_activity(ActivityTypes.conversation_update) + + def is_end_of_conversation(self) -> bool: + """ + Indicates whether this activity is an end of conversation activity. + + :return: True if this activity is an end of conversation activity; otherwise, False. + """ + return self.__is_activity(ActivityTypes.end_of_conversation) + + def is_handoff(self) -> bool: + """ + Indicates whether this activity is a handoff activity. + + :return: True if this activity is a handoff activity; otherwise, False. + """ + return self.__is_activity(ActivityTypes.handoff) + + def is_trace(self) -> bool: + """ + Indicates whether this activity is a trace activity. + + :return: True if this activity is a trace activity; otherwise, False. + """ + return self.__is_activity(ActivityTypes.trace) + + def is_command(self) -> bool: + """ + Indicates whether this activity is a command activity. + + :return: True if this activity is a command activity; otherwise, False. + """ + return self.__is_activity(ActivityTypes.command) + + def is_command_result(self) -> bool: + """ + Indicates whether this activity is a command result activity. + + :return: True if this activity is a command result activity; otherwise, False. + """ + return self.__is_activity(ActivityTypes.command_result) + @staticmethod def create_contact_relation_update_activity(): """ @@ -713,8 +1085,8 @@ def get_mentions(self) -> list[Mention]: :returns: The array of mentions; or an empty array, if none are found. .. remarks:: - This method is defined on the :class:`microsoft_agents.activity.Activity` class, but is only intended for use with a message activity, - where the activity Activity.Type is set to ActivityTypes.Message. + This method is defined on the :class:`microsoft_agents.activity.Activity` class, but is only intended for use with + a message activity, where the activity Activity.Type is set to ActivityTypes.Message. """ if not self.entities: return [] @@ -728,7 +1100,8 @@ def get_reply_conversation_reference( self, reply: ResourceResponse ) -> ConversationReference: """ - Create a ConversationReference based on this Activity's Conversation info and the ResourceResponse from sending an activity. + Create a ConversationReference based on this Activity's Conversation info and the ResourceResponse from sending an + activity. :param reply: ResourceResponse returned from send_activity. diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/errors/error_resources.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/errors/error_resources.py index 455f7bc4a..0d4b7181d 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/errors/error_resources.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/errors/error_resources.py @@ -47,6 +47,12 @@ class ActivityErrorResources: -64005, ) + InvalidTargetedActivityRecipient = ErrorMessage( + "Cannot mark activity as targeted because both the Activity.recipient " + "and `user` argument are None. At least one must be provided.", + -64006, + ) + def __init__(self): """Initialize ActivityErrorResources.""" pass diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/turn_context.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/turn_context.py index edf639f1b..bc734e5d8 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/turn_context.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/turn_context.py @@ -3,7 +3,6 @@ from __future__ import annotations -import re from typing import Optional, Awaitable, TypeVar, Protocol from copy import deepcopy @@ -20,7 +19,6 @@ TurnContextProtocol, ) from microsoft_agents.activity._model_utils import pick_model, SkipNone -from microsoft_agents.activity.entity.entity_types import EntityTypes from microsoft_agents.hosting.core.authorization.claims_identity import ClaimsIdentity import microsoft_agents.hosting.core.telemetry.turn_context.spans as spans @@ -437,33 +435,31 @@ def get_reply_conversation_reference( @staticmethod def remove_recipient_mention(activity: Activity) -> str: - return TurnContext.remove_mention_text(activity, activity.recipient.id) + """ + Removes the recipient's mention text from the activity's text. + + :param activity: The activity to remove the recipient mention from. + :return: The updated activity text. + """ + return activity.remove_recipient_mention() @staticmethod def remove_mention_text(activity: Activity, identifier: str) -> str: """ - Remove a mention matching the given account identifier from activity.text. + Removes the mention text for the given account id from the activity's text. + + :param activity: The activity to remove the mention text from. + :param identifier: The id of the account whose mention text should be removed. + :return: The updated activity text. """ - mentions = TurnContext.get_mentions(activity) - for mention in mentions: - if mention.mentioned and mention.mentioned.id == identifier: - mention_name_match = re.match( - r"(.*?)<\/at>", - re.escape(mention.text or ""), - re.IGNORECASE, - ) - if mention_name_match: - activity.text = re.sub( - mention_name_match.groups()[1], "", activity.text - ) - activity.text = re.sub(r"<\/at>", "", activity.text) - return activity.text + return activity.remove_mention_text(identifier) @staticmethod def get_mentions(activity: Activity) -> list[Mention]: - """Get all mentions from the activity. + """ + Returns all the mentions in the activity. - :param activity: The activity to get mentions from. - :return: A list of Mention objects. + :param activity: the activity to get mentions from + :return: A list of Mention objects representing all mentions in the activity. """ return activity.get_mentions() diff --git a/tests/activity/test_activity_builders.py b/tests/activity/test_activity_builders.py new file mode 100644 index 000000000..b340d7836 --- /dev/null +++ b/tests/activity/test_activity_builders.py @@ -0,0 +1,208 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from microsoft_agents.activity.card_action import CardAction +import pytest + +from microsoft_agents.activity import ( + Activity, + AttachmentLayoutTypes, + Attachment, + ChannelAccount, + DeliveryModes, + Entity, + InputHints, + Mention, + SuggestedActions, + TextFormatTypes, +) + + +class TestActivityFluentBuilders: + def test_fluent_setters_chain_and_set_properties(self): + activity = ( + Activity.create_message_activity() + .with_text("hello") + .with_speak("hello there") + .with_input_hint(InputHints.expecting_input) + .with_summary("a summary") + .with_locale("en-US") + .with_text_format(TextFormatTypes.markdown) + .with_attachment_layout(AttachmentLayoutTypes.carousel) + .with_delivery_mode(DeliveryModes.normal) + .with_name("theName") + .with_value("theValue", "theValueType") + ) + + assert activity.text == "hello" + assert activity.speak == "hello there" + assert activity.input_hint == InputHints.expecting_input + assert activity.summary == "a summary" + assert activity.locale == "en-US" + assert activity.text_format == TextFormatTypes.markdown + assert activity.attachment_layout == AttachmentLayoutTypes.carousel + assert activity.delivery_mode == DeliveryModes.normal + assert activity.name == "theName" + assert activity.value == "theValue" + assert activity.value_type == "theValueType" + + def test_with_value_without_value_type(self): + activity = Activity.create_message_activity().with_value("theValue") + + assert activity.value == "theValue" + assert activity.value_type is None + + def test_with_suggested_actions(self): + actions = SuggestedActions( + to=["u1"], + actions=[CardAction(type="imBack", title="Click Me", value="clicked")], + ) + activity = Activity.create_message_activity().with_suggested_actions(actions) + + assert activity.suggested_actions is actions + + def test_add_text_appends(self): + activity = Activity.create_message_activity().with_text("foo") + activity.add_text("bar") + + assert activity.text == "foobar" + + def test_add_text_appends_when_text_is_none(self): + activity = Activity.create_message_activity() + activity.add_text("bar") + + assert activity.text == "bar" + + def test_add_attachment_adds_attachments(self): + activity = Activity.create_message_activity().add_attachment( + Attachment(content_type="a"), Attachment(content_type="b") + ) + + assert len(activity.attachments) == 2 + assert activity.attachments[0].content_type == "a" + assert activity.attachments[1].content_type == "b" + + def test_add_entity_adds_entities(self): + activity = Activity.create_message_activity().add_entity(Entity(type="myType")) + + assert len(activity.entities) == 1 + assert activity.entities[0].type == "myType" + + +class TestActivityMentions: + def test_add_mention_adds_entity_and_text(self): + account = ChannelAccount(id="u1", name="User One") + activity = ( + Activity.create_message_activity().with_text("hi").add_mention(account) + ) + + assert activity.text == "User One hi" + mention = activity.entities[0] + assert isinstance(mention, Mention) + assert mention.mentioned.id == "u1" + assert mention.text == "User One" + + def test_add_mention_can_skip_text(self): + account = ChannelAccount(id="u1", name="User One") + activity = ( + Activity.create_message_activity() + .with_text("hi") + .add_mention(account, text="Custom", add_text=False) + ) + + assert activity.text == "hi" + mention = activity.entities[0] + assert isinstance(mention, Mention) + assert mention.text == "Custom" + + def test_get_account_mention_returns_match(self): + account = ChannelAccount(id="u1", name="User One") + activity = Activity.create_message_activity().add_mention(account) + + mention = activity.get_account_mention("u1") + assert mention is not None + assert mention.mentioned.id == "u1" + + assert activity.get_account_mention("other") is None + assert activity.get_account_mention(None) is None + + def test_is_recipient_mentioned(self): + recipient = ChannelAccount(id="bot", name="Bot") + activity = Activity.create_message_activity() + activity.recipient = recipient + + assert activity.is_recipient_mentioned() is False + + activity.add_mention(recipient) + assert activity.is_recipient_mentioned() is True + + def test_remove_recipient_mention(self): + recipient = ChannelAccount(id="bot", name="Bot") + activity = Activity.create_message_activity() + activity.recipient = recipient + activity.with_text("Hi Agent").add_mention(recipient) + + assert activity.text == "Bot Hi Agent" + + result = activity.remove_recipient_mention() + assert result == "Hi Agent" + assert activity.text == "Hi Agent" + + +class TestActivityTargeting: + def test_make_targeted_activity_sets_treatment(self): + recipient = ChannelAccount(id="bot", name="Bot") + activity = Activity.create_message_activity() + activity.recipient = recipient + + assert activity.is_targeted_activity() is False + + activity.make_targeted_activity() + assert activity.is_targeted_activity() is True + + def test_make_targeted_activity_uses_user_argument(self): + user = ChannelAccount(id="u1", name="User One") + activity = Activity.create_message_activity().make_targeted_activity(user) + + assert activity.is_targeted_activity() is True + assert activity.recipient.id == "u1" + + def test_make_targeted_activity_is_idempotent(self): + recipient = ChannelAccount(id="bot", name="Bot") + activity = Activity.create_message_activity() + activity.recipient = recipient + activity.make_targeted_activity() + activity.make_targeted_activity() + + treatments = [e for e in activity.entities if e.type == "activityTreatment"] + assert len(treatments) == 1 + + def test_make_targeted_activity_raises_without_recipient_or_user(self): + activity = Activity.create_message_activity() + + with pytest.raises(ValueError): + activity.make_targeted_activity() + + +class TestActivityTypePredicates: + def test_is_type_helpers(self): + assert Activity.create_message_activity().is_message() is True + assert Activity.create_typing_activity().is_typing() is True + assert Activity.create_event_activity().is_event() is True + assert Activity.create_invoke_activity().is_invoke() is True + assert ( + Activity.create_conversation_update_activity().is_conversation_update() + is True + ) + assert ( + Activity.create_end_of_conversation_activity().is_end_of_conversation() + is True + ) + assert Activity.create_handoff_activity().is_handoff() is True + assert Activity.create_message_activity().is_invoke() is False + + def test_is_trace_command_predicates(self): + assert Activity(type="trace").is_trace() is True + assert Activity(type="command").is_command() is True + assert Activity(type="commandResult").is_command_result() is True + assert Activity(type="message").is_command() is False From b62e47a53a95d46f0c4269fd686b01a41a6a2e9b Mon Sep 17 00:00:00 2001 From: kylerohn-msft Date: Wed, 22 Jul 2026 10:29:47 -0700 Subject: [PATCH 02/15] move content_types out of card_factory --- .../microsoft_agents/activity/__init__.py | 3 +- .../activity/content_types.py | 16 ++++++ .../activity/suggested_actions.py | 39 ++++++++++++++ .../hosting/core/card_factory.py | 51 ++++--------------- 4 files changed, 67 insertions(+), 42 deletions(-) create mode 100644 libraries/microsoft-agents-activity/microsoft_agents/activity/content_types.py diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/__init__.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/__init__.py index 730b8754b..90177f92e 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/__init__.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/__init__.py @@ -27,6 +27,7 @@ from .conversation_reference import ConversationReference from .conversation_resource_response import ConversationResourceResponse from .conversations_result import ConversationsResult +from .content_types import ContentTypes from .expected_replies import ExpectedReplies from .entity import ( Entity, @@ -95,7 +96,6 @@ from .token_exchange_resource import TokenExchangeResource from .token_post_resource import TokenPostResource -from .delivery_modes import DeliveryModes from .caller_id_constants import CallerIdConstants from .conversation_update_types import ConversationUpdateTypes @@ -131,6 +131,7 @@ "ConversationReference", "ConversationResourceResponse", "ConversationsResult", + "ContentTypes", "ExpectedReplies", "Entity", "AIEntity", diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/content_types.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/content_types.py new file mode 100644 index 000000000..2599c36f4 --- /dev/null +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/content_types.py @@ -0,0 +1,16 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + + +class ContentTypes: + """Well-known content types for card attachments.""" + + adaptive_card = "application/vnd.microsoft.card.adaptive" + animation_card = "application/vnd.microsoft.card.animation" + audio_card = "application/vnd.microsoft.card.audio" + hero_card = "application/vnd.microsoft.card.hero" + receipt_card = "application/vnd.microsoft.card.receipt" + oauth_card = "application/vnd.microsoft.card.oauth" + signin_card = "application/vnd.microsoft.card.signin" + thumbnail_card = "application/vnd.microsoft.card.thumbnail" + video_card = "application/vnd.microsoft.card.video" diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/suggested_actions.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/suggested_actions.py index f82ca3ec4..3b624df3b 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/suggested_actions.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/suggested_actions.py @@ -21,3 +21,42 @@ class SuggestedActions(AgentsModel): to: list[NonEmptyString] = Field(default_factory=list) actions: list[CardAction] + + def add_action(self, action: CardAction) -> "SuggestedActions": + """ + Adds a single action to the actions and returns this instance. + + :param action: The action to add. + :returns: This instance, to allow for method chaining. + """ + self.actions = self.actions or [] + self.actions.append(action) + return self + + def add_actions(self, *actions: CardAction) -> "SuggestedActions": + """ + Adds one or more actions to the actions and returns this instance. + + :param actions: The actions to add. + :returns: This instance, to allow for method chaining. + """ + if not actions: + return self + + self.actions = self.actions or [] + self.actions.extend(actions) + return self + + def add_recipients(self, *recipients: NonEmptyString) -> "SuggestedActions": + """ + Adds one or more recipient ids to the recipients and returns this instance. + + :param recipients: The recipient ids to add. + :returns: This instance, to allow for method chaining. + """ + if not recipients: + return self + + self.to = self.to or [] + self.to.extend(recipients) + return self diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/card_factory.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/card_factory.py index 5ec369940..04687084c 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/card_factory.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/card_factory.py @@ -5,6 +5,7 @@ AnimationCard, Attachment, AudioCard, + ContentTypes, HeroCard, OAuthCard, ReceiptCard, @@ -14,21 +15,7 @@ ) -class ContentTypes: - adaptive_card = "application/vnd.microsoft.card.adaptive" - animation_card = "application/vnd.microsoft.card.animation" - audio_card = "application/vnd.microsoft.card.audio" - hero_card = "application/vnd.microsoft.card.hero" - receipt_card = "application/vnd.microsoft.card.receipt" - oauth_card = "application/vnd.microsoft.card.oauth" - signin_card = "application/vnd.microsoft.card.signin" - thumbnail_card = "application/vnd.microsoft.card.thumbnail" - video_card = "application/vnd.microsoft.card.video" - - class CardFactory: - content_types = ContentTypes - @staticmethod def adaptive_card(card: dict) -> Attachment: """ @@ -44,9 +31,7 @@ def adaptive_card(card: dict) -> Attachment: "attachment." ) - return Attachment( - content_type=CardFactory.content_types.adaptive_card, content=card - ) + return Attachment(content_type=ContentTypes.adaptive_card, content=card) @staticmethod def animation_card(card: AnimationCard) -> Attachment: @@ -62,9 +47,7 @@ def animation_card(card: AnimationCard) -> Attachment: "unable to prepare attachment." ) - return Attachment( - content_type=CardFactory.content_types.animation_card, content=card - ) + return Attachment(content_type=ContentTypes.animation_card, content=card) @staticmethod def audio_card(card: AudioCard) -> Attachment: @@ -79,9 +62,7 @@ def audio_card(card: AudioCard) -> Attachment: "unable to prepare attachment." ) - return Attachment( - content_type=CardFactory.content_types.audio_card, content=card - ) + return Attachment(content_type=ContentTypes.audio_card, content=card) @staticmethod def hero_card(card: HeroCard) -> Attachment: @@ -98,9 +79,7 @@ def hero_card(card: HeroCard) -> Attachment: "unable to prepare attachment." ) - return Attachment( - content_type=CardFactory.content_types.hero_card, content=card - ) + return Attachment(content_type=ContentTypes.hero_card, content=card) @staticmethod def oauth_card(card: OAuthCard) -> Attachment: @@ -116,9 +95,7 @@ def oauth_card(card: OAuthCard) -> Attachment: "unable to prepare attachment." ) - return Attachment( - content_type=CardFactory.content_types.oauth_card, content=card - ) + return Attachment(content_type=ContentTypes.oauth_card, content=card) @staticmethod def receipt_card(card: ReceiptCard) -> Attachment: @@ -133,9 +110,7 @@ def receipt_card(card: ReceiptCard) -> Attachment: "unable to prepare attachment." ) - return Attachment( - content_type=CardFactory.content_types.receipt_card, content=card - ) + return Attachment(content_type=ContentTypes.receipt_card, content=card) @staticmethod def signin_card(card: SigninCard) -> Attachment: @@ -151,9 +126,7 @@ def signin_card(card: SigninCard) -> Attachment: "unable to prepare attachment." ) - return Attachment( - content_type=CardFactory.content_types.signin_card, content=card - ) + return Attachment(content_type=ContentTypes.signin_card, content=card) @staticmethod def thumbnail_card(card: ThumbnailCard) -> Attachment: @@ -171,9 +144,7 @@ def thumbnail_card(card: ThumbnailCard) -> Attachment: "unable to prepare attachment." ) - return Attachment( - content_type=CardFactory.content_types.thumbnail_card, content=card - ) + return Attachment(content_type=ContentTypes.thumbnail_card, content=card) @staticmethod def video_card(card: VideoCard) -> Attachment: @@ -188,6 +159,4 @@ def video_card(card: VideoCard) -> Attachment: "unable to prepare attachment." ) - return Attachment( - content_type=CardFactory.content_types.video_card, content=card - ) + return Attachment(content_type=ContentTypes.video_card, content=card) From 27ab9213593586c7e486a98afed9448906e6e86a Mon Sep 17 00:00:00 2001 From: kylerohn-msft Date: Wed, 22 Jul 2026 10:30:53 -0700 Subject: [PATCH 03/15] create card base class, add helpers, and deprecate basic/media cards --- .../microsoft_agents/activity/__init__.py | 2 + .../activity/animation_card.py | 66 +++++++++- .../microsoft_agents/activity/audio_card.py | 67 +++++++++- .../microsoft_agents/activity/basic_card.py | 110 +++++++++++++++++ .../microsoft_agents/activity/card.py | 33 +++++ .../microsoft_agents/activity/hero_card.py | 114 +++++++++++++++++- .../microsoft_agents/activity/media_card.py | 64 ++++++++++ .../microsoft_agents/activity/oauth_card.py | 14 ++- .../microsoft_agents/activity/receipt_card.py | 47 +++++++- .../microsoft_agents/activity/signin_card.py | 14 ++- .../activity/thumbnail_card.py | 114 +++++++++++++++++- .../microsoft_agents/activity/video_card.py | 66 +++++++++- 12 files changed, 695 insertions(+), 16 deletions(-) create mode 100644 libraries/microsoft-agents-activity/microsoft_agents/activity/card.py diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/__init__.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/__init__.py index 90177f92e..48f9a9d5e 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/__init__.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/__init__.py @@ -16,6 +16,7 @@ from .attachment_view import AttachmentView from .audio_card import AudioCard from .basic_card import BasicCard +from .card import Card from .card_action import CardAction from .card_image import CardImage from .channels import Channels @@ -120,6 +121,7 @@ "AttachmentView", "AudioCard", "BasicCard", + "Card", "CardAction", "CardImage", "Channels", diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/animation_card.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/animation_card.py index 558ceb5c2..1bcb2015d 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/animation_card.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/animation_card.py @@ -1,14 +1,18 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. +from typing import overload + from .thumbnail_url import ThumbnailUrl +from .attachment import Attachment +from .card import Card +from .content_types import ContentTypes from .media_url import MediaUrl from .card_action import CardAction -from .agents_model import AgentsModel from ._type_aliases import NonEmptyString -class AnimationCard(AgentsModel): +class AnimationCard(Card): """An animation card (Ex: gif or short video clip). :param title: Title of this card @@ -55,3 +59,61 @@ class AnimationCard(AgentsModel): aspect: NonEmptyString = None duration: NonEmptyString = None value: object = None + + def to_attachment(self) -> Attachment: + """ + Creates a new Attachment that wraps this card. + + :returns: The generated attachment. + """ + return Attachment(content_type=ContentTypes.animation_card, content=self) + + @overload + def add_media(self, media: MediaUrl) -> "AnimationCard": + ... + + @overload + def add_media( + self, *, url: NonEmptyString, profile: NonEmptyString | None = None + ) -> "AnimationCard": + ... + + def add_media( + self, + media: MediaUrl | None = None, + *, + url: NonEmptyString | None = None, + profile: NonEmptyString | None = None, + ) -> "AnimationCard": + """ + Adds a media URL and returns this card. + + :param media: The media URL to add. + :param url: The URL of the media, used when no media instance is provided. + :param profile: The profile of the media built from a URL. + :returns: This card, to allow for method chaining. + """ + if media is None: + if url is None: + raise ValueError( + "Either provide a MediaUrl instance or the url parameter." + ) + if profile is None: + media = MediaUrl(url=url) + else: + media = MediaUrl(url=url, profile=profile) + + self.media = self.media or [] + self.media.append(media) + return self + + def add_button(self, button: CardAction) -> "AnimationCard": + """ + Adds a button and returns this card. + + :param button: The button to add. + :returns: This card, to allow for method chaining. + """ + self.buttons = self.buttons or [] + self.buttons.append(button) + return self diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/audio_card.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/audio_card.py index dc7db79e5..14b36193e 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/audio_card.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/audio_card.py @@ -1,14 +1,18 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. -from .agents_model import AgentsModel +from typing import overload + +from .attachment import Attachment +from .card import Card +from .content_types import ContentTypes from .thumbnail_url import ThumbnailUrl from .media_url import MediaUrl from .card_action import CardAction from ._type_aliases import NonEmptyString -class AudioCard(AgentsModel): +class AudioCard(Card): """Audio card. :param title: Title of this card @@ -55,3 +59,62 @@ class AudioCard(AgentsModel): aspect: NonEmptyString = None duration: NonEmptyString = None value: object = None + + def to_attachment(self) -> Attachment: + """ + Creates a new Attachment that wraps this card. + + :returns: The generated attachment. + """ + return Attachment(content_type=ContentTypes.audio_card, content=self) + + @overload + def add_media(self, media: MediaUrl) -> "AudioCard": + ... + + @overload + def add_media( + self, *, url: NonEmptyString, profile: NonEmptyString | None = None + ) -> "AudioCard": + ... + + def add_media( + self, + media: MediaUrl | None = None, + *, + url: NonEmptyString | None = None, + profile: NonEmptyString | None = None, + ) -> "AudioCard": + """ + Adds a media URL and returns this card. + + :param media: The media URL to add. + :param url: The URL of the media, used when no media instance is provided. + :param profile: The profile of the media built from a URL. + :returns: This card, to allow for method chaining. + """ + if media is None: + if url is None: + raise ValueError( + "Either provide a MediaUrl instance or the url parameter." + ) + media = ( + MediaUrl(url=url) + if profile is None + else MediaUrl(url=url, profile=profile) + ) + + self.media = self.media or [] + self.media.append(media) + return self + + def add_button(self, button: CardAction) -> "AudioCard": + """ + Adds a button and returns this card. + + :param button: The button to add. + :returns: This card, to allow for method chaining. + """ + self.buttons = self.buttons or [] + self.buttons.append(button) + return self diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/basic_card.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/basic_card.py index b89cffd66..fda3f7aa8 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/basic_card.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/basic_card.py @@ -1,15 +1,28 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. +from typing import overload + +from typing_extensions import deprecated + +from .action_types import ActionTypes from .agents_model import AgentsModel from .card_image import CardImage from .card_action import CardAction from ._type_aliases import NonEmptyString +@deprecated( + "BasicCard is not an Activity Protocol card type (it has no content type) " + "and will be removed in a future release." +) class BasicCard(AgentsModel): """A basic card. + .. deprecated:: + BasicCard is not an Activity Protocol card type (it has no content type) + and will be removed in a future release. + :param title: Title of the card :type title: str :param subtitle: Subtitle of the card @@ -31,3 +44,100 @@ class BasicCard(AgentsModel): images: list[CardImage] = None buttons: list[CardAction] = None tap: CardAction = None + + @overload + def add_image(self, image: CardImage) -> "BasicCard": + ... + + @overload + def add_image( + self, *, url: NonEmptyString, alt: NonEmptyString | None = None + ) -> "BasicCard": + ... + + def add_image( + self, + image: CardImage | None = None, + *, + url: NonEmptyString | None = None, + alt: NonEmptyString | None = None, + ) -> "BasicCard": + """ + Adds an image and returns this card. + + :param image: The image to add. + :param url: The URL of the image, used when no image instance is provided. + :param alt: The alternate text for the image built from a URL. + :returns: This card, to allow for method chaining. + """ + if image is None: + if url is None: + raise ValueError( + "Either provide a CardImage instance or the url parameter." + ) + if alt is None: + image = CardImage(url=url) + else: + image = CardImage(url=url, alt=alt) + + self.images = self.images or [] + self.images.append(image) + return self + + @overload + def add_button(self, button: CardAction) -> "BasicCard": + ... + + @overload + def add_button( + self, + *, + title: NonEmptyString, + type: NonEmptyString = ActionTypes.im_back, + value: object | None = None, + ) -> "BasicCard": + ... + + def add_button( + self, + button: CardAction | None = None, + *, + title: NonEmptyString | None = None, + type: NonEmptyString = ActionTypes.im_back, + value: object | None = None, + ) -> "BasicCard": + """ + Adds a button and returns this card. + + :param button: The button to add. + :param title: The title of the button, used when no button instance is provided. + :param type: The action type of the button built from a title. + :param value: The value of the button built from a title. Defaults to the title. + :returns: This card, to allow for method chaining. + """ + if button is None: + if title is None: + raise ValueError( + "Either provide a CardAction instance or the title parameter." + ) + button = CardAction( + type=type, title=title, value=value if value is not None else title + ) + + self.buttons = self.buttons or [] + self.buttons.append(button) + return self + + def add_buttons(self, *buttons: CardAction) -> "BasicCard": + """ + Adds one or more buttons and returns this card. + + :param buttons: The buttons to add. + :returns: This card, to allow for method chaining. + """ + if not buttons: + return self + + self.buttons = self.buttons or [] + self.buttons.extend(buttons) + return self diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/card.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/card.py new file mode 100644 index 000000000..369cf0202 --- /dev/null +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/card.py @@ -0,0 +1,33 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from .activity import Activity +from .agents_model import AgentsModel +from .attachment import Attachment +from .activity_types import ActivityTypes + + +class Card(AgentsModel): + """Base class for rich cards that can be sent to a user as an Attachment. + + Each concrete card implements :meth:`to_attachment` to wrap itself in an attachment using its + own content type. :meth:`to_message` builds on that to produce a ready-to-send message activity. + """ + + def to_attachment(self) -> Attachment: + """ + Creates a new Attachment that wraps this card. + + :returns: The generated attachment. + """ + raise NotImplementedError( + "to_attachment must be implemented by concrete Card subclasses." + ) + + def to_message(self) -> Activity: + """ + Creates a new message activity that includes this card as an attachment. + + :returns: An Activity representing a message activity with the card attached. + """ + return Activity(type=ActivityTypes.message, attachments=[self.to_attachment()]) diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/hero_card.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/hero_card.py index 810dc000a..af7ffefe5 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/hero_card.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/hero_card.py @@ -1,13 +1,18 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. +from typing import overload + +from .action_types import ActionTypes +from .attachment import Attachment +from .card import Card from .card_action import CardAction from .card_image import CardImage -from .agents_model import AgentsModel +from .content_types import ContentTypes from ._type_aliases import NonEmptyString -class HeroCard(AgentsModel): +class HeroCard(Card): """A Hero card (card with a single, large image). :param title: Title of the card @@ -31,3 +36,108 @@ class HeroCard(AgentsModel): images: list[CardImage] = None buttons: list[CardAction] = None tap: CardAction = None + + def to_attachment(self) -> Attachment: + """ + Creates a new Attachment that wraps this card. + + :returns: The generated attachment. + """ + return Attachment(content_type=ContentTypes.hero_card, content=self) + + @overload + def add_image(self, image: CardImage) -> "HeroCard": + ... + + @overload + def add_image( + self, *, url: NonEmptyString, alt: NonEmptyString | None = None + ) -> "HeroCard": + ... + + def add_image( + self, + image: CardImage | None = None, + *, + url: NonEmptyString | None = None, + alt: NonEmptyString | None = None, + ) -> "HeroCard": + """ + Adds an image and returns this card. + + :param image: The image to add. + :param url: The URL of the image, used when no image instance is provided. + :param alt: The alternate text for the image built from a URL. + :returns: This card, to allow for method chaining. + """ + if image is None: + if url is None: + raise ValueError( + "Either provide a CardImage instance or the url parameter." + ) + if alt is None: + image = CardImage(url=url) + else: + image = CardImage(url=url, alt=alt) + + self.images = self.images or [] + self.images.append(image) + return self + + @overload + def add_button(self, button: CardAction) -> "HeroCard": + ... + + @overload + def add_button( + self, + *, + title: NonEmptyString, + type: NonEmptyString = ActionTypes.im_back, + value: object | None = None, + ) -> "HeroCard": + ... + + def add_button( + self, + button: CardAction | None = None, + *, + title: NonEmptyString | None = None, + type: NonEmptyString = ActionTypes.im_back, + value: object | None = None, + ) -> "HeroCard": + """ + Adds a button and returns this card. + + :param button: The button to add. + :param title: The title of the button, used when no button instance is provided. + :param type: The action type of the button built from a title. + :param value: The value of the button built from a title. Defaults to the title. + :returns: This card, to allow for method chaining. + """ + if button is None: + if title is None: + raise ValueError( + "Either provide a CardAction instance or the title parameter." + ) + button = CardAction( + type=type, title=title, value=value if value is not None else title + ) + + self.buttons = self.buttons or [] + self.buttons.append(button) + return self + + def add_buttons(self, *buttons: CardAction) -> "HeroCard": + """ + Adds one or more buttons and returns this card. + + :param buttons: The buttons to add. + :returns: This card, to allow for method chaining. + """ + if not buttons: + return self + + self.buttons = self.buttons or [] + self.buttons.extend(buttons) + return self diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/media_card.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/media_card.py index 025c9f0ac..624e8c458 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/media_card.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/media_card.py @@ -1,6 +1,10 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. +from typing import overload + +from typing_extensions import deprecated + from .thumbnail_url import ThumbnailUrl from .media_url import MediaUrl from .card_action import CardAction @@ -8,9 +12,19 @@ from ._type_aliases import NonEmptyString +@deprecated( + "MediaCard is a structural base without an Activity Protocol content type. " + "Use AnimationCard, AudioCard, or VideoCard instead. " + "Will be removed in a future release." +) class MediaCard(AgentsModel): """Media card. + .. deprecated:: + MediaCard is a structural base without an Activity Protocol content type. + Use AnimationCard, AudioCard, or VideoCard instead. Will be removed in a + future release. + :param title: Title of this card :type title: str :param subtitle: Subtitle of this card @@ -55,3 +69,53 @@ class MediaCard(AgentsModel): aspect: NonEmptyString = None duration: NonEmptyString = None value: object = None + + @overload + def add_media(self, media: MediaUrl) -> "MediaCard": + ... + + @overload + def add_media( + self, *, url: NonEmptyString, profile: NonEmptyString | None = None + ) -> "MediaCard": + ... + + def add_media( + self, + media: MediaUrl | None = None, + *, + url: NonEmptyString | None = None, + profile: NonEmptyString | None = None, + ) -> "MediaCard": + """ + Adds a media URL and returns this card. + + :param media: The media URL to add. + :param url: The URL of the media, used when no media instance is provided. + :param profile: The profile of the media built from a URL. + :returns: This card, to allow for method chaining. + """ + if media is None: + if url is None: + raise ValueError( + "Either provide a MediaUrl instance or the url parameter." + ) + if profile is None: + media = MediaUrl(url=url) + else: + media = MediaUrl(url=url, profile=profile) + + self.media = self.media or [] + self.media.append(media) + return self + + def add_button(self, button: CardAction) -> "MediaCard": + """ + Adds a button and returns this card. + + :param button: The button to add. + :returns: This card, to allow for method chaining. + """ + self.buttons = self.buttons or [] + self.buttons.append(button) + return self diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/oauth_card.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/oauth_card.py index a59ecf843..7930ca971 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/oauth_card.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/oauth_card.py @@ -2,14 +2,16 @@ # Licensed under the MIT License. from typing import Optional +from .attachment import Attachment +from .card import Card from .card_action import CardAction -from .agents_model import AgentsModel +from .content_types import ContentTypes from .token_exchange_resource import TokenExchangeResource from .token_post_resource import TokenPostResource from ._type_aliases import NonEmptyString -class OAuthCard(AgentsModel): +class OAuthCard(Card): """A card representing a request to perform a sign in via OAuth. :param text: Text for signin request @@ -25,3 +27,11 @@ class OAuthCard(AgentsModel): buttons: list[CardAction] = None token_exchange_resource: Optional[TokenExchangeResource] = None token_post_resource: TokenPostResource = None + + def to_attachment(self) -> Attachment: + """ + Creates a new Attachment that wraps this card. + + :returns: The generated attachment. + """ + return Attachment(content_type=ContentTypes.oauth_card, content=self) diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/receipt_card.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/receipt_card.py index 6a80cd42f..3af2bd8f1 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/receipt_card.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/receipt_card.py @@ -1,14 +1,16 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. +from .attachment import Attachment +from .card import Card +from .content_types import ContentTypes from .fact import Fact from .receipt_item import ReceiptItem from .card_action import CardAction -from .agents_model import AgentsModel from ._type_aliases import NonEmptyString -class ReceiptCard(AgentsModel): +class ReceiptCard(Card): """A receipt card. :param title: Title of the card @@ -37,3 +39,44 @@ class ReceiptCard(AgentsModel): tax: NonEmptyString = None vat: NonEmptyString = None buttons: list[CardAction] = None + + def to_attachment(self) -> Attachment: + """ + Creates a new Attachment that wraps this card. + + :returns: The generated attachment. + """ + return Attachment(content_type=ContentTypes.receipt_card, content=self) + + def add_fact(self, fact: Fact) -> "ReceiptCard": + """ + Adds a fact and returns this card. + + :param fact: The fact to add. + :returns: This card, to allow for method chaining. + """ + self.facts = self.facts or [] + self.facts.append(fact) + return self + + def add_item(self, item: ReceiptItem) -> "ReceiptCard": + """ + Adds a receipt item and returns this card. + + :param item: The receipt item to add. + :returns: This card, to allow for method chaining. + """ + self.items = self.items or [] + self.items.append(item) + return self + + def add_button(self, button: CardAction) -> "ReceiptCard": + """ + Adds a button and returns this card. + + :param button: The button to add. + :returns: This card, to allow for method chaining. + """ + self.buttons = self.buttons or [] + self.buttons.append(button) + return self diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/signin_card.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/signin_card.py index 31242699b..3f46eb496 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/signin_card.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/signin_card.py @@ -1,12 +1,14 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. +from .attachment import Attachment +from .card import Card from .card_action import CardAction -from .agents_model import AgentsModel +from .content_types import ContentTypes from ._type_aliases import NonEmptyString -class SigninCard(AgentsModel): +class SigninCard(Card): """A card representing a request to sign in. :param text: Text for signin request @@ -17,3 +19,11 @@ class SigninCard(AgentsModel): text: str = None buttons: list[CardAction] = None + + def to_attachment(self) -> Attachment: + """ + Creates a new Attachment that wraps this card. + + :returns: The generated attachment. + """ + return Attachment(content_type=ContentTypes.signin_card, content=self) diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/thumbnail_card.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/thumbnail_card.py index 028446f91..cddbd19a2 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/thumbnail_card.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/thumbnail_card.py @@ -1,13 +1,18 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. +from typing import overload + +from .action_types import ActionTypes +from .attachment import Attachment +from .card import Card from .card_image import CardImage from .card_action import CardAction -from .agents_model import AgentsModel +from .content_types import ContentTypes from ._type_aliases import NonEmptyString -class ThumbnailCard(AgentsModel): +class ThumbnailCard(Card): """A thumbnail card (card with a single, small thumbnail image). :param title: Title of the card @@ -31,3 +36,108 @@ class ThumbnailCard(AgentsModel): images: list[CardImage] = None buttons: list[CardAction] = None tap: CardAction = None + + def to_attachment(self) -> Attachment: + """ + Creates a new Attachment that wraps this card. + + :returns: The generated attachment. + """ + return Attachment(content_type=ContentTypes.thumbnail_card, content=self) + + @overload + def add_image(self, image: CardImage) -> "ThumbnailCard": + ... + + @overload + def add_image( + self, *, url: NonEmptyString, alt: NonEmptyString | None = None + ) -> "ThumbnailCard": + ... + + def add_image( + self, + image: CardImage | None = None, + *, + url: NonEmptyString | None = None, + alt: NonEmptyString | None = None, + ) -> "ThumbnailCard": + """ + Adds an image and returns this card. + + :param image: The image to add. + :param url: The URL of the image, used when no image instance is provided. + :param alt: The alternate text for the image built from a URL. + :returns: This card, to allow for method chaining. + """ + if image is None: + if url is None: + raise ValueError( + "Either provide a CardImage instance or the url parameter." + ) + if alt is None: + image = CardImage(url=url) + else: + image = CardImage(url=url, alt=alt) + + self.images = self.images or [] + self.images.append(image) + return self + + @overload + def add_button(self, button: CardAction) -> "ThumbnailCard": + ... + + @overload + def add_button( + self, + *, + title: NonEmptyString, + type: NonEmptyString = ActionTypes.im_back, + value: object | None = None, + ) -> "ThumbnailCard": + ... + + def add_button( + self, + button: CardAction | None = None, + *, + title: NonEmptyString | None = None, + type: NonEmptyString = ActionTypes.im_back, + value: object | None = None, + ) -> "ThumbnailCard": + """ + Adds a button and returns this card. + + :param button: The button to add. + :param title: The title of the button, used when no button instance is provided. + :param type: The action type of the button built from a title. + :param value: The value of the button built from a title. Defaults to the title. + :returns: This card, to allow for method chaining. + """ + if button is None: + if title is None: + raise ValueError( + "Either provide a CardAction instance or the title parameter." + ) + button = CardAction( + type=type, title=title, value=value if value is not None else title + ) + + self.buttons = self.buttons or [] + self.buttons.append(button) + return self + + def add_buttons(self, *buttons: CardAction) -> "ThumbnailCard": + """ + Adds one or more buttons and returns this card. + + :param buttons: The buttons to add. + :returns: This card, to allow for method chaining. + """ + if not buttons: + return self + + self.buttons = self.buttons or [] + self.buttons.extend(buttons) + return self diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/video_card.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/video_card.py index 5a1fd3923..d5cec2914 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/video_card.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/video_card.py @@ -1,14 +1,18 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. +from typing import overload + +from .attachment import Attachment +from .card import Card +from .content_types import ContentTypes from .thumbnail_url import ThumbnailUrl from .media_url import MediaUrl from .card_action import CardAction -from .agents_model import AgentsModel from ._type_aliases import NonEmptyString -class VideoCard(AgentsModel): +class VideoCard(Card): """Video card. :param title: Title of this card @@ -55,3 +59,61 @@ class VideoCard(AgentsModel): aspect: NonEmptyString = None duration: NonEmptyString = None value: object = None + + def to_attachment(self) -> Attachment: + """ + Creates a new Attachment that wraps this card. + + :returns: The generated attachment. + """ + return Attachment(content_type=ContentTypes.video_card, content=self) + + @overload + def add_media(self, media: MediaUrl) -> "VideoCard": + ... + + @overload + def add_media( + self, *, url: NonEmptyString, profile: NonEmptyString | None = None + ) -> "VideoCard": + ... + + def add_media( + self, + media: MediaUrl | None = None, + *, + url: NonEmptyString | None = None, + profile: NonEmptyString | None = None, + ) -> "VideoCard": + """ + Adds a media URL and returns this card. + + :param media: The media URL to add. + :param url: The URL of the media, used when no media instance is provided. + :param profile: The profile of the media built from a URL. + :returns: This card, to allow for method chaining. + """ + if media is None: + if url is None: + raise ValueError( + "Either provide a MediaUrl instance or the url parameter." + ) + if profile is None: + media = MediaUrl(url=url) + else: + media = MediaUrl(url=url, profile=profile) + + self.media = self.media or [] + self.media.append(media) + return self + + def add_button(self, button: CardAction) -> "VideoCard": + """ + Adds a button and returns this card. + + :param button: The button to add. + :returns: This card, to allow for method chaining. + """ + self.buttons = self.buttons or [] + self.buttons.append(button) + return self From 0c49d786e222887e59a7c969060ee32db1792cf7 Mon Sep 17 00:00:00 2001 From: kylerohn-msft Date: Wed, 22 Jul 2026 12:08:58 -0700 Subject: [PATCH 04/15] transition add_* logic to use pick_model helper --- .../microsoft_agents/activity/animation_card.py | 6 ++---- .../microsoft_agents/activity/audio_card.py | 7 ++----- .../microsoft_agents/activity/basic_card.py | 6 ++---- .../microsoft_agents/activity/hero_card.py | 6 ++---- .../microsoft_agents/activity/media_card.py | 6 ++---- .../microsoft_agents/activity/thumbnail_card.py | 6 ++---- .../microsoft_agents/activity/video_card.py | 6 ++---- 7 files changed, 14 insertions(+), 29 deletions(-) diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/animation_card.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/animation_card.py index 1bcb2015d..c4c857984 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/animation_card.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/animation_card.py @@ -9,6 +9,7 @@ from .content_types import ContentTypes from .media_url import MediaUrl from .card_action import CardAction +from ._model_utils import pick_model from ._type_aliases import NonEmptyString @@ -98,10 +99,7 @@ def add_media( raise ValueError( "Either provide a MediaUrl instance or the url parameter." ) - if profile is None: - media = MediaUrl(url=url) - else: - media = MediaUrl(url=url, profile=profile) + media = pick_model(MediaUrl, url=url, profile=profile) self.media = self.media or [] self.media.append(media) diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/audio_card.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/audio_card.py index 14b36193e..d4b053cab 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/audio_card.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/audio_card.py @@ -9,6 +9,7 @@ from .thumbnail_url import ThumbnailUrl from .media_url import MediaUrl from .card_action import CardAction +from ._model_utils import pick_model from ._type_aliases import NonEmptyString @@ -98,11 +99,7 @@ def add_media( raise ValueError( "Either provide a MediaUrl instance or the url parameter." ) - media = ( - MediaUrl(url=url) - if profile is None - else MediaUrl(url=url, profile=profile) - ) + media = pick_model(MediaUrl, url=url, profile=profile) self.media = self.media or [] self.media.append(media) diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/basic_card.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/basic_card.py index fda3f7aa8..9326ade8f 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/basic_card.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/basic_card.py @@ -9,6 +9,7 @@ from .agents_model import AgentsModel from .card_image import CardImage from .card_action import CardAction +from ._model_utils import pick_model from ._type_aliases import NonEmptyString @@ -75,10 +76,7 @@ def add_image( raise ValueError( "Either provide a CardImage instance or the url parameter." ) - if alt is None: - image = CardImage(url=url) - else: - image = CardImage(url=url, alt=alt) + image = pick_model(CardImage, url=url, alt=alt) self.images = self.images or [] self.images.append(image) diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/hero_card.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/hero_card.py index af7ffefe5..2c8f8a88e 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/hero_card.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/hero_card.py @@ -9,6 +9,7 @@ from .card_action import CardAction from .card_image import CardImage from .content_types import ContentTypes +from ._model_utils import pick_model from ._type_aliases import NonEmptyString @@ -75,10 +76,7 @@ def add_image( raise ValueError( "Either provide a CardImage instance or the url parameter." ) - if alt is None: - image = CardImage(url=url) - else: - image = CardImage(url=url, alt=alt) + image = pick_model(CardImage, url=url, alt=alt) self.images = self.images or [] self.images.append(image) diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/media_card.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/media_card.py index 624e8c458..b41604a7b 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/media_card.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/media_card.py @@ -9,6 +9,7 @@ from .media_url import MediaUrl from .card_action import CardAction from .agents_model import AgentsModel +from ._model_utils import pick_model from ._type_aliases import NonEmptyString @@ -100,10 +101,7 @@ def add_media( raise ValueError( "Either provide a MediaUrl instance or the url parameter." ) - if profile is None: - media = MediaUrl(url=url) - else: - media = MediaUrl(url=url, profile=profile) + media = pick_model(MediaUrl, url=url, profile=profile) self.media = self.media or [] self.media.append(media) diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/thumbnail_card.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/thumbnail_card.py index cddbd19a2..22acf6e4e 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/thumbnail_card.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/thumbnail_card.py @@ -9,6 +9,7 @@ from .card_image import CardImage from .card_action import CardAction from .content_types import ContentTypes +from ._model_utils import pick_model from ._type_aliases import NonEmptyString @@ -75,10 +76,7 @@ def add_image( raise ValueError( "Either provide a CardImage instance or the url parameter." ) - if alt is None: - image = CardImage(url=url) - else: - image = CardImage(url=url, alt=alt) + image = pick_model(CardImage, url=url, alt=alt) self.images = self.images or [] self.images.append(image) diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/video_card.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/video_card.py index d5cec2914..3ef90a5e5 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/video_card.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/video_card.py @@ -9,6 +9,7 @@ from .thumbnail_url import ThumbnailUrl from .media_url import MediaUrl from .card_action import CardAction +from ._model_utils import pick_model from ._type_aliases import NonEmptyString @@ -98,10 +99,7 @@ def add_media( raise ValueError( "Either provide a MediaUrl instance or the url parameter." ) - if profile is None: - media = MediaUrl(url=url) - else: - media = MediaUrl(url=url, profile=profile) + media = pick_model(MediaUrl, url=url, profile=profile) self.media = self.media or [] self.media.append(media) From 28e59badfb8a55083b317eb8f56b20e44e794e9c Mon Sep 17 00:00:00 2001 From: kylerohn-msft Date: Wed, 22 Jul 2026 13:57:29 -0700 Subject: [PATCH 05/15] fix tests --- tests/hosting_core/test_turn_context.py | 8 +++--- tests/hosting_dialogs/test_choice_prompt.py | 17 +++---------- tests/hosting_dialogs/test_oauth_prompt.py | 27 +++++---------------- 3 files changed, 14 insertions(+), 38 deletions(-) diff --git a/tests/hosting_core/test_turn_context.py b/tests/hosting_core/test_turn_context.py index 6f2a34d59..23433c7ed 100644 --- a/tests/hosting_core/test_turn_context.py +++ b/tests/hosting_core/test_turn_context.py @@ -455,8 +455,8 @@ def test_should_remove_at_mention_from_activity(self): text = TurnContext.remove_recipient_mention(activity) - assert text == " test activity" - assert activity.text == " test activity" + assert text == "test activity" + assert activity.text == "test activity" def test_should_remove_at_mention_with_regex_characters(self): activity = Activity( @@ -475,8 +475,8 @@ def test_should_remove_at_mention_with_regex_characters(self): text = TurnContext.remove_recipient_mention(activity) - assert text == " test activity" - assert activity.text == " test activity" + assert text == "test activity" + assert activity.text == "test activity" def test_should_remove_custom_mention_from_activity(self): activity = Activity( diff --git a/tests/hosting_dialogs/test_choice_prompt.py b/tests/hosting_dialogs/test_choice_prompt.py index 990ab6860..3182217e5 100644 --- a/tests/hosting_dialogs/test_choice_prompt.py +++ b/tests/hosting_dialogs/test_choice_prompt.py @@ -26,7 +26,7 @@ PromptOptions, PromptValidatorContext, ) -from microsoft_agents.activity import Activity, ActivityTypes +from microsoft_agents.activity import Activity, ActivityTypes, ContentTypes from tests.hosting_dialogs.helpers import DialogTestAdapter _color_choices: List[Choice] = [ @@ -833,10 +833,7 @@ def assert_expected_activity( activity: Activity, description ): # pylint: disable=unused-argument assert len(activity.attachments) == 1 - assert ( - activity.attachments[0].content_type - == CardFactory.content_types.hero_card - ) + assert activity.attachments[0].content_type == ContentTypes.hero_card assert activity.attachments[0].content.text == "Please choose a size." return True @@ -892,14 +889,8 @@ def assert_expected_activity( activity: Activity, description ): # pylint: disable=unused-argument assert len(activity.attachments) == 2 - assert ( - activity.attachments[0].content_type - == CardFactory.content_types.adaptive_card - ) - assert ( - activity.attachments[1].content_type - == CardFactory.content_types.hero_card - ) + assert activity.attachments[0].content_type == ContentTypes.adaptive_card + assert activity.attachments[1].content_type == ContentTypes.hero_card return True convo_state = ConversationState(MemoryStorage()) diff --git a/tests/hosting_dialogs/test_oauth_prompt.py b/tests/hosting_dialogs/test_oauth_prompt.py index 331abe6a5..4297019f7 100644 --- a/tests/hosting_dialogs/test_oauth_prompt.py +++ b/tests/hosting_dialogs/test_oauth_prompt.py @@ -8,12 +8,12 @@ ActivityTypes, ChannelAccount, ConversationAccount, + ContentTypes, InputHints, SignInConstants, TokenResponse, ) from microsoft_agents.hosting.core import ( - CardFactory, ConversationState, MemoryStorage, TurnContext, @@ -76,10 +76,7 @@ async def callback_handler(turn_context: TurnContext): async def inspector(activity: Activity, description: str = None): assert len(activity.attachments) == 1 - assert ( - activity.attachments[0].content_type - == CardFactory.content_types.oauth_card - ) + assert activity.attachments[0].content_type == ContentTypes.oauth_card adapter.add_user_token( connection_name, activity.channel_id, activity.recipient.id, token @@ -133,10 +130,7 @@ async def exec_test(turn_context: TurnContext): def inspector(activity: Activity, description: str = None): assert len(activity.attachments) == 1 - assert ( - activity.attachments[0].content_type - == CardFactory.content_types.oauth_card - ) + assert activity.attachments[0].content_type == ContentTypes.oauth_card adapter.add_user_token( connection_name, activity.channel_id, @@ -184,10 +178,7 @@ async def exec_test(turn_context: TurnContext): def inspector(activity: Activity, description: str = None): assert len(activity.attachments) == 1 - assert ( - activity.attachments[0].content_type - == CardFactory.content_types.oauth_card - ) + assert activity.attachments[0].content_type == ContentTypes.oauth_card step1 = await adapter.send(magic_code) await step1.assert_reply(inspector) @@ -264,10 +255,7 @@ async def exec_test(turn_context: TurnContext): def inspector(activity: Activity, description: str = None): assert len(activity.attachments) == 1 - assert ( - activity.attachments[0].content_type - == CardFactory.content_types.oauth_card - ) + assert activity.attachments[0].content_type == ContentTypes.oauth_card adapter.add_user_token( connection_name, activity.channel_id, @@ -345,10 +333,7 @@ async def exec_test(turn_context: TurnContext): def inspector(activity_: Activity, description: str = None): assert len(activity_.attachments) == 1 - assert ( - activity_.attachments[0].content_type - == CardFactory.content_types.oauth_card - ) + assert activity_.attachments[0].content_type == ContentTypes.oauth_card adapter.add_user_token( connection_name, activity_.channel_id, From 2280baf0709027ebfd2bae591302b63c40700088 Mon Sep 17 00:00:00 2001 From: kylerohn-msft Date: Wed, 22 Jul 2026 14:23:52 -0700 Subject: [PATCH 06/15] clean up content_types field changes --- .../microsoft_agents/hosting/core/card_factory.py | 14 ++++++++++++++ .../hosting/dialogs/prompts/oauth_prompt.py | 7 +++---- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/card_factory.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/card_factory.py index 04687084c..266e221d1 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/card_factory.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/card_factory.py @@ -1,6 +1,9 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. +from typing import TypeVar +from typing_extensions import deprecated + from microsoft_agents.activity import ( AnimationCard, Attachment, @@ -14,8 +17,12 @@ VideoCard, ) +ContentTypesT = TypeVar("ContentTypesT", bound=ContentTypes) + class CardFactory: + _content_types: type[ContentTypes] = ContentTypes + @staticmethod def adaptive_card(card: dict) -> Attachment: """ @@ -160,3 +167,10 @@ def video_card(card: VideoCard) -> Attachment: ) return Attachment(content_type=ContentTypes.video_card, content=card) + + @property + @deprecated( + "CardFactory.content_types is being relocated to microsoft_agents.activity.ContentTypes." + ) + def content_types(self) -> type[ContentTypes]: + return self._content_types diff --git a/libraries/microsoft-agents-hosting-dialogs/microsoft_agents/hosting/dialogs/prompts/oauth_prompt.py b/libraries/microsoft-agents-hosting-dialogs/microsoft_agents/hosting/dialogs/prompts/oauth_prompt.py index aade7e978..ea290dec0 100644 --- a/libraries/microsoft-agents-hosting-dialogs/microsoft_agents/hosting/dialogs/prompts/oauth_prompt.py +++ b/libraries/microsoft-agents-hosting-dialogs/microsoft_agents/hosting/dialogs/prompts/oauth_prompt.py @@ -12,13 +12,13 @@ ActivityTypes, ActionTypes, CardAction, + ContentTypes, InputHints, SigninCard, SignInConstants, OAuthCard, TokenResponse, TokenExchangeInvokeRequest, - TokenExchangeInvokeResponse, InvokeResponse, ) from microsoft_agents.hosting.core import ( @@ -37,7 +37,6 @@ _FlowStateTag, _FlowResponse, ) -from opentelemetry import context from ..dialog import Dialog from ..dialog_context import DialogContext @@ -282,7 +281,7 @@ async def _send_oauth_card( if OAuthPrompt._channel_suppports_oauth_card(context.activity.channel_id or ""): if not any( - att.content_type == CardFactory.content_types.oauth_card + att.content_type == ContentTypes.oauth_card for att in prompt.attachments ): card_action_type = ActionTypes.signin @@ -344,7 +343,7 @@ async def _send_oauth_card( ) else: if not any( - att.content_type == CardFactory.content_types.signin_card + att.content_type == ContentTypes.signin_card for att in prompt.attachments ): if not hasattr(context.adapter, "get_oauth_sign_in_link"): From c4578c41f550351f714f7b5e548ffeba9ce90698 Mon Sep 17 00:00:00 2001 From: kylerohn-msft Date: Wed, 22 Jul 2026 14:39:54 -0700 Subject: [PATCH 07/15] create AdaptiveCardCard model --- .../microsoft_agents/activity/__init__.py | 2 + .../activity/adaptive_card_card.py | 43 +++++++++++++++++++ 2 files changed, 45 insertions(+) create mode 100644 libraries/microsoft-agents-activity/microsoft_agents/activity/adaptive_card_card.py diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/__init__.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/__init__.py index 48f9a9d5e..cea13d7c1 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/__init__.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/__init__.py @@ -6,6 +6,7 @@ from .activity import Activity from .activity_event_names import ActivityEventNames from .activity_types import ActivityTypes +from .adaptive_card_card import AdaptiveCardCard from .adaptive_card_invoke_action import AdaptiveCardInvokeAction from .adaptive_card_invoke_response import AdaptiveCardInvokeResponse from .adaptive_card_invoke_value import AdaptiveCardInvokeValue @@ -111,6 +112,7 @@ "Activity", "ActionTypes", "ActivityEventNames", + "AdaptiveCardCard", "AdaptiveCardInvokeAction", "AdaptiveCardInvokeResponse", "AdaptiveCardInvokeValue", diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/adaptive_card_card.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/adaptive_card_card.py new file mode 100644 index 000000000..478501f7c --- /dev/null +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/adaptive_card_card.py @@ -0,0 +1,43 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import json + +from .attachment import Attachment +from .card import Card +from .content_types import ContentTypes +from ._type_aliases import NonEmptyString + + +class AdaptiveCardCard(Card): + """A card that carries a raw Adaptive Card JSON payload. + + The JSON is stored verbatim in :attr:`content` and unpacked into the attachment as nested + JSON when the activity is serialized. + + :param content: The Adaptive Card content as a JSON string. + :type content: str + """ + + content: NonEmptyString = None + + def __init__(self, json_content: str): + """ + Initializes a new instance from an Adaptive Card JSON string. + + :param json_content: The Adaptive Card content as a JSON string. + """ + self.content = json_content + + def to_attachment(self) -> Attachment: + """ + Creates a new Attachment that wraps this card. + + The stored JSON string is parsed so that it is embedded as nested JSON in the attachment. + + :returns: The generated attachment. + """ + return Attachment( + content_type=ContentTypes.adaptive_card, + content=json.loads(self.content), + ) From 9bf98ccc9828510f58ae9bbe793423a56328745e Mon Sep 17 00:00:00 2001 From: kylerohn-msft Date: Wed, 22 Jul 2026 14:47:58 -0700 Subject: [PATCH 08/15] add tests --- tests/activity/test_activity_builders.py | 18 +++ tests/activity/test_card_builders.py | 140 +++++++++++++++++++++++ 2 files changed, 158 insertions(+) create mode 100644 tests/activity/test_card_builders.py diff --git a/tests/activity/test_activity_builders.py b/tests/activity/test_activity_builders.py index b340d7836..11a5cdbd2 100644 --- a/tests/activity/test_activity_builders.py +++ b/tests/activity/test_activity_builders.py @@ -89,6 +89,24 @@ def test_add_entity_adds_entities(self): assert activity.entities[0].type == "myType" +class TestSuggestedActionsBuilders: + def test_suggested_actions_fluent_adders(self): + suggested = ( + SuggestedActions() + .add_recipients("r1", "r2") + .add_action(CardAction(type="imBack", title="a")) + .add_actions( + CardAction(type="imBack", title="b"), + CardAction(type="imBack", title="c"), + ) + ) + + assert suggested.to == ["r1", "r2"] + assert len(suggested.actions) == 3 + assert suggested.actions[0].title == "a" + assert suggested.actions[2].title == "c" + + class TestActivityMentions: def test_add_mention_adds_entity_and_text(self): account = ChannelAccount(id="u1", name="User One") diff --git a/tests/activity/test_card_builders.py b/tests/activity/test_card_builders.py new file mode 100644 index 000000000..791dd0457 --- /dev/null +++ b/tests/activity/test_card_builders.py @@ -0,0 +1,140 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from microsoft_agents.activity import ( + ActionTypes, + AdaptiveCardCard, + AnimationCard, + AudioCard, + BasicCard, + CardAction, + CardImage, + ContentTypes, + Fact, + HeroCard, + MediaCard, + MediaUrl, + ReceiptCard, + ReceiptItem, + ThumbnailCard, + VideoCard, +) + + +class TestHeroCardBuilders: + def test_hero_card_fluent_builders(self): + card = ( + HeroCard( + title="t", + subtitle="s", + text="txt", + tap=CardAction(type=ActionTypes.open_url, title="tap"), + ) + .add_image(url="https://img", alt="alt") + .add_image(CardImage(url="https://img2")) + .add_button(title="Yes", value="yes") + .add_button(CardAction(type=ActionTypes.post_back, title="No")) + .add_buttons( + CardAction(type=ActionTypes.im_back, title="A"), + CardAction(type=ActionTypes.im_back, title="B"), + ) + ) + + assert card.title == "t" + assert card.subtitle == "s" + assert card.text == "txt" + assert card.tap.type == ActionTypes.open_url + assert len(card.images) == 2 + assert card.images[0].url == "https://img" + assert card.images[0].alt == "alt" + assert len(card.buttons) == 4 + assert card.buttons[0].title == "Yes" + assert card.buttons[0].value == "yes" + + +class TestThumbnailAndBasicCardBuilders: + def test_thumbnail_and_basic_card_builders(self): + thumb = ThumbnailCard(title="t").add_image(url="u").add_button(title="b") + assert thumb.title == "t" + assert len(thumb.images) == 1 + assert len(thumb.buttons) == 1 + + basic = ( + BasicCard(text="x") + .add_image(CardImage(url="u")) + .add_button(CardAction(type=ActionTypes.im_back, title="b")) + ) + assert basic.text == "x" + assert len(basic.images) == 1 + assert len(basic.buttons) == 1 + + +class TestMediaCardBuilders: + def test_media_card_builders_add_media_and_buttons(self): + animation = ( + AnimationCard(title="a") + .add_media(url="https://m") + .add_button(CardAction(type=ActionTypes.im_back, title="b")) + ) + assert animation.title == "a" + assert len(animation.media) == 1 + assert animation.media[0].url == "https://m" + assert len(animation.buttons) == 1 + + audio = AudioCard().add_media(MediaUrl(url="https://a")) + assert len(audio.media) == 1 + + video = VideoCard().add_media(url="https://v", profile="profile") + assert video.media[0].profile == "profile" + + media = MediaCard(text="m").add_media(url="https://x") + assert media.text == "m" + assert len(media.media) == 1 + + +class TestReceiptCardBuilders: + def test_receipt_card_builders(self): + receipt = ( + ReceiptCard( + title="r", + total="$10", + tax="$1", + tap=CardAction(type=ActionTypes.open_url, title="tap"), + ) + .add_fact(Fact(key="key", value="value")) + .add_item(ReceiptItem(title="item")) + .add_button(CardAction(type=ActionTypes.im_back, title="b")) + ) + + assert receipt.title == "r" + assert receipt.total == "$10" + assert receipt.tax == "$1" + assert receipt.tap.type == ActionTypes.open_url + assert len(receipt.facts) == 1 + assert len(receipt.items) == 1 + assert len(receipt.buttons) == 1 + + +class TestAdaptiveCardCardBuilders: + def test_adaptive_card_card_from_json_to_attachment(self): + json_content = '{"type":"AdaptiveCard","version":"1.4"}' + card = AdaptiveCardCard(json_content) + + assert card.content == json_content + + attachment = card.to_attachment() + assert attachment.content_type == ContentTypes.adaptive_card + assert attachment.content == {"type": "AdaptiveCard", "version": "1.4"} + + def test_adaptive_card_card_serializes_content_as_nested_json(self): + json_content = '{"type":"AdaptiveCard","version":"1.4"}' + activity = AdaptiveCardCard(json_content).to_message() + + data = activity.model_dump(mode="json", by_alias=True, exclude_none=True) + + # Content is unpacked into nested JSON (an object), not an escaped string. + assert data["attachments"][0]["content"] == { + "type": "AdaptiveCard", + "version": "1.4", + } + assert data["attachments"][0]["contentType"] == ContentTypes.adaptive_card From f19b7c6fea4d29bfe7b9d664280edfed1652d7e0 Mon Sep 17 00:00:00 2001 From: kylerohn-msft Date: Wed, 22 Jul 2026 15:11:47 -0700 Subject: [PATCH 09/15] format --- .../microsoft_agents/activity/animation_card.py | 6 ++---- .../microsoft_agents/activity/audio_card.py | 6 ++---- .../microsoft_agents/activity/basic_card.py | 12 ++++-------- .../microsoft_agents/activity/hero_card.py | 12 ++++-------- .../microsoft_agents/activity/media_card.py | 6 ++---- .../microsoft_agents/activity/thumbnail_card.py | 12 ++++-------- .../microsoft_agents/activity/video_card.py | 6 ++---- 7 files changed, 20 insertions(+), 40 deletions(-) diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/animation_card.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/animation_card.py index c4c857984..0617998ca 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/animation_card.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/animation_card.py @@ -70,14 +70,12 @@ def to_attachment(self) -> Attachment: return Attachment(content_type=ContentTypes.animation_card, content=self) @overload - def add_media(self, media: MediaUrl) -> "AnimationCard": - ... + def add_media(self, media: MediaUrl) -> "AnimationCard": ... @overload def add_media( self, *, url: NonEmptyString, profile: NonEmptyString | None = None - ) -> "AnimationCard": - ... + ) -> "AnimationCard": ... def add_media( self, diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/audio_card.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/audio_card.py index d4b053cab..578d87f0e 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/audio_card.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/audio_card.py @@ -70,14 +70,12 @@ def to_attachment(self) -> Attachment: return Attachment(content_type=ContentTypes.audio_card, content=self) @overload - def add_media(self, media: MediaUrl) -> "AudioCard": - ... + def add_media(self, media: MediaUrl) -> "AudioCard": ... @overload def add_media( self, *, url: NonEmptyString, profile: NonEmptyString | None = None - ) -> "AudioCard": - ... + ) -> "AudioCard": ... def add_media( self, diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/basic_card.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/basic_card.py index 9326ade8f..fbb0d05e5 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/basic_card.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/basic_card.py @@ -47,14 +47,12 @@ class BasicCard(AgentsModel): tap: CardAction = None @overload - def add_image(self, image: CardImage) -> "BasicCard": - ... + def add_image(self, image: CardImage) -> "BasicCard": ... @overload def add_image( self, *, url: NonEmptyString, alt: NonEmptyString | None = None - ) -> "BasicCard": - ... + ) -> "BasicCard": ... def add_image( self, @@ -83,8 +81,7 @@ def add_image( return self @overload - def add_button(self, button: CardAction) -> "BasicCard": - ... + def add_button(self, button: CardAction) -> "BasicCard": ... @overload def add_button( @@ -93,8 +90,7 @@ def add_button( title: NonEmptyString, type: NonEmptyString = ActionTypes.im_back, value: object | None = None, - ) -> "BasicCard": - ... + ) -> "BasicCard": ... def add_button( self, diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/hero_card.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/hero_card.py index 2c8f8a88e..41c47ec3f 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/hero_card.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/hero_card.py @@ -47,14 +47,12 @@ def to_attachment(self) -> Attachment: return Attachment(content_type=ContentTypes.hero_card, content=self) @overload - def add_image(self, image: CardImage) -> "HeroCard": - ... + def add_image(self, image: CardImage) -> "HeroCard": ... @overload def add_image( self, *, url: NonEmptyString, alt: NonEmptyString | None = None - ) -> "HeroCard": - ... + ) -> "HeroCard": ... def add_image( self, @@ -83,8 +81,7 @@ def add_image( return self @overload - def add_button(self, button: CardAction) -> "HeroCard": - ... + def add_button(self, button: CardAction) -> "HeroCard": ... @overload def add_button( @@ -93,8 +90,7 @@ def add_button( title: NonEmptyString, type: NonEmptyString = ActionTypes.im_back, value: object | None = None, - ) -> "HeroCard": - ... + ) -> "HeroCard": ... def add_button( self, diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/media_card.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/media_card.py index b41604a7b..2fa8a02b6 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/media_card.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/media_card.py @@ -72,14 +72,12 @@ class MediaCard(AgentsModel): value: object = None @overload - def add_media(self, media: MediaUrl) -> "MediaCard": - ... + def add_media(self, media: MediaUrl) -> "MediaCard": ... @overload def add_media( self, *, url: NonEmptyString, profile: NonEmptyString | None = None - ) -> "MediaCard": - ... + ) -> "MediaCard": ... def add_media( self, diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/thumbnail_card.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/thumbnail_card.py index 22acf6e4e..b45fb9843 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/thumbnail_card.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/thumbnail_card.py @@ -47,14 +47,12 @@ def to_attachment(self) -> Attachment: return Attachment(content_type=ContentTypes.thumbnail_card, content=self) @overload - def add_image(self, image: CardImage) -> "ThumbnailCard": - ... + def add_image(self, image: CardImage) -> "ThumbnailCard": ... @overload def add_image( self, *, url: NonEmptyString, alt: NonEmptyString | None = None - ) -> "ThumbnailCard": - ... + ) -> "ThumbnailCard": ... def add_image( self, @@ -83,8 +81,7 @@ def add_image( return self @overload - def add_button(self, button: CardAction) -> "ThumbnailCard": - ... + def add_button(self, button: CardAction) -> "ThumbnailCard": ... @overload def add_button( @@ -93,8 +90,7 @@ def add_button( title: NonEmptyString, type: NonEmptyString = ActionTypes.im_back, value: object | None = None, - ) -> "ThumbnailCard": - ... + ) -> "ThumbnailCard": ... def add_button( self, diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/video_card.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/video_card.py index 3ef90a5e5..007238654 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/video_card.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/video_card.py @@ -70,14 +70,12 @@ def to_attachment(self) -> Attachment: return Attachment(content_type=ContentTypes.video_card, content=self) @overload - def add_media(self, media: MediaUrl) -> "VideoCard": - ... + def add_media(self, media: MediaUrl) -> "VideoCard": ... @overload def add_media( self, *, url: NonEmptyString, profile: NonEmptyString | None = None - ) -> "VideoCard": - ... + ) -> "VideoCard": ... def add_media( self, From 614d45aa990db8bc211a966c62d75ec67e39405a Mon Sep 17 00:00:00 2001 From: kylerohn-msft Date: Wed, 22 Jul 2026 16:03:57 -0700 Subject: [PATCH 10/15] utilize SkipNone for optional fields and actually use AdaptiveCardCard as a pydantic model --- .../microsoft_agents/activity/adaptive_card_card.py | 8 -------- .../microsoft_agents/activity/animation_card.py | 4 ++-- .../microsoft_agents/activity/audio_card.py | 4 ++-- .../microsoft_agents/activity/basic_card.py | 4 ++-- .../microsoft_agents/activity/hero_card.py | 4 ++-- .../microsoft_agents/activity/media_card.py | 4 ++-- .../microsoft_agents/activity/suggested_actions.py | 2 +- .../microsoft_agents/activity/thumbnail_card.py | 12 ++++++------ .../microsoft_agents/activity/video_card.py | 4 ++-- tests/activity/test_card_builders.py | 8 +++++--- 10 files changed, 24 insertions(+), 30 deletions(-) diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/adaptive_card_card.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/adaptive_card_card.py index 478501f7c..c5f9bd2b3 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/adaptive_card_card.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/adaptive_card_card.py @@ -21,14 +21,6 @@ class AdaptiveCardCard(Card): content: NonEmptyString = None - def __init__(self, json_content: str): - """ - Initializes a new instance from an Adaptive Card JSON string. - - :param json_content: The Adaptive Card content as a JSON string. - """ - self.content = json_content - def to_attachment(self) -> Attachment: """ Creates a new Attachment that wraps this card. diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/animation_card.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/animation_card.py index 0617998ca..cc2868068 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/animation_card.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/animation_card.py @@ -9,7 +9,7 @@ from .content_types import ContentTypes from .media_url import MediaUrl from .card_action import CardAction -from ._model_utils import pick_model +from ._model_utils import pick_model, SkipNone from ._type_aliases import NonEmptyString @@ -97,7 +97,7 @@ def add_media( raise ValueError( "Either provide a MediaUrl instance or the url parameter." ) - media = pick_model(MediaUrl, url=url, profile=profile) + media = pick_model(MediaUrl, url=url, profile=SkipNone(profile)) self.media = self.media or [] self.media.append(media) diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/audio_card.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/audio_card.py index 578d87f0e..4bceb534b 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/audio_card.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/audio_card.py @@ -9,7 +9,7 @@ from .thumbnail_url import ThumbnailUrl from .media_url import MediaUrl from .card_action import CardAction -from ._model_utils import pick_model +from ._model_utils import pick_model, SkipNone from ._type_aliases import NonEmptyString @@ -97,7 +97,7 @@ def add_media( raise ValueError( "Either provide a MediaUrl instance or the url parameter." ) - media = pick_model(MediaUrl, url=url, profile=profile) + media = pick_model(MediaUrl, url=url, profile=SkipNone(profile)) self.media = self.media or [] self.media.append(media) diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/basic_card.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/basic_card.py index fbb0d05e5..24565fa80 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/basic_card.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/basic_card.py @@ -9,7 +9,7 @@ from .agents_model import AgentsModel from .card_image import CardImage from .card_action import CardAction -from ._model_utils import pick_model +from ._model_utils import pick_model, SkipNone from ._type_aliases import NonEmptyString @@ -74,7 +74,7 @@ def add_image( raise ValueError( "Either provide a CardImage instance or the url parameter." ) - image = pick_model(CardImage, url=url, alt=alt) + image = pick_model(CardImage, url=url, alt=SkipNone(alt)) self.images = self.images or [] self.images.append(image) diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/hero_card.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/hero_card.py index 41c47ec3f..eda7fe761 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/hero_card.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/hero_card.py @@ -9,7 +9,7 @@ from .card_action import CardAction from .card_image import CardImage from .content_types import ContentTypes -from ._model_utils import pick_model +from ._model_utils import pick_model, SkipNone from ._type_aliases import NonEmptyString @@ -74,7 +74,7 @@ def add_image( raise ValueError( "Either provide a CardImage instance or the url parameter." ) - image = pick_model(CardImage, url=url, alt=alt) + image = pick_model(CardImage, url=url, alt=SkipNone(alt)) self.images = self.images or [] self.images.append(image) diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/media_card.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/media_card.py index 2fa8a02b6..284982878 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/media_card.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/media_card.py @@ -9,7 +9,7 @@ from .media_url import MediaUrl from .card_action import CardAction from .agents_model import AgentsModel -from ._model_utils import pick_model +from ._model_utils import pick_model, SkipNone from ._type_aliases import NonEmptyString @@ -99,7 +99,7 @@ def add_media( raise ValueError( "Either provide a MediaUrl instance or the url parameter." ) - media = pick_model(MediaUrl, url=url, profile=profile) + media = pick_model(MediaUrl, url=url, profile=SkipNone(profile)) self.media = self.media or [] self.media.append(media) diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/suggested_actions.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/suggested_actions.py index 3b624df3b..ec4dacc8d 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/suggested_actions.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/suggested_actions.py @@ -20,7 +20,7 @@ class SuggestedActions(AgentsModel): """ to: list[NonEmptyString] = Field(default_factory=list) - actions: list[CardAction] + actions: list[CardAction] = None def add_action(self, action: CardAction) -> "SuggestedActions": """ diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/thumbnail_card.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/thumbnail_card.py index b45fb9843..92a994d8a 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/thumbnail_card.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/thumbnail_card.py @@ -9,7 +9,7 @@ from .card_image import CardImage from .card_action import CardAction from .content_types import ContentTypes -from ._model_utils import pick_model +from ._model_utils import pick_model, SkipNone from ._type_aliases import NonEmptyString @@ -74,7 +74,7 @@ def add_image( raise ValueError( "Either provide a CardImage instance or the url parameter." ) - image = pick_model(CardImage, url=url, alt=alt) + image = pick_model(CardImage, url=url, alt=SkipNone(alt)) self.images = self.images or [] self.images.append(image) @@ -88,7 +88,7 @@ def add_button( self, *, title: NonEmptyString, - type: NonEmptyString = ActionTypes.im_back, + card_type: NonEmptyString = ActionTypes.im_back, value: object | None = None, ) -> "ThumbnailCard": ... @@ -97,7 +97,7 @@ def add_button( button: CardAction | None = None, *, title: NonEmptyString | None = None, - type: NonEmptyString = ActionTypes.im_back, + card_type: NonEmptyString = ActionTypes.im_back, value: object | None = None, ) -> "ThumbnailCard": """ @@ -105,7 +105,7 @@ def add_button( :param button: The button to add. :param title: The title of the button, used when no button instance is provided. - :param type: The action type of the button built from a title. + :param card_type: The action type of the button built from a title. :param value: The value of the button built from a title. Defaults to the title. :returns: This card, to allow for method chaining. """ @@ -115,7 +115,7 @@ def add_button( "Either provide a CardAction instance or the title parameter." ) button = CardAction( - type=type, title=title, value=value if value is not None else title + type=card_type, title=title, value=value if value is not None else title ) self.buttons = self.buttons or [] diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/video_card.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/video_card.py index 007238654..a5f73316c 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/video_card.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/video_card.py @@ -9,7 +9,7 @@ from .thumbnail_url import ThumbnailUrl from .media_url import MediaUrl from .card_action import CardAction -from ._model_utils import pick_model +from ._model_utils import pick_model, SkipNone from ._type_aliases import NonEmptyString @@ -97,7 +97,7 @@ def add_media( raise ValueError( "Either provide a MediaUrl instance or the url parameter." ) - media = pick_model(MediaUrl, url=url, profile=profile) + media = pick_model(MediaUrl, url=url, profile=SkipNone(profile)) self.media = self.media or [] self.media.append(media) diff --git a/tests/activity/test_card_builders.py b/tests/activity/test_card_builders.py index 791dd0457..69b93522a 100644 --- a/tests/activity/test_card_builders.py +++ b/tests/activity/test_card_builders.py @@ -1,6 +1,6 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. - +import pytest from microsoft_agents.activity import ( ActionTypes, AdaptiveCardCard, @@ -53,6 +53,7 @@ def test_hero_card_fluent_builders(self): class TestThumbnailAndBasicCardBuilders: + @pytest.mark.filterwarnings("ignore::DeprecationWarning") def test_thumbnail_and_basic_card_builders(self): thumb = ThumbnailCard(title="t").add_image(url="u").add_button(title="b") assert thumb.title == "t" @@ -70,6 +71,7 @@ def test_thumbnail_and_basic_card_builders(self): class TestMediaCardBuilders: + @pytest.mark.filterwarnings("ignore::DeprecationWarning") def test_media_card_builders_add_media_and_buttons(self): animation = ( AnimationCard(title="a") @@ -118,7 +120,7 @@ def test_receipt_card_builders(self): class TestAdaptiveCardCardBuilders: def test_adaptive_card_card_from_json_to_attachment(self): json_content = '{"type":"AdaptiveCard","version":"1.4"}' - card = AdaptiveCardCard(json_content) + card = AdaptiveCardCard(content=json_content) assert card.content == json_content @@ -128,7 +130,7 @@ def test_adaptive_card_card_from_json_to_attachment(self): def test_adaptive_card_card_serializes_content_as_nested_json(self): json_content = '{"type":"AdaptiveCard","version":"1.4"}' - activity = AdaptiveCardCard(json_content).to_message() + activity = AdaptiveCardCard(content=json_content).to_message() data = activity.model_dump(mode="json", by_alias=True, exclude_none=True) From b5d466ca029b2651d350d2a886dbcf4b20601f45 Mon Sep 17 00:00:00 2001 From: Kyle Rohn Date: Wed, 22 Jul 2026 16:05:19 -0700 Subject: [PATCH 11/15] Fair enough Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../microsoft_agents/hosting/core/card_factory.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/card_factory.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/card_factory.py index 266e221d1..aa5152147 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/card_factory.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/card_factory.py @@ -168,9 +168,5 @@ def video_card(card: VideoCard) -> Attachment: return Attachment(content_type=ContentTypes.video_card, content=card) - @property - @deprecated( - "CardFactory.content_types is being relocated to microsoft_agents.activity.ContentTypes." - ) - def content_types(self) -> type[ContentTypes]: - return self._content_types + # Deprecated alias; use microsoft_agents.activity.ContentTypes instead. + content_types: type[ContentTypes] = ContentTypes From 5caeacbd2dccc2e4b1b988696c2016db89c163df Mon Sep 17 00:00:00 2001 From: kylerohn-msft Date: Wed, 22 Jul 2026 16:20:22 -0700 Subject: [PATCH 12/15] mention can return NonEmptyString --- .../microsoft_agents/activity/activity.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/activity.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/activity.py index 5f770e528..7fb25e7de 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/activity.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/activity.py @@ -634,7 +634,7 @@ def is_recipient_mentioned(self) -> bool: and self.get_account_mention(self.recipient.id) is not None ) - def remove_recipient_mention(self) -> NonEmptyString: + def remove_recipient_mention(self) -> str: """ Removes the recipient's mention text from the activity's text. @@ -646,7 +646,7 @@ def remove_recipient_mention(self) -> NonEmptyString: """ return self.remove_mention_text(self.recipient.id if self.recipient else None) - def remove_mention_text(self, identifier: NonEmptyString | None) -> NonEmptyString: + def remove_mention_text(self, identifier: NonEmptyString | None) -> str: """ Removes the mention text for the given account id from the activity's text. From 60acb4113c5db0d233307184a8502f4b3efdc2a0 Mon Sep 17 00:00:00 2001 From: kylerohn-msft Date: Wed, 22 Jul 2026 16:21:44 -0700 Subject: [PATCH 13/15] remove unused imports --- .../microsoft_agents/hosting/core/card_factory.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/card_factory.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/card_factory.py index aa5152147..342a6ddc8 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/card_factory.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/card_factory.py @@ -1,9 +1,6 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. -from typing import TypeVar -from typing_extensions import deprecated - from microsoft_agents.activity import ( AnimationCard, Attachment, @@ -17,8 +14,6 @@ VideoCard, ) -ContentTypesT = TypeVar("ContentTypesT", bound=ContentTypes) - class CardFactory: _content_types: type[ContentTypes] = ContentTypes From 7599109f6757065a845aa787c1de9dd6cf1ddac5 Mon Sep 17 00:00:00 2001 From: kylerohn-msft Date: Fri, 24 Jul 2026 14:42:55 -0700 Subject: [PATCH 14/15] requested changes --- .../microsoft_agents/activity/activity.py | 36 ++++++++++--------- .../microsoft_agents/activity/card.py | 5 ++- .../hosting/core/card_factory.py | 18 +++++----- 3 files changed, 31 insertions(+), 28 deletions(-) diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/activity.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/activity.py index 7fb25e7de..771563d74 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/activity.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/activity.py @@ -432,7 +432,7 @@ def as_typing_activity(self): """ return self if self.__is_activity(ActivityTypes.typing) else None - def with_text(self, text: str) -> "Activity": + def with_text(self, text: str) -> Self: """ Sets the text content of the activity. @@ -442,7 +442,7 @@ def with_text(self, text: str) -> "Activity": self.text = text return self - def with_speak(self, speak: str) -> "Activity": + def with_speak(self, speak: str) -> Self: """ Sets the text to speak for the activity. @@ -452,7 +452,7 @@ def with_speak(self, speak: str) -> "Activity": self.speak = speak return self - def with_input_hint(self, input_hint: str) -> "Activity": + def with_input_hint(self, input_hint: str) -> Self: """ Sets the input hint for the activity. @@ -462,7 +462,7 @@ def with_input_hint(self, input_hint: str) -> "Activity": self.input_hint = input_hint return self - def with_summary(self, summary: str) -> "Activity": + def with_summary(self, summary: str) -> Self: """ Sets the summary of the activity. @@ -472,7 +472,7 @@ def with_summary(self, summary: str) -> "Activity": self.summary = summary return self - def with_locale(self, locale: str) -> "Activity": + def with_locale(self, locale: str) -> Self: """ Sets the locale of the activity. @@ -482,7 +482,7 @@ def with_locale(self, locale: str) -> "Activity": self.locale = locale return self - def with_text_format(self, text_format: str) -> "Activity": + def with_text_format(self, text_format: str) -> Self: """ Sets the text format of the activity. @@ -492,7 +492,7 @@ def with_text_format(self, text_format: str) -> "Activity": self.text_format = text_format return self - def with_attachment_layout(self, attachment_layout: str) -> "Activity": + def with_attachment_layout(self, attachment_layout: str) -> Self: """ Sets the attachment layout hint for the activity. @@ -502,7 +502,7 @@ def with_attachment_layout(self, attachment_layout: str) -> "Activity": self.attachment_layout = attachment_layout return self - def with_delivery_mode(self, delivery_mode: str) -> "Activity": + def with_delivery_mode(self, delivery_mode: str) -> Self: """ Sets the delivery mode of the activity. @@ -512,7 +512,7 @@ def with_delivery_mode(self, delivery_mode: str) -> "Activity": self.delivery_mode = delivery_mode return self - def with_name(self, name: str) -> "Activity": + def with_name(self, name: str) -> Self: """ Sets the name of the activity. @@ -522,7 +522,7 @@ def with_name(self, name: str) -> "Activity": self.name = name return self - def with_value(self, value: object, value_type: str | None = None) -> "Activity": + def with_value(self, value: object, value_type: str | None = None) -> Self: """ Sets the value of the activity, and optionally its value type. @@ -535,7 +535,7 @@ def with_value(self, value: object, value_type: str | None = None) -> "Activity" self.value_type = value_type return self - def with_suggested_actions(self, suggested_actions: SuggestedActions) -> "Activity": + def with_suggested_actions(self, suggested_actions: SuggestedActions) -> Self: """ Sets the suggested actions for the activity. @@ -553,7 +553,7 @@ def add_text(self, text: str) -> None: """ self.text = (self.text or "") + text - def add_attachment(self, *attachments: Attachment) -> "Activity": + def add_attachment(self, *attachments: Attachment) -> Self: """ Adds one or more attachments to the activity. @@ -567,7 +567,7 @@ def add_attachment(self, *attachments: Attachment) -> "Activity": self.attachments.extend(attachments) return self - def add_entity(self, *entities: Entity) -> "Activity": + def add_entity(self, *entities: Entity) -> Self: """ Adds one or more entities to the activity. @@ -586,7 +586,7 @@ def add_mention( account: ChannelAccount, text: NonEmptyString | None = None, add_text: bool = True, - ) -> "Activity": + ) -> Self: """ Adds a mention of the given account to the activity. @@ -698,7 +698,7 @@ def is_targeted_activity(self) -> bool: return False - def make_targeted_activity(self, user: ChannelAccount | None = None) -> "Activity": + def make_targeted_activity(self, user: ChannelAccount | None = None) -> Self: """ Marks this activity as targeted, setting the recipient if provided. @@ -864,7 +864,9 @@ def create_message_activity(): """ return Activity(type=ActivityTypes.message) - def create_reply(self, text: str | None = None, locale: str | None = None): + def create_reply( + self, text: str | None = None, locale: str | None = None + ) -> Activity: """ Creates a new message activity as a response to this activity. @@ -993,7 +995,7 @@ def create_trace_activity( ) @staticmethod - def create_typing_activity() -> "Activity": + def create_typing_activity() -> Activity: """ Creates an instance of the :class:`microsoft_agents.activity.Activity` class as a TypingActivity object. diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/card.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/card.py index 369cf0202..26c36d821 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/card.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/card.py @@ -1,19 +1,22 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. +from abc import ABC, abstractmethod + from .activity import Activity from .agents_model import AgentsModel from .attachment import Attachment from .activity_types import ActivityTypes -class Card(AgentsModel): +class Card(AgentsModel, ABC): """Base class for rich cards that can be sent to a user as an Attachment. Each concrete card implements :meth:`to_attachment` to wrap itself in an attachment using its own content type. :meth:`to_message` builds on that to produce a ready-to-send message activity. """ + @abstractmethod def to_attachment(self) -> Attachment: """ Creates a new Attachment that wraps this card. diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/card_factory.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/card_factory.py index 342a6ddc8..86434d315 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/card_factory.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/card_factory.py @@ -16,8 +16,6 @@ class CardFactory: - _content_types: type[ContentTypes] = ContentTypes - @staticmethod def adaptive_card(card: dict) -> Attachment: """ @@ -49,7 +47,7 @@ def animation_card(card: AnimationCard) -> Attachment: "unable to prepare attachment." ) - return Attachment(content_type=ContentTypes.animation_card, content=card) + return card.to_attachment() @staticmethod def audio_card(card: AudioCard) -> Attachment: @@ -64,7 +62,7 @@ def audio_card(card: AudioCard) -> Attachment: "unable to prepare attachment." ) - return Attachment(content_type=ContentTypes.audio_card, content=card) + return card.to_attachment() @staticmethod def hero_card(card: HeroCard) -> Attachment: @@ -81,7 +79,7 @@ def hero_card(card: HeroCard) -> Attachment: "unable to prepare attachment." ) - return Attachment(content_type=ContentTypes.hero_card, content=card) + return card.to_attachment() @staticmethod def oauth_card(card: OAuthCard) -> Attachment: @@ -97,7 +95,7 @@ def oauth_card(card: OAuthCard) -> Attachment: "unable to prepare attachment." ) - return Attachment(content_type=ContentTypes.oauth_card, content=card) + return card.to_attachment() @staticmethod def receipt_card(card: ReceiptCard) -> Attachment: @@ -112,7 +110,7 @@ def receipt_card(card: ReceiptCard) -> Attachment: "unable to prepare attachment." ) - return Attachment(content_type=ContentTypes.receipt_card, content=card) + return card.to_attachment() @staticmethod def signin_card(card: SigninCard) -> Attachment: @@ -128,7 +126,7 @@ def signin_card(card: SigninCard) -> Attachment: "unable to prepare attachment." ) - return Attachment(content_type=ContentTypes.signin_card, content=card) + return card.to_attachment() @staticmethod def thumbnail_card(card: ThumbnailCard) -> Attachment: @@ -146,7 +144,7 @@ def thumbnail_card(card: ThumbnailCard) -> Attachment: "unable to prepare attachment." ) - return Attachment(content_type=ContentTypes.thumbnail_card, content=card) + return card.to_attachment() @staticmethod def video_card(card: VideoCard) -> Attachment: @@ -161,7 +159,7 @@ def video_card(card: VideoCard) -> Attachment: "unable to prepare attachment." ) - return Attachment(content_type=ContentTypes.video_card, content=card) + return card.to_attachment() # Deprecated alias; use microsoft_agents.activity.ContentTypes instead. content_types: type[ContentTypes] = ContentTypes From 42461ca989b43be96297b01b98f65f67baf748bd Mon Sep 17 00:00:00 2001 From: kylerohn-msft Date: Wed, 29 Jul 2026 09:46:13 -0700 Subject: [PATCH 15/15] parity w .net --- .../microsoft_agents/activity/activity.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/activity.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/activity.py index 771563d74..224c43fb5 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/activity.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/activity.py @@ -545,13 +545,15 @@ def with_suggested_actions(self, suggested_actions: SuggestedActions) -> Self: self.suggested_actions = suggested_actions return self - def add_text(self, text: str) -> None: + def add_text(self, text: str) -> Self: """ Appends text to the existing text content of the activity. :param text: The text to append to the activity's text. + :returns: This activity, to allow for method chaining. """ self.text = (self.text or "") + text + return self def add_attachment(self, *attachments: Attachment) -> Self: """