diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/__init__.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/__init__.py index 7645a28e..02a4980c 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/__init__.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/__init__.py @@ -10,6 +10,7 @@ from .adaptive_card_invoke_action import AdaptiveCardInvokeAction from .adaptive_card_invoke_response import AdaptiveCardInvokeResponse from .adaptive_card_invoke_value import AdaptiveCardInvokeValue +from .adaptive_card_search_invoke_value import AdaptiveCardSearchInvokeValue from .animation_card import AnimationCard from .attachment import Attachment from .attachment_data import AttachmentData @@ -66,6 +67,8 @@ from .receipt_item import ReceiptItem from .resource_response import ResourceResponse from .semantic_action import SemanticAction +from .search_invoke_options import SearchInvokeOptions +from .search_invoke_value import SearchInvokeValue from .signin_card import SigninCard from .suggested_actions import SuggestedActions from .text_highlight import TextHighlight @@ -117,6 +120,7 @@ "AdaptiveCardInvokeAction", "AdaptiveCardInvokeResponse", "AdaptiveCardInvokeValue", + "AdaptiveCardSearchInvokeValue", "AnimationCard", "Attachment", "AttachmentData", @@ -167,6 +171,8 @@ "ReceiptCard", "ReceiptItem", "ResourceResponse", + "SearchInvokeOptions", + "SearchInvokeValue", "SemanticAction", "SigninCard", "SuggestedActions", diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/adaptive_card_invoke_action.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/adaptive_card_invoke_action.py index 07ef9fee..19db2d64 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/adaptive_card_invoke_action.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/adaptive_card_invoke_action.py @@ -1,6 +1,8 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. +from pydantic import Field + from .agents_model import AgentsModel from ._type_aliases import NonEmptyString @@ -14,14 +16,14 @@ class AdaptiveCardInvokeAction(AgentsModel): :param type: The Type of this Adaptive Card Invoke Action. :type type: str :param id: The Id of this Adaptive Card Invoke Action. - :type id: str + :type id: str | None :param verb: The Verb of this Adaptive Card Invoke Action. - :type verb: str + :type verb: str | None :param data: The data of this Adaptive Card Invoke Action. :type data: dict[str, object] """ - type: NonEmptyString = None - id: NonEmptyString = None - verb: NonEmptyString = None - data: dict[NonEmptyString, object] = None + type: str + id: str | None = None + verb: str | None = None + data: dict[NonEmptyString, object] = Field(default_factory=dict) diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/adaptive_card_invoke_response.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/adaptive_card_invoke_response.py index 5708ae0f..9614bee3 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/adaptive_card_invoke_response.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/adaptive_card_invoke_response.py @@ -14,10 +14,10 @@ class AdaptiveCardInvokeResponse(AgentsModel): :type status_code: int :param type: The type of this Card Action Response. :type type: str - :param value: The JSON response object. - :type value: dict[str, object] + :param value: The response object. + :type value: object """ status_code: int = None type: NonEmptyString = None - value: dict[NonEmptyString, object] = None + value: object = None diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/adaptive_card_invoke_value.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/adaptive_card_invoke_value.py index 19b183c6..15b450e3 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/adaptive_card_invoke_value.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/adaptive_card_invoke_value.py @@ -20,6 +20,6 @@ class AdaptiveCardInvokeValue(AgentsModel): :type state: str """ - action: AdaptiveCardInvokeAction = None - authentication: TokenExchangeInvokeRequest = None + action: AdaptiveCardInvokeAction + authentication: TokenExchangeInvokeRequest | None = None state: NonEmptyString = None diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/adaptive_card_search_invoke_value.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/adaptive_card_search_invoke_value.py new file mode 100644 index 00000000..b455e4b0 --- /dev/null +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/adaptive_card_search_invoke_value.py @@ -0,0 +1,13 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from .search_invoke_value import SearchInvokeValue + + +class AdaptiveCardSearchInvokeValue(SearchInvokeValue): + """ + :param dataset: The dataset for this adaptive card search value. + :type dataset: str + """ + + dataset: str | None = None diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/content_types.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/content_types.py index 2599c36f..9ecf62bd 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/content_types.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/content_types.py @@ -10,7 +10,13 @@ class ContentTypes: audio_card = "application/vnd.microsoft.card.audio" hero_card = "application/vnd.microsoft.card.hero" receipt_card = "application/vnd.microsoft.card.receipt" + error = "application/vnd.microsoft.error" 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" + message = "application/vnd.microsoft.activity.message" + login_request = "application/vnd.microsoft.activity.loginRequest" + incorrect_auth_code = "application/vnd.microsoft.error.incorrectAuthCode" + precondition_failed = "application/vnd.microsoft.error.preconditionFailed" + search_response = "application/vnd.microsoft.search.searchResponse" diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/search_invoke_options.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/search_invoke_options.py new file mode 100644 index 00000000..0de9e712 --- /dev/null +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/search_invoke_options.py @@ -0,0 +1,17 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from .agents_model import AgentsModel + + +class SearchInvokeOptions(AgentsModel): + """Defines the query options in the 'SearchInvokeValue' for Invoke activity with name of 'application/search'. + + :param skip: The number of items to skip in the search results. This is an integer that specifies how many items to skip in the search results. + :type skip: int + :param top: The maximum number of items to return in the search results. This is an integer that specifies the maximum number of items to return in the search results. + :type top: int + """ + + skip: int + top: int diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/search_invoke_value.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/search_invoke_value.py new file mode 100644 index 00000000..bbaed653 --- /dev/null +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/search_invoke_value.py @@ -0,0 +1,21 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from .agents_model import AgentsModel +from .search_invoke_options import SearchInvokeOptions + + +class SearchInvokeValue(AgentsModel): + """Defines the structure that arrives in Activity.value for invoke activity with name of 'application/search'. + + :param kind: The kind of search being performed. This is a string that is used to identify the type of search being performed. + :type kind: str + :param query_text: The text of the search query. This is a string that contains the text of the search query being performed. + :type query_text: str + :param query_options: The options for the search query. This is an object that contains the options for the search query being performed. + :type query_options: :class:`microsoft_agents.activity.search_invoke_options.SearchInvokeOptions` + """ + + kind: str + query_text: str + query_options: SearchInvokeOptions diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/adaptive_card/__init__.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/adaptive_card/__init__.py new file mode 100644 index 00000000..7a50b7a8 --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/adaptive_card/__init__.py @@ -0,0 +1,6 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from .adaptive_card import AdaptiveCard + +__all__ = ["AdaptiveCard"] diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/adaptive_card/_type_defs.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/adaptive_card/_type_defs.py new file mode 100644 index 00000000..db093bcf --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/adaptive_card/_type_defs.py @@ -0,0 +1,46 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from typing import Awaitable, Protocol + +from microsoft_agents.activity import ( + AdaptiveCardInvokeResponse, + AdaptiveCardInvokeValue, +) + +from microsoft_agents.hosting.core.turn_context import TurnContext +from microsoft_agents.hosting.core.app.state import TurnState + +from .models import ( + AdaptiveCardSearchParams, + AdaptiveCardSearchResult, + Query, +) + + +class ActionExecuteHandler(Protocol): + def __call__( + self, context: TurnContext, state: TurnState, data: object, / + ) -> Awaitable[AdaptiveCardInvokeResponse]: ... + + +class ActionExecuteValueHandler(Protocol): + def __call__( + self, context: TurnContext, state: TurnState, value: AdaptiveCardInvokeValue, / + ) -> Awaitable[AdaptiveCardInvokeResponse]: ... + + +class ActionSubmitHandler(Protocol): + def __call__( + self, context: TurnContext, state: TurnState, data: object, / + ) -> Awaitable[None]: ... + + +class SearchHandler(Protocol): + def __call__( + self, + context: TurnContext, + state: TurnState, + query: Query[AdaptiveCardSearchParams], + /, + ) -> Awaitable[list[AdaptiveCardSearchResult]]: ... diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/adaptive_card/adaptive_card.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/adaptive_card/adaptive_card.py new file mode 100644 index 00000000..eea68091 --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/adaptive_card/adaptive_card.py @@ -0,0 +1,275 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from __future__ import annotations + +from dataclasses import asdict +from http import HTTPStatus +from typing import TYPE_CHECKING, Callable, Pattern + +import pydantic + +from microsoft_agents.activity import ( + Activity, + ActivityTypes, + AdaptiveCardInvokeResponse, + AdaptiveCardInvokeValue, + AdaptiveCardSearchInvokeValue, + Channels, + ChannelId, + InvokeResponse, +) +from microsoft_agents.hosting.core.turn_context import TurnContext + +from ..state import TurnState +from . import factory +from ._type_defs import ( + ActionExecuteHandler, + ActionSubmitHandler, + SearchHandler, +) +from .models import AdaptiveCardSearchParams, Query + +if TYPE_CHECKING: + from ..agent_application import AgentApplication + +_ACTION_EXECUTE_TYPE = "Action.Execute" +_ACTION_INVOKE_NAME = "adaptiveCard/action" +_SEARCH_INVOKE_NAME = "application/search" + + +class AdaptiveCard: + """Register handlers for Adaptive Card activities.""" + + def __init__(self, app: AgentApplication): + """Initialize an Adaptive Card route registrar for an application.""" + self._app = app + + def action_execute( + self, + verb: str | Pattern[str], + *, + auth_handlers: list[str] | None = None, + **kwargs, + ) -> Callable[[ActionExecuteHandler], ActionExecuteHandler]: + """Register an ``Action.Execute`` handler that receives the action data.""" + + def selector(context: TurnContext) -> bool: + activity = context.activity + if ( + activity.type != ActivityTypes.invoke + or activity.name != _ACTION_INVOKE_NAME + ): + return False + + try: + invoke_value = AdaptiveCardInvokeValue.model_validate(activity.value) + except pydantic.ValidationError: + return False + + verb_value = ( + invoke_value.action.verb if invoke_value.action is not None else None + ) + return self._matches(verb, verb_value) + + def register(func: Callable) -> Callable: + async def handler(context: TurnContext, state: TurnState) -> None: + invoke_value, response = self._validate_action_execute_value(context) + + if invoke_value is not None: + response = await func(context, state, invoke_value.action.data) + + response = response or AdaptiveCardInvokeResponse( + status_code=HTTPStatus.OK + ) + + await self._send_invoke_response( + context, response, status_code=HTTPStatus.OK + ) + + kwargs.pop("is_invoke", None) + self._app.add_route( + selector, + handler, + is_invoke=True, + auth_handlers=auth_handlers, + **kwargs, + ) + return func + + return register + + def action_submit( + self, + verb: str | Pattern[str], + *, + auth_handlers: list[str] | None = None, + submit_filter: str = "verb", + **kwargs, + ) -> Callable[[ActionSubmitHandler], ActionSubmitHandler]: + """Register an Adaptive Card ``Action.Submit`` handler.""" + + def selector(context: TurnContext) -> bool: + activity = context.activity + if ( + activity.type != ActivityTypes.message + or activity.text + or activity.value is None + ): + return False + + verb_value = None + if isinstance(activity.value, dict): + verb_value = activity.value.get(submit_filter) + return self._matches(verb, verb_value) + + def register(func: ActionSubmitHandler) -> ActionSubmitHandler: + async def handler(context: TurnContext, state: TurnState) -> None: + await func(context, state, context.activity.value) + + self._app.add_route( + selector, + handler, + is_invoke=False, + auth_handlers=auth_handlers, + **kwargs, + ) + return func + + return register + + def search( + self, + dataset: str | Pattern[str], + *, + auth_handlers: list[str] | None = None, + **kwargs, + ) -> Callable[[SearchHandler], SearchHandler]: + """Register an Adaptive Card dynamic-search handler.""" + + def selector(context: TurnContext) -> bool: + activity = context.activity + if ( + activity.type != ActivityTypes.invoke + or activity.name != _SEARCH_INVOKE_NAME + ): + return False + + try: + invoke_value = AdaptiveCardSearchInvokeValue.model_validate( + activity.value + ) + except pydantic.ValidationError: + return False + + return self._matches(dataset, invoke_value.dataset) + + def register(func: SearchHandler) -> SearchHandler: + async def handler(context: TurnContext, state: TurnState) -> None: + value, response = self._validate_search_value(context) + if value is not None: + options = value.query_options + query = Query( + count=options.top, + skip=options.skip, + parameters=AdaptiveCardSearchParams( + query_text=value.query_text, + dataset=value.dataset or "", + ), + ) + results = await func(context, state, query) + response = factory.search_response( + {"results": [asdict(result) for result in results]} + ) + + await self._send_invoke_response( + context, response, response.status_code or HTTPStatus.OK + ) + + kwargs.pop("is_invoke", None) + self._app.add_route( + selector, + handler, + is_invoke=True, + auth_handlers=auth_handlers, + **kwargs, + ) + return func + + return register + + def _validate_action_execute_value( + self, context: TurnContext + ) -> tuple[AdaptiveCardInvokeValue | None, AdaptiveCardInvokeResponse]: + if context.activity.value is None: + return None, factory.bad_request("Missing value property for Invoke Action") + + try: + value = AdaptiveCardInvokeValue.model_validate(context.activity.value) + except pydantic.ValidationError: + return None, factory.bad_request( + "Value property is not a properly formed Invoke Action" + ) + + if value.action is None: + return None, factory.bad_request("Missing action property") + if value.action.type != _ACTION_EXECUTE_TYPE: + return None, factory.not_supported( + f"The Invoke Action '{value.action.type}' was not expected." + ) + + return value, AdaptiveCardInvokeResponse() + + def _validate_search_value( + self, context: TurnContext + ) -> tuple[AdaptiveCardSearchInvokeValue | None, AdaptiveCardInvokeResponse]: + value = context.activity.value + if value is None: + return None, factory.bad_request("Missing value property for search") + + try: + search_invoke_value = AdaptiveCardSearchInvokeValue.model_validate(value) + except pydantic.ValidationError: + return None, factory.bad_request( + "Value property is not a properly formed search invoke value" + ) + + missing = [] + if not search_invoke_value.kind: + if ChannelId.get_channel(context.activity.channel_id) == Channels.ms_teams: + search_invoke_value.kind = "search" + else: + missing.append("kind") + + if not search_invoke_value.query_text: + missing.append("queryText") + + if missing: + return None, factory.bad_request( + f"Missing '{', '.join(missing)}' property for search" + ) + return search_invoke_value, AdaptiveCardInvokeResponse() + + @staticmethod + def _matches(selector: str | Pattern[str], value: object) -> bool: + if not isinstance(value, str): + return False + if isinstance(selector, str): + return selector == value + return selector.search(value) is not None + + @staticmethod + async def _send_invoke_response( + context: TurnContext, + body: AdaptiveCardInvokeResponse, + status_code: int | HTTPStatus = HTTPStatus.OK, + ) -> None: + await context.send_activity( + Activity( + type=ActivityTypes.invoke_response, + value=InvokeResponse( + status=int(status_code), + body=body.model_dump(mode="json", by_alias=True, exclude_none=True), + ), + ) + ) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/adaptive_card/factory.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/adaptive_card/factory.py new file mode 100644 index 00000000..c957a695 --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/adaptive_card/factory.py @@ -0,0 +1,99 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from http import HTTPStatus + +from microsoft_agents.activity import ( + AdaptiveCardInvokeResponse, + ContentTypes, + OAuthCard, +) + + +def adaptive_card(adaptive_card_json: str) -> AdaptiveCardInvokeResponse: + return AdaptiveCardInvokeResponse( + status_code=HTTPStatus.OK, + type=ContentTypes.adaptive_card, + value=adaptive_card_json, + ) + + +def search_response(result: dict | str) -> AdaptiveCardInvokeResponse: + return AdaptiveCardInvokeResponse( + status_code=HTTPStatus.OK, + type=ContentTypes.search_response, + value=result, + ) + + +def message(msg: str) -> AdaptiveCardInvokeResponse: + return AdaptiveCardInvokeResponse( + status_code=HTTPStatus.OK, + type=ContentTypes.message, + value=msg, + ) + + +def login(card: OAuthCard) -> AdaptiveCardInvokeResponse: + return AdaptiveCardInvokeResponse( + status_code=HTTPStatus.UNAUTHORIZED, + type=ContentTypes.login_request, + value=card, + ) + + +def incorrect_auth_code() -> AdaptiveCardInvokeResponse: + return AdaptiveCardInvokeResponse( + status_code=HTTPStatus.UNAUTHORIZED, + type=ContentTypes.incorrect_auth_code, + ) + + +def precondition_failed( + message: str, code: str | None = None +) -> AdaptiveCardInvokeResponse: + return AdaptiveCardInvokeResponse( + status_code=HTTPStatus.PRECONDITION_FAILED, + type=ContentTypes.precondition_failed, + value={ + "message": message, + "code": code or str(HTTPStatus.PRECONDITION_FAILED), + }, + ) + + +def error( + status_code: int, message: str, code: str | None = None +) -> AdaptiveCardInvokeResponse: + return AdaptiveCardInvokeResponse( + status_code=status_code, + type=ContentTypes.error, + value={ + "code": code or str(status_code), + "message": message, + }, + ) + + +def bad_request(message: str) -> AdaptiveCardInvokeResponse: + return error( + HTTPStatus.BAD_REQUEST, + message, + "BadRequest", + ) + + +def not_supported(message: str) -> AdaptiveCardInvokeResponse: + return error( + HTTPStatus.NOT_IMPLEMENTED, + message, + "NotSupported", + ) + + +def internal_error(message: str) -> AdaptiveCardInvokeResponse: + return error( + HTTPStatus.INTERNAL_SERVER_ERROR, + message, + "InternalError", + ) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/adaptive_card/models.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/adaptive_card/models.py new file mode 100644 index 00000000..f47fb679 --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/adaptive_card/models.py @@ -0,0 +1,29 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from dataclasses import dataclass +from typing import Generic, TypeVar + +ParamsT = TypeVar("ParamsT") + + +@dataclass +class AdaptiveCardSearchParams: + + query_text: str + dataset: str + + +@dataclass +class AdaptiveCardSearchResult: + + title: str + value: str + + +@dataclass +class Query(Generic[ParamsT]): + + count: int + skip: int + parameters: ParamsT diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/agent_application.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/agent_application.py index 1996f67c..f81ac303 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/agent_application.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/agent_application.py @@ -21,6 +21,7 @@ TypeVar, cast, overload, + Optional, ) from microsoft_agents.activity import ( @@ -53,6 +54,7 @@ ) from ._routes import _RouteList, _Route, RouteRank, _agentic_selector from .proactive import Proactive +from .adaptive_card import AdaptiveCard logger = logging.getLogger(__name__) @@ -76,6 +78,7 @@ class AgentApplication(Agent, Generic[StateT]): _options: ApplicationOptions _adapter: ChannelServiceAdapter | None = None + _adaptive_card: AdaptiveCard _auth: Authorization _proactive: Proactive | None = None _internal_before_turn: list[Callable[[TurnContext, StateT], Awaitable[bool]]] @@ -105,6 +108,7 @@ def __init__( :param kwargs: Additional configuration parameters. :type kwargs: Any """ + self._adaptive_card = AdaptiveCard(self) self._route_list = _RouteList[StateT]() self._internal_before_turn = [] self._internal_after_turn = [] @@ -236,6 +240,16 @@ def adapter(self) -> ChannelServiceAdapter: return self._adapter + @property + def adaptive_card(self) -> AdaptiveCard: + """ + The application's Adaptive Card manager. + + :return: The Adaptive Card manager for the application. + :rtype: :class:`microsoft_agents.hosting.core.app.adaptive_card.AdaptiveCard` + """ + return self._adaptive_card + @property def auth(self) -> Authorization: """ diff --git a/test_samples/cards/README.md b/test_samples/cards/README.md new file mode 100644 index 00000000..649ca8d5 --- /dev/null +++ b/test_samples/cards/README.md @@ -0,0 +1,29 @@ +# Cards sample + +This sample ports the .NET cards sample to the Python `AgentApplication` +AdaptiveCard system. It demonstrates: + +- Adaptive Card `Action.Submit` +- Adaptive Card `Action.Execute` +- Adaptive Card dynamic search with `Data.Query` +- Hero, thumbnail, audio, video, animation, and receipt cards + +`Action.Execute` and dynamic search require Microsoft Teams. +Dynamic search filters a catalog of Microsoft Agents SDK packages and retrieves +their current metadata from PyPI's project JSON API. + +## Run + +1. Copy `env.TEMPLATE` to `.env` and configure the service connection. +2. From this directory, run: + + ```pwsh + python agent.py + ``` + +3. Configure the Azure Bot messaging endpoint as + `https:///api/messages`. + +Send any message to display the command card. Available commands are +`static_submit`, `dynamic_search`, `action_execute`, `hero`, `thumbnail`, +`audio`, `video`, `animation`, and `receipt`. diff --git a/test_samples/cards/agent.py b/test_samples/cards/agent.py new file mode 100644 index 00000000..6a1bf2bd --- /dev/null +++ b/test_samples/cards/agent.py @@ -0,0 +1,182 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import asyncio +from os import environ +from pathlib import Path + +import aiohttp +from dotenv import load_dotenv + +from microsoft_agents.activity import ( + ActivityTypes, + AdaptiveCardInvokeResponse, + ContentTypes, + load_configuration_from_env, +) +from microsoft_agents.authentication.msal import MsalConnectionManager +from microsoft_agents.hosting.aiohttp import CloudAdapter +from microsoft_agents.hosting.core import ( + AgentApplication, + Authorization, + MemoryStorage, + TurnContext, + TurnState, +) +from microsoft_agents.hosting.core.app.adaptive_card.models import ( + AdaptiveCardSearchResult, +) + +from card_commands import handle_card_command, send_card_commands +from start_server import start_server + +_ROOT = Path(__file__).parent +_RESOURCES = _ROOT / "resources" +_PYPI_PACKAGES = ( + "microsoft-agents-activity", + "microsoft-agents-hosting-core", + "microsoft-agents-hosting-aiohttp", + "microsoft-agents-hosting-fastapi", + "microsoft-agents-authentication-msal", + "microsoft-agents-hosting-msteams", + "microsoft-agents-storage-blob", + "microsoft-agents-storage-cosmos", + "microsoft-agents-copilotstudio-client", +) + +load_dotenv(_ROOT / ".env") +config = load_configuration_from_env(environ) + +storage = MemoryStorage() +connection_manager = MsalConnectionManager(**config) +adapter = CloudAdapter(connection_manager=connection_manager) +authorization = Authorization(storage, connection_manager, **config) + +app = AgentApplication[TurnState]( + storage=storage, + adapter=adapter, + authorization=authorization, + start_typing_timer=False, + remove_recipient_mention=False, + **config, +) + + +def _resource_text(name: str) -> str: + return (_RESOURCES / name).read_text(encoding="utf-8") + + +@app.conversation_update("membersAdded") +async def on_members_added(context: TurnContext, _state: TurnState) -> None: + await context.send_activity( + "Hello and welcome! This sample demonstrates Adaptive Cards and " + "activity-protocol cards." + ) + await send_card_commands(context) + + +@app.adaptive_card.action_submit("StaticSubmit") +async def on_static_submit( + context: TurnContext, _state: TurnState, data: object +) -> None: + selection = data.get("choiceSelect") if isinstance(data, dict) else None + await context.send_activity(f"Statically selected option: {selection}") + + +@app.adaptive_card.action_submit("DynamicSubmit") +async def on_dynamic_submit( + context: TurnContext, _state: TurnState, data: object +) -> None: + selection = data.get("choiceSelect") if isinstance(data, dict) else None + await context.send_activity(f"Dynamically selected option: {selection}") + + +@app.adaptive_card.action_execute("refresh") +async def on_refresh( + _context: TurnContext, _state: TurnState, _data: object +) -> AdaptiveCardInvokeResponse: + return AdaptiveCardInvokeResponse( + status_code=200, + type=ContentTypes.adaptive_card, + value=_resource_text("ActionExecuteSignIn.json"), + ) + + +@app.adaptive_card.action_execute("signin") +async def on_sign_in( + context: TurnContext, _state: TurnState, _data: object +) -> AdaptiveCardInvokeResponse: + await context.send_activity("Action.Execute sign-in handler called.") + return AdaptiveCardInvokeResponse( + status_code=200, + type=ContentTypes.adaptive_card, + value=_resource_text("ActionExecuteSignOut.json"), + ) + + +@app.adaptive_card.action_execute("signout") +async def on_sign_out( + context: TurnContext, _state: TurnState, _data: object +) -> AdaptiveCardInvokeResponse: + await context.send_activity("Action.Execute sign-out handler called.") + return AdaptiveCardInvokeResponse(status_code=200) + + +async def _get_pypi_result( + session: aiohttp.ClientSession, package_name: str +) -> AdaptiveCardSearchResult | None: + async with session.get( + f"https://pypi.org/pypi/{package_name}/json" + ) as response: + if response.status == 404: + return None + response.raise_for_status() + payload = await response.json() + + info = payload.get("info", {}) + name = info.get("name") or package_name + version = info.get("version") or "" + summary = info.get("summary") or "No description available." + return AdaptiveCardSearchResult( + title=name, + value=f"{name} {version} - {summary}".strip(), + ) + + +@app.adaptive_card.search("pypipackages") +async def on_search( + _context: TurnContext, _state: TurnState, query +) -> list[AdaptiveCardSearchResult]: + query_text = query.parameters.query_text.strip().lower().replace("_", "-") + candidates = [ + package_name + for package_name in _PYPI_PACKAGES + if query_text in package_name + ] + if query_text and query_text not in candidates: + candidates.append(query_text) + + start = query.skip + stop = start + query.count + candidates = candidates[start:stop] + + async with aiohttp.ClientSession() as session: + results = await asyncio.gather( + *(_get_pypi_result(session, name) for name in candidates) + ) + + return [result for result in results if result is not None] + + +@app.activity(ActivityTypes.message) +async def on_message(context: TurnContext, _state: TurnState) -> None: + await handle_card_command(context) + + +if __name__ == "__main__": + start_server( + agent_application=app, + auth_configuration=( + connection_manager.get_default_connection_configuration() + ), + ) diff --git a/test_samples/cards/card_commands.py b/test_samples/cards/card_commands.py new file mode 100644 index 00000000..77874ebd --- /dev/null +++ b/test_samples/cards/card_commands.py @@ -0,0 +1,280 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from collections.abc import Awaitable, Callable +from pathlib import Path + +from microsoft_agents.activity import ( + ActionTypes, + AdaptiveCardCard, + AnimationCard, + AudioCard, + CardAction, + CardImage, + ChannelId, + Channels, + Fact, + HeroCard, + MediaUrl, + ReceiptCard, + ReceiptItem, + ThumbnailCard, + ThumbnailUrl, + VideoCard, +) +from microsoft_agents.hosting.core import MessageFactory, TurnContext + +_RESOURCES = Path(__file__).parent / "resources" +_AGENT_IMAGE = ( + "https://github.com/microsoft/Agents-for-net/blob/main/" + "src/images/agent.png?raw=true" +) + +CardHandler = Callable[[TurnContext], Awaitable[None]] + + +def _adaptive_card(resource_name: str) -> AdaptiveCardCard: + return AdaptiveCardCard( + content=(_RESOURCES / resource_name).read_text(encoding="utf-8") + ) + + +async def _send_attachment(context: TurnContext, attachment) -> None: + await context.send_activity(MessageFactory.attachment(attachment)) + + +async def send_static_submit_card(context: TurnContext) -> None: + await _send_attachment( + context, + _adaptive_card("StaticSearchCard.json").to_attachment(), + ) + + +async def send_dynamic_search_card(context: TurnContext) -> None: + await _send_attachment( + context, + _adaptive_card("DynamicSearchCard.json").to_attachment(), + ) + + +async def send_action_execute_card(context: TurnContext) -> None: + await _send_attachment( + context, + _adaptive_card("ActionExecuteWithRefresh.json").to_attachment(), + ) + + +async def send_hero_card(context: TurnContext) -> None: + card = HeroCard( + title="Hero Card", + text=( + "Microsoft 365 Agents SDK provides an integrated environment " + "purpose-built for agent development." + ), + images=[CardImage(url=_AGENT_IMAGE)], + buttons=[ + CardAction( + type=ActionTypes.open_url, + title="Agents SDK", + value="https://learn.microsoft.com/microsoft-365/agents-sdk/", + ), + CardAction( + type=ActionTypes.open_url, + title="Agents SDK API", + value=( + "https://learn.microsoft.com/python/api/" + "?view=m365-agents-sdk" + ), + ), + ], + ) + await _send_attachment(context, card.to_attachment()) + + +async def send_thumbnail_card(context: TurnContext) -> None: + card = ThumbnailCard( + title="Thumbnail Card", + text=( + "Microsoft 365 Agents SDK provides an integrated environment " + "purpose-built for agent development." + ), + images=[CardImage(url=_AGENT_IMAGE)], + buttons=[ + CardAction( + type=ActionTypes.open_url, + title="Agents SDK", + value="https://learn.microsoft.com/microsoft-365/agents-sdk/", + ) + ], + ) + await _send_attachment(context, card.to_attachment()) + + +async def send_audio_card(context: TurnContext) -> None: + card = AudioCard( + title="I am your father", + subtitle="Star Wars: Episode V - The Empire Strikes Back", + text=( + "A media-card example using an audio clip and an external " + "information link." + ), + image=ThumbnailUrl( + url=( + "https://upload.wikimedia.org/wikipedia/en/3/3c/" + "SW_-_Empire_Strikes_Back.jpg" + ) + ), + media=[ + MediaUrl( + url=( + "https://www.mediacollege.com/downloads/sound-effects/" + "star-wars/darthvader/darthvader_yourfather.wav" + ) + ) + ], + buttons=[ + CardAction( + type=ActionTypes.open_url, + title="Read More", + value=( + "https://en.wikipedia.org/wiki/" + "The_Empire_Strikes_Back" + ), + ) + ], + ) + await _send_attachment(context, card.to_attachment()) + + +async def send_video_card(context: TurnContext) -> None: + card = VideoCard( + title="Big Buck Bunny", + subtitle="by the Blender Institute", + text=( + "Big Buck Bunny is an open-source animated short film created " + "with Blender." + ), + aspect="4:3", + image=ThumbnailUrl( + url=( + "https://upload.wikimedia.org/wikipedia/commons/thumb/c/c5/" + "Big_buck_bunny_poster_big.jpg/220px-" + "Big_buck_bunny_poster_big.jpg" + ) + ), + media=[ + MediaUrl( + url=( + "http://download.blender.org/peach/bigbuckbunny_movies/" + "BigBuckBunny_320x180.mp4" + ) + ) + ], + buttons=[ + CardAction( + type=ActionTypes.open_url, + title="Learn More", + value="https://peach.blender.org/", + ) + ], + ) + await _send_attachment(context, card.to_attachment()) + + +async def send_animation_card(context: TurnContext) -> None: + card = AnimationCard( + title="Animation Card", + media=[MediaUrl(url="https://i.giphy.com/Ki55RUbOV5njy.gif")], + aspect="4:3", + ) + await _send_attachment(context, card.to_attachment()) + + +async def send_receipt_card(context: TurnContext) -> None: + card = ReceiptCard( + title="John Doe", + facts=[ + Fact(key="Order Number", value="1234"), + Fact(key="Payment Method", value="VISA 5555-****"), + ], + items=[ + ReceiptItem( + title="Data Transfer", + price="$ 38.45", + quantity="368", + image=CardImage( + url=( + "https://github.com/amido/azure-vector-icons/raw/" + "master/renders/traffic-manager.png" + ) + ), + ), + ReceiptItem( + title="App Service", + price="$ 45.00", + quantity="720", + image=CardImage( + url=( + "https://github.com/amido/azure-vector-icons/raw/" + "master/renders/cloud-service.png" + ) + ), + ), + ], + tax="$ 7.50", + total="$ 90.95", + buttons=[ + CardAction( + type=ActionTypes.open_url, + title="More information", + value="https://azure.microsoft.com/pricing/", + ) + ], + ) + await _send_attachment(context, card.to_attachment()) + + +_CARD_COMMANDS: dict[str, CardHandler] = { + "static_submit": send_static_submit_card, + "dynamic_search": send_dynamic_search_card, + "action_execute": send_action_execute_card, + "hero": send_hero_card, + "thumbnail": send_thumbnail_card, + "audio": send_audio_card, + "video": send_video_card, + "animation": send_animation_card, + "receipt": send_receipt_card, +} + + +async def send_card_commands(context: TurnContext) -> None: + card = HeroCard( + title="Types of cards", + buttons=[ + CardAction( + type=ActionTypes.im_back, + title=command, + value=command, + ) + for command in _CARD_COMMANDS + ], + ) + await _send_attachment(context, card.to_attachment()) + + +async def handle_card_command(context: TurnContext) -> bool: + command = (context.activity.text or "").strip().lower() + handler = _CARD_COMMANDS.get(command) + if handler is None: + await send_card_commands(context) + return False + + if ( + command in {"dynamic_search", "action_execute"} + and ChannelId.get_channel(context.activity.channel_id) != Channels.ms_teams + ): + await context.send_activity(f"Only Teams supports `{command}`.") + return True + + await handler(context) + return True diff --git a/test_samples/cards/env.TEMPLATE b/test_samples/cards/env.TEMPLATE new file mode 100644 index 00000000..3c1c93a5 --- /dev/null +++ b/test_samples/cards/env.TEMPLATE @@ -0,0 +1,3 @@ +CONNECTIONS__SERVICE_CONNECTION__SETTINGS__CLIENTID=client-id +CONNECTIONS__SERVICE_CONNECTION__SETTINGS__CLIENTSECRET=client-secret +CONNECTIONS__SERVICE_CONNECTION__SETTINGS__TENANTID=tenant-id diff --git a/test_samples/cards/resources/ActionExecuteSignIn.json b/test_samples/cards/resources/ActionExecuteSignIn.json new file mode 100644 index 00000000..aca39c9c --- /dev/null +++ b/test_samples/cards/resources/ActionExecuteSignIn.json @@ -0,0 +1,22 @@ +{ + "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", + "version": "1.5", + "type": "AdaptiveCard", + "body": [ + { + "type": "TextBlock", + "text": "Simulated Sign In" + }, + { + "type": "ActionSet", + "fallback": "drop", + "actions": [ + { + "type": "Action.Execute", + "title": "Sign In", + "verb": "signin" + } + ] + } + ] +} diff --git a/test_samples/cards/resources/ActionExecuteSignOut.json b/test_samples/cards/resources/ActionExecuteSignOut.json new file mode 100644 index 00000000..1c0e6ca5 --- /dev/null +++ b/test_samples/cards/resources/ActionExecuteSignOut.json @@ -0,0 +1,22 @@ +{ + "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", + "version": "1.5", + "type": "AdaptiveCard", + "body": [ + { + "type": "TextBlock", + "text": "Simulated Sign Out" + }, + { + "type": "ActionSet", + "fallback": "drop", + "actions": [ + { + "type": "Action.Execute", + "title": "Sign out", + "verb": "signout" + } + ] + } + ] +} diff --git a/test_samples/cards/resources/ActionExecuteWithRefresh.json b/test_samples/cards/resources/ActionExecuteWithRefresh.json new file mode 100644 index 00000000..fa755fda --- /dev/null +++ b/test_samples/cards/resources/ActionExecuteWithRefresh.json @@ -0,0 +1,19 @@ +{ + "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", + "version": "1.5", + "type": "AdaptiveCard", + "refresh": { + "action": { + "fallback": "drop", + "type": "Action.Execute", + "title": "Refresh", + "verb": "refresh" + } + }, + "body": [ + { + "type": "TextBlock", + "text": "Show Action.Execute" + } + ] +} diff --git a/test_samples/cards/resources/DynamicSearchCard.json b/test_samples/cards/resources/DynamicSearchCard.json new file mode 100644 index 00000000..382a83d4 --- /dev/null +++ b/test_samples/cards/resources/DynamicSearchCard.json @@ -0,0 +1,53 @@ +{ + "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", + "version": "1.3", + "type": "AdaptiveCard", + "body": [ + { + "type": "TextBlock", + "size": "medium", + "weight": "bolder", + "text": "Dynamic Adaptive Card" + }, + { + "type": "TextBlock", + "text": "Select from the list, or start typing a package name.", + "wrap": true + }, + { + "type": "Input.ChoiceSet", + "id": "choiceSelect", + "placeholder": "Package name", + "label": "PyPI package search", + "isRequired": true, + "errorMessage": "There was an error", + "isMultiSelect": true, + "style": "filtered", + "choices": [ + { + "title": "microsoft-agents-activity", + "value": "microsoft-agents-activity" + }, + { + "title": "microsoft-agents-hosting-core", + "value": "microsoft-agents-hosting-core" + }, + { + "title": "microsoft-agents-copilotstudio-client", + "value": "microsoft-agents-copilotstudio-client" + } + ], + "choices.data": { + "type": "Data.Query", + "dataset": "pypipackages" + } + } + ], + "actions": [ + { + "type": "Action.Submit", + "title": "Submit", + "data": {"verb": "DynamicSubmit"} + } + ] +} diff --git a/test_samples/cards/resources/StaticSearchCard.json b/test_samples/cards/resources/StaticSearchCard.json new file mode 100644 index 00000000..8a5c8b54 --- /dev/null +++ b/test_samples/cards/resources/StaticSearchCard.json @@ -0,0 +1,43 @@ +{ + "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", + "version": "1.3", + "type": "AdaptiveCard", + "body": [ + { + "type": "TextBlock", + "size": "medium", + "weight": "bolder", + "text": "Static Adaptive Card" + }, + { + "type": "TextBlock", + "text": "Select an IDE from the list.", + "wrap": true + }, + { + "type": "Input.ChoiceSet", + "id": "choiceSelect", + "placeholder": "Search for an IDE", + "style": "filtered", + "choices": [ + {"title": "Visual Studio", "value": "visual_studio"}, + {"title": "IntelliJ IDEA", "value": "intellij_idea"}, + {"title": "Aptana Studio 3", "value": "aptana_studio_3"}, + {"title": "PyCharm", "value": "pycharm"}, + {"title": "PhpStorm", "value": "phpstorm"}, + {"title": "WebStorm", "value": "webstorm"}, + {"title": "NetBeans", "value": "netbeans"}, + {"title": "Eclipse", "value": "eclipse"}, + {"title": "RubyMine", "value": "rubymine"}, + {"title": "Visual Studio Code", "value": "visual_studio_code"} + ] + } + ], + "actions": [ + { + "type": "Action.Submit", + "title": "Submit", + "data": {"verb": "StaticSubmit"} + } + ] +} diff --git a/test_samples/cards/start_server.py b/test_samples/cards/start_server.py new file mode 100644 index 00000000..7be62c23 --- /dev/null +++ b/test_samples/cards/start_server.py @@ -0,0 +1,32 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from os import environ + +from aiohttp.web import Application, Request, Response, run_app + +from microsoft_agents.hosting.aiohttp import ( + CloudAdapter, + jwt_authorization_middleware, + start_agent_process, +) +from microsoft_agents.hosting.core import AgentApplication, AgentAuthConfiguration + + +def start_server( + agent_application: AgentApplication, + auth_configuration: AgentAuthConfiguration, +) -> None: + async def entry_point(request: Request) -> Response: + adapter: CloudAdapter = request.app["adapter"] + agent: AgentApplication = request.app["agent_app"] + return await start_agent_process(request, agent, adapter) + + web_app = Application(middlewares=[jwt_authorization_middleware]) + web_app.router.add_post("/api/messages", entry_point) + web_app.router.add_get("/", lambda _: Response(text="Cards sample")) + web_app["agent_configuration"] = auth_configuration + web_app["agent_app"] = agent_application + web_app["adapter"] = agent_application.adapter + + run_app(web_app, host="localhost", port=int(environ.get("PORT", 3978))) diff --git a/tests/hosting_core/app/test_adaptive_card.py b/tests/hosting_core/app/test_adaptive_card.py new file mode 100644 index 00000000..9760afd5 --- /dev/null +++ b/tests/hosting_core/app/test_adaptive_card.py @@ -0,0 +1,365 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import re + +import pytest + +from microsoft_agents.activity import ( + Activity, + ActivityTypes, + AdaptiveCardInvokeResponse, + ContentTypes, +) +from microsoft_agents.hosting.core import MemoryStorage, TurnContext +from microsoft_agents.hosting.core.app import ( + AgentApplication, + ApplicationOptions, + TurnState, +) +from microsoft_agents.hosting.core.app.adaptive_card import AdaptiveCard +from microsoft_agents.hosting.core.app.adaptive_card.models import ( + AdaptiveCardSearchResult, +) +from microsoft_agents.hosting.core.app.oauth import Authorization +from tests._common.testing_objects import TestingConnectionManager as _ConnectionManager + + +class _StubAdapter: + def __init__(self): + self.sent_activities: list[Activity] = [] + + async def send_activities(self, context, activities): + self.sent_activities.extend(activities) + return [None] * len(activities) + + +def _make_app() -> AgentApplication[TurnState]: + storage = MemoryStorage() + return AgentApplication[TurnState]( + options=ApplicationOptions(storage=storage), + authorization=Authorization( + storage=storage, + connection_manager=_ConnectionManager(), + ), + ) + + +def _make_context(activity_type: str, **kwargs) -> TurnContext: + activity = Activity( + type=activity_type, + channel_id="test", + conversation={"id": "conv1"}, + from_property={"id": "user1"}, + recipient={"id": "bot1"}, + service_url="https://test", + **kwargs, + ) + return TurnContext(_StubAdapter(), activity) + + +def _action_execute_value(verb: str, data: dict | None = None) -> dict: + return { + "action": { + "type": "Action.Execute", + "id": "action-id", + "verb": verb, + "data": data or {"testKey": "test-value"}, + }, + "authentication": {}, + } + + +def _search_value(dataset: str) -> dict: + return { + "kind": "search", + "queryText": "test-query", + "queryOptions": {"skip": 0, "top": 15}, + "dataset": dataset, + } + + +def test_regex_match_searches_value_like_dotnet(): + assert AdaptiveCard._matches(re.compile("save"), "prefix-save-suffix") + assert not AdaptiveCard._matches(re.compile("^save$"), "prefix-save-suffix") + + +@pytest.mark.asyncio +async def test_action_execute_exact_verb_matches(): + app = _make_app() + received_data = None + + @app.adaptive_card.action_execute("test-verb") + async def handler(context, state, data): + nonlocal received_data + received_data = data + return AdaptiveCardInvokeResponse( + status_code=200, + type=ContentTypes.message, + value="handled", + ) + + context = _make_context( + ActivityTypes.invoke, + name="adaptiveCard/action", + value=_action_execute_value("test-verb"), + ) + + await app._on_activity(context, TurnState()) + + assert received_data == {"testKey": "test-value"} + assert len(context.adapter.sent_activities) == 1 + response = context.adapter.sent_activities[0] + assert response.type == ActivityTypes.invoke_response + assert response.value.status == 200 + assert response.value.body == { + "statusCode": 200, + "type": ContentTypes.message, + "value": "handled", + } + + +@pytest.mark.asyncio +async def test_action_execute_unmatched_verb_is_ignored(): + app = _make_app() + called = False + + @app.adaptive_card.action_execute("test-verb") + async def handler(context, state, data): + nonlocal called + called = True + return AdaptiveCardInvokeResponse(status_code=200) + + context = _make_context( + ActivityTypes.invoke, + name="adaptiveCard/action", + value=_action_execute_value("other-verb"), + ) + + await app._on_activity(context, TurnState()) + + assert not called + assert context.adapter.sent_activities == [] + + +@pytest.mark.asyncio +async def test_action_execute_invalid_value_is_ignored(): + app = _make_app() + called = False + + @app.adaptive_card.action_execute("test-verb") + async def handler(context, state, data): + nonlocal called + called = True + return AdaptiveCardInvokeResponse(status_code=200) + + context = _make_context( + ActivityTypes.invoke, + name="adaptiveCard/action", + value="not-an-invoke-value", + ) + + await app._on_activity(context, TurnState()) + + assert not called + assert context.adapter.sent_activities == [] + + +@pytest.mark.asyncio +async def test_action_execute_regex_matches_substring(): + app = _make_app() + received_data = None + + @app.adaptive_card.action_execute(re.compile("save")) + async def handler(context, state, data): + nonlocal received_data + received_data = data + return AdaptiveCardInvokeResponse( + status_code=200, + type=ContentTypes.message, + value="saved", + ) + + context = _make_context( + ActivityTypes.invoke, + name="adaptiveCard/action", + value=_action_execute_value("prefix-save-suffix", {"id": 1}), + ) + + await app._on_activity(context, TurnState()) + + assert received_data == {"id": 1} + assert len(context.adapter.sent_activities) == 1 + assert context.adapter.sent_activities[0].type == ActivityTypes.invoke_response + + +@pytest.mark.asyncio +async def test_action_submit_matches_value_filter(): + app = _make_app() + received_data = None + + @app.adaptive_card.action_submit("submit") + async def handler(context, state, data): + nonlocal received_data + received_data = data + + context = _make_context( + ActivityTypes.message, + value={"verb": "submit", "id": 1}, + ) + + await app._on_activity(context, TurnState()) + + assert received_data == {"verb": "submit", "id": 1} + + +@pytest.mark.asyncio +async def test_action_submit_unmatched_verb_is_ignored(): + app = _make_app() + called = False + + @app.adaptive_card.action_submit("expected") + async def handler(context, state, data): + nonlocal called + called = True + + context = _make_context( + ActivityTypes.message, + value={"verb": "other"}, + ) + + await app._on_activity(context, TurnState()) + + assert not called + + +@pytest.mark.asyncio +async def test_action_submit_message_with_text_is_ignored(): + app = _make_app() + called = False + + @app.adaptive_card.action_submit("submit") + async def handler(context, state, data): + nonlocal called + called = True + + context = _make_context( + ActivityTypes.message, + text="not an Action.Submit activity", + value={"verb": "submit"}, + ) + + await app._on_activity(context, TurnState()) + + assert not called + + +@pytest.mark.asyncio +async def test_search_exact_dataset_matches(): + app = _make_app() + received_query = None + + @app.adaptive_card.search("test-dataset") + async def handler(context, state, query): + nonlocal received_query + received_query = query + return [AdaptiveCardSearchResult(title="Title", value="Value")] + + context = _make_context( + ActivityTypes.invoke, + name="application/search", + value=_search_value("test-dataset"), + ) + + await app._on_activity(context, TurnState()) + + assert received_query.parameters.query_text == "test-query" + assert received_query.parameters.dataset == "test-dataset" + assert received_query.skip == 0 + assert received_query.count == 15 + assert len(context.adapter.sent_activities) == 1 + response = context.adapter.sent_activities[0] + assert response.type == ActivityTypes.invoke_response + assert response.value.status == 200 + assert response.value.body == { + "statusCode": 200, + "type": "application/vnd.microsoft.search.searchResponse", + "value": {"results": [{"title": "Title", "value": "Value"}]}, + } + + +@pytest.mark.asyncio +async def test_search_unmatched_dataset_is_ignored(): + app = _make_app() + called = False + + @app.adaptive_card.search("expected-dataset") + async def handler(context, state, query): + nonlocal called + called = True + return [] + + context = _make_context( + ActivityTypes.invoke, + name="application/search", + value=_search_value("other-dataset"), + ) + + await app._on_activity(context, TurnState()) + + assert not called + assert context.adapter.sent_activities == [] + + +@pytest.mark.asyncio +async def test_search_invalid_value_is_ignored(): + app = _make_app() + called = False + + @app.adaptive_card.search("test-dataset") + async def handler(context, state, query): + nonlocal called + called = True + return [] + + context = _make_context( + ActivityTypes.invoke, + name="application/search", + value={"dataset": "test-dataset"}, + ) + + await app._on_activity(context, TurnState()) + + assert not called + assert context.adapter.sent_activities == [] + + +@pytest.mark.asyncio +async def test_search_regex_matches_substring(): + app = _make_app() + received_query = None + + @app.adaptive_card.search(re.compile("products")) + async def handler(context, state, query): + nonlocal received_query + received_query = query + return [AdaptiveCardSearchResult(title="Product", value="product-1")] + + context = _make_context( + ActivityTypes.invoke, + name="application/search", + value={ + "kind": "search", + "queryText": "prod", + "queryOptions": {"skip": 2, "top": 5}, + "dataset": "contoso-products-v2", + }, + ) + + await app._on_activity(context, TurnState()) + + assert received_query.parameters.query_text == "prod" + assert received_query.parameters.dataset == "contoso-products-v2" + assert received_query.skip == 2 + assert received_query.count == 5 + assert len(context.adapter.sent_activities) == 1