diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/__init__.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/__init__.py index 85286180d..7645a28e9 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 @@ -16,6 +17,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 @@ -27,6 +29,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, @@ -96,7 +99,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 @@ -111,6 +113,7 @@ "Activity", "ActionTypes", "ActivityEventNames", + "AdaptiveCardCard", "AdaptiveCardInvokeAction", "AdaptiveCardInvokeResponse", "AdaptiveCardInvokeValue", @@ -121,6 +124,7 @@ "AttachmentView", "AudioCard", "BasicCard", + "Card", "CardAction", "CardImage", "Channels", @@ -132,6 +136,7 @@ "ConversationReference", "ConversationResourceResponse", "ConversationsResult", + "ContentTypes", "ExpectedReplies", "Entity", "AIEntity", diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/activity.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/activity.py index 8e0b7080b..224c43fb5 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,377 @@ def as_typing_activity(self): """ return self if self.__is_activity(ActivityTypes.typing) else None + def with_text(self, text: str) -> Self: + """ + 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) -> Self: + """ + 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) -> Self: + """ + 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) -> Self: + """ + 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) -> Self: + """ + 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) -> Self: + """ + 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) -> Self: + """ + 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) -> Self: + """ + 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) -> Self: + """ + 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) -> Self: + """ + 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) -> Self: + """ + 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) -> 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: + """ + 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) -> Self: + """ + 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, + ) -> Self: + """ + 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) -> str: + """ + 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) -> str: + """ + 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) -> Self: + """ + 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(): """ @@ -492,7 +866,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. @@ -621,7 +997,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. @@ -713,8 +1089,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 +1104,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/adaptive_card_card.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/adaptive_card_card.py new file mode 100644 index 000000000..c5f9bd2b3 --- /dev/null +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/adaptive_card_card.py @@ -0,0 +1,35 @@ +# 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 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), + ) 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..cc2868068 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,19 @@ # 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 ._model_utils import pick_model, SkipNone 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 +60,56 @@ 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." + ) + media = pick_model(MediaUrl, url=url, profile=SkipNone(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..4bceb534b 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,19 @@ # 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 ._model_utils import pick_model, SkipNone from ._type_aliases import NonEmptyString -class AudioCard(AgentsModel): +class AudioCard(Card): """Audio card. :param title: Title of this card @@ -55,3 +60,56 @@ 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 = pick_model(MediaUrl, url=url, profile=SkipNone(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..24565fa80 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,29 @@ # 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 ._model_utils import pick_model, SkipNone 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 +45,93 @@ 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." + ) + image = pick_model(CardImage, url=url, alt=SkipNone(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..26c36d821 --- /dev/null +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/card.py @@ -0,0 +1,36 @@ +# 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, 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. + + :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/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/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-activity/microsoft_agents/activity/hero_card.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/hero_card.py index 810dc000a..eda7fe761 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,19 @@ # 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 ._model_utils import pick_model, SkipNone 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 +37,101 @@ 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." + ) + image = pick_model(CardImage, url=url, alt=SkipNone(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..284982878 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/media_card.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/media_card.py @@ -1,16 +1,31 @@ # 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 from .agents_model import AgentsModel +from ._model_utils import pick_model, SkipNone 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 +70,48 @@ 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." + ) + media = pick_model(MediaUrl, url=url, profile=SkipNone(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/suggested_actions.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/suggested_actions.py index f82ca3ec4..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,4 +20,43 @@ class SuggestedActions(AgentsModel): """ to: list[NonEmptyString] = Field(default_factory=list) - actions: list[CardAction] + actions: list[CardAction] = None + + 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-activity/microsoft_agents/activity/thumbnail_card.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/thumbnail_card.py index 028446f91..92a994d8a 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,19 @@ # 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 ._model_utils import pick_model, SkipNone 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 +37,101 @@ 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." + ) + image = pick_model(CardImage, url=url, alt=SkipNone(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, + card_type: NonEmptyString = ActionTypes.im_back, + value: object | None = None, + ) -> "ThumbnailCard": ... + + def add_button( + self, + button: CardAction | None = None, + *, + title: NonEmptyString | None = None, + card_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 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. + """ + if button is None: + if title is None: + raise ValueError( + "Either provide a CardAction instance or the title parameter." + ) + button = CardAction( + type=card_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..a5f73316c 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,19 @@ # 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 ._model_utils import pick_model, SkipNone from ._type_aliases import NonEmptyString -class VideoCard(AgentsModel): +class VideoCard(Card): """Video card. :param title: Title of this card @@ -55,3 +60,56 @@ 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." + ) + media = pick_model(MediaUrl, url=url, profile=SkipNone(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 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..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 @@ -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 card.to_attachment() @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 card.to_attachment() @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 card.to_attachment() @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 card.to_attachment() @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 card.to_attachment() @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 card.to_attachment() @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 card.to_attachment() @staticmethod def video_card(card: VideoCard) -> Attachment: @@ -188,6 +159,7 @@ def video_card(card: VideoCard) -> Attachment: "unable to prepare attachment." ) - return Attachment( - content_type=CardFactory.content_types.video_card, content=card - ) + return card.to_attachment() + + # Deprecated alias; use microsoft_agents.activity.ContentTypes instead. + content_types: type[ContentTypes] = ContentTypes 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 74ebac636..f0d596aa3 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 @@ -410,33 +408,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/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 8fd172844..283b0b43a 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,6 +12,7 @@ ActivityTypes, ActionTypes, CardAction, + ContentTypes, InputHints, SigninCard, SignInConstants, @@ -37,7 +38,6 @@ _FlowStateTag, _FlowResponse, ) -from opentelemetry import context from ..dialog import Dialog from ..dialog_context import DialogContext @@ -284,7 +284,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 @@ -343,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"): diff --git a/tests/activity/test_activity_builders.py b/tests/activity/test_activity_builders.py new file mode 100644 index 000000000..11a5cdbd2 --- /dev/null +++ b/tests/activity/test_activity_builders.py @@ -0,0 +1,226 @@ +# 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 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") + 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 diff --git a/tests/activity/test_card_builders.py b/tests/activity/test_card_builders.py new file mode 100644 index 000000000..69b93522a --- /dev/null +++ b/tests/activity/test_card_builders.py @@ -0,0 +1,142 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +import pytest +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: + @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" + 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: + @pytest.mark.filterwarnings("ignore::DeprecationWarning") + 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(content=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(content=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 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,