diff --git a/.azdo/ci-pr.yaml b/.azdo/ci-pr.yaml index 2447de33d..4ff9e892f 100644 --- a/.azdo/ci-pr.yaml +++ b/.azdo/ci-pr.yaml @@ -73,10 +73,10 @@ steps: python -m pip install ./dist/microsoft_agents_copilotstudio_client*.whl python -m pip install ./dist/microsoft_agents_hosting_aiohttp*.whl python -m pip install ./dist/microsoft_agents_hosting_dialogs*.whl - if python -c "import sys; sys.exit(0 if sys.version_info >= (3, 12) else 1)"; then - python -m pip install ./dist/microsoft_agents_hosting_teams*.whl + if python -c "import sys; sys.exit(0 if sys.version_info >= (3, 11) else 1)"; then + python -m pip install ./dist/microsoft_agents_hosting_msteams*.whl else - echo "Skipping microsoft_agents_hosting_teams: requires Python 3.12+" + echo "Skipping microsoft_agents_hosting_msteams: requires Python 3.11+" fi python -m pip install ./dist/microsoft_agents_hosting_slack*.whl python -m pip install ./dist/microsoft_agents_storage_blob*.whl diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index 2f13f4e97..297e4cfd7 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -62,10 +62,10 @@ jobs: python -m pip install ./dist/microsoft_agents_copilotstudio_client*.whl python -m pip install ./dist/microsoft_agents_hosting_aiohttp*.whl python -m pip install ./dist/microsoft_agents_hosting_dialogs*.whl - if python -c "import sys; sys.exit(0 if sys.version_info >= (3, 12) else 1)"; then - python -m pip install ./dist/microsoft_agents_hosting_teams*.whl + if python -c "import sys; sys.exit(0 if sys.version_info >= (3, 11) else 1)"; then + python -m pip install ./dist/microsoft_agents_hosting_msteams*.whl else - echo "Skipping microsoft_agents_hosting_teams: requires Python 3.12+" + echo "Skipping microsoft_agents_hosting_msteams: requires Python 3.11+" fi python -m pip install ./dist/microsoft_agents_hosting_slack*.whl python -m pip install ./dist/microsoft_agents_storage_blob*.whl diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/__init__.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/__init__.py index e4edc5258..8a4b57e4e 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/__init__.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/__init__.py @@ -45,6 +45,8 @@ ProductInfo, Thing, StreamInfo, + ActivityTreatment, + ActivityTreatmentTypes, ) from .error import Error from .error_response import ErrorResponse @@ -199,5 +201,7 @@ "load_configuration_from_env", "ChannelAdapterProtocol", "TurnContextProtocol", + "ActivityTreatment", + "ActivityTreatmentTypes", "TokenOrSignInResourceResponse", ] diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/__init__.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/__init__.py index 32345420a..80259ccfa 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/__init__.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/__init__.py @@ -1,6 +1,7 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. +from .activity_treatment import ActivityTreatment, ActivityTreatmentTypes from .mention import Mention from .entity import Entity from .entity_types import EntityTypes @@ -35,4 +36,6 @@ "Place", "ProductInfo", "Thing", + "ActivityTreatment", + "ActivityTreatmentTypes", ] diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/activity_treatment.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/activity_treatment.py new file mode 100644 index 000000000..5318b29cd --- /dev/null +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/activity_treatment.py @@ -0,0 +1,27 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from enum import Enum +from typing import Literal + +from .entity import Entity +from .entity_types import EntityTypes + + +class ActivityTreatmentTypes(str, Enum): + """Well-known enumeration of activity treatment types.""" + + TARGETED = "targeted" + + +class ActivityTreatment(Entity): + """Activity treatment information (entity type: "activityTreatment"). + + :param treatment: The type of treatment + :type treatment: ~microsoft_agents.activity.ActivityTreatmentTypes + :param type: Type of this entity (RFC 3987 IRI) + :type type: str + """ + + type: Literal[EntityTypes.ACTIVITY_TREATMENT] = EntityTypes.ACTIVITY_TREATMENT + treatment: ActivityTreatmentTypes diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/entity_types.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/entity_types.py index 0cb3da0fe..66b3faf57 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/entity_types.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/entity_types.py @@ -7,6 +7,7 @@ class EntityTypes(str, Enum): """Well-known enumeration of entity types.""" + ACTIVITY_TREATMENT = "activityTreatment" GEO_COORDINATES = "GeoCoordinates" MENTION = "mention" PLACE = "Place" diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/_type_defs.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/_type_defs.py index 2520a2941..e0e195dfc 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/_type_defs.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/_type_defs.py @@ -13,3 +13,9 @@ class RouteHandler(Protocol[StateT]): def __call__(self, context: TurnContext, state: StateT, /) -> Awaitable[None]: ... + + +class HandoffHandler(Protocol[StateT]): + def __call__( + self, context: TurnContext, state: StateT, handoff_data: str + ) -> Awaitable[None]: ... 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 2ce8e082e..de32b5cd1 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 @@ -43,7 +43,11 @@ from .typing_indicator import TypingIndicator from .telemetry import spans -from ._type_defs import RouteHandler, RouteSelector +from ._type_defs import ( + RouteHandler, + HandoffHandler, + RouteSelector, +) from ._routes import _RouteList, _Route, RouteRank, _agentic_selector from .proactive import Proactive, ProactiveOptions @@ -69,7 +73,7 @@ class AgentApplication(Agent, Generic[StateT]): _options: ApplicationOptions _adapter: Optional[ChannelServiceAdapter] = None - _auth: Optional[Authorization] = None + _auth: Authorization _proactive: Optional[Proactive] = None _internal_before_turn: list[Callable[[TurnContext, StateT], Awaitable[bool]]] _internal_after_turn: list[Callable[[TurnContext, StateT], Awaitable[bool]]] @@ -614,11 +618,11 @@ def __call(func: RouteHandler[StateT]) -> RouteHandler[StateT]: @overload def handoff( self, - func: Callable[[TurnContext, StateT, str], Awaitable[None]], + func: HandoffHandler[StateT], *, auth_handlers: Optional[list[str]] = None, **kwargs, - ) -> Callable[[TurnContext, StateT, str], Awaitable[None]]: ... + ) -> HandoffHandler[StateT]: ... @overload def handoff( @@ -627,21 +631,21 @@ def handoff( auth_handlers: Optional[list[str]] = None, **kwargs: Any, ) -> Callable[ - [Callable[[TurnContext, StateT, str], Awaitable[None]]], - Callable[[TurnContext, StateT, str], Awaitable[None]], + [HandoffHandler[StateT]], + HandoffHandler[StateT], ]: ... def handoff( self, - func: Optional[Callable[[TurnContext, StateT, str], Awaitable[None]]] = None, + func: Optional[HandoffHandler[StateT]] = None, *, auth_handlers: Optional[list[str]] = None, **kwargs, ) -> ( - Callable[[TurnContext, StateT, str], Awaitable[None]] + HandoffHandler[StateT] | Callable[ - [Callable[[TurnContext, StateT, str], Awaitable[None]]], - Callable[[TurnContext, StateT, str], Awaitable[None]], + [HandoffHandler[StateT]], + HandoffHandler[StateT], ] ): """ @@ -655,7 +659,7 @@ async def on_handoff(context: TurnContext, state: TurnState, continuation: str): print(continuation) :param func: Optional handler to register directly without using decorator syntax. - :type func: Optional[Callable[[TurnContext, StateT, str], Awaitable[None]]] + :type func: Optional[HandoffHandler[StateT]] :param auth_handlers: Optional list of authorization handler IDs for the route. :type auth_handlers: Optional[list[str]] :param kwargs: Additional route configuration passed to :meth:`microsoft_agents.hosting.core.AgentApplication.add_route`. @@ -668,8 +672,8 @@ def __selector(context: TurnContext) -> bool: ) def __call( - func: Callable[[TurnContext, StateT, str], Awaitable[None]], - ) -> Callable[[TurnContext, StateT, str], Awaitable[None]]: + func: HandoffHandler[StateT], + ) -> HandoffHandler[StateT]: async def __handler(context: TurnContext, state: StateT): if ( isinstance(context.activity.value, dict) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/app_options.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/app_options.py index 27a2013f7..bb3ebc6ff 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/app_options.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/app_options.py @@ -10,6 +10,7 @@ from typing import Callable, Optional from microsoft_agents.hosting.core.app.oauth import AuthHandler +from microsoft_agents.hosting.core.authorization import Connections from microsoft_agents.hosting.core.storage import Storage # from .auth import AuthOptions 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 ac56b93d0..3bd9a7cd5 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 @@ -27,6 +27,8 @@ class TurnContext(TurnContextProtocol): # Same constant as in the BF Adapter, duplicating here to avoid circular dependency _INVOKE_RESPONSE_KEY = "TurnContext.InvokeResponse" + _activity: Activity + def __init__( self, adapter_or_context, @@ -43,7 +45,7 @@ def __init__( self._identity = adapter_or_context.identity else: self.adapter = adapter_or_context - self._activity = request + self._activity = request # exception thrown if None further down self.responses: list[Activity] = [] self._services: dict = {} self._on_send_activities: Callable[ @@ -60,7 +62,7 @@ def __init__( if self.adapter is None: raise TypeError("TurnContext must be instantiated with an adapter.") - if self.activity is None: + if self._activity is None: raise TypeError( "TurnContext must be instantiated with a request parameter of type Activity." ) @@ -83,7 +85,7 @@ def copy_to(self, context: "TurnContext") -> None: """ for attribute in [ "adapter", - "activity", + "_activity", "_responded", "_services", "_on_send_activities", @@ -187,7 +189,7 @@ async def send_activity( activity_or_text: Activity | str, speak: str | None = None, input_hint: str | None = None, - ) -> ResourceResponse | None: + ) -> ResourceResponse: """ Sends a single activity or message to the user. :param activity_or_text: @@ -203,7 +205,7 @@ async def send_activity( activity_or_text.speak = speak result = await self.send_activities([activity_or_text]) - return result[0] if result else None + return result[0] if result else ResourceResponse() async def send_activities( self, activities: list[Activity] diff --git a/libraries/microsoft-agents-hosting-teams/LICENSE b/libraries/microsoft-agents-hosting-msteams/LICENSE similarity index 100% rename from libraries/microsoft-agents-hosting-teams/LICENSE rename to libraries/microsoft-agents-hosting-msteams/LICENSE diff --git a/libraries/microsoft-agents-hosting-teams/MANIFEST.in b/libraries/microsoft-agents-hosting-msteams/MANIFEST.in similarity index 100% rename from libraries/microsoft-agents-hosting-teams/MANIFEST.in rename to libraries/microsoft-agents-hosting-msteams/MANIFEST.in diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/__init__.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/__init__.py new file mode 100644 index 000000000..dd8ab8626 --- /dev/null +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/__init__.py @@ -0,0 +1,39 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""Microsoft 365 Agents SDK -- Microsoft Teams hosting extension. + +This package layers Teams-specific functionality on top of an +:class:`~microsoft_agents.hosting.core.AgentApplication`. Its entry point is +:class:`TeamsAgentExtension`, which exposes namespaced route registration for +Teams events (channels, teams, meetings, messages, message extensions, task +modules, configuration, and file consent). It also provides the Teams-aware +:class:`TeamsTurnContext` and :class:`TeamsActivity` helpers. +""" + +from .teams_agent_extension import TeamsAgentExtension +from .channel import Channel +from .config import Config +from .file_consent import FileConsent +from .meeting import Meeting +from .message import Message +from .message_extension import MessageExtension +from .task_module import TaskModule +from .team import Team + +from .teams_activity import TeamsActivity +from .teams_turn_context import TeamsTurnContext + +__all__ = [ + "TeamsAgentExtension", + "Channel", + "Config", + "FileConsent", + "Meeting", + "Message", + "MessageExtension", + "TaskModule", + "Team", + "TeamsActivity", + "TeamsTurnContext", +] diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/_graph.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/_graph.py new file mode 100644 index 000000000..4d4b18aba --- /dev/null +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/_graph.py @@ -0,0 +1,86 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""Microsoft Graph client integration for the Teams hosting layer. + +Builds a :class:`msgraph.GraphServiceClient` whose requests are authenticated +with tokens obtained from the agent's configured authorization, so handlers can +call Microsoft Graph on behalf of the current turn. +""" + +from typing import Any + +from kiota_abstractions.request_information import RequestInformation +from kiota_abstractions.authentication import AuthenticationProvider + +from msgraph import GraphServiceClient, GraphRequestAdapter + +from microsoft_agents.hosting.core import ( + AgentApplication, + TurnContext, +) + + +class _SDKAuthenticationProvider(AuthenticationProvider): + """Kiota authentication provider backed by the agent's authorization. + + Acquires an access token for the current turn via + :meth:`AgentApplication.auth.get_token` and attaches it to outgoing Graph + requests as a bearer token. + """ + + def __init__( + self, + app: AgentApplication, + context: TurnContext, + handler_name: str | None = None, + ): + """Capture the context needed to resolve a token at request time. + + :param app: The agent application whose authorization issues tokens. + :param context: The current turn context. + :param handler_name: The auth handler name used to acquire the token. + """ + self._app = app + self._context = context + self._handler_name = handler_name + + async def authenticate_request( + self, + request: RequestInformation, + additional_authentication_context: dict[str, Any] | None = None, + ) -> None: + """Attach a bearer token to the outgoing Graph request. + + :param request: The request to authenticate. + :param additional_authentication_context: Optional Kiota authentication + context; unused but accepted to satisfy the provider interface. + """ + if additional_authentication_context is None: + additional_authentication_context = {} + + token_response = await self._app.auth.get_token( + self._context, self._handler_name + ) + if token_response: + request.headers["Authorization"] = f"Bearer {token_response.token}" + + +def _create_graph_service_client( + app: AgentApplication, + context: TurnContext, + handler_name: str | None = None, +) -> GraphServiceClient: + """Create a Graph client authenticated for the current turn. + + :param app: The agent application whose authorization issues tokens. + :param context: The current turn context. + :param handler_name: Optional auth handler name used to acquire the token. + :return: A :class:`GraphServiceClient` that authenticates each request via + the agent's authorization. + """ + return GraphServiceClient( + request_adapter=GraphRequestAdapter( + _SDKAuthenticationProvider(app, context, handler_name) + ) + ) diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/_teams_api_client.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/_teams_api_client.py new file mode 100644 index 000000000..a19863230 --- /dev/null +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/_teams_api_client.py @@ -0,0 +1,78 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""Construction and caching of the Teams :class:`ApiClient` for a turn. + +The client is cached on the turn state so it is built at most once per turn, and +is configured with a token factory derived from the turn's identity when one is +available. +""" + +from microsoft_teams.common import ClientOptions +from microsoft_teams.api import ApiClient + +from microsoft_agents.hosting.core import ( + Connections, + TurnContext, +) + +_TEAMS_API_CLIENT_KEY = "TeamsApiClient" + + +def _get_teams_api_client(context: TurnContext) -> ApiClient: + """ + Get the cached Teams API client from the context. + + :param context: The turn context. + :return: The cached Teams API client. + :raises ValueError: If the Teams API client is not found. + """ + api_client = context.turn_state.get(_TEAMS_API_CLIENT_KEY) + if isinstance(api_client, ApiClient): + return api_client + raise ValueError("Unable to retrieve Teams API client.") + + +def _set_teams_api_client( + context: TurnContext, connection_manager: Connections +) -> None: + """ + Set the Teams API client in the context if it is not already set. + + :param context: The turn context. + :param connection_manager: The connection manager. + """ + + if _TEAMS_API_CLIENT_KEY in context.turn_state: + return + + headers = { + "Accept": "application/json", + "Content-Type": "application/json", + } + + options: ClientOptions + + if context.identity: + provider = connection_manager.get_token_provider( + context.identity, context.activity.service_url + ) + + async def token_factory() -> str: + return await provider.get_access_token( + "https://api.botframework.com", + ["https://api.botframework.com/.default"], + ) + + options = ClientOptions( + base_url=context.activity.service_url, headers=headers, token=token_factory + ) + else: + options = ClientOptions(base_url=context.activity.service_url, headers=headers) + + api_client = ApiClient( + context.activity.service_url, + options, + ) + + context.turn_state[_TEAMS_API_CLIENT_KEY] = api_client diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/_utils.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/_utils.py new file mode 100644 index 000000000..570e1889e --- /dev/null +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/_utils.py @@ -0,0 +1,148 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""Internal utility helpers for the Teams hosting layer.""" + +import re + +from typing import Any + +from http import HTTPStatus + +from microsoft_teams.api.models.channel_data import ChannelData, ChannelInfo, TeamInfo + +from microsoft_agents.activity import ( + Activity, + ActivityTypes, + InvokeResponse, +) +from microsoft_agents.hosting.core import TurnContext + +from .type_defs import CommandSelector + + +def _try_get_channel_data(activity: Activity) -> ChannelData | None: + """Attempt to extract and parse Teams channel data from the activity's channel_data. + + :param activity: The current activity. + :return: A :class:`ChannelData` instance parsed from channel_data, or None if absent. + """ + data = activity.channel_data + if data is None: + return None + if isinstance(data, ChannelData): + return data + return ChannelData.model_validate(data) + + +def _get_channel_data(activity: Activity) -> ChannelData: + """Extract and parse Teams channel data from the activity's channel_data. + + :param activity: The current activity. + :return: A :class:`ChannelData` instance parsed from channel_data. + :raises ValueError: If channel_data is absent. + :raises pydantic.ValidationError: If channel_data cannot be deserialized. + """ + channel_data = _try_get_channel_data(activity) + if channel_data is None: + raise ValueError("channel_data is required") + return channel_data + + +def _get_channel_info(activity: Activity) -> ChannelInfo: + """Extract Teams channel info from the activity's channel_data. + + :param activity: The current activity. + :return: The parsed :class:`ChannelInfo` from channel_data. + :raises ValueError: If channel_data or channel_data.channel is absent. + :raises pydantic.ValidationError: If channel_data cannot be deserialized. + """ + channel_data = _get_channel_data(activity) + if channel_data.channel is None: + raise ValueError("channel_data.channel is required") + return channel_data.channel + + +def _get_team_info(activity: Activity) -> TeamInfo: + """Extract Teams team info from the activity's channel_data. + + :param activity: The current activity. + :return: The parsed :class:`TeamInfo` from channel_data. + :raises ValueError: If channel_data or channel_data.team is absent. + :raises pydantic.ValidationError: If channel_data cannot be deserialized. + """ + channel_data = _get_channel_data(activity) + if channel_data.team is None: + raise ValueError("channel_data.team is required") + return channel_data.team + + +def _match_selector(selector: CommandSelector, value: str | None) -> bool: + """Return True if *value* matches *selector*. + + :param selector: A literal string, compiled regex, or None. None matches anything. + :param value: The string to test. None never matches a non-None selector. + """ + if selector is None: + return True + if value is None: + return False + if isinstance(selector, str): + return selector == value + return bool(re.fullmatch(selector, value)) + + +def _get_command_id(value: Any) -> str | None: + """Extract the message extension command id from an activity value. + + Handles both raw dicts (camelCase ``commandId`` or snake_case ``command_id``) + and parsed objects exposing either attribute, so command-id matching is + consistent across all message extension selectors. + + :param value: The raw activity value. + :return: The command id if present, otherwise None. + """ + if isinstance(value, dict): + return value.get("commandId") or value.get("command_id") + if value is not None: + return getattr(value, "commandId", None) or getattr(value, "command_id", None) + return None + + +def _get_channel_event_type(context: TurnContext) -> str | None: + """Extract the Teams channel event type from the activity's channel_data. + + :param context: The current turn context. + :return: The event type string (e.g. ``"channelCreated"``), or None if absent. + """ + data = context.activity.channel_data + if data is None: + return None + if isinstance(data, dict): + return data.get("eventType") or data.get("event_type") + return getattr(data, "event_type", None) or getattr(data, "eventType", None) + + +async def _send_invoke_response(context: TurnContext, body: Any = None) -> None: + """Send an invoke response activity with HTTP 200 and an optional body. + + Pydantic models are serialised via ``model_dump``; all other values are passed + through as-is. + + :param context: The current turn context. + :param body: Optional response payload. Pydantic models are auto-serialised to JSON. + """ + serialized_body = None + if body is not None: + if hasattr(body, "model_dump"): + serialized_body = body.model_dump( + mode="json", by_alias=True, exclude_none=True + ) + else: + serialized_body = body + await context.send_activity( + Activity( + type=ActivityTypes.invoke_response, + value=InvokeResponse(status=int(HTTPStatus.OK), body=serialized_body), + ) + ) diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/channel/__init__.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/channel/__init__.py new file mode 100644 index 000000000..076f7a2b0 --- /dev/null +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/channel/__init__.py @@ -0,0 +1,8 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""Teams channel route registration exports.""" + +from .channel import Channel + +__all__ = ["Channel"] diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/channel/channel.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/channel/channel.py new file mode 100644 index 000000000..dac06cab3 --- /dev/null +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/channel/channel.py @@ -0,0 +1,392 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""Route registration helpers for Teams channel conversation update events.""" + +import re +from typing import Generic, Optional, overload + +from microsoft_agents.activity import ActivityTypes, Channels +from microsoft_agents.hosting.core import ( + AgentApplication, + RouteRank, + TurnContext, +) + +from microsoft_agents.hosting.msteams.teams_turn_context import TeamsTurnContext +from microsoft_agents.hosting.msteams.type_defs import ( + _RouteDecorator, + StateT, +) +from microsoft_agents.hosting.msteams._utils import ( + _get_channel_event_type, + _get_channel_info, +) + +from .route_handlers import ChannelUpdateHandler + + +class Channel(Generic[StateT]): + """Route registration for Teams channel conversation update events. + + Access via :attr:`TeamsAgentExtension.channels`. + """ + + def __init__(self, app: AgentApplication[StateT]) -> None: + """Initialise with the owning :class:`AgentApplication`. + + :param app: The application to register routes on. + """ + self._app = app + + def _create_decorator( + self, + event_type: str | re.Pattern, + *, + auth_handlers: Optional[list[str]] = None, + rank: RouteRank = RouteRank.DEFAULT, + ) -> _RouteDecorator[ChannelUpdateHandler[StateT]]: + """Build a route decorator that matches a specific Teams channel event type.""" + + def __selector(context: TurnContext) -> bool: + event_match = False + channel_event_type = _get_channel_event_type(context) + if channel_event_type: + if isinstance(event_type, re.Pattern): + event_match = ( + re.fullmatch(event_type, channel_event_type) is not None + ) + else: + event_match = event_type == channel_event_type + + return ( + context.activity.type == ActivityTypes.conversation_update + and context.activity.channel_id == Channels.ms_teams + and event_match + ) + + def __call(func: ChannelUpdateHandler[StateT]) -> ChannelUpdateHandler[StateT]: + async def __handler(context: TurnContext, state: StateT) -> None: + teams_context = TeamsTurnContext(context, self._app) + channel_info = _get_channel_info(teams_context.activity) + await func(teams_context, state, channel_info) + + self._app.add_route( + __selector, __handler, rank=rank, auth_handlers=auth_handlers + ) + return func + + return __call + + @overload + def event( + self, handler: ChannelUpdateHandler[StateT] + ) -> ChannelUpdateHandler[StateT]: ... + + @overload + def event( + self, *, auth_handlers: Optional[list[str]] = ..., rank: RouteRank = ... + ) -> _RouteDecorator[ChannelUpdateHandler[StateT]]: ... + + def event( + self, + handler: Optional[ChannelUpdateHandler[StateT]] = None, + *, + auth_handlers: Optional[list[str]] = None, + rank: RouteRank = RouteRank.DEFAULT, + ) -> ChannelUpdateHandler[StateT] | _RouteDecorator[ChannelUpdateHandler[StateT]]: + """Register a handler for Teams channel conversation update events. + + :param handler: Optional handler to register directly; omit for decorator-style usage. + :param auth_handlers: Optional list of auth handler names to run before the route. + :param rank: Route priority rank. + :return: The registered handler, or a decorator when used without a handler. + """ + decorator = self._create_decorator( + re.compile(r"channel.*"), auth_handlers=auth_handlers, rank=rank + ) + if handler is not None: + return decorator(handler) + return decorator + + @overload + def created( + self, handler: ChannelUpdateHandler[StateT] + ) -> ChannelUpdateHandler[StateT]: ... + + @overload + def created( + self, *, auth_handlers: Optional[list[str]] = ..., rank: RouteRank = ... + ) -> _RouteDecorator[ChannelUpdateHandler[StateT]]: ... + + def created( + self, + handler: Optional[ChannelUpdateHandler[StateT]] = None, + *, + auth_handlers: Optional[list[str]] = None, + rank: RouteRank = RouteRank.DEFAULT, + ) -> ChannelUpdateHandler[StateT] | _RouteDecorator[ChannelUpdateHandler[StateT]]: + """Register a handler for Teams channelCreated conversation update events. + + :param handler: Optional handler to register directly; omit for decorator-style usage. + :param auth_handlers: Optional list of auth handler names to run before the route. + :param rank: Route priority rank. + :return: The registered handler, or a decorator when used without a handler. + """ + decorator = self._create_decorator( + "channelCreated", auth_handlers=auth_handlers, rank=rank + ) + if handler is not None: + return decorator(handler) + return decorator + + @overload + def deleted( + self, handler: ChannelUpdateHandler[StateT] + ) -> ChannelUpdateHandler[StateT]: ... + + @overload + def deleted( + self, *, auth_handlers: Optional[list[str]] = ..., rank: RouteRank = ... + ) -> _RouteDecorator[ChannelUpdateHandler[StateT]]: ... + + def deleted( + self, + handler: Optional[ChannelUpdateHandler[StateT]] = None, + *, + auth_handlers: Optional[list[str]] = None, + rank: RouteRank = RouteRank.DEFAULT, + ) -> ChannelUpdateHandler[StateT] | _RouteDecorator[ChannelUpdateHandler[StateT]]: + """Register a handler for Teams channelDeleted conversation update events. + + :param handler: Optional handler to register directly; omit for decorator-style usage. + :param auth_handlers: Optional list of auth handler names to run before the route. + :param rank: Route priority rank. + :return: The registered handler, or a decorator when used without a handler. + """ + decorator = self._create_decorator( + "channelDeleted", auth_handlers=auth_handlers, rank=rank + ) + if handler is not None: + return decorator(handler) + return decorator + + @overload + def renamed( + self, handler: ChannelUpdateHandler[StateT] + ) -> ChannelUpdateHandler[StateT]: ... + + @overload + def renamed( + self, *, auth_handlers: Optional[list[str]] = ..., rank: RouteRank = ... + ) -> _RouteDecorator[ChannelUpdateHandler[StateT]]: ... + + def renamed( + self, + handler: Optional[ChannelUpdateHandler[StateT]] = None, + *, + auth_handlers: Optional[list[str]] = None, + rank: RouteRank = RouteRank.DEFAULT, + ) -> ChannelUpdateHandler[StateT] | _RouteDecorator[ChannelUpdateHandler[StateT]]: + """Register a handler for Teams channelRenamed conversation update events. + + :param handler: Optional handler to register directly; omit for decorator-style usage. + :param auth_handlers: Optional list of auth handler names to run before the route. + :param rank: Route priority rank. + :return: The registered handler, or a decorator when used without a handler. + """ + decorator = self._create_decorator( + "channelRenamed", auth_handlers=auth_handlers, rank=rank + ) + if handler is not None: + return decorator(handler) + return decorator + + @overload + def shared( + self, handler: ChannelUpdateHandler[StateT] + ) -> ChannelUpdateHandler[StateT]: ... + + @overload + def shared( + self, *, auth_handlers: Optional[list[str]] = ..., rank: RouteRank = ... + ) -> _RouteDecorator[ChannelUpdateHandler[StateT]]: ... + + def shared( + self, + handler: Optional[ChannelUpdateHandler[StateT]] = None, + *, + auth_handlers: Optional[list[str]] = None, + rank: RouteRank = RouteRank.DEFAULT, + ) -> ChannelUpdateHandler[StateT] | _RouteDecorator[ChannelUpdateHandler[StateT]]: + """Register a handler for Teams channelShared conversation update events. + + :param handler: Optional handler to register directly; omit for decorator-style usage. + :param auth_handlers: Optional list of auth handler names to run before the route. + :param rank: Route priority rank. + :return: The registered handler, or a decorator when used without a handler. + """ + decorator = self._create_decorator( + "channelShared", auth_handlers=auth_handlers, rank=rank + ) + if handler is not None: + return decorator(handler) + return decorator + + @overload + def unshared( + self, handler: ChannelUpdateHandler[StateT] + ) -> ChannelUpdateHandler[StateT]: ... + + @overload + def unshared( + self, *, auth_handlers: Optional[list[str]] = ..., rank: RouteRank = ... + ) -> _RouteDecorator[ChannelUpdateHandler[StateT]]: ... + + def unshared( + self, + handler: Optional[ChannelUpdateHandler[StateT]] = None, + *, + auth_handlers: Optional[list[str]] = None, + rank: RouteRank = RouteRank.DEFAULT, + ) -> ChannelUpdateHandler[StateT] | _RouteDecorator[ChannelUpdateHandler[StateT]]: + """Register a handler for Teams channelUnshared conversation update events. + + :param handler: Optional handler to register directly; omit for decorator-style usage. + :param auth_handlers: Optional list of auth handler names to run before the route. + :param rank: Route priority rank. + :return: The registered handler, or a decorator when used without a handler. + """ + decorator = self._create_decorator( + "channelUnshared", auth_handlers=auth_handlers, rank=rank + ) + if handler is not None: + return decorator(handler) + return decorator + + @overload + def restored( + self, handler: ChannelUpdateHandler[StateT] + ) -> ChannelUpdateHandler[StateT]: ... + + @overload + def restored( + self, *, auth_handlers: Optional[list[str]] = ..., rank: RouteRank = ... + ) -> _RouteDecorator[ChannelUpdateHandler[StateT]]: ... + + def restored( + self, + handler: Optional[ChannelUpdateHandler[StateT]] = None, + *, + auth_handlers: Optional[list[str]] = None, + rank: RouteRank = RouteRank.DEFAULT, + ) -> ChannelUpdateHandler[StateT] | _RouteDecorator[ChannelUpdateHandler[StateT]]: + """Register a handler for Teams channelRestored conversation update events. + + :param handler: Optional handler to register directly; omit for decorator-style usage. + :param auth_handlers: Optional list of auth handler names to run before the route. + :param rank: Route priority rank. + :return: The registered handler, or a decorator when used without a handler. + """ + decorator = self._create_decorator( + "channelRestored", auth_handlers=auth_handlers, rank=rank + ) + if handler is not None: + return decorator(handler) + return decorator + + @overload + def members_added( + self, handler: ChannelUpdateHandler[StateT] + ) -> ChannelUpdateHandler[StateT]: ... + + @overload + def members_added( + self, *, auth_handlers: Optional[list[str]] = ..., rank: RouteRank = ... + ) -> _RouteDecorator[ChannelUpdateHandler[StateT]]: ... + + def members_added( + self, + handler: Optional[ChannelUpdateHandler[StateT]] = None, + *, + auth_handlers: Optional[list[str]] = None, + rank: RouteRank = RouteRank.DEFAULT, + ) -> ChannelUpdateHandler[StateT] | _RouteDecorator[ChannelUpdateHandler[StateT]]: + """Register a handler for Teams membersAdded conversation update events. + + :param handler: Optional handler to register directly; omit for decorator-style usage. + :param auth_handlers: Optional list of auth handler names to run before the route. + :param rank: Route priority rank. + :return: The registered handler, or a decorator when used without a handler. + """ + + def __selector(context: TurnContext) -> bool: + return ( + context.activity.type == ActivityTypes.conversation_update + and context.activity.channel_id == Channels.ms_teams + and isinstance(context.activity.members_added, list) + and len(context.activity.members_added) > 0 + ) + + def __call(func: ChannelUpdateHandler[StateT]) -> ChannelUpdateHandler[StateT]: + async def __func(context: TurnContext, state: StateT) -> None: + teams_context = TeamsTurnContext(context, self._app) + channel_info = _get_channel_info(teams_context.activity) + await func(teams_context, state, channel_info) + + self._app.add_route( + __selector, __func, rank=rank, auth_handlers=auth_handlers + ) + return func + + if handler is not None: + return __call(handler) + return __call + + @overload + def members_removed( + self, handler: ChannelUpdateHandler[StateT] + ) -> ChannelUpdateHandler[StateT]: ... + + @overload + def members_removed( + self, *, auth_handlers: Optional[list[str]] = ..., rank: RouteRank = ... + ) -> _RouteDecorator[ChannelUpdateHandler[StateT]]: ... + + def members_removed( + self, + handler: Optional[ChannelUpdateHandler[StateT]] = None, + *, + auth_handlers: Optional[list[str]] = None, + rank: RouteRank = RouteRank.DEFAULT, + ) -> ChannelUpdateHandler[StateT] | _RouteDecorator[ChannelUpdateHandler[StateT]]: + """Register a handler for Teams membersRemoved conversation update events. + + :param handler: Optional handler to register directly; omit for decorator-style usage. + :param auth_handlers: Optional list of auth handler names to run before the route. + :param rank: Route priority rank. + :return: The registered handler, or a decorator when used without a handler. + """ + + def __selector(context: TurnContext) -> bool: + return ( + context.activity.type == ActivityTypes.conversation_update + and context.activity.channel_id == Channels.ms_teams + and isinstance(context.activity.members_removed, list) + and len(context.activity.members_removed) > 0 + ) + + def __call(func: ChannelUpdateHandler[StateT]) -> ChannelUpdateHandler[StateT]: + async def __func(context: TurnContext, state: StateT) -> None: + teams_context = TeamsTurnContext(context, self._app) + channel_info = _get_channel_info(teams_context.activity) + await func(teams_context, state, channel_info) + + self._app.add_route( + __selector, __func, rank=rank, auth_handlers=auth_handlers + ) + return func + + if handler is not None: + return __call(handler) + return __call diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/channel/route_handlers.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/channel/route_handlers.py new file mode 100644 index 000000000..54dd30606 --- /dev/null +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/channel/route_handlers.py @@ -0,0 +1,35 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""Protocol definition for Teams channel update route handlers.""" + +from typing import Awaitable, Protocol + +from microsoft_teams.api.models.channel_data import ChannelInfo + +from microsoft_agents.hosting.msteams.teams_turn_context import TeamsTurnContext +from microsoft_agents.hosting.msteams.type_defs import _StateContra + + +class ChannelUpdateHandler(Protocol[_StateContra]): + """Protocol for a handler invoked on Teams channel update events. + + Handlers receive the Teams turn context, the current turn state, and the + parsed :class:`ChannelInfo` from the activity's channel data. + """ + + def __call__( + self, + context: TeamsTurnContext, + state: _StateContra, + data: ChannelInfo, + /, + ) -> Awaitable[None]: + """Handle a channel update event. + + :param context: Teams-aware turn context. + :param state: The current turn state. + :param data: Parsed channel info from the incoming activity. + :return: An awaitable that completes when the handler finishes. + """ + ... diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/config/__init__.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/config/__init__.py new file mode 100644 index 000000000..6ce4eb3c8 --- /dev/null +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/config/__init__.py @@ -0,0 +1,8 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""Teams configuration route registration exports.""" + +from .config import Config + +__all__ = ["Config"] diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/config/config.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/config/config.py new file mode 100644 index 000000000..ce37a190d --- /dev/null +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/config/config.py @@ -0,0 +1,133 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""Route registration helpers for Teams configuration (config/fetch, config/submit) invokes.""" + +from typing import Generic, Optional, overload + +from microsoft_agents.activity import ActivityTypes +from microsoft_agents.hosting.core import ( + AgentApplication, + RouteRank, + TurnContext, +) + +from microsoft_agents.hosting.msteams.teams_turn_context import TeamsTurnContext +from microsoft_agents.hosting.msteams.type_defs import ( + _RouteDecorator, + StateT, +) +from microsoft_agents.hosting.msteams._utils import _send_invoke_response + +from .route_handlers import ConfigHandler + + +class Config(Generic[StateT]): + """Route registration for Teams config invoke activities. + + Access via :attr:`TeamsAgentExtension.config`. + """ + + def __init__(self, app: AgentApplication[StateT]) -> None: + """Initialise with the owning :class:`AgentApplication`. + + :param app: The application to register routes on. + """ + self._app = app + + def _create_decorator( + self, + name: str, + *, + auth_handlers: Optional[list[str]] = None, + rank: RouteRank = RouteRank.DEFAULT, + ) -> _RouteDecorator[ConfigHandler[StateT]]: + """Build a route decorator for a Teams configuration invoke name. + + :param name: The invoke activity name to match (e.g. ``"config/fetch"``). + :param auth_handlers: Optional auth handler names to run before the route. + :param rank: Route priority rank. + :return: A decorator that registers the handler. + """ + + def __selector(context: TurnContext) -> bool: + return ( + context.activity.type == ActivityTypes.invoke + and context.activity.name == name + ) + + def __call(func: ConfigHandler[StateT]) -> ConfigHandler[StateT]: + async def __handler(context: TurnContext, state: StateT) -> None: + teams_context = TeamsTurnContext(context, self._app) + response = await func(teams_context, state, context.activity.value) + if response is not None: + await _send_invoke_response(context, response) + + self._app.add_route( + __selector, + __handler, + is_invoke=True, + rank=rank, + auth_handlers=auth_handlers, + ) + return func + + return __call + + @overload + def fetch(self, handler: ConfigHandler[StateT]) -> ConfigHandler[StateT]: ... + + @overload + def fetch( + self, *, auth_handlers: Optional[list[str]] = ..., rank: RouteRank = ... + ) -> _RouteDecorator[ConfigHandler[StateT]]: ... + + def fetch( + self, + handler: Optional[ConfigHandler[StateT]] = None, + *, + auth_handlers: Optional[list[str]] = None, + rank: RouteRank = RouteRank.DEFAULT, + ) -> ConfigHandler[StateT] | _RouteDecorator[ConfigHandler[StateT]]: + """Register a handler for config/fetch invokes. + + :param handler: Optional handler to register directly; omit for decorator-style usage. + :param auth_handlers: Optional list of auth handler names to run before the route. + :param rank: Route priority rank. + :return: The registered handler, or a decorator when used without a handler. + """ + decorator = self._create_decorator( + "config/fetch", auth_handlers=auth_handlers, rank=rank + ) + if handler is not None: + return decorator(handler) + return decorator + + @overload + def submit(self, handler: ConfigHandler[StateT]) -> ConfigHandler[StateT]: ... + + @overload + def submit( + self, *, auth_handlers: Optional[list[str]] = ..., rank: RouteRank = ... + ) -> _RouteDecorator[ConfigHandler[StateT]]: ... + + def submit( + self, + handler: Optional[ConfigHandler[StateT]] = None, + *, + auth_handlers: Optional[list[str]] = None, + rank: RouteRank = RouteRank.DEFAULT, + ) -> ConfigHandler[StateT] | _RouteDecorator[ConfigHandler[StateT]]: + """Register a handler for config/submit invokes. + + :param handler: Optional handler to register directly; omit for decorator-style usage. + :param auth_handlers: Optional list of auth handler names to run before the route. + :param rank: Route priority rank. + :return: The registered handler, or a decorator when used without a handler. + """ + decorator = self._create_decorator( + "config/submit", auth_handlers=auth_handlers, rank=rank + ) + if handler is not None: + return decorator(handler) + return decorator diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/config/route_handlers.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/config/route_handlers.py new file mode 100644 index 000000000..c6c096b34 --- /dev/null +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/config/route_handlers.py @@ -0,0 +1,35 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""Protocol definition for Teams configuration route handlers.""" + +from typing import Any, Awaitable, Protocol + +from microsoft_teams.api.models.config import ConfigResponse + +from microsoft_agents.hosting.msteams.teams_turn_context import TeamsTurnContext +from microsoft_agents.hosting.msteams.type_defs import _StateContra + + +class ConfigHandler(Protocol[_StateContra]): + """Protocol for a handler invoked on Teams config/fetch or config/submit activities. + + The handler returns a :class:`ConfigResponse` which is automatically sent back + as an invoke response by the routing layer. + """ + + def __call__( + self, + context: TeamsTurnContext, + state: _StateContra, + config_data: Any, + /, + ) -> Awaitable[ConfigResponse]: + """Handle a configuration invoke. + + :param context: Teams-aware turn context. + :param state: The current turn state. + :param config_data: Raw value payload from the invoke activity. + :return: A :class:`ConfigResponse` to send back to Teams. + """ + ... diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/errors/__init__.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/errors/__init__.py similarity index 72% rename from libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/errors/__init__.py rename to libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/errors/__init__.py index 8d767abf6..91e4e3935 100644 --- a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/errors/__init__.py +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/errors/__init__.py @@ -1,15 +1,13 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. -""" -Error resources for Microsoft Agents Hosting Teams package. -""" +"""Teams error resource exports for the Microsoft Agents hosting package.""" from microsoft_agents.activity.errors import ErrorMessage from .error_resources import TeamsErrorResources -# Singleton instance +# Shared Teams error resource instance. teams_errors = TeamsErrorResources() __all__ = ["ErrorMessage", "TeamsErrorResources", "teams_errors"] diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/errors/error_resources.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/errors/error_resources.py similarity index 79% rename from libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/errors/error_resources.py rename to libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/errors/error_resources.py index f324224f2..75f08eedd 100644 --- a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/errors/error_resources.py +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/errors/error_resources.py @@ -1,21 +1,13 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. -""" -Teams error resources for Microsoft Agents SDK. - -Error codes are in the range -62000 to -62999. -""" +"""Teams error resource definitions for Microsoft Agents SDK error codes.""" from microsoft_agents.activity.errors import ErrorMessage class TeamsErrorResources: - """ - Error messages for Teams operations. - - Error codes are organized in the range -62000 to -62999. - """ + """Provide error messages for Teams operations using the -62000 to -62999 range.""" TeamsBadRequest = ErrorMessage( "BadRequest", @@ -67,6 +59,7 @@ class TeamsErrorResources: -62009, ) - def __init__(self): - """Initialize TeamsErrorResources.""" - pass + TeamsTenantIdRequired = ErrorMessage( + "tenant_id is required.", + -62010, + ) diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/file_consent/__init__.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/file_consent/__init__.py new file mode 100644 index 000000000..6bf788862 --- /dev/null +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/file_consent/__init__.py @@ -0,0 +1,8 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""Teams file consent route registration helpers exposed by the subpackage.""" + +from .file_consent import FileConsent + +__all__ = ["FileConsent"] diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/file_consent/file_consent.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/file_consent/file_consent.py new file mode 100644 index 000000000..c7db5b206 --- /dev/null +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/file_consent/file_consent.py @@ -0,0 +1,147 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""Route registration helpers for Teams fileConsent/invoke activities.""" + +from typing import Generic, Optional, overload + +from microsoft_teams.api.models import FileConsentCardResponse + +from microsoft_agents.activity import ActivityTypes +from microsoft_agents.hosting.core import ( + AgentApplication, + RouteRank, + TurnContext, +) + +from microsoft_agents.hosting.msteams.teams_turn_context import TeamsTurnContext +from microsoft_agents.hosting.msteams.type_defs import ( + _RouteDecorator, + StateT, +) +from microsoft_agents.hosting.msteams._utils import _send_invoke_response + +from .route_handlers import FileConsentHandler + + +class FileConsent(Generic[StateT]): + """Route registration for Teams file consent invoke activities. + + Access via :attr:`TeamsAgentExtension.file_consent`. + """ + + def __init__(self, app: AgentApplication[StateT]) -> None: + """Initialise with the owning :class:`AgentApplication`. + + :param app: The application to register routes on. + """ + self._app = app + + def _create_decorator( + self, + action: str, + *, + auth_handlers: Optional[list[str]] = None, + rank: RouteRank = RouteRank.DEFAULT, + ) -> _RouteDecorator[FileConsentHandler[StateT]]: + """Build a route decorator for a fileConsent/invoke action. + + :param action: File consent action value to match. + :param auth_handlers: Optional list of auth handler names to run before the route. + :param rank: Route priority used when multiple routes match. + :return: A decorator that registers a file consent handler. + """ + + def __selector(context: TurnContext) -> bool: + """Return True when the activity is a matching file consent invoke.""" + return ( + context.activity.type == ActivityTypes.invoke + and context.activity.name == "fileConsent/invoke" + and isinstance(context.activity.value, dict) + and context.activity.value.get("action") == action + ) + + def __call(func: FileConsentHandler[StateT]) -> FileConsentHandler[StateT]: + """Register the supplied file consent handler.""" + + async def __handler(context: TurnContext, state: StateT) -> None: + """Adapt the core turn context and dispatch to the file consent handler.""" + teams_context = TeamsTurnContext(context, self._app) + file_consent = FileConsentCardResponse.model_validate( + context.activity.value or {} + ) + await func(teams_context, state, file_consent) + await _send_invoke_response(context) + + self._app.add_route( + __selector, + __handler, + is_invoke=True, + rank=rank, + auth_handlers=auth_handlers, + ) + return func + + return __call + + @overload + def accept( + self, handler: FileConsentHandler[StateT] + ) -> FileConsentHandler[StateT]: ... + + @overload + def accept( + self, *, auth_handlers: Optional[list[str]] = ..., rank: RouteRank = ... + ) -> _RouteDecorator[FileConsentHandler[StateT]]: ... + + def accept( + self, + handler: Optional[FileConsentHandler[StateT]] = None, + *, + auth_handlers: Optional[list[str]] = None, + rank: RouteRank = RouteRank.DEFAULT, + ) -> FileConsentHandler[StateT] | _RouteDecorator[FileConsentHandler[StateT]]: + """Register a handler for accepted Teams file consent invokes. + + :param handler: Optional handler to register directly; omit for decorator use. + :param auth_handlers: Optional list of auth handler names to run before the route. + :param rank: Route priority used when multiple routes match. + :return: The registered handler, or a decorator when used without a handler. + """ + decorator = self._create_decorator( + "accept", auth_handlers=auth_handlers, rank=rank + ) + if handler is not None: + return decorator(handler) + return decorator + + @overload + def decline( + self, handler: FileConsentHandler[StateT] + ) -> FileConsentHandler[StateT]: ... + + @overload + def decline( + self, *, auth_handlers: Optional[list[str]] = ..., rank: RouteRank = ... + ) -> _RouteDecorator[FileConsentHandler[StateT]]: ... + + def decline( + self, + handler: Optional[FileConsentHandler[StateT]] = None, + *, + auth_handlers: Optional[list[str]] = None, + rank: RouteRank = RouteRank.DEFAULT, + ) -> FileConsentHandler[StateT] | _RouteDecorator[FileConsentHandler[StateT]]: + """Register a handler for declined Teams file consent invokes. + + :param handler: Optional handler to register directly; omit for decorator use. + :param auth_handlers: Optional list of auth handler names to run before the route. + :param rank: Route priority used when multiple routes match. + :return: The registered handler, or a decorator when used without a handler. + """ + decorator = self._create_decorator( + "decline", auth_handlers=auth_handlers, rank=rank + ) + if handler is not None: + return decorator(handler) + return decorator diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/file_consent/route_handlers.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/file_consent/route_handlers.py new file mode 100644 index 000000000..a4bfb7084 --- /dev/null +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/file_consent/route_handlers.py @@ -0,0 +1,35 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""Protocol definition for Teams file consent route handlers.""" + +from typing import Awaitable, Protocol + +from microsoft_teams.api.models import FileConsentCardResponse + +from microsoft_agents.hosting.msteams.teams_turn_context import TeamsTurnContext +from microsoft_agents.hosting.msteams.type_defs import _StateContra + + +class FileConsentHandler(Protocol[_StateContra]): + """Protocol for a handler invoked on fileConsent/invoke activities. + + Called for both ``accept`` and ``decline`` actions; the routing layer sends + the invoke response automatically after the handler returns. + """ + + def __call__( + self, + context: TeamsTurnContext, + state: _StateContra, + file_consent: FileConsentCardResponse, + /, + ) -> Awaitable[None]: + """Handle a file consent invoke. + + :param context: Teams-aware turn context. + :param state: The current turn state. + :param file_consent: Parsed file consent card response from the invoke payload. + :return: An awaitable that completes when the handler finishes. + """ + ... diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/meeting/__init__.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/meeting/__init__.py new file mode 100644 index 000000000..74ed18871 --- /dev/null +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/meeting/__init__.py @@ -0,0 +1,8 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""Teams meeting route registration helpers exposed by the subpackage.""" + +from .meeting import Meeting + +__all__ = ["Meeting"] diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/meeting/meeting.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/meeting/meeting.py new file mode 100644 index 000000000..fed394b72 --- /dev/null +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/meeting/meeting.py @@ -0,0 +1,268 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""Route registration helpers for Teams meeting lifecycle event activities.""" + +from typing import Generic, Optional, overload + +from microsoft_teams.api.models.meetings import MeetingDetails + +from microsoft_agents.activity import ActivityTypes +from microsoft_agents.activity.teams import MeetingParticipantsEventDetails +from microsoft_agents.hosting.core import ( + AgentApplication, + RouteRank, + TurnContext, +) + +from microsoft_agents.hosting.msteams.teams_turn_context import TeamsTurnContext +from microsoft_agents.hosting.msteams.type_defs import ( + _RouteDecorator, + StateT, +) + +from .route_handlers import ( + MeetingStartHandler, + MeetingEndHandler, + MeetingParticipantsEventHandler, +) + + +class Meeting(Generic[StateT]): + """Route registration for Teams meeting lifecycle event activities. + + Access via :attr:`TeamsAgentExtension.meetings`. + """ + + def __init__(self, app: AgentApplication[StateT]) -> None: + """Initialise with the owning :class:`AgentApplication`. + + :param app: The application to register routes on. + """ + self._app = app + + @overload + def start( + self, handler: MeetingStartHandler[StateT] + ) -> MeetingStartHandler[StateT]: ... + + @overload + def start( + self, *, auth_handlers: Optional[list[str]] = ..., rank: RouteRank = ... + ) -> _RouteDecorator[MeetingStartHandler[StateT]]: ... + + def start( + self, + handler: Optional[MeetingStartHandler[StateT]] = None, + *, + auth_handlers: Optional[list[str]] = None, + rank: RouteRank = RouteRank.DEFAULT, + ) -> MeetingStartHandler[StateT] | _RouteDecorator[MeetingStartHandler[StateT]]: + """Register a handler for Teams meeting start events. + + :param handler: Optional handler to register directly; omit for decorator use. + :param auth_handlers: Optional list of auth handler names to run before the route. + :param rank: Route priority used when multiple routes match. + :return: The registered handler, or a decorator when used without a handler. + """ + + def __selector(context: TurnContext) -> bool: + """Return True when the activity is a Teams meeting start event.""" + return ( + context.activity.type == ActivityTypes.event + and context.activity.name == "application/vnd.microsoft.meetingStart" + ) + + def __call(func: MeetingStartHandler[StateT]) -> MeetingStartHandler[StateT]: + """Register the supplied meeting start handler.""" + + async def __handler(context: TurnContext, state: StateT) -> None: + """Adapt the core turn context and dispatch to the meeting start handler.""" + teams_context = TeamsTurnContext(context, self._app) + meeting = MeetingDetails.model_validate(context.activity.value or {}) + await func(teams_context, state, meeting) + + self._app.add_route( + __selector, + __handler, + rank=rank, + auth_handlers=auth_handlers, + ) + return func + + if handler is not None: + return __call(handler) + return __call + + @overload + def end(self, handler: MeetingEndHandler[StateT]) -> MeetingEndHandler[StateT]: ... + + @overload + def end( + self, *, auth_handlers: Optional[list[str]] = ..., rank: RouteRank = ... + ) -> _RouteDecorator[MeetingEndHandler[StateT]]: ... + + def end( + self, + handler: Optional[MeetingEndHandler[StateT]] = None, + *, + auth_handlers: Optional[list[str]] = None, + rank: RouteRank = RouteRank.DEFAULT, + ) -> MeetingEndHandler[StateT] | _RouteDecorator[MeetingEndHandler[StateT]]: + """Register a handler for Teams meeting end events. + + :param handler: Optional handler to register directly; omit for decorator use. + :param auth_handlers: Optional list of auth handler names to run before the route. + :param rank: Route priority used when multiple routes match. + :return: The registered handler, or a decorator when used without a handler. + """ + + def __selector(context: TurnContext) -> bool: + """Return True when the activity is a Teams meeting end event.""" + return ( + context.activity.type == ActivityTypes.event + and context.activity.name == "application/vnd.microsoft.meetingEnd" + ) + + def __call(func: MeetingEndHandler[StateT]) -> MeetingEndHandler[StateT]: + """Register the supplied meeting end handler.""" + + async def __handler(context: TurnContext, state: StateT) -> None: + """Adapt the core turn context and dispatch to the meeting end handler.""" + teams_context = TeamsTurnContext(context, self._app) + meeting = MeetingDetails.model_validate(context.activity.value or {}) + await func(teams_context, state, meeting) + + self._app.add_route( + __selector, + __handler, + rank=rank, + auth_handlers=auth_handlers, + ) + return func + + if handler is not None: + return __call(handler) + return __call + + @overload + def participants_join( + self, handler: MeetingParticipantsEventHandler[StateT] + ) -> MeetingParticipantsEventHandler[StateT]: ... + + @overload + def participants_join( + self, *, auth_handlers: Optional[list[str]] = ..., rank: RouteRank = ... + ) -> _RouteDecorator[MeetingParticipantsEventHandler[StateT]]: ... + + def participants_join( + self, + handler: Optional[MeetingParticipantsEventHandler[StateT]] = None, + *, + auth_handlers: Optional[list[str]] = None, + rank: RouteRank = RouteRank.DEFAULT, + ) -> ( + MeetingParticipantsEventHandler[StateT] + | _RouteDecorator[MeetingParticipantsEventHandler[StateT]] + ): + """Register a handler for Teams meeting participant join events. + + :param handler: Optional handler to register directly; omit for decorator use. + :param auth_handlers: Optional list of auth handler names to run before the route. + :param rank: Route priority used when multiple routes match. + :return: The registered handler, or a decorator when used without a handler. + """ + + def __selector(context: TurnContext) -> bool: + """Return True when the activity is a Teams participant join event.""" + return ( + context.activity.type == ActivityTypes.event + and context.activity.name + == "application/vnd.microsoft.meetingParticipantJoin" + ) + + def __call( + func: MeetingParticipantsEventHandler[StateT], + ) -> MeetingParticipantsEventHandler[StateT]: + """Register the supplied meeting participants handler.""" + + async def __handler(context: TurnContext, state: StateT) -> None: + """Adapt the core turn context and dispatch to the participants handler.""" + teams_context = TeamsTurnContext(context, self._app) + details = MeetingParticipantsEventDetails.model_validate( + context.activity.value or {} + ) + await func(teams_context, state, details) + + self._app.add_route( + __selector, + __handler, + rank=rank, + auth_handlers=auth_handlers, + ) + return func + + if handler is not None: + return __call(handler) + return __call + + @overload + def participants_leave( + self, handler: MeetingParticipantsEventHandler[StateT] + ) -> MeetingParticipantsEventHandler[StateT]: ... + + @overload + def participants_leave( + self, *, auth_handlers: Optional[list[str]] = ..., rank: RouteRank = ... + ) -> _RouteDecorator[MeetingParticipantsEventHandler[StateT]]: ... + + def participants_leave( + self, + handler: Optional[MeetingParticipantsEventHandler[StateT]] = None, + *, + auth_handlers: Optional[list[str]] = None, + rank: RouteRank = RouteRank.DEFAULT, + ) -> ( + MeetingParticipantsEventHandler[StateT] + | _RouteDecorator[MeetingParticipantsEventHandler[StateT]] + ): + """Register a handler for Teams meeting participant leave events. + + :param handler: Optional handler to register directly; omit for decorator use. + :param auth_handlers: Optional list of auth handler names to run before the route. + :param rank: Route priority used when multiple routes match. + :return: The registered handler, or a decorator when used without a handler. + """ + + def __selector(context: TurnContext) -> bool: + """Return True when the activity is a Teams participant leave event.""" + return ( + context.activity.type == ActivityTypes.event + and context.activity.name + == "application/vnd.microsoft.meetingParticipantLeave" + ) + + def __call( + func: MeetingParticipantsEventHandler[StateT], + ) -> MeetingParticipantsEventHandler[StateT]: + """Register the supplied meeting participants handler.""" + + async def __handler(context: TurnContext, state: StateT) -> None: + """Adapt the core turn context and dispatch to the participants handler.""" + teams_context = TeamsTurnContext(context, self._app) + details = MeetingParticipantsEventDetails.model_validate( + context.activity.value or {} + ) + await func(teams_context, state, details) + + self._app.add_route( + __selector, + __handler, + rank=rank, + auth_handlers=auth_handlers, + ) + return func + + if handler is not None: + return __call(handler) + return __call diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/meeting/route_handlers.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/meeting/route_handlers.py new file mode 100644 index 000000000..adef1927f --- /dev/null +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/meeting/route_handlers.py @@ -0,0 +1,72 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""Protocol definitions for Teams meeting event route handlers.""" + +from typing import Awaitable, Protocol + +from microsoft_teams.api.models.meetings import MeetingDetails +from microsoft_agents.activity.teams import MeetingParticipantsEventDetails + +from microsoft_agents.hosting.msteams.teams_turn_context import TeamsTurnContext +from microsoft_agents.hosting.msteams.type_defs import _StateContra + + +class MeetingStartHandler(Protocol[_StateContra]): + """Protocol for a handler invoked when a Teams meeting starts.""" + + def __call__( + self, + context: TeamsTurnContext, + state: _StateContra, + meeting: MeetingDetails, + /, + ) -> Awaitable[None]: + """Handle a meeting start event. + + :param context: Teams-aware turn context. + :param state: The current turn state. + :param meeting: Details of the meeting that started. + :return: An awaitable that completes when the handler finishes. + """ + ... + + +class MeetingEndHandler(Protocol[_StateContra]): + """Protocol for a handler invoked when a Teams meeting ends.""" + + def __call__( + self, + context: TeamsTurnContext, + state: _StateContra, + meeting: MeetingDetails, + /, + ) -> Awaitable[None]: + """Handle a meeting end event. + + :param context: Teams-aware turn context. + :param state: The current turn state. + :param meeting: Details of the meeting that ended. + :return: An awaitable that completes when the handler finishes. + """ + ... + + +class MeetingParticipantsEventHandler(Protocol[_StateContra]): + """Protocol for a handler invoked when participants join or leave a Teams meeting.""" + + def __call__( + self, + context: TeamsTurnContext, + state: _StateContra, + meeting: MeetingParticipantsEventDetails, + /, + ) -> Awaitable[None]: + """Handle a meeting participant join or leave event. + + :param context: Teams-aware turn context. + :param state: The current turn state. + :param meeting: Details of the participants event. + :return: An awaitable that completes when the handler finishes. + """ + ... diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/message/__init__.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/message/__init__.py new file mode 100644 index 000000000..b86375229 --- /dev/null +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/message/__init__.py @@ -0,0 +1,10 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""Teams message route registration helpers exposed by the subpackage.""" + +from .message import Message + +__all__ = [ + "Message", +] diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/message/message.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/message/message.py new file mode 100644 index 000000000..01fff239a --- /dev/null +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/message/message.py @@ -0,0 +1,294 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""Route registration helpers for Teams message update and actionable message activities.""" + +from typing import Generic, Optional, overload + +from microsoft_teams.api.models.o365 import O365ConnectorCardActionQuery + +from microsoft_agents.activity import ActivityTypes, Channels +from microsoft_agents.hosting.core import ( + AgentApplication, + RouteRank, + TurnContext, +) + +from microsoft_agents.hosting.msteams.route_handlers import ( + TeamsRouteHandler, + wrap_teams_route_handler, +) +from microsoft_agents.hosting.msteams.teams_turn_context import TeamsTurnContext +from microsoft_agents.hosting.msteams.type_defs import ( + _RouteDecorator, + StateT, +) +from microsoft_agents.hosting.msteams._utils import ( + _get_channel_event_type, + _send_invoke_response, +) + +from .route_handlers import ExecuteActionHandler, ReadReceiptHandler + + +class Message(Generic[StateT]): + """Route registration for Teams message update and actionable message activities. + + Access via :attr:`TeamsAgentExtension.messages`. + """ + + def __init__(self, app: AgentApplication[StateT]) -> None: + """Initialise with the owning :class:`AgentApplication`. + + :param app: The application to register routes on. + """ + self._app = app + + def _create_basic_decorator( + self, + event_type: str, + message_type: ActivityTypes, + *, + auth_handlers: Optional[list[str]] = None, + rank: RouteRank = RouteRank.DEFAULT, + ) -> _RouteDecorator[TeamsRouteHandler[StateT]]: + """Build a route decorator for a Teams message channel event type. + + :param event_type: Teams channel event type to match. + :param message_type: Activity type that must carry the channel event. + :param auth_handlers: Optional list of auth handler names to run before the route. + :param rank: Route priority used when multiple routes match. + :return: A decorator that registers a Teams route handler. + """ + + def __selector(context: TurnContext) -> bool: + """Return True when the activity matches the configured message event.""" + return ( + context.activity.type == message_type + and context.activity.channel_id == Channels.ms_teams + and _get_channel_event_type(context) == event_type + ) + + def __call(func: TeamsRouteHandler[StateT]) -> TeamsRouteHandler[StateT]: + """Register the supplied Teams route handler.""" + self._app.add_route( + __selector, + wrap_teams_route_handler(func, self._app), + rank=rank, + auth_handlers=auth_handlers, + ) + return func + + return __call + + @overload + def edit(self, handler: TeamsRouteHandler[StateT]) -> TeamsRouteHandler[StateT]: ... + + @overload + def edit( + self, *, auth_handlers: Optional[list[str]] = ..., rank: RouteRank = ... + ) -> _RouteDecorator[TeamsRouteHandler[StateT]]: ... + + def edit( + self, + handler: Optional[TeamsRouteHandler[StateT]] = None, + *, + auth_handlers: Optional[list[str]] = None, + rank: RouteRank = RouteRank.DEFAULT, + ) -> TeamsRouteHandler[StateT] | _RouteDecorator[TeamsRouteHandler[StateT]]: + """Register a handler for Teams editMessage events. + + :param handler: Optional handler to register directly; omit for decorator use. + :param auth_handlers: Optional list of auth handler names to run before the route. + :param rank: Route priority used when multiple routes match. + :return: The registered handler, or a decorator when used without a handler. + """ + decorator = self._create_basic_decorator( + "editMessage", + ActivityTypes.message_update, + auth_handlers=auth_handlers, + rank=rank, + ) + if handler is not None: + return decorator(handler) + return decorator + + @overload + def undelete( + self, handler: TeamsRouteHandler[StateT] + ) -> TeamsRouteHandler[StateT]: ... + + @overload + def undelete( + self, *, auth_handlers: Optional[list[str]] = ..., rank: RouteRank = ... + ) -> _RouteDecorator[TeamsRouteHandler[StateT]]: ... + + def undelete( + self, + handler: Optional[TeamsRouteHandler[StateT]] = None, + *, + auth_handlers: Optional[list[str]] = None, + rank: RouteRank = RouteRank.DEFAULT, + ) -> TeamsRouteHandler[StateT] | _RouteDecorator[TeamsRouteHandler[StateT]]: + """Register a handler for Teams undeleteMessage events. + + :param handler: Optional handler to register directly; omit for decorator use. + :param auth_handlers: Optional list of auth handler names to run before the route. + :param rank: Route priority used when multiple routes match. + :return: The registered handler, or a decorator when used without a handler. + """ + decorator = self._create_basic_decorator( + "undeleteMessage", + ActivityTypes.message_update, + auth_handlers=auth_handlers, + rank=rank, + ) + if handler is not None: + return decorator(handler) + return decorator + + @overload + def delete( + self, handler: TeamsRouteHandler[StateT] + ) -> TeamsRouteHandler[StateT]: ... + + @overload + def delete( + self, *, auth_handlers: Optional[list[str]] = ..., rank: RouteRank = ... + ) -> _RouteDecorator[TeamsRouteHandler[StateT]]: ... + + def delete( + self, + handler: Optional[TeamsRouteHandler[StateT]] = None, + *, + auth_handlers: Optional[list[str]] = None, + rank: RouteRank = RouteRank.DEFAULT, + ) -> TeamsRouteHandler[StateT] | _RouteDecorator[TeamsRouteHandler[StateT]]: + """Register a handler for Teams softDeleteMessage events. + + :param handler: Optional handler to register directly; omit for decorator use. + :param auth_handlers: Optional list of auth handler names to run before the route. + :param rank: Route priority used when multiple routes match. + :return: The registered handler, or a decorator when used without a handler. + """ + decorator = self._create_basic_decorator( + "softDeleteMessage", + ActivityTypes.message_delete, + auth_handlers=auth_handlers, + rank=rank, + ) + if handler is not None: + return decorator(handler) + return decorator + + @overload + def read_receipt( + self, handler: ReadReceiptHandler[StateT] + ) -> ReadReceiptHandler[StateT]: ... + + @overload + def read_receipt( + self, *, auth_handlers: Optional[list[str]] = ..., rank: RouteRank = ... + ) -> _RouteDecorator[ReadReceiptHandler[StateT]]: ... + + def read_receipt( + self, + handler: Optional[ReadReceiptHandler[StateT]] = None, + *, + auth_handlers: Optional[list[str]] = None, + rank: RouteRank = RouteRank.DEFAULT, + ) -> ReadReceiptHandler[StateT] | _RouteDecorator[ReadReceiptHandler[StateT]]: + """Register a handler for Teams readReceipt events. + + :param handler: Optional handler to register directly; omit for decorator use. + :param auth_handlers: Optional list of auth handler names to run before the route. + :param rank: Route priority used when multiple routes match. + :return: The registered handler, or a decorator when used without a handler. + :raises TypeError: If the incoming activity value is not a raw dict. + """ + + def __selector(context: TurnContext) -> bool: + """Return True when the activity is a Teams read receipt event.""" + return ( + context.activity.type == ActivityTypes.event + and context.activity.name == "application/vnd.microsoft.readReceipt" + ) + + def __call(func: ReadReceiptHandler[StateT]) -> ReadReceiptHandler[StateT]: + """Register the supplied read receipt handler.""" + + async def __handler(context: TurnContext, state: StateT) -> None: + """Adapt the raw read receipt payload and dispatch to the handler.""" + teams_context = TeamsTurnContext(context, self._app) + value = context.activity.value + if not isinstance(value, dict): + raise TypeError( + f"read_receipt: activity.value must be a dict, got {type(value).__name__}" + ) + await func(teams_context, state, value) + + self._app.add_route( + __selector, __handler, rank=rank, auth_handlers=auth_handlers + ) + return func + + if handler is not None: + return __call(handler) + return __call + + @overload + def execute_action( + self, handler: ExecuteActionHandler[StateT] + ) -> ExecuteActionHandler[StateT]: ... + + @overload + def execute_action( + self, *, auth_handlers: Optional[list[str]] = ..., rank: RouteRank = ... + ) -> _RouteDecorator[ExecuteActionHandler[StateT]]: ... + + def execute_action( + self, + handler: Optional[ExecuteActionHandler[StateT]] = None, + *, + auth_handlers: Optional[list[str]] = None, + rank: RouteRank = RouteRank.DEFAULT, + ) -> ExecuteActionHandler[StateT] | _RouteDecorator[ExecuteActionHandler[StateT]]: + """Register a handler for actionableMessage/executeAction invokes. + + :param handler: Optional handler to register directly; omit for decorator use. + :param auth_handlers: Optional list of auth handler names to run before the route. + :param rank: Route priority used when multiple routes match. + :return: The registered handler, or a decorator when used without a handler. + """ + + def __selector(context: TurnContext) -> bool: + """Return True when the activity is an actionable message execute invoke.""" + return ( + context.activity.type == ActivityTypes.invoke + and context.activity.name == "actionableMessage/executeAction" + ) + + def __call(func: ExecuteActionHandler[StateT]) -> ExecuteActionHandler[StateT]: + """Register the supplied execute action handler.""" + + async def __handler(context: TurnContext, state: StateT) -> None: + """Adapt the invoke payload and dispatch to the execute action handler.""" + teams_context = TeamsTurnContext(context, self._app) + query = O365ConnectorCardActionQuery.model_validate( + context.activity.value or {} + ) + await func(teams_context, state, query) + await _send_invoke_response(context) + + self._app.add_route( + __selector, + __handler, + is_invoke=True, + rank=rank, + auth_handlers=auth_handlers, + ) + return func + + if handler is not None: + return __call(handler) + return __call diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/message/route_handlers.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/message/route_handlers.py new file mode 100644 index 000000000..6d1473910 --- /dev/null +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/message/route_handlers.py @@ -0,0 +1,51 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""Protocol definitions for Teams message and actionable message route handlers.""" + +from typing import Awaitable, Protocol + +from microsoft_teams.api.models.o365 import O365ConnectorCardActionQuery + +from microsoft_agents.hosting.msteams.teams_turn_context import TeamsTurnContext +from microsoft_agents.hosting.msteams.type_defs import _StateContra + + +class ExecuteActionHandler(Protocol[_StateContra]): + """Protocol for a handler invoked on actionableMessage/executeAction activities.""" + + def __call__( + self, + context: TeamsTurnContext, + state: _StateContra, + query: O365ConnectorCardActionQuery, + /, + ) -> Awaitable[None]: + """Handle an O365 connector card action execution. + + :param context: Teams-aware turn context. + :param state: The current turn state. + :param query: The parsed O365 connector card action query. + :return: An awaitable that completes when the handler finishes. + """ + ... + + +class ReadReceiptHandler(Protocol[_StateContra]): + """Protocol for a handler invoked on Teams read receipt events.""" + + def __call__( + self, + context: TeamsTurnContext, + state: _StateContra, + data: dict, + /, + ) -> Awaitable[None]: + """Handle a read receipt event. + + :param context: Teams-aware turn context. + :param state: The current turn state. + :param data: Raw event payload from the activity value. + :return: An awaitable that completes when the handler finishes. + """ + ... diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/message_extension/__init__.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/message_extension/__init__.py new file mode 100644 index 000000000..de7e80be7 --- /dev/null +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/message_extension/__init__.py @@ -0,0 +1,8 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""Teams message extension subpackage exposing route registration helpers.""" + +from .message_extension import MessageExtension + +__all__ = ["MessageExtension"] diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/message_extension/message_extension.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/message_extension/message_extension.py new file mode 100644 index 000000000..fa5e2d1f6 --- /dev/null +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/message_extension/message_extension.py @@ -0,0 +1,643 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""Route registration helpers for Teams Message Extension (composeExtension) invokes.""" + +from typing import Generic, Optional, Callable, overload + +from microsoft_teams.api.models import AppBasedLinkQuery +from microsoft_teams.api.models.messaging_extension import ( + MessagingExtensionQuery, + MessagingExtensionAction, +) + +from microsoft_agents.activity import Activity, ActivityTypes + +from microsoft_agents.hosting.core import ( + AgentApplication, + RouteRank, + TurnContext, +) + +from microsoft_agents.hosting.msteams.teams_turn_context import TeamsTurnContext +from microsoft_agents.hosting.msteams.type_defs import ( + StateT, + CommandSelector, + _RouteDecorator, +) + +from microsoft_agents.hosting.msteams._utils import ( + _get_command_id, + _match_selector, + _send_invoke_response, +) + +from .route_handlers import ( + FetchActionHandler, + QueryHandler, + SubmitActionHandler, + MessagePreviewEditHandler, + MessagePreviewSendHandler, + SelectItemHandler, + QueryLinkHandler, + QueryUrlSettingHandler, + CardButtonClickedHandler, + SettingHandler, +) + + +def _extract_activity_preview(value: object) -> Activity: + """Extract and deserialize the first botActivityPreview entry. + + :param value: The raw composeExtension/submitAction activity value. + :return: The first activity preview parsed as an :class:`Activity`. + :raises ValueError: If value is not a dict or no preview entries are present. + :raises pydantic.ValidationError: If the preview cannot be deserialized. + """ + if not isinstance(value, dict): + raise ValueError( + f"botActivityPreview: activity.value must be a dict, got {type(value).__name__}" + ) + previews = value.get("botActivityPreview") or [] + if not previews: + raise ValueError( + "botActivityPreview: no preview activity found in activity.value" + ) + return Activity.model_validate(previews[0]) + + +class MessageExtension(Generic[StateT]): + """Route registration for Teams Message Extension (composeExtension) invoke activities. + + Access via :attr:`TeamsAgentExtension.message_extensions`. + """ + + def __init__(self, app: AgentApplication[StateT]) -> None: + """Initialise with the owning :class:`AgentApplication`. + + :param app: The application to register routes on. + """ + self._app = app + + def query( + self, + command_id: CommandSelector = None, + *, + auth_handlers: Optional[list[str]] = None, + rank: RouteRank = RouteRank.DEFAULT, + ) -> _RouteDecorator[QueryHandler[StateT]]: + """Register a handler for composeExtension/query invokes. + + :param command_id: Optional command identifier or regex to match a specific Teams command. + :param auth_handlers: Optional auth handler names to run before the route. + :param rank: Route priority. + :return: A decorator that registers the handler and returns it. + """ + + def __selector(context: TurnContext) -> bool: + if ( + context.activity.type != ActivityTypes.invoke + or context.activity.name != "composeExtension/query" + ): + return False + + value = context.activity.value + return _match_selector(command_id, _get_command_id(value)) + + def __call(func: QueryHandler[StateT]) -> QueryHandler[StateT]: + async def __handler(context: TurnContext, state: StateT) -> None: + teams_context = TeamsTurnContext(context, self._app) + query = MessagingExtensionQuery.model_validate( + context.activity.value or {} + ) + response = await func(teams_context, state, query) + if response is not None: + await _send_invoke_response(context, response) + + self._app.add_route( + __selector, + __handler, + is_invoke=True, + rank=rank, + auth_handlers=auth_handlers, + ) + return func + + return __call + + @overload + def select_item( + self, handler: SelectItemHandler[StateT] + ) -> SelectItemHandler[StateT]: ... + + @overload + def select_item( + self, *, auth_handlers: Optional[list[str]] = ..., rank: RouteRank = ... + ) -> _RouteDecorator[SelectItemHandler[StateT]]: ... + + def select_item( + self, + handler: Optional[SelectItemHandler[StateT]] = None, + *, + auth_handlers: Optional[list[str]] = None, + rank: RouteRank = RouteRank.DEFAULT, + ) -> SelectItemHandler[StateT] | _RouteDecorator[SelectItemHandler[StateT]]: + """Register a handler for composeExtension/selectItem invokes. + + :param handler: Optional handler receiving the context, state, and selected item payload. + :param auth_handlers: Optional auth handler names to run before the route. + :param rank: Route priority. + :return: The registered handler when provided, otherwise a decorator that registers it. + """ + + def __selector(context: TurnContext) -> bool: + return ( + context.activity.type == ActivityTypes.invoke + and context.activity.name == "composeExtension/selectItem" + ) + + def __call(func: SelectItemHandler[StateT]) -> SelectItemHandler[StateT]: + async def __handler(context: TurnContext, state: StateT) -> None: + teams_context = TeamsTurnContext(context, self._app) + response = await func(teams_context, state, context.activity.value) + if response is not None: + await _send_invoke_response(context, response) + + self._app.add_route( + __selector, + __handler, + is_invoke=True, + rank=rank, + auth_handlers=auth_handlers, + ) + return func + + if handler is not None: + return __call(handler) + return __call + + def submit_action( + self, + command_id: CommandSelector = None, + *, + auth_handlers: Optional[list[str]] = None, + rank: RouteRank = RouteRank.DEFAULT, + ) -> _RouteDecorator[SubmitActionHandler[StateT]]: + """Register a handler for composeExtension/submitAction invokes. + + Excludes submitAction invokes that carry a bot message preview action. + + :param command_id: Optional command identifier or regex to match a specific Teams command. + :param auth_handlers: Optional auth handler names to run before the route. + :param rank: Route priority. + :return: A decorator that registers the handler and returns it. + """ + + def __selector(context: TurnContext) -> bool: + if ( + context.activity.type != ActivityTypes.invoke + or context.activity.name != "composeExtension/submitAction" + ): + return False + value = context.activity.value + if isinstance(value, dict): + bot_message_preview_action = value.get("botMessagePreviewAction") + else: + bot_message_preview_action = getattr( + value, "botMessagePreviewAction", None + ) + if bot_message_preview_action: + return False + return _match_selector(command_id, _get_command_id(value)) + + def __call(func: SubmitActionHandler[StateT]) -> SubmitActionHandler[StateT]: + async def __handler(context: TurnContext, state: StateT) -> None: + teams_context = TeamsTurnContext(context, self._app) + action = MessagingExtensionAction.model_validate( + context.activity.value or {} + ) + response = await func(teams_context, state, action) + if response is not None: + await _send_invoke_response(context, response) + + self._app.add_route( + __selector, + __handler, + is_invoke=True, + rank=rank, + auth_handlers=auth_handlers, + ) + return func + + return __call + + def message_preview_edit( + self, + command_id: CommandSelector = None, + *, + auth_handlers: Optional[list[str]] = None, + rank: RouteRank = RouteRank.DEFAULT, + ) -> _RouteDecorator[MessagePreviewEditHandler[StateT]]: + """Register a handler for composeExtension message preview edit invokes. + + :param command_id: Optional command identifier or regex to match a specific Teams command. + :param auth_handlers: Optional auth handler names to run before the route. + :param rank: Route priority. + :return: A decorator that registers the handler and returns it. + """ + + def __selector(context: TurnContext) -> bool: + if ( + context.activity.type != ActivityTypes.invoke + or context.activity.name != "composeExtension/submitAction" + ): + return False + value = context.activity.value + if not isinstance(value, dict): + return False + if value.get("botMessagePreviewAction") != "edit": + return False + return _match_selector(command_id, _get_command_id(value)) + + def __call( + func: MessagePreviewEditHandler[StateT], + ) -> MessagePreviewEditHandler[StateT]: + async def __handler(context: TurnContext, state: StateT) -> None: + teams_context = TeamsTurnContext(context, self._app) + activity_preview = _extract_activity_preview(context.activity.value) + response = await func(teams_context, state, activity_preview) + if response is not None: + await _send_invoke_response(context, response) + + self._app.add_route( + __selector, + __handler, + is_invoke=True, + rank=rank, + auth_handlers=auth_handlers, + ) + return func + + return __call + + def message_preview_send( + self, + command_id: CommandSelector = None, + *, + auth_handlers: Optional[list[str]] = None, + rank: RouteRank = RouteRank.DEFAULT, + ) -> _RouteDecorator[MessagePreviewSendHandler[StateT]]: + """Register a handler for composeExtension message preview send invokes. + + :param command_id: Optional command identifier or regex to match a specific Teams command. + :param auth_handlers: Optional auth handler names to run before the route. + :param rank: Route priority. + :return: A decorator that registers the handler and returns it. + """ + + def __selector(context: TurnContext) -> bool: + if ( + context.activity.type != ActivityTypes.invoke + or context.activity.name != "composeExtension/submitAction" + ): + return False + value = context.activity.value + if not isinstance(value, dict): + return False + if value.get("botMessagePreviewAction") != "send": + return False + return _match_selector(command_id, _get_command_id(value)) + + def __call( + func: MessagePreviewSendHandler[StateT], + ) -> MessagePreviewSendHandler[StateT]: + async def __handler(context: TurnContext, state: StateT) -> None: + teams_context = TeamsTurnContext(context, self._app) + activity_preview = _extract_activity_preview(context.activity.value) + response = await func(teams_context, state, activity_preview) + if response is not None: + await _send_invoke_response(context, response) + + self._app.add_route( + __selector, + __handler, + is_invoke=True, + rank=rank, + auth_handlers=auth_handlers, + ) + return func + + return __call + + def fetch_action( + self, + command_id: CommandSelector = None, + *, + auth_handlers: Optional[list[str]] = None, + rank: RouteRank = RouteRank.DEFAULT, + ) -> _RouteDecorator[FetchActionHandler[StateT]]: + """Register a handler for composeExtension/fetchTask invokes. + + :param command_id: Optional command identifier or regex to match a specific Teams command. + :param auth_handlers: Optional auth handler names to run before the route. + :param rank: Route priority. + :return: A decorator that registers the handler and returns it. + """ + + def __selector(context: TurnContext) -> bool: + value = context.activity.value + return ( + context.activity.type == ActivityTypes.invoke + and context.activity.name == "composeExtension/fetchTask" + and isinstance(value, dict) + and _match_selector(command_id, _get_command_id(value)) + ) + + def __call(func: FetchActionHandler[StateT]) -> FetchActionHandler[StateT]: + async def __handler(context: TurnContext, state: StateT) -> None: + teams_context = TeamsTurnContext(context, self._app) + action = MessagingExtensionAction.model_validate( + context.activity.value or {} + ) + response = await func(teams_context, state, action) + if response is not None: + await _send_invoke_response(context, response) + + self._app.add_route( + __selector, + __handler, + is_invoke=True, + rank=rank, + auth_handlers=auth_handlers, + ) + return func + + return __call + + @overload + def query_link( + self, handler: QueryLinkHandler[StateT] + ) -> QueryLinkHandler[StateT]: ... + + @overload + def query_link( + self, *, auth_handlers: Optional[list[str]] = ..., rank: RouteRank = ... + ) -> _RouteDecorator[QueryLinkHandler[StateT]]: ... + + def query_link( + self, + handler: Optional[QueryLinkHandler[StateT]] = None, + *, + auth_handlers: Optional[list[str]] = None, + rank: RouteRank = RouteRank.DEFAULT, + ) -> QueryLinkHandler[StateT] | _RouteDecorator[QueryLinkHandler[StateT]]: + """Register a handler for composeExtension/queryLink invokes. + + :param handler: Optional handler receiving the context, state, and app-based link query. + :param auth_handlers: Optional auth handler names to run before the route. + :param rank: Route priority. + :return: The registered handler when provided, otherwise a decorator that registers it. + """ + + def __selector(context: TurnContext) -> bool: + return ( + context.activity.type == ActivityTypes.invoke + and context.activity.name == "composeExtension/queryLink" + ) + + def __call(func: QueryLinkHandler[StateT]) -> QueryLinkHandler[StateT]: + async def __handler(context: TurnContext, state: StateT) -> None: + teams_context = TeamsTurnContext(context, self._app) + query = AppBasedLinkQuery.model_validate(context.activity.value or {}) + response = await func(teams_context, state, query) + if response is not None: + await _send_invoke_response(context, response) + + self._app.add_route( + __selector, + __handler, + is_invoke=True, + rank=rank, + auth_handlers=auth_handlers, + ) + return func + + if handler is not None: + return __call(handler) + return __call + + @overload + def anonymous_query_link( + self, handler: QueryLinkHandler[StateT] + ) -> QueryLinkHandler[StateT]: ... + + @overload + def anonymous_query_link( + self, *, auth_handlers: Optional[list[str]] = ..., rank: RouteRank = ... + ) -> _RouteDecorator[QueryLinkHandler[StateT]]: ... + + def anonymous_query_link( + self, + handler: Optional[QueryLinkHandler[StateT]] = None, + *, + auth_handlers: Optional[list[str]] = None, + rank: RouteRank = RouteRank.DEFAULT, + ) -> QueryLinkHandler[StateT] | _RouteDecorator[QueryLinkHandler[StateT]]: + """Register a handler for composeExtension/anonymousQueryLink invokes. + + :param handler: Optional handler receiving the context, state, and app-based link query. + :param auth_handlers: Optional auth handler names to run before the route. + :param rank: Route priority. + :return: The registered handler when provided, otherwise a decorator that registers it. + """ + + def __selector(context: TurnContext) -> bool: + return ( + context.activity.type == ActivityTypes.invoke + and context.activity.name == "composeExtension/anonymousQueryLink" + ) + + def __call(func: QueryLinkHandler[StateT]) -> QueryLinkHandler[StateT]: + async def __handler(context: TurnContext, state: StateT) -> None: + teams_context = TeamsTurnContext(context, self._app) + query = AppBasedLinkQuery.model_validate(context.activity.value or {}) + response = await func(teams_context, state, query) + if response is not None: + await _send_invoke_response(context, response) + + self._app.add_route( + __selector, + __handler, + is_invoke=True, + rank=rank, + auth_handlers=auth_handlers, + ) + return func + + if handler is not None: + return __call(handler) + return __call + + @overload + def query_setting_url( + self, handler: QueryUrlSettingHandler[StateT] + ) -> QueryUrlSettingHandler[StateT]: ... + + @overload + def query_setting_url( + self, *, auth_handlers: Optional[list[str]] = ..., rank: RouteRank = ... + ) -> _RouteDecorator[QueryUrlSettingHandler[StateT]]: ... + + def query_setting_url( + self, + handler: Optional[QueryUrlSettingHandler[StateT]] = None, + *, + auth_handlers: Optional[list[str]] = None, + rank: RouteRank = RouteRank.DEFAULT, + ) -> ( + QueryUrlSettingHandler[StateT] | _RouteDecorator[QueryUrlSettingHandler[StateT]] + ): + """Register a handler for composeExtension/querySettingUrl invokes. + + :param handler: Optional handler receiving the context, state, and settings URL query. + :param auth_handlers: Optional auth handler names to run before the route. + :param rank: Route priority. + :return: The registered handler when provided, otherwise a decorator that registers it. + """ + + def __selector(context: TurnContext) -> bool: + return ( + context.activity.type == ActivityTypes.invoke + and context.activity.name == "composeExtension/querySettingUrl" + ) + + def __call( + func: QueryUrlSettingHandler[StateT], + ) -> QueryUrlSettingHandler[StateT]: + async def __handler(context: TurnContext, state: StateT) -> None: + teams_context = TeamsTurnContext(context, self._app) + query = MessagingExtensionQuery.model_validate( + context.activity.value or {} + ) + response = await func(teams_context, state, query) + if response is not None: + await _send_invoke_response(context, response) + + self._app.add_route( + __selector, + __handler, + is_invoke=True, + rank=rank, + auth_handlers=auth_handlers, + ) + return func + + if handler is not None: + return __call(handler) + return __call + + @overload + def setting(self, handler: SettingHandler[StateT]) -> SettingHandler[StateT]: ... + + @overload + def setting( + self, *, auth_handlers: list[str] | None = ..., rank: RouteRank = ... + ) -> _RouteDecorator[SettingHandler[StateT]]: ... + + def setting( + self, + handler: SettingHandler[StateT] | None = None, + *, + auth_handlers: list[str] | None = None, + rank: RouteRank = RouteRank.DEFAULT, + ) -> SettingHandler[StateT] | _RouteDecorator[SettingHandler[StateT]]: + """Register a handler for composeExtension/setting invokes. + + :param handler: Optional handler receiving the context, state, and settings query payload. + :param auth_handlers: Optional auth handler names to run before the route. + :param rank: Route priority. + :return: The registered handler when provided, otherwise a decorator that registers it. + """ + + def __selector(context: TurnContext) -> bool: + return ( + context.activity.type == ActivityTypes.invoke + and context.activity.name == "composeExtension/setting" + ) + + def __call(func: SettingHandler[StateT]) -> SettingHandler[StateT]: + async def __handler(context: TurnContext, state: StateT) -> None: + teams_context = TeamsTurnContext(context, self._app) + query = MessagingExtensionQuery.model_validate( + context.activity.value or {} + ) + res = await func(teams_context, state, query) + await _send_invoke_response(context, res) + + self._app.add_route( + __selector, + __handler, + is_invoke=True, + rank=rank, + auth_handlers=auth_handlers, + ) + return func + + if handler is not None: + return __call(handler) + return __call + + @overload + def card_button_clicked( + self, handler: CardButtonClickedHandler[StateT] + ) -> CardButtonClickedHandler[StateT]: ... + + @overload + def card_button_clicked( + self, *, auth_handlers: list[str] | None = ..., rank: RouteRank = ... + ) -> _RouteDecorator[CardButtonClickedHandler[StateT]]: ... + + def card_button_clicked( + self, + handler: CardButtonClickedHandler[StateT] | None = None, + *, + auth_handlers: list[str] | None = None, + rank: RouteRank = RouteRank.DEFAULT, + ) -> ( + CardButtonClickedHandler[StateT] + | _RouteDecorator[CardButtonClickedHandler[StateT]] + ): + """Register a handler for composeExtension/onCardButtonClicked invokes. + + :param handler: Optional handler receiving the context, state, and card action payload. + :param auth_handlers: Optional auth handler names to run before the route. + :param rank: Route priority. + :return: The registered handler when provided, otherwise a decorator that registers it. + """ + + def __selector(context: TurnContext) -> bool: + return ( + context.activity.type == ActivityTypes.invoke + and context.activity.name == "composeExtension/onCardButtonClicked" + ) + + def __call( + func: CardButtonClickedHandler[StateT], + ) -> CardButtonClickedHandler[StateT]: + async def __handler(context: TurnContext, state: StateT) -> None: + teams_context = TeamsTurnContext(context, self._app) + await func(teams_context, state, context.activity.value) + await _send_invoke_response(context) + + self._app.add_route( + __selector, + __handler, + is_invoke=True, + rank=rank, + auth_handlers=auth_handlers, + ) + return func + + if handler is not None: + return __call(handler) + return __call diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/message_extension/route_handlers.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/message_extension/route_handlers.py new file mode 100644 index 000000000..5cac32c1b --- /dev/null +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/message_extension/route_handlers.py @@ -0,0 +1,222 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""Protocol definitions for Teams Message Extension route handlers.""" + +from typing import ( + Any, + Awaitable, + Protocol, +) + +from microsoft_teams.api.models import ( + MessagingExtensionAction, + MessagingExtensionQuery, + MessagingExtensionActionResponse, + MessagingExtensionResponse, + AppBasedLinkQuery, +) + +from microsoft_agents.activity import Activity + +from microsoft_agents.hosting.msteams.teams_turn_context import TeamsTurnContext +from microsoft_agents.hosting.msteams.type_defs import _StateContra + + +class FetchActionHandler(Protocol[_StateContra]): + """Protocol for a handler invoked on composeExtension/fetchTask activities.""" + + def __call__( + self, + context: TeamsTurnContext, + state: _StateContra, + action: MessagingExtensionAction, + /, + ) -> Awaitable[MessagingExtensionActionResponse]: + """Handle a fetch task invoke. + + :param context: Teams-aware turn context. + :param state: The current turn state. + :param action: The parsed messaging extension action from the invoke payload. + :return: A response containing the task module to display. + """ + ... + + +class SubmitActionHandler(Protocol[_StateContra]): + """Protocol for a handler invoked on composeExtension/submitAction activities.""" + + def __call__( + self, + context: TeamsTurnContext, + state: _StateContra, + action: MessagingExtensionAction, + /, + ) -> Awaitable[MessagingExtensionResponse]: + """Handle a submit action invoke. + + :param context: Teams-aware turn context. + :param state: The current turn state. + :param action: The parsed messaging extension action from the invoke payload. + :return: A messaging extension response. + """ + ... + + +class MessagePreviewEditHandler(Protocol[_StateContra]): + """Protocol for a handler invoked on composeExtension/submitAction with botMessagePreviewAction == 'edit'.""" + + def __call__( + self, + context: TeamsTurnContext, + state: _StateContra, + activity_preview: Activity, + /, + ) -> Awaitable[MessagingExtensionResponse]: + """Handle a message preview edit request. + + :param context: Teams-aware turn context. + :param state: The current turn state. + :param activity_preview: The parsed botActivityPreview[0] activity from the invoke payload. + :return: A messaging extension response. + """ + ... + + +class MessagePreviewSendHandler(Protocol[_StateContra]): + """Protocol for a handler invoked on composeExtension/submitAction with botMessagePreviewAction == 'send'.""" + + def __call__( + self, + context: TeamsTurnContext, + state: _StateContra, + activity_preview: Activity, + /, + ) -> Awaitable[MessagingExtensionResponse | None]: + """Handle a message preview send request. + + :param context: Teams-aware turn context. + :param state: The current turn state. + :param activity_preview: The parsed botActivityPreview[0] activity from the invoke payload. + :return: A messaging extension response, or None when no response body is required. + """ + ... + + +class QueryHandler(Protocol[_StateContra]): + """Protocol for a handler invoked on composeExtension/query activities.""" + + def __call__( + self, + context: TeamsTurnContext, + state: _StateContra, + query: MessagingExtensionQuery, + /, + ) -> Awaitable[MessagingExtensionResponse]: + """Handle a message extension query invoke. + + :param context: Teams-aware turn context. + :param state: The current turn state. + :param query: The parsed messaging extension query. + :return: A messaging extension response containing search results. + """ + ... + + +class SelectItemHandler(Protocol[_StateContra]): + """Protocol for a handler invoked on composeExtension/selectItem activities.""" + + def __call__( + self, + context: TeamsTurnContext, + state: _StateContra, + item: Any, + /, + ) -> Awaitable[MessagingExtensionResponse]: + """Handle a select item invoke. + + :param context: Teams-aware turn context. + :param state: The current turn state. + :param item: The raw item selected by the user. + :return: A messaging extension response. + """ + ... + + +class QueryLinkHandler(Protocol[_StateContra]): + """Protocol for a handler invoked on composeExtension/queryLink or anonymousQueryLink activities.""" + + def __call__( + self, + context: TeamsTurnContext, + state: _StateContra, + query: AppBasedLinkQuery, + /, + ) -> Awaitable[MessagingExtensionResponse]: + """Handle a link unfurling query. + + :param context: Teams-aware turn context. + :param state: The current turn state. + :param query: The app-based link query containing the URL to unfurl. + :return: A messaging extension response with the unfurled card. + """ + ... + + +class QueryUrlSettingHandler(Protocol[_StateContra]): + """Protocol for a handler invoked on composeExtension/querySettingUrl activities.""" + + def __call__( + self, + context: TeamsTurnContext, + state: _StateContra, + query: MessagingExtensionQuery, + /, + ) -> Awaitable[MessagingExtensionResponse]: + """Handle a query setting URL invoke. + + :param context: Teams-aware turn context. + :param state: The current turn state. + :param query: The messaging extension query for the settings URL. + :return: A messaging extension response containing the settings page URL. + """ + ... + + +class SettingHandler(Protocol[_StateContra]): + """Protocol for a handler invoked on composeExtension/setting activities.""" + + def __call__( + self, + context: TeamsTurnContext, + state: _StateContra, + query: MessagingExtensionQuery, + /, + ) -> Awaitable[MessagingExtensionResponse]: + """Handle a setting invoke. + + :param context: Teams-aware turn context. + :param state: The current turn state. + :param query: The messaging extension query with the settings data. + :return: A messaging extension response. + """ + ... + + +class CardButtonClickedHandler(Protocol[_StateContra]): + """Protocol for a handler invoked on composeExtension/onCardButtonClicked activities.""" + + def __call__( + self, + context: TeamsTurnContext, + state: _StateContra, + card: Any, + /, + ) -> Awaitable[None]: + """Handle a card button clicked invoke. + + :param context: Teams-aware turn context. + :param state: The current turn state. + :param card: The raw card data from the invoke payload. + """ + ... diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/proactive_service_endpoints.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/proactive_service_endpoints.py new file mode 100644 index 000000000..61bc8cc32 --- /dev/null +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/proactive_service_endpoints.py @@ -0,0 +1,22 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""Well-known Microsoft Teams service endpoint URLs for proactive messaging. + +These constants identify the Teams service endpoint for each cloud environment. +Use them only when the incoming request's ``serviceUrl`` is unavailable; once a +``serviceUrl`` has been returned from a prior conversation, cache and reuse that +value instead. +""" + +# Service endpoint for the public global Teams environment. +PUBLIC_GLOBAL = "https://smba.trafficmanager.net/teams/" + +# Service endpoint for the GCC (Government Community Cloud) Teams environment. +GCC = "https://smba.infra.gcc.teams.microsoft.com/teams" + +# Service endpoint for the GCC High Teams environment. +GCC_HIGH = "https://smba.infra.gov.teams.microsoft.us/teams" + +# Service endpoint for the DoD (Department of Defense) Teams environment. +DOD = "https://smba.infra.dod.teams.microsoft.us/teams" diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/route_handlers.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/route_handlers.py new file mode 100644 index 000000000..3df2e0f89 --- /dev/null +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/route_handlers.py @@ -0,0 +1,88 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""Protocol definitions for Teams-aware route and handoff handlers.""" + +from __future__ import annotations + +from typing import ( + Awaitable, + Protocol, +) + +from microsoft_agents.hosting.core import AgentApplication, TurnContext +from microsoft_agents.hosting.core.app._type_defs import ( + RouteHandler, + HandoffHandler, +) + +from .teams_turn_context import TeamsTurnContext +from .type_defs import _StateContra + + +class TeamsRouteHandler(Protocol[_StateContra]): + """Protocol for a Teams route handler that receives a :class:`TeamsTurnContext`.""" + + def __call__( + self, context: TeamsTurnContext, state: _StateContra, / + ) -> Awaitable[None]: + """Handle a turn with Teams context. + + :param context: Teams-aware turn context. + :param state: The current turn state. + """ + ... + + +def wrap_teams_route_handler( + handler: TeamsRouteHandler[_StateContra], app: AgentApplication +) -> RouteHandler[_StateContra]: + """Adapt a :class:`TeamsRouteHandler` into a plain :class:`RouteHandler`. + + Wraps *handler* so that the core routing engine (which passes a plain + :class:`TurnContext`) receives a compatible callable. + + :param handler: The Teams-specific handler to wrap. + :param app: The agent application handling the turn. + :return: A :class:`RouteHandler` that upgrades the context before delegating. + """ + + async def __func(context: TurnContext, state: _StateContra) -> None: + teams_context = TeamsTurnContext(context, app) + await handler(teams_context, state) + + return __func + + +class TeamsHandoffHandler(Protocol[_StateContra]): + """Protocol for a Teams handoff handler that receives handoff continuation data.""" + + def __call__( + self, context: TeamsTurnContext, state: _StateContra, handoff_data: str, / + ) -> Awaitable[None]: + """Handle a handoff activity with Teams context. + + :param context: Teams-aware turn context. + :param state: The current turn state. + :param handoff_data: Opaque continuation data from the handoff initiator. + """ + ... + + +def wrap_teams_handoff_handler( + handler: TeamsHandoffHandler[_StateContra], app: AgentApplication +) -> HandoffHandler[_StateContra]: + """Adapt a :class:`TeamsHandoffHandler` into a plain :class:`HandoffHandler`. + + :param handler: The Teams-specific handoff handler to wrap. + :param app: The agent application handling the turn. + :return: A :class:`HandoffHandler` that upgrades the context before delegating. + """ + + async def __func( + context: TurnContext, state: _StateContra, handoff_data: str + ) -> None: + teams_context = TeamsTurnContext(context, app) + await handler(teams_context, state, handoff_data) + + return __func diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/task_module/__init__.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/task_module/__init__.py new file mode 100644 index 000000000..3343edb3b --- /dev/null +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/task_module/__init__.py @@ -0,0 +1,8 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""Teams task module subpackage exposing route registration helpers.""" + +from .task_module import TaskModule + +__all__ = ["TaskModule"] diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/task_module/route_handlers.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/task_module/route_handlers.py new file mode 100644 index 000000000..684142e1e --- /dev/null +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/task_module/route_handlers.py @@ -0,0 +1,51 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""Protocol definitions for Teams Task Module route handlers.""" + +from typing import Awaitable, Protocol + +from microsoft_teams.api.models import TaskModuleRequest, TaskModuleResponse + +from microsoft_agents.hosting.msteams.teams_turn_context import TeamsTurnContext +from microsoft_agents.hosting.msteams.type_defs import _StateContra + + +class FetchHandler(Protocol[_StateContra]): + """Protocol for a handler invoked on task/fetch activities.""" + + def __call__( + self, + context: TeamsTurnContext, + state: _StateContra, + request: TaskModuleRequest, + /, + ) -> Awaitable[TaskModuleResponse]: + """Handle a task module fetch invoke. + + :param context: Teams-aware turn context. + :param state: The current turn state. + :param request: The parsed task module request payload. + :return: A task module response describing the UI to display. + """ + ... + + +class SubmitHandler(Protocol[_StateContra]): + """Protocol for a handler invoked on task/submit activities.""" + + def __call__( + self, + context: TeamsTurnContext, + state: _StateContra, + request: TaskModuleRequest, + /, + ) -> Awaitable[TaskModuleResponse]: + """Handle a task module submit invoke. + + :param context: Teams-aware turn context. + :param state: The current turn state. + :param request: The parsed task module request payload containing user input. + :return: A task module response (e.g. a message or a follow-up task module). + """ + ... diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/task_module/task_module.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/task_module/task_module.py new file mode 100644 index 000000000..40671c935 --- /dev/null +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/task_module/task_module.py @@ -0,0 +1,139 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""Route registration helpers for Teams Task Module (task/fetch, task/submit) invokes.""" + +from typing import Any, Generic, Optional + +from microsoft_teams.api.models.task_module import TaskModuleRequest + +from microsoft_agents.activity import ActivityTypes + +from microsoft_agents.hosting.core import AgentApplication, RouteRank, TurnContext + +from microsoft_agents.hosting.msteams.teams_turn_context import TeamsTurnContext +from microsoft_agents.hosting.msteams.type_defs import ( + CommandSelector, + _RouteDecorator, + StateT, +) +from microsoft_agents.hosting.msteams._utils import ( + _match_selector, + _send_invoke_response, +) + +from .route_handlers import FetchHandler, SubmitHandler + + +class TaskModule(Generic[StateT]): + """Route registration for Teams Task Module (task/fetch, task/submit) invoke activities. + + Access via :attr:`TeamsAgentExtension.task_modules`. + """ + + def __init__(self, app: AgentApplication[StateT]) -> None: + """Initialise with the owning :class:`AgentApplication`. + + :param app: The application to register routes on. + """ + self._app = app + + @staticmethod + def _get_verb(value: Optional[Any]) -> Optional[str]: + """Extract the verb from a task module request payload. + + :param value: The raw activity value (expected to be a dict with a ``data`` sub-dict). + :return: The verb string if present, otherwise None. + """ + if not isinstance(value, dict): + return None + data = value.get("data") + if isinstance(data, dict): + return data.get("verb") + return None + + def fetch( + self, + verb: CommandSelector = None, + *, + auth_handlers: Optional[list[str]] = None, + rank: RouteRank = RouteRank.DEFAULT, + ) -> _RouteDecorator[FetchHandler[StateT]]: + """Register a handler for task/fetch invokes. + + :param verb: Optional verb string or regex to match a specific Teams task command. + If None, matches all task/fetch invokes. + :param auth_handlers: Optional auth handler names to run before the route. + :param rank: Route priority. + :return: A decorator that registers the handler and returns it. + """ + + def __selector(context: TurnContext) -> bool: + if ( + context.activity.type != ActivityTypes.invoke + or context.activity.name != "task/fetch" + ): + return False + return _match_selector(verb, TaskModule._get_verb(context.activity.value)) + + def __call(func: FetchHandler[StateT]) -> FetchHandler[StateT]: + async def __handler(context: TurnContext, state: StateT) -> None: + teams_context = TeamsTurnContext(context, self._app) + request = TaskModuleRequest.model_validate(context.activity.value or {}) + response = await func(teams_context, state, request) + if response is not None: + await _send_invoke_response(context, response) + + self._app.add_route( + __selector, + __handler, + is_invoke=True, + rank=rank, + auth_handlers=auth_handlers, + ) + return func + + return __call + + def submit( + self, + verb: CommandSelector = None, + *, + auth_handlers: Optional[list[str]] = None, + rank: RouteRank = RouteRank.DEFAULT, + ) -> _RouteDecorator[SubmitHandler[StateT]]: + """Register a handler for task/submit invokes. + + :param verb: Optional verb string or regex to match a specific Teams task command. + If None, matches all task/submit invokes. + :param auth_handlers: Optional auth handler names to run before the route. + :param rank: Route priority. + :return: A decorator that registers the handler and returns it. + """ + + def __selector(context: TurnContext) -> bool: + if ( + context.activity.type != ActivityTypes.invoke + or context.activity.name != "task/submit" + ): + return False + return _match_selector(verb, TaskModule._get_verb(context.activity.value)) + + def __call(func: SubmitHandler[StateT]) -> SubmitHandler[StateT]: + async def __handler(context: TurnContext, state: StateT) -> None: + teams_context = TeamsTurnContext(context, self._app) + request = TaskModuleRequest.model_validate(context.activity.value or {}) + response = await func(teams_context, state, request) + if response is not None: + await _send_invoke_response(context, response) + + self._app.add_route( + __selector, + __handler, + is_invoke=True, + rank=rank, + auth_handlers=auth_handlers, + ) + return func + + return __call diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/team/__init__.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/team/__init__.py new file mode 100644 index 000000000..54a1a58a8 --- /dev/null +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/team/__init__.py @@ -0,0 +1,8 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""Teams team subpackage exposing team lifecycle route helpers.""" + +from .team import Team + +__all__ = ["Team"] diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/team/route_handlers.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/team/route_handlers.py new file mode 100644 index 000000000..aa89f387c --- /dev/null +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/team/route_handlers.py @@ -0,0 +1,30 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""Protocol definition for Teams team update route handlers.""" + +from typing import Awaitable, Protocol + +from microsoft_teams.api.models.channel_data import TeamInfo + +from microsoft_agents.hosting.msteams.teams_turn_context import TeamsTurnContext +from microsoft_agents.hosting.msteams.type_defs import _StateContra + + +class TeamUpdateHandler(Protocol[_StateContra]): + """Protocol for a handler invoked on Teams team conversation update events.""" + + def __call__( + self, + context: TeamsTurnContext, + state: _StateContra, + data: TeamInfo, + /, + ) -> Awaitable[None]: + """Handle a team update event. + + :param context: Teams-aware turn context. + :param state: The current turn state. + :param data: Parsed team info from the incoming activity's channel data. + """ + ... diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/team/team.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/team/team.py new file mode 100644 index 000000000..a95da7381 --- /dev/null +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/team/team.py @@ -0,0 +1,303 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""Route registration helpers for Teams team conversation update events.""" + +import re +from typing import Generic, Optional, overload + +from microsoft_agents.activity import ActivityTypes, Channels +from microsoft_agents.hosting.core import ( + AgentApplication, + RouteRank, + TurnContext, +) + +from microsoft_agents.hosting.msteams.teams_turn_context import TeamsTurnContext +from microsoft_agents.hosting.msteams.type_defs import ( + _RouteDecorator, + StateT, +) +from microsoft_agents.hosting.msteams._utils import ( + _get_channel_event_type, + _get_team_info, +) + +from .route_handlers import TeamUpdateHandler + + +class Team(Generic[StateT]): + """Route registration for Teams team conversation update events. + + Access via :attr:`TeamsAgentExtension.teams`. + """ + + def __init__(self, app: AgentApplication[StateT]) -> None: + """Initialise with the owning :class:`AgentApplication`. + + :param app: The application to register routes on. + """ + self._app = app + + def _create_decorator( + self, + event_type: str | re.Pattern, + *, + auth_handlers: Optional[list[str]] = None, + rank: RouteRank = RouteRank.DEFAULT, + ) -> _RouteDecorator[TeamUpdateHandler[StateT]]: + """Build a route decorator for a specific Teams team event type. + + :param event_type: Literal event type or regex to match against Teams channel data. + :param auth_handlers: Optional auth handler names to run before the route. + :param rank: Route priority. + :return: A decorator that registers the handler and returns it. + """ + + def __selector(context: TurnContext) -> bool: + + event_match = False + channel_event_type = _get_channel_event_type(context) + if channel_event_type: + if isinstance(event_type, re.Pattern): + event_match = ( + re.fullmatch(event_type, channel_event_type) is not None + ) + else: + event_match = event_type == channel_event_type + + return ( + context.activity.type == ActivityTypes.conversation_update + and context.activity.channel_id == Channels.ms_teams + and event_match + ) + + def __call(func: TeamUpdateHandler[StateT]) -> TeamUpdateHandler[StateT]: + async def __handler(context: TurnContext, state: StateT) -> None: + teams_context = TeamsTurnContext(context, self._app) + team_info = _get_team_info(teams_context.activity) + await func(teams_context, state, team_info) + + self._app.add_route( + __selector, __handler, rank=rank, auth_handlers=auth_handlers + ) + return func + + return __call + + @overload + def event( + self, handler: TeamUpdateHandler[StateT] + ) -> TeamUpdateHandler[StateT]: ... + + @overload + def event( + self, *, auth_handlers: Optional[list[str]] = ..., rank: RouteRank = ... + ) -> _RouteDecorator[TeamUpdateHandler[StateT]]: ... + + def event( + self, + handler: Optional[TeamUpdateHandler[StateT]] = None, + *, + auth_handlers: Optional[list[str]] = None, + rank: RouteRank = RouteRank.DEFAULT, + ) -> TeamUpdateHandler[StateT] | _RouteDecorator[TeamUpdateHandler[StateT]]: + """Register a handler for Teams team event conversation update events. + + :param handler: Optional handler receiving the context, state, and parsed team channel data. + :param auth_handlers: Optional auth handler names to run before the route. + :param rank: Route priority. + :return: The registered handler when provided, otherwise a decorator that registers it. + """ + decorator = self._create_decorator( + re.compile(r"team.*"), auth_handlers=auth_handlers, rank=rank + ) + if handler is not None: + return decorator(handler) + return decorator + + @overload + def archived( + self, handler: TeamUpdateHandler[StateT] + ) -> TeamUpdateHandler[StateT]: ... + + @overload + def archived( + self, *, auth_handlers: Optional[list[str]] = ..., rank: RouteRank = ... + ) -> _RouteDecorator[TeamUpdateHandler[StateT]]: ... + + def archived( + self, + handler: Optional[TeamUpdateHandler[StateT]] = None, + *, + auth_handlers: Optional[list[str]] = None, + rank: RouteRank = RouteRank.DEFAULT, + ) -> TeamUpdateHandler[StateT] | _RouteDecorator[TeamUpdateHandler[StateT]]: + """Register a handler for Teams teamArchived conversation update events. + + :param handler: Optional handler receiving the context, state, and parsed team channel data. + :param auth_handlers: Optional auth handler names to run before the route. + :param rank: Route priority. + :return: The registered handler when provided, otherwise a decorator that registers it. + """ + decorator = self._create_decorator( + "teamArchived", auth_handlers=auth_handlers, rank=rank + ) + if handler is not None: + return decorator(handler) + return decorator + + @overload + def deleted( + self, handler: TeamUpdateHandler[StateT] + ) -> TeamUpdateHandler[StateT]: ... + + @overload + def deleted( + self, *, auth_handlers: Optional[list[str]] = ..., rank: RouteRank = ... + ) -> _RouteDecorator[TeamUpdateHandler[StateT]]: ... + + def deleted( + self, + handler: Optional[TeamUpdateHandler[StateT]] = None, + *, + auth_handlers: Optional[list[str]] = None, + rank: RouteRank = RouteRank.DEFAULT, + ) -> TeamUpdateHandler[StateT] | _RouteDecorator[TeamUpdateHandler[StateT]]: + """Register a handler for Teams teamDeleted conversation update events. + + :param handler: Optional handler receiving the context, state, and parsed team channel data. + :param auth_handlers: Optional auth handler names to run before the route. + :param rank: Route priority. + :return: The registered handler when provided, otherwise a decorator that registers it. + """ + decorator = self._create_decorator( + "teamDeleted", auth_handlers=auth_handlers, rank=rank + ) + if handler is not None: + return decorator(handler) + return decorator + + @overload + def hard_deleted( + self, handler: TeamUpdateHandler[StateT] + ) -> TeamUpdateHandler[StateT]: ... + + @overload + def hard_deleted( + self, *, auth_handlers: Optional[list[str]] = ..., rank: RouteRank = ... + ) -> _RouteDecorator[TeamUpdateHandler[StateT]]: ... + + def hard_deleted( + self, + handler: Optional[TeamUpdateHandler[StateT]] = None, + *, + auth_handlers: Optional[list[str]] = None, + rank: RouteRank = RouteRank.DEFAULT, + ) -> TeamUpdateHandler[StateT] | _RouteDecorator[TeamUpdateHandler[StateT]]: + """Register a handler for Teams teamHardDeleted conversation update events. + + :param handler: Optional handler receiving the context, state, and parsed team channel data. + :param auth_handlers: Optional auth handler names to run before the route. + :param rank: Route priority. + :return: The registered handler when provided, otherwise a decorator that registers it. + """ + decorator = self._create_decorator( + "teamHardDeleted", auth_handlers=auth_handlers, rank=rank + ) + if handler is not None: + return decorator(handler) + return decorator + + @overload + def renamed( + self, handler: TeamUpdateHandler[StateT] + ) -> TeamUpdateHandler[StateT]: ... + + @overload + def renamed( + self, *, auth_handlers: Optional[list[str]] = ..., rank: RouteRank = ... + ) -> _RouteDecorator[TeamUpdateHandler[StateT]]: ... + + def renamed( + self, + handler: Optional[TeamUpdateHandler[StateT]] = None, + *, + auth_handlers: Optional[list[str]] = None, + rank: RouteRank = RouteRank.DEFAULT, + ) -> TeamUpdateHandler[StateT] | _RouteDecorator[TeamUpdateHandler[StateT]]: + """Register a handler for Teams teamRenamed conversation update events. + + :param handler: Optional handler receiving the context, state, and parsed team channel data. + :param auth_handlers: Optional auth handler names to run before the route. + :param rank: Route priority. + :return: The registered handler when provided, otherwise a decorator that registers it. + """ + decorator = self._create_decorator( + "teamRenamed", auth_handlers=auth_handlers, rank=rank + ) + if handler is not None: + return decorator(handler) + return decorator + + @overload + def restored( + self, handler: TeamUpdateHandler[StateT] + ) -> TeamUpdateHandler[StateT]: ... + + @overload + def restored( + self, *, auth_handlers: Optional[list[str]] = ..., rank: RouteRank = ... + ) -> _RouteDecorator[TeamUpdateHandler[StateT]]: ... + + def restored( + self, + handler: Optional[TeamUpdateHandler[StateT]] = None, + *, + auth_handlers: Optional[list[str]] = None, + rank: RouteRank = RouteRank.DEFAULT, + ) -> TeamUpdateHandler[StateT] | _RouteDecorator[TeamUpdateHandler[StateT]]: + """Register a handler for Teams teamRestored conversation update events. + + :param handler: Optional handler receiving the context, state, and parsed team channel data. + :param auth_handlers: Optional auth handler names to run before the route. + :param rank: Route priority. + :return: The registered handler when provided, otherwise a decorator that registers it. + """ + decorator = self._create_decorator( + "teamRestored", auth_handlers=auth_handlers, rank=rank + ) + if handler is not None: + return decorator(handler) + return decorator + + @overload + def unarchived( + self, handler: TeamUpdateHandler[StateT] + ) -> TeamUpdateHandler[StateT]: ... + + @overload + def unarchived( + self, *, auth_handlers: Optional[list[str]] = ..., rank: RouteRank = ... + ) -> _RouteDecorator[TeamUpdateHandler[StateT]]: ... + + def unarchived( + self, + handler: Optional[TeamUpdateHandler[StateT]] = None, + *, + auth_handlers: Optional[list[str]] = None, + rank: RouteRank = RouteRank.DEFAULT, + ) -> TeamUpdateHandler[StateT] | _RouteDecorator[TeamUpdateHandler[StateT]]: + """Register a handler for Teams teamUnarchived conversation update events. + + :param handler: Optional handler receiving the context, state, and parsed team channel data. + :param auth_handlers: Optional auth handler names to run before the route. + :param rank: Route priority. + :return: The registered handler when provided, otherwise a decorator that registers it. + """ + decorator = self._create_decorator( + "teamUnarchived", auth_handlers=auth_handlers, rank=rank + ) + if handler is not None: + return decorator(handler) + return decorator diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_activity.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_activity.py new file mode 100644 index 000000000..fb47071df --- /dev/null +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_activity.py @@ -0,0 +1,123 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""Teams-aware :class:`Activity` subclass exposing Teams channel data helpers.""" + +from typing import Literal + +from microsoft_teams.api.models import ( + ChannelData, + FeedbackLoop, + MeetingInfo, + NotificationInfo, + TeamInfo, +) + +from microsoft_agents.activity import Activity + +from ._utils import _try_get_channel_data + + +class TeamsActivity(Activity): + """An :class:`Activity` extended with Teams-specific accessors. + + .. note:: + Instances of this class are produced by reassigning ``__class__`` on an + existing :class:`Activity` (see :class:`TeamsTurnContext`), so no new + instance fields may be added here -- only methods that derive values + from the activity's existing ``channel_data``. + """ + + def _get_channel_data(self) -> ChannelData | None: + """Return the parsed Teams channel data, or None if absent. + + :return: The parsed :class:`ChannelData`, or None if the activity has no + channel data. + """ + return _try_get_channel_data(self) + + def get_selected_channel_id(self) -> str | None: + """Get the ID of the selected channel from the activity, if it exists. + + :return: The ID of the selected channel, or None if it doesn't exist. + """ + channel_data = self._get_channel_data() + if ( + channel_data + and channel_data.settings + and channel_data.settings.selected_channel + ): + return channel_data.settings.selected_channel.id + return None + + def get_channel_id(self) -> str | None: + """Get the ID of the channel from the activity, if it exists. + + :return: The ID of the channel, or None if it doesn't exist. + """ + channel_data = self._get_channel_data() + if channel_data and channel_data.channel: + return channel_data.channel.id + return None + + def get_meeting_info(self) -> MeetingInfo | None: + """Get the meeting info from the activity, if it exists. + + :return: The meeting info, or None if it doesn't exist. + """ + channel_data = self._get_channel_data() + if channel_data: + return channel_data.meeting + return None + + def get_team_info(self) -> TeamInfo | None: + """Get the team info from the activity, if it exists. + + :return: The team info, or None if it doesn't exist. + """ + channel_data = self._get_channel_data() + if channel_data: + return channel_data.team + return None + + def notify_user( + self, + alert_in_meeting: bool = False, + external_resource_url: str | None = None, + ): + """Notify the user about the activity. + + :param alert_in_meeting: Whether to alert the user in a meeting. + :param external_resource_url: The URL of an external resource to link to. + """ + channel_data = self._get_channel_data() + if not channel_data: + channel_data = ChannelData() + + # in both cases need to re-set it because _get_channel_data() can + # return a serialized version of the stored data + self.channel_data = channel_data + + channel_data.notification = NotificationInfo( + alert=not alert_in_meeting, + alert_in_meeting=alert_in_meeting, + external_resource_url=external_resource_url, + ) + + def enable_feedback_loop( + self, feedback_loop_type: Literal["default", "custom"] = "default" + ) -> bool: + """Enable a feedback loop for the activity. + + :param feedback_loop_type: The type of feedback loop to enable. + :return: True if the feedback loop was enabled, False otherwise. + """ + + channel_data = self._get_channel_data() + if channel_data is not None: + return False + + self.channel_data = ChannelData( + feedback_loop=FeedbackLoop(type=feedback_loop_type) + ) + return True diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_agent_extension.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_agent_extension.py new file mode 100644 index 000000000..68f620541 --- /dev/null +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_agent_extension.py @@ -0,0 +1,309 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""The :class:`TeamsAgentExtension` entry point for Teams route registration. + +Attaches Teams-specific route namespaces (channels, teams, meetings, messages, +message extensions, task modules, configuration, and file consent) to an +:class:`AgentApplication`, wires the Teams before-turn hook, and exposes helpers +for obtaining Teams API and Microsoft Graph clients. +""" + +from __future__ import annotations + +from typing import ( + Callable, + Generic, + Optional, + Pattern, + Protocol, +) + +from msgraph import GraphServiceClient +from microsoft_teams.api import ApiClient + +from microsoft_agents.activity import ( + ActivityTypes, + Channels, + ConversationUpdateTypes, + MessageReactionTypes, + MessageUpdateTypes, +) +from microsoft_agents.hosting.core import ( + AgentApplication, + TurnContext, +) +from microsoft_agents.hosting.core.app._type_defs import RouteHandler, HandoffHandler + +from .channel import Channel +from .config import Config +from .file_consent import FileConsent +from .meeting import Meeting +from .message import Message +from .message_extension import MessageExtension +from .route_handlers import ( + TeamsRouteHandler, + TeamsHandoffHandler, + wrap_teams_route_handler, + wrap_teams_handoff_handler, +) +from .task_module import TaskModule +from .team import Team + +from ._graph import _create_graph_service_client +from ._teams_api_client import ( + _get_teams_api_client, + _set_teams_api_client, +) +from ._utils import _try_get_channel_data + +from .teams_activity import TeamsActivity +from .type_defs import StateT + + +class _AppRouteDecorator(Protocol[StateT]): + """Protocol for a decorator returned by :class:`TeamsAgentExtension` route methods.""" + + def __call__(self, func: TeamsRouteHandler[StateT], /) -> RouteHandler[StateT]: + """Register *func* as a Teams route handler. + + :param func: Teams-aware handler to register. + :return: The wrapped core route handler. + """ + ... + + +class TeamsAgentExtension(Generic[StateT]): + """ + Adds Teams-specific route registration to an AgentApplication. + + Usage:: + + app = AgentApplication(options) + teams = TeamsAgentExtension(app) + + @teams.task_modules.fetch("myVerb") + async def handle_fetch(context, state, request: TaskModuleRequest): + return TaskModuleResponse(...) + + @teams.message_extensions.query("searchCmd") + async def handle_query(context, state, query: MessagingExtensionQuery): + return MessagingExtensionResponse(...) + + @teams.meetings.start() + async def handle_meeting_start(context, state, meeting: MeetingDetails): + ... + """ + + def __init__(self, app: AgentApplication[StateT]) -> None: + """Attach Teams route namespaces to *app*. + + :param app: The :class:`AgentApplication` to extend with Teams-specific routes. + """ + self._app = app + + self._channel: Channel[StateT] = Channel(app) + self._configuration: Config[StateT] = Config(app) + self._file_consent: FileConsent[StateT] = FileConsent(app) + self._meeting: Meeting[StateT] = Meeting(app) + self._message: Message[StateT] = Message(app) + self._message_extension: MessageExtension[StateT] = MessageExtension(app) + self._task_module: TaskModule[StateT] = TaskModule(app) + self._team: Team[StateT] = Team(app) + + self._configure_app() + + def _configure_app(self): + """Configure the underlying AgentApplication with Teams-specific routes.""" + + async def on_before_turn(context: TurnContext, state: StateT) -> bool: + if context.activity.channel_id == Channels.ms_teams: + _set_teams_api_client(context, self._app.connection_manager) + # caches the deserialized version of ChannelData + context.activity.channel_data = _try_get_channel_data(context.activity) + return True + + self._app.before_turn(on_before_turn) + + @property + def channels(self) -> Channel[StateT]: + """Route registration for Channel events.""" + return self._channel + + @property + def config(self) -> Config[StateT]: + """Route registration for Configuration events.""" + return self._configuration + + @property + def file_consent(self) -> FileConsent[StateT]: + """Route registration for File Consent events.""" + return self._file_consent + + @property + def meetings(self) -> Meeting[StateT]: + """Route registration for Meeting lifecycle events.""" + return self._meeting + + @property + def messages(self) -> Message[StateT]: + """Route registration for messaging activities.""" + return self._message + + @property + def message_extensions(self) -> MessageExtension[StateT]: + """Route registration for Message Extension (composeExtension) invokes.""" + return self._message_extension + + @property + def task_modules(self) -> TaskModule[StateT]: + """Route registration for Task Module (task/fetch, task/submit) invokes.""" + return self._task_module + + @property + def teams(self) -> Team[StateT]: + """Route registration for Team lifecycle events.""" + return self._team + + # AgentApplication route hooks + + def _wrap_decorator( + self, decorator: Callable[[RouteHandler[StateT]], RouteHandler[StateT]] + ) -> Callable[[TeamsRouteHandler[StateT]], RouteHandler[StateT]]: + """Wrap a core route decorator so it accepts a :class:`TeamsRouteHandler`. + + The returned decorator converts the Teams handler via + :func:`wrap_teams_route_handler` before passing it to *decorator*, keeping the + Teams context upgrade transparent to callers. + + :param decorator: A core route decorator from :class:`AgentApplication`. + :return: A decorator that accepts and registers a :class:`TeamsRouteHandler`. + """ + + def __call(func: TeamsRouteHandler[StateT]) -> RouteHandler[StateT]: + return decorator(wrap_teams_route_handler(func, self._app)) + + return __call + + def activity( + self, + activity_type: str | ActivityTypes | list[str | ActivityTypes], + *, + auth_handlers: Optional[list[str]] = None, + **kwargs, + ) -> _AppRouteDecorator[StateT]: + """Register a handler for one or more activity types. + + :param activity_type: Activity type(s) to match (e.g. ``ActivityTypes.message``). + :param auth_handlers: Optional list of auth handler names to run before the route. + :return: A decorator that registers the handler and returns it. + """ + return self._wrap_decorator( + self._app.activity(activity_type, auth_handlers=auth_handlers, **kwargs) + ) + + def message( + self, + select: str | Pattern[str] | list[str | Pattern[str]], + *, + auth_handlers: Optional[list[str]] = None, + **kwargs, + ) -> _AppRouteDecorator[StateT]: + """Register a handler for message activities matching *select*. + + :param select: A literal string, regex pattern, or list of either to match against + the message text. + :param auth_handlers: Optional list of auth handler names to run before the route. + :return: A decorator that registers the handler and returns it. + """ + return self._wrap_decorator( + self._app.message(select, auth_handlers=auth_handlers, **kwargs) + ) + + def conversation_update( + self, + type: ConversationUpdateTypes, + *, + auth_handlers: Optional[list[str]] = None, + **kwargs, + ) -> _AppRouteDecorator[StateT]: + """Register a handler for a specific conversation update event type. + + :param type: The :class:`ConversationUpdateTypes` value to match. + :param auth_handlers: Optional list of auth handler names to run before the route. + :return: A decorator that registers the handler and returns it. + """ + return self._wrap_decorator( + self._app.conversation_update(type, auth_handlers=auth_handlers, **kwargs) + ) + + def message_reaction( + self, + type: MessageReactionTypes, + *, + auth_handlers: Optional[list[str]] = None, + **kwargs, + ) -> _AppRouteDecorator[StateT]: + """Register a handler for a specific message reaction type. + + :param type: The :class:`MessageReactionTypes` value to match (e.g. ``reactionsAdded``). + :param auth_handlers: Optional list of auth handler names to run before the route. + :return: A decorator that registers the handler and returns it. + """ + return self._wrap_decorator( + self._app.message_reaction(type, auth_handlers=auth_handlers, **kwargs) + ) + + def message_update( + self, + type: MessageUpdateTypes, + *, + auth_handlers: Optional[list[str]] = None, + **kwargs, + ) -> _AppRouteDecorator[StateT]: + """Register a handler for a specific message update event type. + + :param type: The :class:`MessageUpdateTypes` value to match (e.g. ``editMessage``). + :param auth_handlers: Optional list of auth handler names to run before the route. + :return: A decorator that registers the handler and returns it. + """ + return self._wrap_decorator( + self._app.message_update(type, auth_handlers=auth_handlers, **kwargs) + ) + + def handoff( + self, + *, + auth_handlers: Optional[list[str]] = None, + **kwargs, + ) -> Callable[[TeamsHandoffHandler[StateT]], HandoffHandler[StateT]]: + """Register a Teams handoff handler. + + :param auth_handlers: Optional list of auth handler names to run before the route. + :return: A decorator that registers the handler and returns it. + """ + + def __call(func: TeamsHandoffHandler[StateT]) -> HandoffHandler[StateT]: + return self._app.handoff(auth_handlers=auth_handlers, **kwargs)( + wrap_teams_handoff_handler(func, self._app) + ) + + return __call + + def get_teams_api_client(self, context: TurnContext) -> ApiClient: + """Get the Teams API client. + + :return: The Teams API client. + """ + return _get_teams_api_client(context) + + def get_graph_client( + self, context: TurnContext, handler_name: str | None = None + ) -> GraphServiceClient: + """Get the Graph Service client. + + :param context: The turn context. + :param handler_name: The name of the handler. + :return: The Graph Service client. + """ + return _create_graph_service_client(self._app, context, handler_name) diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_turn_context.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_turn_context.py new file mode 100644 index 000000000..9685bfb6c --- /dev/null +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_turn_context.py @@ -0,0 +1,124 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""Teams-specific turn context wrapper.""" + +from __future__ import annotations + +from typing import cast + +from microsoft_teams.api import ApiClient + +from microsoft_agents.activity import ( + Activity, + ActivityTreatment, + ActivityTreatmentTypes, + ResourceResponse, +) +from microsoft_agents.hosting.core import AgentApplication, TurnContext + +from ._teams_api_client import _get_teams_api_client, _set_teams_api_client +from .teams_activity import TeamsActivity + + +class TeamsTurnContext(TurnContext): + """A context object for handling Teams-specific turn functionality. + + Wraps a plain :class:`TurnContext` so that Teams-aware route handlers + receive a typed context without changing the core routing engine. + """ + + def __init__(self, context: TurnContext, app: AgentApplication) -> None: + """Initialise the Teams turn context from a plain turn context. + + :param context: The base turn context provided by the core runtime. + :param app: The agent application that is handling the turn. + """ + super().__init__(context) + self._app = app + self._turn_state = context.turn_state + + self._original = context + + self._set_teams_activity() + + _set_teams_api_client(self, app.connection_manager) + + def _set_teams_activity(self) -> None: + """ + Replace the default Activity class with TeamsActivity + + This allows the activity to be treated as a TeamsActivity, which provides + additional methods for working with Teams-specific data. + + This is a bit of a hack, but it's the only way to get the TeamsActivity class to be used. + There are possible workarounds, but because: + 1. we do not expect new properties to be added to the TeamsActivity class + 2. we define the TeamsActivity class, and we manage its lifecycle here + this is the most straightforward approach for now. + The main pitfall beyond the obvious ones is if a user defines a custom Activity + and brings in their own Adapter that creates it. This is a tradeoff. + """ + self._activity.__class__ = TeamsActivity + self._teams_activity = cast(TeamsActivity, self._activity) + + @property + def responded(self) -> bool: + return self._original.responded + + @responded.setter + def responded(self, value: bool): + self._original.responded = value + + @property + def streaming_response(self): + return self._original.streaming_response + + @property + def activity(self) -> TeamsActivity: + """The current activity, typed as a :class:`TeamsActivity`. + + :return: The turn's activity exposing Teams-specific accessors. + """ + return self._teams_activity + + @property + def api_client(self) -> ApiClient: + """Get the API client for the Teams turn context.""" + return _get_teams_api_client(self) + + @staticmethod + def _make_targeted_activity(activity: Activity) -> None: + """ + Make an activity targeted. + + :param activity: The activity to make targeted. + :return: None + """ + activity.entities = activity.entities or [] + activity.entities.append( + ActivityTreatment(treatment=ActivityTreatmentTypes.TARGETED) + ) + + async def send_targeted_activity(self, activity: Activity) -> ResourceResponse: + """ + Send a targeted activity. + + :param activity: The activity to send. + :return: The resource response. + """ + TeamsTurnContext._make_targeted_activity(activity) + return await self.send_activity(activity) + + async def send_targeted_activities( + self, activities: list[Activity] + ) -> list[ResourceResponse]: + """ + Send a list of targeted activities. + + :param activities: The list of activities to send. + :return: A list of resource responses. + """ + for activity in activities: + TeamsTurnContext._make_targeted_activity(activity) + return await self.send_activities(activities) diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/type_defs.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/type_defs.py new file mode 100644 index 000000000..7b689f787 --- /dev/null +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/type_defs.py @@ -0,0 +1,37 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""Shared type aliases and protocols used across the Teams hosting sub-package.""" + +from typing import ( + Callable, + TYPE_CHECKING, + TypeVar, + Pattern, + Protocol, +) + +from microsoft_agents.hosting.core import TurnState + +if TYPE_CHECKING: + from .teams_turn_context import TeamsTurnContext + +TeamsRouteSelector = Callable[["TeamsTurnContext"], bool] + +StateT = TypeVar("StateT", bound=TurnState) +_StateContra = TypeVar("_StateContra", bound=TurnState, contravariant=True) +RouteHandlerT = TypeVar("RouteHandlerT", bound=Callable) + +CommandSelector = str | Pattern[str] | None + + +class _RouteDecorator(Protocol[RouteHandlerT]): + """Protocol for a decorator that registers *func* as a route and returns it unchanged.""" + + def __call__(self, func: RouteHandlerT) -> RouteHandlerT: + """Register *func* as a route handler and return it. + + :param func: The handler to register. + :return: The same handler, unmodified, so it can be used as a plain callable. + """ + ... diff --git a/libraries/microsoft-agents-hosting-teams/pyproject.toml b/libraries/microsoft-agents-hosting-msteams/pyproject.toml similarity index 85% rename from libraries/microsoft-agents-hosting-teams/pyproject.toml rename to libraries/microsoft-agents-hosting-msteams/pyproject.toml index 3ab2c1459..a21a04f28 100644 --- a/libraries/microsoft-agents-hosting-teams/pyproject.toml +++ b/libraries/microsoft-agents-hosting-msteams/pyproject.toml @@ -3,16 +3,17 @@ requires = ["setuptools"] build-backend = "setuptools.build_meta" [project] -name = "microsoft-agents-hosting-teams" +name = "microsoft-agents-hosting-msteams" dynamic = ["version", "dependencies"] description = "Integration library for Microsoft Agents with Teams" readme = {file = "readme.md", content-type = "text/markdown"} authors = [{name = "Microsoft Corporation"}] license = "MIT" license-files = ["LICENSE"] -requires-python = ">=3.12" +requires-python = ">=3.11" classifiers = [ "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", diff --git a/libraries/microsoft-agents-hosting-msteams/readme.md b/libraries/microsoft-agents-hosting-msteams/readme.md new file mode 100644 index 000000000..f4b2dfb02 --- /dev/null +++ b/libraries/microsoft-agents-hosting-msteams/readme.md @@ -0,0 +1,495 @@ +# microsoft-agents-hosting-msteams + +[![PyPI version](https://img.shields.io/pypi/v/microsoft-agents-hosting-msteams)](https://pypi.org/project/microsoft-agents-hosting-msteams/) + +Teams-specific extension for the Microsoft 365 Agents SDK for Python. Provides the `TeamsAgentExtension` and a full set of typed, decorator-based route registrars for every Teams invoke and event activity — messaging extensions, task modules, meeting lifecycle events, channel and team management, file consent, config invokes, and more. + +All handlers receive a `TeamsTurnContext`, a Teams-aware wrapper around the base `TurnContext` that surfaces the Teams API client and typed activity helpers. + +## What is this? + +This library is part of the **Microsoft 365 Agents SDK for Python** — a comprehensive framework for building enterprise-grade conversational AI agents. The SDK enables developers to create intelligent agents that work across Microsoft Teams, M365 Copilot, Copilot Studio, and web chat. + +## Release Notes + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
VersionDateRelease Notes
1.1.02026-06-19 + + 1.1.0 Release Notes + +
1.0.02026-05-22 + + 1.0.0 Release Notes + +
0.9.02026-04-15 + + 0.9.0 Release Notes + +
0.8.02026-02-23 + + 0.8.0 Release Notes + +
0.7.02026-01-21 + + 0.7.0 Release Notes + +
0.6.12025-12-01 + + 0.6.1 Release Notes + +
0.6.02025-11-18 + + 0.6.0 Release Notes + +
0.5.02025-10-22 + + 0.5.0 Release Notes + +
+ +## Packages Overview + +| Package Name | PyPI Version | Description | +|--------------|-------------|-------------| +| `microsoft-agents-activity` | [![PyPI](https://img.shields.io/pypi/v/microsoft-agents-activity)](https://pypi.org/project/microsoft-agents-activity/) | Types and validators implementing the Activity protocol spec. | +| `microsoft-agents-hosting-core` | [![PyPI](https://img.shields.io/pypi/v/microsoft-agents-hosting-core)](https://pypi.org/project/microsoft-agents-hosting-core/) | Core library for Microsoft Agents hosting. | +| `microsoft-agents-hosting-aiohttp` | [![PyPI](https://img.shields.io/pypi/v/microsoft-agents-hosting-aiohttp)](https://pypi.org/project/microsoft-agents-hosting-aiohttp/) | Configures aiohttp to run the Agent. | +| `microsoft-agents-hosting-msteams` | [![PyPI](https://img.shields.io/pypi/v/microsoft-agents-hosting-msteams)](https://pypi.org/project/microsoft-agents-hosting-msteams/) | Provides classes to host an Agent for Teams. | +| `microsoft-agents-hosting-dialogs` | [![PyPI](https://img.shields.io/pypi/v/microsoft-agents-hosting-dialogs)](https://pypi.org/project/microsoft-agents-hosting-dialogs/) | Dialog system with waterfall dialogs, prompts, and multi-turn conversation management. | +| `microsoft-agents-storage-blob` | [![PyPI](https://img.shields.io/pypi/v/microsoft-agents-storage-blob)](https://pypi.org/project/microsoft-agents-storage-blob/) | Extension to use Azure Blob as storage. | +| `microsoft-agents-storage-cosmos` | [![PyPI](https://img.shields.io/pypi/v/microsoft-agents-storage-cosmos)](https://pypi.org/project/microsoft-agents-storage-cosmos/) | Extension to use CosmosDB as storage. | +| `microsoft-agents-authentication-msal` | [![PyPI](https://img.shields.io/pypi/v/microsoft-agents-authentication-msal)](https://pypi.org/project/microsoft-agents-authentication-msal/) | MSAL-based authentication for Microsoft Agents. | + +Additionally we provide a Copilot Studio Client, to interact with Agents created in CopilotStudio: + +| Package Name | PyPI Version | Description | +|--------------|-------------|-------------| +| `microsoft-agents-copilotstudio-client` | [![PyPI](https://img.shields.io/pypi/v/microsoft-agents-copilotstudio-client)](https://pypi.org/project/microsoft-agents-copilotstudio-client/) | Direct to Engine client to interact with Agents created in CopilotStudio | + +## Installation + +```bash +pip install microsoft-agents-hosting-msteams +``` + +## Key Classes + +| Class | Description | +|-------|-------------| +| `TeamsAgentExtension` | Attaches to an `AgentApplication` and exposes all Teams route registrars as typed properties. | +| `TeamsTurnContext` | Teams-aware context passed to every handler. Provides the Teams API client and typed `TeamsActivity` helpers. | +| `MessageExtension` | Route registrar for all `composeExtension/*` invokes (query, fetch action, submit, link unfurling, settings, etc.). | +| `TaskModule` | Route registrar for `task/fetch` and `task/submit` invokes. | +| `Meeting` | Route registrar for meeting start/end and participant join/leave events. | +| `Channel` | Route registrar for channel created/deleted/renamed/shared events. | +| `Team` | Route registrar for team archived/renamed/restored/deleted events. | +| `FileConsent` | Route registrar for file consent accept/decline invokes. | +| `Config` | Route registrar for `config/fetch` and `config/submit` invokes. | +| `Message` | Route registrar for message edit, delete, undelete, read receipts, and actionable message execute. | + +## Usage + +### Setup + +Wrap your `AgentApplication` with `TeamsAgentExtension` to access all Teams-specific route registrars: + +```python +from microsoft_agents.hosting.core import AgentApplication, TurnState +from microsoft_agents.hosting.msteams import TeamsAgentExtension + +app = AgentApplication[TurnState]() +teams = TeamsAgentExtension(app) +``` + +`TeamsAgentExtension` registers a `before_turn` hook that deserializes `activity.channel_data` into a typed `ChannelData` object and creates a pre-authenticated Teams API client on the turn state for every Teams activity. + +### The Teams context + +Every handler registered through `TeamsAgentExtension` receives a `TeamsTurnContext` instead of a plain `TurnContext`. + +#### `context.activity` → `TeamsActivity` + +The activity is upgraded to `TeamsActivity`, which adds helper methods on top of the standard `Activity`: + +```python +# Get the Teams channel the activity came from +channel_id = context.activity.get_channel_id() + +# Get the team info embedded in channel_data +team = context.activity.get_team_info() + +# Get the meeting the activity was sent in +meeting = context.activity.get_meeting_info() + +# Mark the outgoing reply to target a specific user in a meeting +context.activity.notify_user(alert_in_meeting=True) + +# Attach a feedback loop to a message being sent (Copilot scenarios) +context.activity.enable_feedback_loop() +``` + +#### `context.api_client` → `ApiClient` + +Direct access to the Teams REST API client, pre-authenticated for the current turn. See [Teams API client](#teams-api-client) below. + +#### Sending targeted activities + +`TeamsTurnContext` adds two methods for sending activities that target a specific user in a meeting: + +```python +await context.send_targeted_activity(activity) +await context.send_targeted_activities([activity1, activity2]) +``` + +### Messaging Extensions + +```python +from microsoft_teams.api.models import ( + MessagingExtensionQuery, + MessagingExtensionResponse, + MessagingExtensionAction, + MessagingExtensionActionResponse, + AppBasedLinkQuery, +) + +# Search-based extension +@teams.message_extensions.query("searchCmd") +async def on_search(context: TeamsTurnContext, state, query: MessagingExtensionQuery): + return MessagingExtensionResponse(...) + +# Action-based extension — open a task module to collect input +@teams.message_extensions.fetch_action("myCmd") +async def on_fetch_action(context: TeamsTurnContext, state, action: MessagingExtensionAction): + return MessagingExtensionActionResponse(...) + +# Handle submission from the task module +@teams.message_extensions.submit_action("myCmd") +async def on_submit(context: TeamsTurnContext, state, action: MessagingExtensionAction): + return MessagingExtensionResponse(...) + +# Link unfurling +@teams.message_extensions.query_link +async def on_query_link(context: TeamsTurnContext, state, query: AppBasedLinkQuery): + return MessagingExtensionResponse(...) + +# Anonymous link unfurling (before the user has authenticated) +@teams.message_extensions.anonymous_query_link +async def on_anon_link(context: TeamsTurnContext, state, query: AppBasedLinkQuery): + return MessagingExtensionResponse(...) + +# Bot message preview — edit stage +@teams.message_extensions.message_preview_edit("myCmd") +async def on_preview_edit(context: TeamsTurnContext, state, preview: Activity): + return MessagingExtensionResponse(...) + +# Bot message preview — send stage +@teams.message_extensions.message_preview_send("myCmd") +async def on_preview_send(context: TeamsTurnContext, state, preview: Activity): + return MessagingExtensionResponse(...) + +# Select item from search results +@teams.message_extensions.select_item +async def on_select_item(context: TeamsTurnContext, state, item): + return MessagingExtensionResponse(...) + +# Card button clicked +@teams.message_extensions.card_button_clicked +async def on_card_click(context: TeamsTurnContext, state, card): + ... +``` + +All `command_id` arguments accept a plain string, a compiled `re.Pattern`, or `None` to match all commands. + +#### Settings page + +```python +@teams.message_extensions.query_setting_url +async def on_settings_url(context: TeamsTurnContext, state, query: MessagingExtensionQuery): + return MessagingExtensionResponse( + compose_extension=MessagingExtensionResult(type="config", suggested_actions=...) + ) + +@teams.message_extensions.setting +async def on_settings_submit(context: TeamsTurnContext, state, query: MessagingExtensionQuery): + await save_settings(context, query.state) +``` + +### Task Modules + +```python +from microsoft_teams.api.models import TaskModuleRequest, TaskModuleResponse + +@teams.task_modules.fetch("myVerb") +async def on_task_fetch(context: TeamsTurnContext, state, request: TaskModuleRequest): + return TaskModuleResponse(...) + +@teams.task_modules.submit("myVerb") +async def on_task_submit(context: TeamsTurnContext, state, request: TaskModuleRequest): + return TaskModuleResponse(...) +``` + +The first argument is a verb — a string or regex matched against `activity.value.data.verb`. Omit it to match all `task/fetch` or `task/submit` invokes. + +### Meeting Events + +```python +from microsoft_teams.api.models import MeetingDetails +from microsoft_agents.activity.teams import MeetingParticipantsEventDetails + +@teams.meetings.start +async def on_meeting_start(context: TeamsTurnContext, state, meeting: MeetingDetails): + ... + +@teams.meetings.end +async def on_meeting_end(context: TeamsTurnContext, state, meeting: MeetingDetails): + ... + +@teams.meetings.participants_join +async def on_participants_join(context: TeamsTurnContext, state, details: MeetingParticipantsEventDetails): + ... + +@teams.meetings.participants_leave +async def on_participants_leave(context: TeamsTurnContext, state, details: MeetingParticipantsEventDetails): + ... +``` + +### Channel Events + +```python +from microsoft_teams.api.models import ChannelInfo + +@teams.channels.created +async def on_channel_created(context: TeamsTurnContext, state, channel: ChannelInfo): + print(f"New channel: {channel.name}") + +@teams.channels.renamed +async def on_channel_renamed(context: TeamsTurnContext, state, channel: ChannelInfo): + ... + +# Also available: deleted, shared, unshared, restored, members_added, members_removed +``` + +`teams.channels.event()` is a catch-all that matches any `channel.*` event type via regex. + +### Team Events + +```python +from microsoft_teams.api.models import TeamInfo + +@teams.teams.renamed +async def on_team_renamed(context: TeamsTurnContext, state, team: TeamInfo): ... + +# Also available: archived, unarchived, deleted, hard_deleted, restored +``` + +`teams.teams.event()` is a catch-all matching any `team.*` event type. + +### File Consent + +```python +from microsoft_teams.api.models import FileConsentCardResponse + +@teams.file_consent.accept +async def on_file_accept(context: TeamsTurnContext, state, consent: FileConsentCardResponse): + await upload_file(consent.upload_info) + +@teams.file_consent.decline +async def on_file_decline(context: TeamsTurnContext, state, consent: FileConsentCardResponse): + ... +``` + +Teams expects a 200 OK with no body for both; the routing layer sends that automatically. + +### Config Invokes + +```python +from microsoft_teams.api.models import ConfigResponse + +@teams.config.fetch +async def on_config_fetch(context: TeamsTurnContext, state, config_data): + return ConfigResponse(...) + +@teams.config.submit +async def on_config_submit(context: TeamsTurnContext, state, config_data): + return ConfigResponse(...) +``` + +`config_data` is the raw `activity.value` payload. + +### Message Updates + +```python +from microsoft_teams.api.models import O365ConnectorCardActionQuery + +@teams.messages.edit +async def on_message_edit(context: TeamsTurnContext, state): + ... + +@teams.messages.delete +async def on_message_delete(context: TeamsTurnContext, state): + ... + +@teams.messages.undelete +async def on_message_undelete(context: TeamsTurnContext, state): + ... + +@teams.messages.read_receipt +async def on_read_receipt(context: TeamsTurnContext, state, data: dict): + last_read_id = data.get("lastReadMessageId") + ... + +@teams.messages.execute_action +async def on_execute_action(context: TeamsTurnContext, state, query: O365ConnectorCardActionQuery): + ... +``` + +### Generic Teams routes + +`TeamsAgentExtension` also wraps the underlying `AgentApplication` route methods so all handlers automatically receive a `TeamsTurnContext`: + +```python +from microsoft_agents.activity import ActivityTypes, ConversationUpdateTypes + +@teams.activity(ActivityTypes.message) +async def on_any_message(context: TeamsTurnContext, state): + await context.send_activity(f"Echo: {context.activity.text}") + +@teams.message(r"^hello") +async def on_hello(context: TeamsTurnContext, state): + await context.send_activity("Hi there!") + +@teams.conversation_update(ConversationUpdateTypes.members_added) +async def on_members_added(context: TeamsTurnContext, state): + for member in context.activity.members_added: + if member.id != context.activity.recipient.id: + await context.send_activity(f"Welcome, {member.name}!") +``` + +## Teams API client + +`context.api_client` is a pre-authenticated `ApiClient` pointing at the Teams connector service for the current turn. It wraps the Teams REST API and is the right tool for operations on channels, conversations, and members. + +```python +# Get the list of members in the current conversation +members = await context.api_client.conversations.get_conversation_members( + context.activity.conversation.id +) + +# Send a proactive message to a specific conversation +await context.api_client.conversations.send_to_conversation( + context.activity.conversation.id, + activity, +) +``` + +You can also get the client directly from the extension if you have a context but aren't inside a Teams handler: + +```python +client = teams.get_teams_api_client(context) +``` + +## Microsoft Graph client + +For operations that go beyond the Teams connector service — user profiles, SharePoint, calendar, etc. — use the Graph client: + +```python +@teams.message(r"^profile") +async def on_profile(context: TeamsTurnContext, state): + graph = teams.get_graph_client(context, handler_name="UserAuth") + user = await graph.users.by_user_id(context.activity.from_.aad_object_id).get() + await context.send_activity(f"Display name: {user.display_name}") +``` + +The `handler_name` argument names an OAuth connection configured in the app's `auth` settings. If omitted, the app's default auth handler is used. + +## Invoke response conventions + +Teams expects a synchronous HTTP response for all invoke activities. The routing layer handles this automatically — return a value from your handler and the framework serializes and sends it. + +| Namespace | Sends response? | Response type | +|---|---|---| +| `task_modules.fetch/submit` | If handler returns non-`None` | `TaskModuleResponse` | +| `message_extensions.query/select_item/fetch_action/submit_action/message_preview_*` | If handler returns non-`None` | `MessagingExtensionResponse` / `MessagingExtensionActionResponse` | +| `message_extensions.query_link/anonymous_query_link/query_setting_url` | If handler returns non-`None` | `MessagingExtensionResponse` | +| `message_extensions.setting` | Always | `MessagingExtensionResponse` (or 200 with no body if handler returns `None`) | +| `message_extensions.card_button_clicked` | Always | empty 200 | +| `config.fetch/submit` | If handler returns non-`None` | `ConfigResponse` | +| `file_consent.accept/decline` | Always | empty 200 | +| `messages.execute_action` | Always | empty 200 | +| `meetings`, `channels`, `teams`, `messages.edit/undelete/delete` | Never — not invokes | — | + +## Features Supported + +- **Messaging Extensions** — query, fetch action, submit action, link unfurling, anonymous link unfurling, settings URL, configure settings, card button clicked, bot message preview (edit/send) +- **Task Modules** — fetch and submit with optional verb matching +- **Meeting Lifecycle** — start, end, participant join/leave +- **Channel Management** — created, deleted, renamed, shared, unshared, restored, members added/removed +- **Team Management** — archived, unarchived, deleted, hard deleted, renamed, restored +- **File Consent** — accept and decline flows +- **Config Invokes** — `config/fetch` and `config/submit` +- **Message Updates** — edit, delete, undelete, read receipts, actionable message execute +- **TeamsAgentExtension routing** — wraps any `AgentApplication` with zero configuration + +# Quick Links + +- 📦 [All SDK Packages on PyPI](https://pypi.org/search/?q=microsoft-agents) +- 📖 [Complete Documentation](https://aka.ms/agents) +- 💡 [Python Samples Repository](https://github.com/microsoft/Agents/tree/main/samples/python) +- 🐛 [Report Issues](https://github.com/microsoft/Agents-for-python/issues) + +# Sample Applications + +|Name|Description|README| +|----|----|----| +|Quickstart|Simplest agent|[Quickstart](https://github.com/microsoft/Agents/blob/main/samples/python/quickstart/README.md)| +|Auto Sign In|Simple OAuth agent using Graph and GitHub|[auto-signin](https://github.com/microsoft/Agents/blob/main/samples/python/auto-signin/README.md)| +|OBO Authorization|OBO flow to access a Copilot Studio Agent|[obo-authorization](https://github.com/microsoft/Agents/blob/main/samples/python/obo-authorization/README.md)| +|Semantic Kernel Integration|A weather agent built with Semantic Kernel|[semantic-kernel-multiturn](https://github.com/microsoft/Agents/blob/main/samples/python/semantic-kernel-multiturn/README.md)| +|Streaming Agent|Streams OpenAI responses|[azure-ai-streaming](https://github.com/microsoft/Agents/blob/main/samples/python/azureai-streaming/README.md)| +|Copilot Studio Client|Console app to consume a Copilot Studio Agent|[copilotstudio-client](https://github.com/microsoft/Agents/blob/main/samples/python/copilotstudio-client/README.md)| +|Cards Agent|Agent that uses rich cards to enhance conversation design|[cards](https://github.com/microsoft/Agents/blob/main/samples/python/cards/README.md)| diff --git a/libraries/microsoft-agents-hosting-teams/setup.py b/libraries/microsoft-agents-hosting-msteams/setup.py similarity index 95% rename from libraries/microsoft-agents-hosting-teams/setup.py rename to libraries/microsoft-agents-hosting-msteams/setup.py index 62611d341..2cb83478c 100644 --- a/libraries/microsoft-agents-hosting-teams/setup.py +++ b/libraries/microsoft-agents-hosting-msteams/setup.py @@ -15,5 +15,6 @@ f"microsoft-agents-hosting-core=={package_version}", "aiohttp>=3.11.11", "microsoft-teams-api>=2.0.0,<3", + "msgraph-sdk>=1.58.0", ], ) diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/__init__.py b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/__init__.py deleted file mode 100644 index 2723ff546..000000000 --- a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/__init__.py +++ /dev/null @@ -1,17 +0,0 @@ -from .teams_activity_handler import TeamsActivityHandler -from .teams_agent_extension import ( - TeamsAgentExtension, - MessageExtension, - TaskModule, - Meeting, -) -from .teams_info import TeamsInfo - -__all__ = [ - "TeamsActivityHandler", - "TeamsAgentExtension", - "MessageExtension", - "TaskModule", - "Meeting", - "TeamsInfo", -] diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/teams_activity_handler.py b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/teams_activity_handler.py deleted file mode 100644 index c0511e2d7..000000000 --- a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/teams_activity_handler.py +++ /dev/null @@ -1,911 +0,0 @@ -""" -Copyright (c) Microsoft Corporation. All rights reserved. -Licensed under the MIT License. -""" - -from http import HTTPStatus -from typing import Any - -from microsoft_agents.hosting.core import ActivityHandler, TurnContext -from microsoft_agents.hosting.teams.errors import teams_errors -from microsoft_agents.activity import ( - InvokeResponse, - ChannelAccount, -) - -from microsoft_agents.activity.teams import ( - AppBasedLinkQuery, - TeamInfo, - ChannelInfo, - ConfigResponse, - FileConsentCardResponse, - MeetingEndEventDetails, - MeetingParticipantsEventDetails, - MeetingStartEventDetails, - MessagingExtensionAction, - MessagingExtensionActionResponse, - MessagingExtensionQuery, - MessagingExtensionResponse, - O365ConnectorCardActionQuery, - ReadReceiptInfo, - SigninStateVerificationQuery, - TabRequest, - TabResponse, - TabSubmit, - TaskModuleRequest, - TaskModuleResponse, - TeamsChannelAccount, - TeamsChannelData, -) - -from .teams_info import TeamsInfo - - -class TeamsActivityHandler(ActivityHandler): - """ - The TeamsActivityHandler is derived from the ActivityHandler class and adds support for - Microsoft Teams-specific functionality. - """ - - async def on_invoke_activity(self, turn_context: TurnContext) -> InvokeResponse: - """ - Handles invoke activities. - - :param turn_context: The context object for the turn. - :return: An InvokeResponse. - """ - - try: - if ( - not turn_context.activity.name - and turn_context.activity.channel_id == "msteams" - ): - return await self.on_teams_card_action_invoke(turn_context) - else: - name = turn_context.activity.name - value = turn_context.activity.value - - if name == "config/fetch": - return self._create_invoke_response( - await self.on_teams_config_fetch(turn_context, value) - ) - elif name == "config/submit": - return self._create_invoke_response( - await self.on_teams_config_submit(turn_context, value) - ) - elif name == "fileConsent/invoke": - return self._create_invoke_response( - await self.on_teams_file_consent(turn_context, value) - ) - elif name == "actionableMessage/executeAction": - await self.on_teams_o365_connector_card_action(turn_context, value) - return self._create_invoke_response() - elif name == "composeExtension/queryLink": - return self._create_invoke_response( - await self.on_teams_app_based_link_query(turn_context, value) - ) - elif name == "composeExtension/anonymousQueryLink": - return self._create_invoke_response( - await self.on_teams_anonymous_app_based_link_query( - turn_context, value - ) - ) - elif name == "composeExtension/query": - query = MessagingExtensionQuery.model_validate(value) - return self._create_invoke_response( - await self.on_teams_messaging_extension_query( - turn_context, query - ) - ) - elif name == "composeExtension/selectItem": - return self._create_invoke_response( - await self.on_teams_messaging_extension_select_item( - turn_context, value - ) - ) - elif name == "composeExtension/submitAction": - return self._create_invoke_response( - await self.on_teams_messaging_extension_submit_action_dispatch( - turn_context, value - ) - ) - elif name == "composeExtension/fetchTask": - return self._create_invoke_response( - await self.on_teams_messaging_extension_fetch_task( - turn_context, value - ) - ) - elif name == "composeExtension/querySettingUrl": - return self._create_invoke_response( - await self.on_teams_messaging_extension_configuration_query_setting_url( - turn_context, value - ) - ) - elif name == "composeExtension/setting": - await self.on_teams_messaging_extension_configuration_setting( - turn_context, value - ) - return self._create_invoke_response() - elif name == "composeExtension/onCardButtonClicked": - await self.on_teams_messaging_extension_card_button_clicked( - turn_context, value - ) - return self._create_invoke_response() - elif name == "task/fetch": - task_module_request = TaskModuleRequest.model_validate(value) - return self._create_invoke_response( - await self.on_teams_task_module_fetch( - turn_context, task_module_request - ) - ) - elif name == "task/submit": - task_module_request = TaskModuleRequest.model_validate(value) - return self._create_invoke_response( - await self.on_teams_task_module_submit( - turn_context, task_module_request - ) - ) - elif name == "tab/fetch": - return self._create_invoke_response( - await self.on_teams_tab_fetch(turn_context, value) - ) - elif name == "tab/submit": - return self._create_invoke_response( - await self.on_teams_tab_submit(turn_context, value) - ) - else: - return await super().on_invoke_activity(turn_context) - except Exception as err: - if str(err) == str(teams_errors.TeamsNotImplemented): - return InvokeResponse(status=int(HTTPStatus.NOT_IMPLEMENTED)) - elif str(err) == str(teams_errors.TeamsBadRequest): - return InvokeResponse(status=int(HTTPStatus.BAD_REQUEST)) - raise - - async def on_teams_card_action_invoke( - self, turn_context: TurnContext - ) -> InvokeResponse: - """ - Handles card action invoke. - - :param turn_context: The context object for the turn. - :return: An InvokeResponse. - """ - raise NotImplementedError(str(teams_errors.TeamsNotImplemented)) - - async def on_teams_config_fetch( - self, turn_context: TurnContext, config_data: Any - ) -> ConfigResponse: - """ - Handles config fetch. - - :param turn_context: The context object for the turn. - :param config_data: The config data. - :return: A ConfigResponse. - """ - raise NotImplementedError(str(teams_errors.TeamsNotImplemented)) - - async def on_teams_config_submit( - self, turn_context: TurnContext, config_data: Any - ) -> ConfigResponse: - """ - Handles config submit. - - :param turn_context: The context object for the turn. - :param config_data: The config data. - :return: A ConfigResponse. - """ - raise NotImplementedError(str(teams_errors.TeamsNotImplemented)) - - async def on_teams_file_consent( - self, - turn_context: TurnContext, - file_consent_card_response: FileConsentCardResponse, - ) -> None: - """ - Handles file consent. - - :param turn_context: The context object for the turn. - :param file_consent_card_response: The file consent card response. - :return: None - """ - if file_consent_card_response.action == "accept": - return await self.on_teams_file_consent_accept( - turn_context, file_consent_card_response - ) - elif file_consent_card_response.action == "decline": - return await self.on_teams_file_consent_decline( - turn_context, file_consent_card_response - ) - else: - raise ValueError(str(teams_errors.TeamsBadRequest)) - - async def on_teams_file_consent_accept( - self, - turn_context: TurnContext, - file_consent_card_response: FileConsentCardResponse, - ) -> None: - """ - Handles file consent accept. - - :param turn_context: The context object for the turn. - :param file_consent_card_response: The file consent card response. - :return: None - """ - raise NotImplementedError(str(teams_errors.TeamsNotImplemented)) - - async def on_teams_file_consent_decline( - self, - turn_context: TurnContext, - file_consent_card_response: FileConsentCardResponse, - ) -> None: - """ - Handles file consent decline. - - :param turn_context: The context object for the turn. - :param file_consent_card_response: The file consent card response. - :return: None - """ - raise NotImplementedError(str(teams_errors.TeamsNotImplemented)) - - async def on_teams_o365_connector_card_action( - self, turn_context: TurnContext, query: O365ConnectorCardActionQuery - ) -> None: - """ - Handles O365 connector card action. - - :param turn_context: The context object for the turn. - :param query: The O365 connector card action query. - :return: None - """ - raise NotImplementedError(str(teams_errors.TeamsNotImplemented)) - - async def on_teams_signin_verify_state( - self, turn_context: TurnContext, query: SigninStateVerificationQuery - ) -> None: - """ - Handles sign-in verify state. - - :param turn_context: The context object for the turn. - :param query: The sign-in state verification query. - :return: None - """ - raise NotImplementedError(str(teams_errors.TeamsNotImplemented)) - - async def on_teams_signin_token_exchange( - self, turn_context: TurnContext, query: SigninStateVerificationQuery - ) -> None: - """ - Handles sign-in token exchange. - - :param turn_context: The context object for the turn. - :param query: The sign-in state verification query. - :return: None - """ - raise NotImplementedError(str(teams_errors.TeamsNotImplemented)) - - async def on_teams_app_based_link_query( - self, turn_context: TurnContext, query: AppBasedLinkQuery - ) -> MessagingExtensionResponse: - """ - Handles app-based link query. - - :param turn_context: The context object for the turn. - :param query: The app-based link query. - :return: A MessagingExtensionResponse. - """ - raise NotImplementedError(str(teams_errors.TeamsNotImplemented)) - - async def on_teams_anonymous_app_based_link_query( - self, turn_context: TurnContext, query: AppBasedLinkQuery - ) -> MessagingExtensionResponse: - """ - Handles anonymous app-based link query. - - :param turn_context: The context object for the turn. - :param query: The app-based link query. - :return: A MessagingExtensionResponse. - """ - raise NotImplementedError(str(teams_errors.TeamsNotImplemented)) - - async def on_teams_messaging_extension_query( - self, turn_context: TurnContext, query: MessagingExtensionQuery - ) -> MessagingExtensionResponse: - """ - Handles messaging extension query. - - :param turn_context: The context object for the turn. - :param query: The messaging extension query. - :return: A MessagingExtensionResponse. - """ - raise NotImplementedError(str(teams_errors.TeamsNotImplemented)) - - async def on_teams_messaging_extension_select_item( - self, turn_context: TurnContext, query: Any - ) -> MessagingExtensionResponse: - """ - Handles messaging extension select item. - - :param turn_context: The context object for the turn. - :param query: The query. - :return: A MessagingExtensionResponse. - """ - raise NotImplementedError(str(teams_errors.TeamsNotImplemented)) - - async def on_teams_messaging_extension_submit_action_dispatch( - self, turn_context: TurnContext, action: MessagingExtensionAction - ) -> MessagingExtensionActionResponse: - """ - Handles messaging extension submit action dispatch. - - :param turn_context: The context object for the turn. - :param action: The messaging extension action. - :return: A MessagingExtensionActionResponse. - """ - if action.bot_message_preview_action: - if action.bot_message_preview_action == "edit": - return await self.on_teams_messaging_extension_message_preview_edit( - turn_context, action - ) - elif action.bot_message_preview_action == "send": - return await self.on_teams_messaging_extension_message_preview_send( - turn_context, action - ) - else: - raise ValueError(str(teams_errors.TeamsBadRequest)) - else: - return await self.on_teams_messaging_extension_submit_action( - turn_context, action - ) - - async def on_teams_messaging_extension_submit_action( - self, turn_context: TurnContext, action: MessagingExtensionAction - ) -> MessagingExtensionActionResponse: - """ - Handles messaging extension submit action. - - :param turn_context: The context object for the turn. - :param action: The messaging extension action. - :return: A MessagingExtensionActionResponse. - """ - raise NotImplementedError(str(teams_errors.TeamsNotImplemented)) - - async def on_teams_messaging_extension_message_preview_edit( - self, turn_context: TurnContext, action: MessagingExtensionAction - ) -> MessagingExtensionActionResponse: - """ - Handles messaging extension message preview edit. - - :param turn_context: The context object for the turn. - :param action: The messaging extension action. - :return: A MessagingExtensionActionResponse. - """ - raise NotImplementedError(str(teams_errors.TeamsNotImplemented)) - - async def on_teams_messaging_extension_message_preview_send( - self, turn_context: TurnContext, action: MessagingExtensionAction - ) -> MessagingExtensionActionResponse: - """ - Handles messaging extension message preview send. - - :param turn_context: The context object for the turn. - :param action: The messaging extension action. - :return: A MessagingExtensionActionResponse. - """ - raise NotImplementedError(str(teams_errors.TeamsNotImplemented)) - - async def on_teams_messaging_extension_fetch_task( - self, turn_context: TurnContext, action: MessagingExtensionAction - ) -> MessagingExtensionActionResponse: - """ - Handles messaging extension fetch task. - - :param turn_context: The context object for the turn. - :param action: The messaging extension action. - :return: A MessagingExtensionActionResponse. - """ - raise NotImplementedError(str(teams_errors.TeamsNotImplemented)) - - async def on_teams_messaging_extension_configuration_query_setting_url( - self, turn_context: TurnContext, query: MessagingExtensionQuery - ) -> MessagingExtensionResponse: - """ - Handles messaging extension configuration query setting URL. - - :param turn_context: The context object for the turn. - :param query: The messaging extension query. - :return: A MessagingExtensionResponse. - """ - raise NotImplementedError(str(teams_errors.TeamsNotImplemented)) - - async def on_teams_messaging_extension_configuration_setting( - self, turn_context: TurnContext, settings: Any - ) -> None: - """ - Handles messaging extension configuration setting. - - :param turn_context: The context object for the turn. - :param settings: The settings. - :return: None - """ - raise NotImplementedError(str(teams_errors.TeamsNotImplemented)) - - async def on_teams_messaging_extension_card_button_clicked( - self, turn_context: TurnContext, card_data: Any - ) -> None: - """ - Handles messaging extension card button clicked. - - :param turn_context: The context object for the turn. - :param card_data: The card data. - :return: None - """ - raise NotImplementedError(str(teams_errors.TeamsNotImplemented)) - - async def on_teams_task_module_fetch( - self, turn_context: TurnContext, task_module_request: TaskModuleRequest - ) -> TaskModuleResponse: - """ - Handles task module fetch. - - :param turn_context: The context object for the turn. - :param task_module_request: The task module request. - :return: A TaskModuleResponse. - """ - raise NotImplementedError(str(teams_errors.TeamsNotImplemented)) - - async def on_teams_task_module_submit( - self, turn_context: TurnContext, task_module_request: TaskModuleRequest - ) -> TaskModuleResponse: - """ - Handles task module submit. - - :param turn_context: The context object for the turn. - :param task_module_request: The task module request. - :return: A TaskModuleResponse. - """ - raise NotImplementedError(str(teams_errors.TeamsNotImplemented)) - - async def on_teams_tab_fetch( - self, turn_context: TurnContext, tab_request: TabRequest - ) -> TabResponse: - """ - Handles tab fetch. - - :param turn_context: The context object for the turn. - :param tab_request: The tab request. - :return: A TabResponse. - """ - raise NotImplementedError(str(teams_errors.TeamsNotImplemented)) - - async def on_teams_tab_submit( - self, turn_context: TurnContext, tab_submit: TabSubmit - ) -> TabResponse: - """ - Handles tab submit. - - :param turn_context: The context object for the turn. - :param tab_submit: The tab submit. - :return: A TabResponse. - """ - raise NotImplementedError(str(teams_errors.TeamsNotImplemented)) - - async def on_conversation_update_activity(self, turn_context: TurnContext): - """ - Dispatches conversation update activity. - - :param turn_context: The context object for the turn. - :return: None - """ - if turn_context.activity.channel_id == "msteams": - channel_data = ( - TeamsChannelData.model_validate(turn_context.activity.channel_data) - if turn_context.activity.channel_data - else None - ) - - if ( - turn_context.activity.members_added - and len(turn_context.activity.members_added) > 0 - ): - return await self.on_teams_members_added_dispatch( - turn_context.activity.members_added, - channel_data.team if channel_data else None, - turn_context, - ) - - if ( - turn_context.activity.members_removed - and len(turn_context.activity.members_removed) > 0 - ): - return await self.on_teams_members_removed(turn_context) - - if not channel_data or not channel_data.event_type: - return await super().on_conversation_update_activity(turn_context) - - event_type = channel_data.event_type - - if event_type == "channelCreated": - return await self.on_teams_channel_created(turn_context) - elif event_type == "channelDeleted": - return await self.on_teams_channel_deleted(turn_context) - elif event_type == "channelRenamed": - return await self.on_teams_channel_renamed(turn_context) - elif event_type == "teamArchived": - return await self.on_teams_team_archived(turn_context) - elif event_type == "teamDeleted": - return await self.on_teams_team_deleted(turn_context) - elif event_type == "teamHardDeleted": - return await self.on_teams_team_hard_deleted(turn_context) - elif event_type == "channelRestored": - return await self.on_teams_channel_restored(turn_context) - elif event_type == "teamRenamed": - return await self.on_teams_team_renamed(turn_context) - elif event_type == "teamRestored": - return await self.on_teams_team_restored(turn_context) - elif event_type == "teamUnarchived": - return await self.on_teams_team_unarchived(turn_context) - - return await super().on_conversation_update_activity(turn_context) - - async def on_message_update_activity(self, turn_context: TurnContext): - """ - Dispatches message update activity. - - :param turn_context: The context object for the turn. - :return: None - """ - if turn_context.activity.channel_id == "msteams": - channel_data = channel_data = ( - TeamsChannelData.model_validate(turn_context.activity.channel_data) - if turn_context.activity.channel_data - else None - ) - - event_type = channel_data.event_type if channel_data else None - - if event_type == "undeleteMessage": - return await self.on_teams_message_undelete(turn_context) - elif event_type == "editMessage": - return await self.on_teams_message_edit(turn_context) - - return await super().on_message_update_activity(turn_context) - - async def on_message_delete_activity(self, turn_context: TurnContext) -> None: - """ - Dispatches message delete activity. - - :param turn_context: The context object for the turn. - :return: None - """ - if turn_context.activity.channel_id == "msteams": - channel_data = channel_data = ( - TeamsChannelData.model_validate(turn_context.activity.channel_data) - if turn_context.activity.channel_data - else None - ) - - event_type = channel_data.event_type if channel_data else None - - if event_type == "softDeleteMessage": - return await self.on_teams_message_soft_delete(turn_context) - - return await super().on_message_delete_activity(turn_context) - - async def on_teams_message_undelete(self, turn_context: TurnContext) -> None: - """ - Handles Teams message undelete. - - :param turn_context: The context object for the turn. - :return: None - """ - return - - async def on_teams_message_edit(self, turn_context: TurnContext) -> None: - """ - Handles Teams message edit. - - :param turn_context: The context object for the turn. - :return: None - """ - return - - async def on_teams_message_soft_delete(self, turn_context: TurnContext) -> None: - """ - Handles Teams message soft delete. - - :param turn_context: The context object for the turn. - :return: None - """ - return - - async def on_teams_members_added_dispatch( - self, - members_added: list[ChannelAccount], - team_info: TeamInfo, - turn_context: TurnContext, - ) -> None: - """ - Dispatches processing of Teams members added to the conversation. - Processes the members_added collection to get full member information when possible. - - :param members_added: The list of members being added to the conversation. - :param team_info: The team info object. - :param turn_context: The context object for the turn. - :return: None - """ - teams_members_added = [] - - for member in members_added: - # If the member has properties or is the agent/bot being added to the conversation - if len(member.properties) or ( - turn_context.activity.recipient - and turn_context.activity.recipient.id == member.id - ): - - # Convert the ChannelAccount to TeamsChannelAccount - # TODO: Converter between these two classes - teams_member = TeamsChannelAccount.model_validate( - member.model_dump(by_alias=True, exclude_unset=True) - ) - teams_members_added.append(teams_member) - else: - # Try to get the full member details from Teams - try: - teams_member = await TeamsInfo.get_member(turn_context, member.id) - teams_members_added.append(teams_member) - except Exception as err: - # Handle case where conversation is not found - if "ConversationNotFound" in str(err): - teams_channel_account = TeamsChannelAccount( - id=member.id, - name=member.name, - aad_object_id=getattr(member, "aad_object_id", None), - role=getattr(member, "role", None), - ) - teams_members_added.append(teams_channel_account) - else: - # Propagate any other errors - raise - - await self.on_teams_members_added(teams_members_added, team_info, turn_context) - - async def on_teams_members_added( - self, - teams_members_added: list[TeamsChannelAccount], - team_info: TeamInfo, - turn_context: TurnContext, - ) -> None: - """ - Handles Teams members added. - - :param turn_context: The context object for the turn. - :return: None - """ - await self.on_members_added_activity(teams_members_added, turn_context) - - async def on_teams_members_removed_dispatch( - self, - members_removed: list[ChannelAccount], - team_info: TeamInfo, - turn_context: TurnContext, - ) -> None: - """ - Dispatches processing of Teams members removed from the conversation. - """ - teams_members_removed = [] - for member in members_removed: - teams_members_removed.append( - TeamsChannelAccount.model_validate( - member.model_dump(by_alias=True, exclude_unset=True) - ) - ) - return await self.on_teams_members_removed( - teams_members_removed, team_info, turn_context - ) - - async def on_teams_members_removed( - self, - teams_members_removed: list[TeamsChannelAccount], - team_info: TeamInfo, - turn_context: TurnContext, - ) -> None: - """ - Handles Teams members removed. - - :param turn_context: The context object for the turn. - :return: None - """ - await self.on_members_removed_activity(teams_members_removed, turn_context) - - async def on_teams_channel_created( - self, channel_info: ChannelInfo, team_info: TeamInfo, turn_context: TurnContext - ) -> None: - """ - Handles Teams channel created. - - :param turn_context: The context object for the turn. - :return: None - """ - return - - async def on_teams_channel_deleted( - self, channel_info: ChannelInfo, team_info: TeamInfo, turn_context: TurnContext - ) -> None: - """ - Handles Teams channel deleted. - - :param turn_context: The context object for the turn. - :return: None - """ - return - - async def on_teams_channel_renamed( - self, channel_info: ChannelInfo, team_info: TeamInfo, turn_context: TurnContext - ) -> None: - """ - Handles Teams channel renamed. - - :param turn_context: The context object for the turn. - :return: None - """ - return - - async def on_teams_team_archived( - self, team_info: TeamInfo, turn_context: TurnContext - ) -> None: - """ - Handles Teams team archived. - - :param turn_context: The context object for the turn. - :return: None - """ - return - - async def on_teams_team_deleted( - self, team_info: TeamInfo, turn_context: TurnContext - ) -> None: - """ - Handles Teams team deleted. - - :param turn_context: The context object for the turn. - :return: None - """ - return - - async def on_teams_team_hard_deleted( - self, team_info: TeamInfo, turn_context: TurnContext - ) -> None: - """ - Handles Teams team hard deleted. - - :param turn_context: The context object for the turn. - :return: None - """ - return - - async def on_teams_channel_restored( - self, channel_info: ChannelInfo, team_info: TeamInfo, turn_context: TurnContext - ) -> None: - """ - Handles Teams channel restored. - - :param turn_context: The context object for the turn. - :return: None - """ - return - - async def on_teams_team_renamed( - self, team_info: TeamInfo, turn_context: TurnContext - ) -> None: - """ - Handles Teams team renamed. - - :param turn_context: The context object for the turn. - :return: None - """ - return - - async def on_teams_team_restored( - self, team_info: TeamInfo, turn_context: TurnContext - ) -> None: - """ - Handles Teams team restored. - - :param turn_context: The context object for the turn. - :return: None - """ - return - - async def on_teams_team_unarchived( - self, team_info: TeamInfo, turn_context: TurnContext - ) -> None: - """ - Handles Teams team unarchived. - - :param turn_context: The context object for the turn. - :return: None - """ - return - - async def on_event_activity(self, turn_context: TurnContext) -> None: - """ - Dispatches event activity. - - :param turn_context: The context object for the turn. - :return: None - """ - if turn_context.activity.channel_id == "msteams": - if turn_context.activity.name == "application/vnd.microsoft.readReceipt": - return await self.on_teams_read_receipt(turn_context) - elif turn_context.activity.name == "application/vnd.microsoft.meetingStart": - return await self.on_teams_meeting_start(turn_context) - elif turn_context.activity.name == "application/vnd.microsoft.meetingEnd": - return await self.on_teams_meeting_end(turn_context) - elif ( - turn_context.activity.name - == "application/vnd.microsoft.meetingParticipantJoin" - ): - return await self.on_teams_meeting_participants_join(turn_context) - elif ( - turn_context.activity.name - == "application/vnd.microsoft.meetingParticipantLeave" - ): - return await self.on_teams_meeting_participants_leave(turn_context) - - return await super().on_event_activity(turn_context) - - async def on_teams_meeting_start( - self, meeting: MeetingStartEventDetails, turn_context: TurnContext - ) -> None: - """ - Handles Teams meeting start. - - :param turn_context: The context object for the turn. - :return: None - """ - return - - async def on_teams_meeting_end( - self, meeting: MeetingEndEventDetails, turn_context: TurnContext - ) -> None: - """ - Handles Teams meeting end. - - :param turn_context: The context object for the turn. - :return: None - """ - return - - async def on_teams_read_receipt( - self, read_receipt: ReadReceiptInfo, turn_context: TurnContext - ) -> None: - """ - Handles Teams read receipt. - - :param turn_context: The context object for the turn. - :return: None - """ - return - - async def on_teams_meeting_participants_join( - self, meeting: MeetingParticipantsEventDetails, turn_context: TurnContext - ) -> None: - """ - Handles Teams meeting participants join. - - :param turn_context: The context object for the turn. - :return: None - """ - return - - async def on_teams_meeting_participants_leave( - self, meeting: MeetingParticipantsEventDetails, turn_context: TurnContext - ) -> None: - """ - Handles Teams meeting participants leave. - - :param turn_context: The context object for the turn. - :return: None - """ - return diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/teams_agent_extension.py b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/teams_agent_extension.py deleted file mode 100644 index 34cdef2fd..000000000 --- a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/teams_agent_extension.py +++ /dev/null @@ -1,1285 +0,0 @@ -""" -Copyright (c) Microsoft Corporation. All rights reserved. -Licensed under the MIT License. -""" - -from __future__ import annotations - -import re -from http import HTTPStatus -from typing import Any, Callable, Generic, Optional, Pattern, TypeVar - -from microsoft_agents.activity import Activity, ActivityTypes, InvokeResponse -from microsoft_agents.hosting.core import TurnContext -from microsoft_agents.hosting.core.app import AgentApplication, RouteRank -from microsoft_agents.hosting.core.app.state import TurnState - -from microsoft_agents.activity.teams import ( - MeetingParticipantsEventDetails, - ReadReceiptInfo, -) -from microsoft_teams.api.models import ( - AppBasedLinkQuery, - FileConsentCardResponse, - MeetingDetails, - MessagingExtensionAction, - MessagingExtensionQuery, - O365ConnectorCardActionQuery, - TaskModuleRequest, -) - -StateT = TypeVar("StateT", bound=TurnState) - -CommandSelector = str | Pattern[str] | None - - -def _match_selector(selector: CommandSelector, value: Optional[str]) -> bool: - if selector is None: - return True - if value is None: - return False - if isinstance(selector, str): - return selector == value - return bool(re.match(selector, value)) - - -def _get_channel_event_type(context: TurnContext) -> Optional[str]: - data = context.activity.channel_data - if data is None: - return None - if isinstance(data, dict): - return data.get("eventType") or data.get("event_type") - return getattr(data, "event_type", None) - - -async def _send_invoke_response(context: TurnContext, body: Any = None) -> None: - serialized_body = None - if body is not None: - if hasattr(body, "model_dump"): - serialized_body = body.model_dump( - mode="json", by_alias=True, exclude_none=True - ) - else: - serialized_body = body - await context.send_activity( - Activity( - type=ActivityTypes.invoke_response, - value=InvokeResponse(status=int(HTTPStatus.OK), body=serialized_body), - ) - ) - - -class MessageExtension(Generic[StateT]): - """ - Route registration for Teams Message Extension (composeExtension) invoke activities. - Access via TeamsAgentExtension.message_extension. - """ - - def __init__(self, app: AgentApplication[StateT]) -> None: - self._app = app - - def on_query( - self, - command_id: CommandSelector = None, - *, - auth_handlers: Optional[list[str]] = None, - rank: RouteRank = RouteRank.DEFAULT, - ) -> Callable: - """Register a handler for composeExtension/query invokes.""" - - def __selector(context: TurnContext) -> bool: - if ( - context.activity.type != ActivityTypes.invoke - or context.activity.name != "composeExtension/query" - ): - return False - - value = context.activity.value - command_value: Optional[str] = None - if isinstance(value, dict): - command_value = value.get("commandId") or value.get("command_id") - elif value is not None: - command_value = getattr(value, "commandId", None) or getattr( - value, "command_id", None - ) - - return _match_selector(command_id, command_value) - - def __call(func: Callable) -> Callable: - async def __handler(context: TurnContext, state: StateT) -> None: - query = MessagingExtensionQuery.model_validate( - context.activity.value or {} - ) - response = await func(context, state, query) - if response is not None: - await _send_invoke_response(context, response) - - self._app.add_route( - __selector, - __handler, - is_invoke=True, - rank=rank, - auth_handlers=auth_handlers, - ) - return func - - return __call - - def on_select_item( - self, - handler: Optional[Callable] = None, - *, - auth_handlers: Optional[list[str]] = None, - rank: RouteRank = RouteRank.DEFAULT, - ) -> Callable: - """Register a handler for composeExtension/selectItem invokes.""" - - def __selector(context: TurnContext) -> bool: - return ( - context.activity.type == ActivityTypes.invoke - and context.activity.name == "composeExtension/selectItem" - ) - - def __register(func: Callable) -> Callable: - async def __handler(context: TurnContext, state: StateT) -> None: - response = await func(context, state, context.activity.value) - if response is not None: - await _send_invoke_response(context, response) - - self._app.add_route( - __selector, - __handler, - is_invoke=True, - rank=rank, - auth_handlers=auth_handlers, - ) - return func - - if handler is not None: - return __register(handler) - return __register - - def on_submit_action( - self, - command_id: CommandSelector = None, - *, - auth_handlers: Optional[list[str]] = None, - rank: RouteRank = RouteRank.DEFAULT, - ) -> Callable: - """Register a handler for composeExtension/submitAction invokes (not bot message preview).""" - - def __selector(context: TurnContext) -> bool: - if ( - context.activity.type != ActivityTypes.invoke - or context.activity.name != "composeExtension/submitAction" - ): - return False - value = context.activity.value - if isinstance(value, dict): - bot_message_preview_action = value.get("botMessagePreviewAction") - resolved_command_id = value.get("commandId") or value.get("command_id") - else: - bot_message_preview_action = getattr( - value, "botMessagePreviewAction", None - ) - resolved_command_id = getattr(value, "commandId", None) or getattr( - value, "command_id", None - ) - if bot_message_preview_action: - return False - return _match_selector(command_id, resolved_command_id) - - def __call(func: Callable) -> Callable: - async def __handler(context: TurnContext, state: StateT) -> None: - action = MessagingExtensionAction.model_validate( - context.activity.value or {} - ) - response = await func(context, state, action) - if response is not None: - await _send_invoke_response(context, response) - - self._app.add_route( - __selector, - __handler, - is_invoke=True, - rank=rank, - auth_handlers=auth_handlers, - ) - return func - - return __call - - def on_agent_message_preview_edit( - self, - command_id: CommandSelector = None, - *, - auth_handlers: Optional[list[str]] = None, - rank: RouteRank = RouteRank.DEFAULT, - ) -> Callable: - """Register a handler for composeExtension/submitAction with botMessagePreviewAction == 'edit'.""" - - def __selector(context: TurnContext) -> bool: - if ( - context.activity.type != ActivityTypes.invoke - or context.activity.name != "composeExtension/submitAction" - ): - return False - value = context.activity.value or {} - if value.get("botMessagePreviewAction") != "edit": - return False - return _match_selector(command_id, value.get("commandId")) - - def __call(func: Callable) -> Callable: - async def __handler(context: TurnContext, state: StateT) -> None: - action = MessagingExtensionAction.model_validate( - context.activity.value or {} - ) - response = await func(context, state, action) - if response is not None: - await _send_invoke_response(context, response) - - self._app.add_route( - __selector, - __handler, - is_invoke=True, - rank=rank, - auth_handlers=auth_handlers, - ) - return func - - return __call - - def on_agent_message_preview_send( - self, - command_id: CommandSelector = None, - *, - auth_handlers: Optional[list[str]] = None, - rank: RouteRank = RouteRank.DEFAULT, - ) -> Callable: - """Register a handler for composeExtension/submitAction with botMessagePreviewAction == 'send'.""" - - def __selector(context: TurnContext) -> bool: - if ( - context.activity.type != ActivityTypes.invoke - or context.activity.name != "composeExtension/submitAction" - ): - return False - value = context.activity.value or {} - if value.get("botMessagePreviewAction") != "send": - return False - return _match_selector(command_id, value.get("commandId")) - - def __call(func: Callable) -> Callable: - async def __handler(context: TurnContext, state: StateT) -> None: - action = MessagingExtensionAction.model_validate( - context.activity.value or {} - ) - response = await func(context, state, action) - if response is not None: - await _send_invoke_response(context, response) - - self._app.add_route( - __selector, - __handler, - is_invoke=True, - rank=rank, - auth_handlers=auth_handlers, - ) - return func - - return __call - - def on_fetch_task( - self, - command_id: CommandSelector = None, - *, - auth_handlers: Optional[list[str]] = None, - rank: RouteRank = RouteRank.DEFAULT, - ) -> Callable: - """Register a handler for composeExtension/fetchTask invokes.""" - - def __selector(context: TurnContext) -> bool: - return ( - context.activity.type == ActivityTypes.invoke - and context.activity.name == "composeExtension/fetchTask" - and _match_selector( - command_id, - (context.activity.value or {}).get("commandId"), - ) - ) - - def __call(func: Callable) -> Callable: - async def __handler(context: TurnContext, state: StateT) -> None: - action = MessagingExtensionAction.model_validate( - context.activity.value or {} - ) - response = await func(context, state, action) - if response is not None: - await _send_invoke_response(context, response) - - self._app.add_route( - __selector, - __handler, - is_invoke=True, - rank=rank, - auth_handlers=auth_handlers, - ) - return func - - return __call - - def on_query_link( - self, - handler: Optional[Callable] = None, - *, - auth_handlers: Optional[list[str]] = None, - rank: RouteRank = RouteRank.DEFAULT, - ) -> Callable: - """Register a handler for composeExtension/queryLink invokes.""" - - def __selector(context: TurnContext) -> bool: - return ( - context.activity.type == ActivityTypes.invoke - and context.activity.name == "composeExtension/queryLink" - ) - - def __register(func: Callable) -> Callable: - async def __handler(context: TurnContext, state: StateT) -> None: - query = AppBasedLinkQuery.model_validate(context.activity.value or {}) - response = await func(context, state, query) - if response is not None: - await _send_invoke_response(context, response) - - self._app.add_route( - __selector, - __handler, - is_invoke=True, - rank=rank, - auth_handlers=auth_handlers, - ) - return func - - if handler is not None: - return __register(handler) - return __register - - def on_anonymous_query_link( - self, - handler: Optional[Callable] = None, - *, - auth_handlers: Optional[list[str]] = None, - rank: RouteRank = RouteRank.DEFAULT, - ) -> Callable: - """Register a handler for composeExtension/anonymousQueryLink invokes.""" - - def __selector(context: TurnContext) -> bool: - return ( - context.activity.type == ActivityTypes.invoke - and context.activity.name == "composeExtension/anonymousQueryLink" - ) - - def __register(func: Callable) -> Callable: - async def __handler(context: TurnContext, state: StateT) -> None: - query = AppBasedLinkQuery.model_validate(context.activity.value or {}) - response = await func(context, state, query) - if response is not None: - await _send_invoke_response(context, response) - - self._app.add_route( - __selector, - __handler, - is_invoke=True, - rank=rank, - auth_handlers=auth_handlers, - ) - return func - - if handler is not None: - return __register(handler) - return __register - - def on_query_url_setting( - self, - handler: Optional[Callable] = None, - *, - auth_handlers: Optional[list[str]] = None, - rank: RouteRank = RouteRank.DEFAULT, - ) -> Callable: - """Register a handler for composeExtension/querySettingUrl invokes.""" - - def __selector(context: TurnContext) -> bool: - return ( - context.activity.type == ActivityTypes.invoke - and context.activity.name == "composeExtension/querySettingUrl" - ) - - def __register(func: Callable) -> Callable: - async def __handler(context: TurnContext, state: StateT) -> None: - query = MessagingExtensionQuery.model_validate( - context.activity.value or {} - ) - response = await func(context, state, query) - if response is not None: - await _send_invoke_response(context, response) - - self._app.add_route( - __selector, - __handler, - is_invoke=True, - rank=rank, - auth_handlers=auth_handlers, - ) - return func - - if handler is not None: - return __register(handler) - return __register - - def on_configure_settings( - self, - handler: Optional[Callable] = None, - *, - auth_handlers: Optional[list[str]] = None, - rank: RouteRank = RouteRank.DEFAULT, - ) -> Callable: - """Register a handler for composeExtension/setting invokes.""" - - def __selector(context: TurnContext) -> bool: - return ( - context.activity.type == ActivityTypes.invoke - and context.activity.name == "composeExtension/setting" - ) - - def __register(func: Callable) -> Callable: - async def __handler(context: TurnContext, state: StateT) -> None: - await func(context, state, context.activity.value) - await _send_invoke_response(context) - - self._app.add_route( - __selector, - __handler, - is_invoke=True, - rank=rank, - auth_handlers=auth_handlers, - ) - return func - - if handler is not None: - return __register(handler) - return __register - - def on_card_button_clicked( - self, - handler: Optional[Callable] = None, - *, - auth_handlers: Optional[list[str]] = None, - rank: RouteRank = RouteRank.DEFAULT, - ) -> Callable: - """Register a handler for composeExtension/onCardButtonClicked invokes.""" - - def __selector(context: TurnContext) -> bool: - return ( - context.activity.type == ActivityTypes.invoke - and context.activity.name == "composeExtension/onCardButtonClicked" - ) - - def __register(func: Callable) -> Callable: - async def __handler(context: TurnContext, state: StateT) -> None: - await func(context, state, context.activity.value) - await _send_invoke_response(context) - - self._app.add_route( - __selector, - __handler, - is_invoke=True, - rank=rank, - auth_handlers=auth_handlers, - ) - return func - - if handler is not None: - return __register(handler) - return __register - - -class TaskModule(Generic[StateT]): - """ - Route registration for Teams Task Module (task/fetch, task/submit) invoke activities. - Access via TeamsAgentExtension.task_module. - """ - - def __init__(self, app: AgentApplication[StateT]) -> None: - self._app = app - - @staticmethod - def _get_verb(value: Optional[Any]) -> Optional[str]: - if not isinstance(value, dict): - return None - data = value.get("data") - if isinstance(data, dict): - return data.get("verb") - return None - - def on_fetch( - self, - verb: CommandSelector = None, - *, - auth_handlers: Optional[list[str]] = None, - rank: RouteRank = RouteRank.DEFAULT, - ) -> Callable: - """Register a handler for task/fetch invokes. - - :param verb: Optional verb string or regex to match against task data. - If None, matches all task/fetch invokes. - """ - - def __selector(context: TurnContext) -> bool: - if ( - context.activity.type != ActivityTypes.invoke - or context.activity.name != "task/fetch" - ): - return False - return _match_selector(verb, TaskModule._get_verb(context.activity.value)) - - def __call(func: Callable) -> Callable: - async def __handler(context: TurnContext, state: StateT) -> None: - request = TaskModuleRequest.model_validate(context.activity.value or {}) - response = await func(context, state, request) - if response is not None: - await _send_invoke_response(context, response) - - self._app.add_route( - __selector, - __handler, - is_invoke=True, - rank=rank, - auth_handlers=auth_handlers, - ) - return func - - return __call - - def on_submit( - self, - verb: CommandSelector = None, - *, - auth_handlers: Optional[list[str]] = None, - rank: RouteRank = RouteRank.DEFAULT, - ) -> Callable: - """Register a handler for task/submit invokes. - - :param verb: Optional verb string or regex to match against task data. - If None, matches all task/submit invokes. - """ - - def __selector(context: TurnContext) -> bool: - if ( - context.activity.type != ActivityTypes.invoke - or context.activity.name != "task/submit" - ): - return False - return _match_selector(verb, TaskModule._get_verb(context.activity.value)) - - def __call(func: Callable) -> Callable: - async def __handler(context: TurnContext, state: StateT) -> None: - request = TaskModuleRequest.model_validate(context.activity.value or {}) - response = await func(context, state, request) - if response is not None: - await _send_invoke_response(context, response) - - self._app.add_route( - __selector, - __handler, - is_invoke=True, - rank=rank, - auth_handlers=auth_handlers, - ) - return func - - return __call - - -class Meeting(Generic[StateT]): - """ - Route registration for Teams Meeting event activities. - Access via TeamsAgentExtension.meeting. - """ - - def __init__(self, app: AgentApplication[StateT]) -> None: - self._app = app - - def on_start( - self, - handler: Optional[Callable] = None, - *, - auth_handlers: Optional[list[str]] = None, - rank: RouteRank = RouteRank.DEFAULT, - ) -> Callable: - """Register a handler for meeting start events.""" - - def __selector(context: TurnContext) -> bool: - return ( - context.activity.type == ActivityTypes.event - and context.activity.name == "application/vnd.microsoft.meetingStart" - ) - - def __register(func: Callable) -> Callable: - async def __handler(context: TurnContext, state: StateT) -> None: - meeting = MeetingDetails.model_validate(context.activity.value or {}) - await func(context, state, meeting) - - self._app.add_route( - __selector, - __handler, - rank=rank, - auth_handlers=auth_handlers, - ) - return func - - if handler is not None: - return __register(handler) - return __register - - def on_end( - self, - handler: Optional[Callable] = None, - *, - auth_handlers: Optional[list[str]] = None, - rank: RouteRank = RouteRank.DEFAULT, - ) -> Callable: - """Register a handler for meeting end events.""" - - def __selector(context: TurnContext) -> bool: - return ( - context.activity.type == ActivityTypes.event - and context.activity.name == "application/vnd.microsoft.meetingEnd" - ) - - def __register(func: Callable) -> Callable: - async def __handler(context: TurnContext, state: StateT) -> None: - meeting = MeetingDetails.model_validate(context.activity.value or {}) - await func(context, state, meeting) - - self._app.add_route( - __selector, - __handler, - rank=rank, - auth_handlers=auth_handlers, - ) - return func - - if handler is not None: - return __register(handler) - return __register - - def on_participants_join( - self, - handler: Optional[Callable] = None, - *, - auth_handlers: Optional[list[str]] = None, - rank: RouteRank = RouteRank.DEFAULT, - ) -> Callable: - """Register a handler for meeting participant join events.""" - - def __selector(context: TurnContext) -> bool: - return ( - context.activity.type == ActivityTypes.event - and context.activity.name - == "application/vnd.microsoft.meetingParticipantJoin" - ) - - def __register(func: Callable) -> Callable: - async def __handler(context: TurnContext, state: StateT) -> None: - details = MeetingParticipantsEventDetails.model_validate( - context.activity.value or {} - ) - await func(context, state, details) - - self._app.add_route( - __selector, - __handler, - rank=rank, - auth_handlers=auth_handlers, - ) - return func - - if handler is not None: - return __register(handler) - return __register - - def on_participants_leave( - self, - handler: Optional[Callable] = None, - *, - auth_handlers: Optional[list[str]] = None, - rank: RouteRank = RouteRank.DEFAULT, - ) -> Callable: - """Register a handler for meeting participant leave events.""" - - def __selector(context: TurnContext) -> bool: - return ( - context.activity.type == ActivityTypes.event - and context.activity.name - == "application/vnd.microsoft.meetingParticipantLeave" - ) - - def __register(func: Callable) -> Callable: - async def __handler(context: TurnContext, state: StateT) -> None: - details = MeetingParticipantsEventDetails.model_validate( - context.activity.value or {} - ) - await func(context, state, details) - - self._app.add_route( - __selector, - __handler, - rank=rank, - auth_handlers=auth_handlers, - ) - return func - - if handler is not None: - return __register(handler) - return __register - - -class TeamsAgentExtension(Generic[StateT]): - """ - Adds Teams-specific route registration to an AgentApplication. - - Usage:: - - app = AgentApplication(options) - teams = TeamsAgentExtension(app) - - @teams.task_module.on_fetch("myVerb") - async def handle_fetch(context, state, request: TaskModuleRequest): - return TaskModuleResponse(...) - - @teams.message_extension.on_query("searchCmd") - async def handle_query(context, state, query: MessagingExtensionQuery): - return MessagingExtensionResponse(...) - - @teams.meeting.on_start - async def handle_meeting_start(context, state, meeting: MeetingDetails): - ... - """ - - def __init__(self, app: AgentApplication[StateT]) -> None: - self._app = app - self._message_extension: MessageExtension[StateT] = MessageExtension(app) - self._task_module: TaskModule[StateT] = TaskModule(app) - self._meeting: Meeting[StateT] = Meeting(app) - - @property - def message_extension(self) -> MessageExtension[StateT]: - """Route registration for Message Extension (composeExtension) invokes.""" - return self._message_extension - - @property - def task_module(self) -> TaskModule[StateT]: - """Route registration for Task Module (task/fetch, task/submit) invokes.""" - return self._task_module - - @property - def meeting(self) -> Meeting[StateT]: - """Route registration for Meeting lifecycle events.""" - return self._meeting - - # ── Message update / delete ──────────────────────────────────────────── - - def on_message_edit( - self, - handler: Optional[Callable] = None, - *, - auth_handlers: Optional[list[str]] = None, - rank: RouteRank = RouteRank.DEFAULT, - ) -> Callable: - """Register a handler for Teams editMessage events.""" - - def __selector(context: TurnContext) -> bool: - return ( - context.activity.type == ActivityTypes.message_update - and context.activity.channel_id == "msteams" - and _get_channel_event_type(context) == "editMessage" - ) - - def __register(func: Callable) -> Callable: - self._app.add_route( - __selector, func, rank=rank, auth_handlers=auth_handlers - ) - return func - - if handler is not None: - return __register(handler) - return __register - - def on_message_undelete( - self, - handler: Optional[Callable] = None, - *, - auth_handlers: Optional[list[str]] = None, - rank: RouteRank = RouteRank.DEFAULT, - ) -> Callable: - """Register a handler for Teams undeleteMessage events.""" - - def __selector(context: TurnContext) -> bool: - return ( - context.activity.type == ActivityTypes.message_update - and context.activity.channel_id == "msteams" - and _get_channel_event_type(context) == "undeleteMessage" - ) - - def __register(func: Callable) -> Callable: - self._app.add_route( - __selector, func, rank=rank, auth_handlers=auth_handlers - ) - return func - - if handler is not None: - return __register(handler) - return __register - - def on_message_soft_delete( - self, - handler: Optional[Callable] = None, - *, - auth_handlers: Optional[list[str]] = None, - rank: RouteRank = RouteRank.DEFAULT, - ) -> Callable: - """Register a handler for Teams softDeleteMessage events.""" - - def __selector(context: TurnContext) -> bool: - return ( - context.activity.type == ActivityTypes.message_delete - and context.activity.channel_id == "msteams" - and _get_channel_event_type(context) == "softDeleteMessage" - ) - - def __register(func: Callable) -> Callable: - self._app.add_route( - __selector, func, rank=rank, auth_handlers=auth_handlers - ) - return func - - if handler is not None: - return __register(handler) - return __register - - # ── Read receipt ─────────────────────────────────────────────────────── - - def on_read_receipt( - self, - handler: Optional[Callable] = None, - *, - auth_handlers: Optional[list[str]] = None, - rank: RouteRank = RouteRank.DEFAULT, - ) -> Callable: - """Register a handler for Teams readReceipt events.""" - - def __selector(context: TurnContext) -> bool: - return ( - context.activity.type == ActivityTypes.event - and context.activity.name == "application/vnd.microsoft.readReceipt" - ) - - def __register(func: Callable) -> Callable: - async def __handler(context: TurnContext, state: StateT) -> None: - receipt = ReadReceiptInfo.model_validate(context.activity.value or {}) - await func(context, state, receipt) - - self._app.add_route( - __selector, __handler, rank=rank, auth_handlers=auth_handlers - ) - return func - - if handler is not None: - return __register(handler) - return __register - - # ── Config ───────────────────────────────────────────────────────────── - - def on_config_fetch( - self, - handler: Optional[Callable] = None, - *, - auth_handlers: Optional[list[str]] = None, - rank: RouteRank = RouteRank.DEFAULT, - ) -> Callable: - """Register a handler for config/fetch invokes.""" - - def __selector(context: TurnContext) -> bool: - return ( - context.activity.type == ActivityTypes.invoke - and context.activity.name == "config/fetch" - ) - - def __register(func: Callable) -> Callable: - async def __handler(context: TurnContext, state: StateT) -> None: - response = await func(context, state, context.activity.value) - if response is not None: - await _send_invoke_response(context, response) - - self._app.add_route( - __selector, - __handler, - is_invoke=True, - rank=rank, - auth_handlers=auth_handlers, - ) - return func - - if handler is not None: - return __register(handler) - return __register - - def on_config_submit( - self, - handler: Optional[Callable] = None, - *, - auth_handlers: Optional[list[str]] = None, - rank: RouteRank = RouteRank.DEFAULT, - ) -> Callable: - """Register a handler for config/submit invokes.""" - - def __selector(context: TurnContext) -> bool: - return ( - context.activity.type == ActivityTypes.invoke - and context.activity.name == "config/submit" - ) - - def __register(func: Callable) -> Callable: - async def __handler(context: TurnContext, state: StateT) -> None: - response = await func(context, state, context.activity.value) - if response is not None: - await _send_invoke_response(context, response) - - self._app.add_route( - __selector, - __handler, - is_invoke=True, - rank=rank, - auth_handlers=auth_handlers, - ) - return func - - if handler is not None: - return __register(handler) - return __register - - # ── File consent ─────────────────────────────────────────────────────── - - def on_file_consent_accept( - self, - handler: Optional[Callable] = None, - *, - auth_handlers: Optional[list[str]] = None, - rank: RouteRank = RouteRank.DEFAULT, - ) -> Callable: - """Register a handler for fileConsent/invoke with action == 'accept'.""" - - def __selector(context: TurnContext) -> bool: - return ( - context.activity.type == ActivityTypes.invoke - and context.activity.name == "fileConsent/invoke" - and isinstance(context.activity.value, dict) - and context.activity.value.get("action") == "accept" - ) - - def __register(func: Callable) -> Callable: - async def __handler(context: TurnContext, state: StateT) -> None: - file_consent = FileConsentCardResponse.model_validate( - context.activity.value or {} - ) - await func(context, state, file_consent) - await _send_invoke_response(context) - - self._app.add_route( - __selector, - __handler, - is_invoke=True, - rank=rank, - auth_handlers=auth_handlers, - ) - return func - - if handler is not None: - return __register(handler) - return __register - - def on_file_consent_decline( - self, - handler: Optional[Callable] = None, - *, - auth_handlers: Optional[list[str]] = None, - rank: RouteRank = RouteRank.DEFAULT, - ) -> Callable: - """Register a handler for fileConsent/invoke with action == 'decline'.""" - - def __selector(context: TurnContext) -> bool: - return ( - context.activity.type == ActivityTypes.invoke - and context.activity.name == "fileConsent/invoke" - and isinstance(context.activity.value, dict) - and context.activity.value.get("action") == "decline" - ) - - def __register(func: Callable) -> Callable: - async def __handler(context: TurnContext, state: StateT) -> None: - file_consent = FileConsentCardResponse.model_validate( - context.activity.value or {} - ) - await func(context, state, file_consent) - await _send_invoke_response(context) - - self._app.add_route( - __selector, - __handler, - is_invoke=True, - rank=rank, - auth_handlers=auth_handlers, - ) - return func - - if handler is not None: - return __register(handler) - return __register - - # ── O365 Connector ───────────────────────────────────────────────────── - - def on_o365_connector_card_action( - self, - handler: Optional[Callable] = None, - *, - auth_handlers: Optional[list[str]] = None, - rank: RouteRank = RouteRank.DEFAULT, - ) -> Callable: - """Register a handler for actionableMessage/executeAction invokes.""" - - def __selector(context: TurnContext) -> bool: - return ( - context.activity.type == ActivityTypes.invoke - and context.activity.name == "actionableMessage/executeAction" - ) - - def __register(func: Callable) -> Callable: - async def __handler(context: TurnContext, state: StateT) -> None: - query = O365ConnectorCardActionQuery.model_validate( - context.activity.value or {} - ) - await func(context, state, query) - await _send_invoke_response(context) - - self._app.add_route( - __selector, - __handler, - is_invoke=True, - rank=rank, - auth_handlers=auth_handlers, - ) - return func - - if handler is not None: - return __register(handler) - return __register - - # ── Conversation update events ───────────────────────────────────────── - - def on_members_added( - self, - handler: Optional[Callable] = None, - *, - auth_handlers: Optional[list[str]] = None, - rank: RouteRank = RouteRank.DEFAULT, - ) -> Callable: - """Register a handler for Teams membersAdded conversation update events.""" - - def __selector(context: TurnContext) -> bool: - return ( - context.activity.type == ActivityTypes.conversation_update - and context.activity.channel_id == "msteams" - and isinstance(context.activity.members_added, list) - and len(context.activity.members_added) > 0 - ) - - def __register(func: Callable) -> Callable: - self._app.add_route( - __selector, func, rank=rank, auth_handlers=auth_handlers - ) - return func - - if handler is not None: - return __register(handler) - return __register - - def on_members_removed( - self, - handler: Optional[Callable] = None, - *, - auth_handlers: Optional[list[str]] = None, - rank: RouteRank = RouteRank.DEFAULT, - ) -> Callable: - """Register a handler for Teams membersRemoved conversation update events.""" - - def __selector(context: TurnContext) -> bool: - return ( - context.activity.type == ActivityTypes.conversation_update - and context.activity.channel_id == "msteams" - and isinstance(context.activity.members_removed, list) - and len(context.activity.members_removed) > 0 - ) - - def __register(func: Callable) -> Callable: - self._app.add_route( - __selector, func, rank=rank, auth_handlers=auth_handlers - ) - return func - - if handler is not None: - return __register(handler) - return __register - - def on_channel_created( - self, - handler: Optional[Callable] = None, - *, - auth_handlers: Optional[list[str]] = None, - rank: RouteRank = RouteRank.DEFAULT, - ) -> Callable: - """Register a handler for Teams channelCreated conversation update events.""" - return self._on_teams_channel_event( - "channelCreated", handler, auth_handlers=auth_handlers, rank=rank - ) - - def on_channel_deleted( - self, - handler: Optional[Callable] = None, - *, - auth_handlers: Optional[list[str]] = None, - rank: RouteRank = RouteRank.DEFAULT, - ) -> Callable: - """Register a handler for Teams channelDeleted conversation update events.""" - return self._on_teams_channel_event( - "channelDeleted", handler, auth_handlers=auth_handlers, rank=rank - ) - - def on_channel_renamed( - self, - handler: Optional[Callable] = None, - *, - auth_handlers: Optional[list[str]] = None, - rank: RouteRank = RouteRank.DEFAULT, - ) -> Callable: - """Register a handler for Teams channelRenamed conversation update events.""" - return self._on_teams_channel_event( - "channelRenamed", handler, auth_handlers=auth_handlers, rank=rank - ) - - def on_channel_restored( - self, - handler: Optional[Callable] = None, - *, - auth_handlers: Optional[list[str]] = None, - rank: RouteRank = RouteRank.DEFAULT, - ) -> Callable: - """Register a handler for Teams channelRestored conversation update events.""" - return self._on_teams_channel_event( - "channelRestored", handler, auth_handlers=auth_handlers, rank=rank - ) - - def on_team_archived( - self, - handler: Optional[Callable] = None, - *, - auth_handlers: Optional[list[str]] = None, - rank: RouteRank = RouteRank.DEFAULT, - ) -> Callable: - """Register a handler for Teams teamArchived conversation update events.""" - return self._on_teams_channel_event( - "teamArchived", handler, auth_handlers=auth_handlers, rank=rank - ) - - def on_team_deleted( - self, - handler: Optional[Callable] = None, - *, - auth_handlers: Optional[list[str]] = None, - rank: RouteRank = RouteRank.DEFAULT, - ) -> Callable: - """Register a handler for Teams teamDeleted conversation update events.""" - return self._on_teams_channel_event( - "teamDeleted", handler, auth_handlers=auth_handlers, rank=rank - ) - - def on_team_hard_deleted( - self, - handler: Optional[Callable] = None, - *, - auth_handlers: Optional[list[str]] = None, - rank: RouteRank = RouteRank.DEFAULT, - ) -> Callable: - """Register a handler for Teams teamHardDeleted conversation update events.""" - return self._on_teams_channel_event( - "teamHardDeleted", handler, auth_handlers=auth_handlers, rank=rank - ) - - def on_team_renamed( - self, - handler: Optional[Callable] = None, - *, - auth_handlers: Optional[list[str]] = None, - rank: RouteRank = RouteRank.DEFAULT, - ) -> Callable: - """Register a handler for Teams teamRenamed conversation update events.""" - return self._on_teams_channel_event( - "teamRenamed", handler, auth_handlers=auth_handlers, rank=rank - ) - - def on_team_restored( - self, - handler: Optional[Callable] = None, - *, - auth_handlers: Optional[list[str]] = None, - rank: RouteRank = RouteRank.DEFAULT, - ) -> Callable: - """Register a handler for Teams teamRestored conversation update events.""" - return self._on_teams_channel_event( - "teamRestored", handler, auth_handlers=auth_handlers, rank=rank - ) - - def on_team_unarchived( - self, - handler: Optional[Callable] = None, - *, - auth_handlers: Optional[list[str]] = None, - rank: RouteRank = RouteRank.DEFAULT, - ) -> Callable: - """Register a handler for Teams teamUnarchived conversation update events.""" - return self._on_teams_channel_event( - "teamUnarchived", handler, auth_handlers=auth_handlers, rank=rank - ) - - def _on_teams_channel_event( - self, - event_type: str, - handler: Optional[Callable], - *, - auth_handlers: Optional[list[str]] = None, - rank: RouteRank = RouteRank.DEFAULT, - ) -> Callable: - def __selector(context: TurnContext) -> bool: - return ( - context.activity.type == ActivityTypes.conversation_update - and context.activity.channel_id == "msteams" - and _get_channel_event_type(context) == event_type - ) - - def __register(func: Callable) -> Callable: - self._app.add_route( - __selector, func, rank=rank, auth_handlers=auth_handlers - ) - return func - - if handler is not None: - return __register(handler) - return __register diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/teams_info.py b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/teams_info.py deleted file mode 100644 index b90eb6f84..000000000 --- a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/teams_info.py +++ /dev/null @@ -1,666 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -"""Teams information utilities for Microsoft Agents.""" - -from typing import Optional, Any - -from microsoft_agents.activity import Activity, Channels, ConversationParameters - -from microsoft_agents.activity.teams import ( - TeamsChannelAccount, - TeamsMeetingParticipant, - MeetingInfo, - TeamDetails, - TeamsPagedMembersResult, - MeetingNotification, - MeetingNotificationResponse, - TeamsMember, - BatchOperationStateResponse, - BatchFailedEntriesResponse, - CancelOperationResponse, - TeamsBatchOperationResponse, - ChannelInfo, -) -from microsoft_agents.hosting.core.connector.teams import TeamsConnectorClient -from microsoft_agents.hosting.core import ( - ChannelServiceAdapter, - TurnContext, - error_resources, -) -from microsoft_agents.hosting.teams.errors import teams_errors - - -class TeamsInfo: - """Teams information utilities for interacting with Teams-specific data.""" - - @staticmethod - async def get_meeting_participant( - context: TurnContext, - meeting_id: Optional[str] = None, - participant_id: Optional[str] = None, - tenant_id: Optional[str] = None, - ) -> TeamsMeetingParticipant: - """ - Gets the meeting participant information. - - Args: - context: The turn context. - meeting_id: The meeting ID. If not provided, it will be extracted from the activity. - participant_id: The participant ID. If not provided, it will be extracted from the activity. - tenant_id: The tenant ID. If not provided, it will be extracted from the activity. - - Returns: - The meeting participant information. - - Raises: - ValueError: If required parameters are missing. - """ - if not context: - raise ValueError(str(teams_errors.TeamsContextRequired)) - - activity = context.activity - teams_channel_data: dict = activity.channel_data - - if meeting_id is None: - meeting_id = teams_channel_data.get("meeting", {}).get("id", None) - - if not meeting_id: - raise ValueError(str(teams_errors.TeamsMeetingIdRequired)) - - if participant_id is None: - participant_id = getattr(activity.from_property, "aad_object_id", None) - - if not participant_id: - raise ValueError(str(teams_errors.TeamsParticipantIdRequired)) - - if tenant_id is None: - tenant_id = teams_channel_data.get("tenant", {}).get("id", None) - - rest_client = TeamsInfo._get_rest_client(context) - result = await rest_client.fetch_meeting_participant( - meeting_id, participant_id, tenant_id - ) - return result - - @staticmethod - async def get_meeting_info( - context: TurnContext, meeting_id: Optional[str] = None - ) -> MeetingInfo: - """ - Gets the meeting information. - - Args: - context: The turn context. - meeting_id: The meeting ID. If not provided, it will be extracted from the activity. - - Returns: - The meeting information. - - Raises: - ValueError: If required parameters are missing. - """ - if not meeting_id: - teams_channel_data: dict = context.activity.channel_data - meeting_id = teams_channel_data.get("meeting", {}).get("id", None) - - if not meeting_id: - raise ValueError(str(teams_errors.TeamsMeetingIdRequired)) - - rest_client = TeamsInfo._get_rest_client(context) - result = await rest_client.fetch_meeting_info(meeting_id) - return result - - @staticmethod - async def get_team_details( - context: TurnContext, team_id: Optional[str] = None - ) -> TeamDetails: - """ - Gets the team details. - - Args: - context: The turn context. - team_id: The team ID. If not provided, it will be extracted from the activity. - - Returns: - The team details. - - Raises: - ValueError: If required parameters are missing. - """ - if not team_id: - teams_channel_data: dict = context.activity.channel_data - team_id = teams_channel_data.get("team", {}).get("id", None) - - if not team_id: - raise ValueError(str(teams_errors.TeamsTeamIdRequired)) - - rest_client = TeamsInfo._get_rest_client(context) - result = await rest_client.fetch_team_details(team_id) - return result - - @staticmethod - async def send_message_to_teams_channel( - context: TurnContext, - activity: Activity, - teams_channel_id: str, - app_id: Optional[str] = None, - ) -> tuple[dict[str, Any], str]: - """ - Sends a message to a Teams channel. - - Args: - context: The turn context. - activity: The activity to send. - teams_channel_id: The Teams channel ID. - app_id: The application ID. - - Returns: - A tuple containing the conversation reference and new activity ID. - - Raises: - ValueError: If required parameters are missing. - """ - if not context: - raise ValueError(str(teams_errors.TeamsTurnContextRequired)) - - if not activity: - raise ValueError(str(teams_errors.TeamsActivityRequired)) - - if not teams_channel_id: - raise ValueError(str(teams_errors.TeamsChannelIdRequired)) - - convo_params = ConversationParameters( - is_group=True, - channel_data={ - "channel": { - "id": teams_channel_id, - }, - }, - activity=activity, - agent=context.activity.recipient, - ) - - conversation_reference = None - new_activity_id = None - - if app_id and isinstance(context.adapter, ChannelServiceAdapter): - - async def _conversation_callback( - turn_context: TurnContext, - ) -> None: - """ - Callback for create_conversation. - - Args: - turn_context: The turn context. - conversation_reference: The conversation reference to update. - new_activity_id: The new activity ID to update. - """ - nonlocal conversation_reference, new_activity_id - conversation_reference = ( - turn_context.activity.get_conversation_reference() - ) - new_activity_id = turn_context.activity.id - - await context.adapter.create_conversation( - app_id, - Channels.ms_teams, - context.activity.service_url, - "https://api.botframework.com", - convo_params, - _conversation_callback, - ) - else: - connector_client = TeamsInfo._get_rest_client(context) - conversation_resource_response = ( - await connector_client.conversations.create_conversation(convo_params) - ) - conversation_reference = context.activity.get_conversation_reference() - conversation_reference.conversation.id = conversation_resource_response.id - new_activity_id = conversation_resource_response.activity_id - - return conversation_reference, new_activity_id - - @staticmethod - async def get_team_channels( - context: TurnContext, team_id: Optional[str] = None - ) -> list[ChannelInfo]: - """ - Gets the channels of a team. - - Args: - context: The turn context. - team_id: The team ID. If not provided, it will be extracted from the activity. - - Returns: - The list of channels. - - Raises: - ValueError: If required parameters are missing. - """ - if not team_id: - teams_channel_data: dict = context.activity.channel_data - team_id = teams_channel_data.get("team", {}).get("id", None) - - if not team_id: - raise ValueError(str(teams_errors.TeamsTeamIdRequired)) - - rest_client = TeamsInfo._get_rest_client(context) - return await rest_client.fetch_channel_list(team_id) - - @staticmethod - async def get_paged_members( - context: TurnContext, - page_size: Optional[int] = None, - continuation_token: Optional[str] = None, - ) -> TeamsPagedMembersResult: - """ - Gets the paged members of a team or conversation. - - Args: - context: The turn context. - page_size: The page size. - continuation_token: The continuation token. - - Returns: - The paged members result. - - Raises: - ValueError: If required parameters are missing. - """ - teams_channel_data: dict = context.activity.channel_data - team_id = teams_channel_data.get("team", {}).get("id", None) - - if team_id: - return await TeamsInfo.get_paged_team_members( - context, team_id, page_size, continuation_token - ) - else: - conversation_id = ( - context.activity.conversation.id - if context.activity.conversation - else None - ) - if not conversation_id: - raise ValueError(str(teams_errors.TeamsConversationIdRequired)) - - rest_client = TeamsInfo._get_rest_client(context) - return await rest_client.get_conversation_paged_member( - conversation_id, page_size, continuation_token - ) - - @staticmethod - async def get_member(context: TurnContext, user_id: str) -> TeamsChannelAccount: - """ - Gets a member of a team or conversation. - - Args: - context: The turn context. - user_id: The user ID. - - Returns: - The member information. - - Raises: - ValueError: If required parameters are missing. - """ - teams_channel_data: dict = context.activity.channel_data - team_id = teams_channel_data.get("team", {}).get("id", None) - - if team_id: - return await TeamsInfo.get_team_member(context, team_id, user_id) - else: - conversation_id = ( - context.activity.conversation.id - if context.activity.conversation - else None - ) - if not conversation_id: - raise ValueError(str(teams_errors.TeamsConversationIdRequired)) - - return await TeamsInfo._get_member_internal( - context, conversation_id, user_id - ) - - @staticmethod - async def get_paged_team_members( - context: TurnContext, - team_id: Optional[str] = None, - page_size: Optional[int] = None, - continuation_token: Optional[str] = None, - ) -> TeamsPagedMembersResult: - """ - Gets the paged members of a team. - - Args: - context: The turn context. - team_id: The team ID. If not provided, it will be extracted from the activity. - page_size: The page size. - continuation_token: The continuation token. - - Returns: - The paged members result. - - Raises: - ValueError: If required parameters are missing. - """ - if not team_id: - teams_channel_data: dict = context.activity.channel_data - team_id = teams_channel_data.get("team", {}).get("id", None) - - if not team_id: - raise ValueError(str(teams_errors.TeamsTeamIdRequired)) - - rest_client = TeamsInfo._get_rest_client(context) - paged_results = await rest_client.get_conversation_paged_member( - team_id, page_size, continuation_token - ) - - # Fetch all pages if there are more - while paged_results.continuation_token: - next_results = await rest_client.get_conversation_paged_member( - team_id, page_size, paged_results.continuation_token - ) - paged_results.members.extend(next_results.members) - paged_results.continuation_token = next_results.continuation_token - - return paged_results - - @staticmethod - async def get_team_member( - context: TurnContext, team_id: str, user_id: str - ) -> TeamsChannelAccount: - """ - Gets a member of a team. - - Args: - context: The turn context. - team_id: The team ID. - user_id: The user ID. - - Returns: - The member information. - - Raises: - ValueError: If required parameters are missing. - """ - rest_client = TeamsInfo._get_rest_client(context) - return await rest_client.get_conversation_member(team_id, user_id) - - @staticmethod - async def send_meeting_notification( - context: TurnContext, - notification: MeetingNotification, - meeting_id: Optional[str] = None, - ) -> MeetingNotificationResponse: - """ - Sends a meeting notification. - - Args: - context: The turn context. - notification: The meeting notification. - meeting_id: The meeting ID. If not provided, it will be extracted from the activity. - - Returns: - The meeting notification response. - - Raises: - ValueError: If required parameters are missing. - """ - activity = context.activity - - if meeting_id is None: - teams_channel_data: dict = activity.channel_data - meeting_id = teams_channel_data.get("meeting", {}).get("id", None) - - if not meeting_id: - raise ValueError(str(teams_errors.TeamsMeetingIdRequired)) - - rest_client = TeamsInfo._get_rest_client(context) - return await rest_client.send_meeting_notification(meeting_id, notification) - - @staticmethod - async def send_message_to_list_of_users( - context: TurnContext, - activity: Activity, - tenant_id: str, - members: list[TeamsMember], - ) -> TeamsBatchOperationResponse: - """ - Sends a message to a list of users. - - Args: - context: The turn context. - activity: The activity to send. - tenant_id: The tenant ID. - members: The list of members. - - Returns: - The batch operation response. - - Raises: - ValueError: If required parameters are missing. - """ - if not activity: - raise ValueError(str(error_resources.ActivityRequired)) - if not tenant_id: - raise ValueError( - error_resources.RequiredParameterMissing.format("tenant_id") - ) - if not members or len(members) == 0: - raise ValueError("members list is required.") - - rest_client = TeamsInfo._get_rest_client(context) - return await rest_client.send_message_to_list_of_users( - activity, tenant_id, members - ) - - @staticmethod - async def send_message_to_all_users_in_tenant( - context: TurnContext, activity: Activity, tenant_id: str - ) -> TeamsBatchOperationResponse: - """ - Sends a message to all users in a tenant. - - Args: - context: The turn context. - activity: The activity to send. - tenant_id: The tenant ID. - - Returns: - The batch operation response. - - Raises: - ValueError: If required parameters are missing. - """ - if not activity: - raise ValueError(str(error_resources.ActivityRequired)) - if not tenant_id: - raise ValueError( - error_resources.RequiredParameterMissing.format("tenant_id") - ) - - rest_client = TeamsInfo._get_rest_client(context) - return await rest_client.send_message_to_all_users_in_tenant( - activity, tenant_id - ) - - @staticmethod - async def send_message_to_all_users_in_team( - context: TurnContext, activity: Activity, tenant_id: str, team_id: str - ) -> TeamsBatchOperationResponse: - """ - Sends a message to all users in a team. - - Args: - context: The turn context. - activity: The activity to send. - tenant_id: The tenant ID. - team_id: The team ID. - - Returns: - The batch operation response. - - Raises: - ValueError: If required parameters are missing. - """ - if not activity: - raise ValueError(str(error_resources.ActivityRequired)) - if not tenant_id: - raise ValueError( - error_resources.RequiredParameterMissing.format("tenant_id") - ) - if not team_id: - raise ValueError(str(teams_errors.TeamsTeamIdRequired)) - - rest_client = TeamsInfo._get_rest_client(context) - return await rest_client.send_message_to_all_users_in_team( - activity, tenant_id, team_id - ) - - @staticmethod - async def send_message_to_list_of_channels( - context: TurnContext, - activity: Activity, - tenant_id: str, - members: list[TeamsMember], - ) -> TeamsBatchOperationResponse: - """ - Sends a message to a list of channels. - - Args: - context: The turn context. - activity: The activity to send. - tenant_id: The tenant ID. - members: The list of members. - - Returns: - The batch operation response. - - Raises: - ValueError: If required parameters are missing. - """ - if not activity: - raise ValueError(str(error_resources.ActivityRequired)) - if not tenant_id: - raise ValueError( - error_resources.RequiredParameterMissing.format("tenant_id") - ) - if not members or len(members) == 0: - raise ValueError("members list is required.") - - rest_client = TeamsInfo._get_rest_client(context) - return await rest_client.send_message_to_list_of_channels( - activity, tenant_id, members - ) - - @staticmethod - async def get_operation_state( - context: TurnContext, operation_id: str - ) -> BatchOperationStateResponse: - """ - Gets the operation state. - - Args: - context: The turn context. - operation_id: The operation ID. - - Returns: - The operation state response. - - Raises: - ValueError: If required parameters are missing. - """ - if not operation_id: - raise ValueError("operation_id is required.") - - rest_client = TeamsInfo._get_rest_client(context) - return await rest_client.get_operation_state(operation_id) - - @staticmethod - async def get_failed_entries( - context: TurnContext, operation_id: str - ) -> BatchFailedEntriesResponse: - """ - Gets the failed entries of an operation. - - Args: - context: The turn context. - operation_id: The operation ID. - - Returns: - The failed entries response. - - Raises: - ValueError: If required parameters are missing. - """ - if not operation_id: - raise ValueError("operation_id is required.") - - rest_client = TeamsInfo._get_rest_client(context) - return await rest_client.get_failed_entries(operation_id) - - @staticmethod - async def cancel_operation( - context: TurnContext, operation_id: str - ) -> CancelOperationResponse: - """ - Cancels an operation. - - Args: - context: The turn context. - operation_id: The operation ID. - - Returns: - The cancel operation response. - - Raises: - ValueError: If required parameters are missing. - """ - if not operation_id: - raise ValueError("operation_id is required.") - - rest_client = TeamsInfo._get_rest_client(context) - return await rest_client.cancel_operation(operation_id) - - @staticmethod - async def _get_member_internal( - context: TurnContext, conversation_id: str, user_id: str - ) -> TeamsChannelAccount: - """ - Internal method to get a member from a conversation. - - Args: - context: The turn context. - conversation_id: The conversation ID. - user_id: The user ID. - - Returns: - The member information. - - Raises: - ValueError: If required parameters are missing. - """ - rest_client = TeamsInfo._get_rest_client(context) - return await rest_client.get_conversation_member(conversation_id, user_id) - - @staticmethod - def _get_rest_client(context: TurnContext) -> TeamsConnectorClient: - """ - Gets the Teams connector client from the context. - - Args: - context: The turn context. - - Returns: - The Teams connector client. - - Raises: - ValueError: If the client is not available in the context. - """ - # TODO: Varify key - client = context.turn_state.get("ConnectorClient") - if not client: - raise ValueError("TeamsConnectorClient is not available in the context.") - return client diff --git a/libraries/microsoft-agents-hosting-teams/readme.md b/libraries/microsoft-agents-hosting-teams/readme.md deleted file mode 100644 index 9ed79b7a0..000000000 --- a/libraries/microsoft-agents-hosting-teams/readme.md +++ /dev/null @@ -1,169 +0,0 @@ -# Microsoft Agents Hosting - Teams - -[![PyPI version](https://img.shields.io/pypi/v/microsoft-agents-hosting-teams)](https://pypi.org/project/microsoft-agents-hosting-teams/) - -Integration library for building Microsoft Teams agents using the Microsoft 365 Agents SDK. This library provides specialized handlers and utilities for Teams-specific functionality like messaging extensions, task modules, adaptive cards, and meeting events. - -This library extends the core hosting capabilities with Teams-specific features. It handles Teams' unique interaction patterns like messaging extensions, tab applications, task modules, and meeting integrations. Think of it as the bridge that makes your agent "Teams-native" rather than just a generic chatbot. - -This library is still in flux, as the interfaces to Teams continue to evolve. - -# What is this? -This library is part of the **Microsoft 365 Agents SDK for Python** - a comprehensive framework for building enterprise-grade conversational AI agents. The SDK enables developers to create intelligent agents that work across multiple platforms including Microsoft Teams, M365 Copilot, Copilot Studio, and web chat, with support for third-party integrations like Slack, Facebook Messenger, and Twilio. - -## Release Notes - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
VersionDateRelease Notes
1.1.02026-06-19 - - 1.1.0 Release Notes - -
1.0.02026-05-22 - - 1.0.0 Release Notes - -
0.9.02026-04-15 - - 0.9.0 Release Notes - -
0.8.02026-02-23 - - 0.8.0 Release Notes - -
0.7.02026-01-21 - - 0.7.0 Release Notes - -
0.6.12025-12-01 - - 0.6.1 Release Notes - -
0.6.02025-11-18 - - 0.6.0 Release Notes - -
0.5.02025-10-22 - - 0.5.0 Release Notes - -
- -## Packages Overview - -We offer the following PyPI packages to create conversational experiences based on Agents: - -| Package Name | PyPI Version | Description | -|--------------|-------------|-------------| -| `microsoft-agents-activity` | [![PyPI](https://img.shields.io/pypi/v/microsoft-agents-activity)](https://pypi.org/project/microsoft-agents-activity/) | Types and validators implementing the Activity protocol spec. | -| `microsoft-agents-hosting-core` | [![PyPI](https://img.shields.io/pypi/v/microsoft-agents-hosting-core)](https://pypi.org/project/microsoft-agents-hosting-core/) | Core library for Microsoft Agents hosting. | -| `microsoft-agents-hosting-aiohttp` | [![PyPI](https://img.shields.io/pypi/v/microsoft-agents-hosting-aiohttp)](https://pypi.org/project/microsoft-agents-hosting-aiohttp/) | Configures aiohttp to run the Agent. | -| `microsoft-agents-hosting-teams` | [![PyPI](https://img.shields.io/pypi/v/microsoft-agents-hosting-teams)](https://pypi.org/project/microsoft-agents-hosting-teams/) | Provides classes to host an Agent for Teams. | -| `microsoft-agents-hosting-dialogs` | [![PyPI](https://img.shields.io/pypi/v/microsoft-agents-hosting-dialogs)](https://pypi.org/project/microsoft-agents-hosting-dialogs/) | Dialog system with waterfall dialogs, prompts, and multi-turn conversation management. | -| `microsoft-agents-storage-blob` | [![PyPI](https://img.shields.io/pypi/v/microsoft-agents-storage-blob)](https://pypi.org/project/microsoft-agents-storage-blob/) | Extension to use Azure Blob as storage. | -| `microsoft-agents-storage-cosmos` | [![PyPI](https://img.shields.io/pypi/v/microsoft-agents-storage-cosmos)](https://pypi.org/project/microsoft-agents-storage-cosmos/) | Extension to use CosmosDB as storage. | -| `microsoft-agents-authentication-msal` | [![PyPI](https://img.shields.io/pypi/v/microsoft-agents-authentication-msal)](https://pypi.org/project/microsoft-agents-authentication-msal/) | MSAL-based authentication for Microsoft Agents. | - -Additionally we provide a Copilot Studio Client, to interact with Agents created in CopilotStudio: - -| Package Name | PyPI Version | Description | -|--------------|-------------|-------------| -| `microsoft-agents-copilotstudio-client` | [![PyPI](https://img.shields.io/pypi/v/microsoft-agents-copilotstudio-client)](https://pypi.org/project/microsoft-agents-copilotstudio-client/) | Direct to Engine client to interact with Agents created in CopilotStudio | - - -## Installation - -```bash -pip install microsoft-agents-hosting-teams -``` - -## Key Classes Reference - -- **`TeamsActivityHandler`** - Main handler class with Teams-specific event methods -- **`TeamsInfo`** - Utility class for Teams operations (members, meetings, channels) -- **`MessagingExtensionQuery/Response`** - Handle search and messaging extensions -- **`TaskModuleRequest/Response`** - Interactive dialogs and forms -- **`TabRequest/Response`** - Tab application interactions - -## Features Supported - -✅ **Messaging Extensions** - Search and action-based extensions -✅ **Task Modules** - Interactive dialogs and forms -✅ **Adaptive Cards** - Rich card interactions -✅ **Meeting Events** - Start, end, participant changes -✅ **Team Management** - Member operations, channel messaging -✅ **File Handling** - Upload/download with consent flow -✅ **Tab Apps** - Personal and team tab interactions -✅ **Proactive Messaging** - Send messages to channels/users - -## Migration from Bot Framework - -| Bot Framework Teams | Microsoft Agents Teams | -|-------------------|------------------------| -| `TeamsActivityHandler` | `TeamsActivityHandler` | -| `TeamsInfo` | `TeamsInfo` | -| `on_teams_members_added` | `on_teams_members_added_activity` | -| `MessagingExtensionQuery` | `MessagingExtensionQuery` | -| `TaskModuleRequest` | `TaskModuleRequest` | - -# Quick Links - -- 📦 [All SDK Packages on PyPI](https://pypi.org/search/?q=microsoft-agents) -- 📖 [Complete Documentation](https://aka.ms/agents) -- 💡 [Python Samples Repository](https://github.com/microsoft/Agents/tree/main/samples/python) -- 🐛 [Report Issues](https://github.com/microsoft/Agents-for-python/issues) - -# Sample Applications - -|Name|Description|README| -|----|----|----| -|Quickstart|Simplest agent|[Quickstart](https://github.com/microsoft/Agents/blob/main/samples/python/quickstart/README.md)| -|Auto Sign In|Simple OAuth agent using Graph and GitHub|[auto-signin](https://github.com/microsoft/Agents/blob/main/samples/python/auto-signin/README.md)| -|OBO Authorization|OBO flow to access a Copilot Studio Agent|[obo-authorization](https://github.com/microsoft/Agents/blob/main/samples/python/obo-authorization/README.md)| -|Semantic Kernel Integration|A weather agent built with Semantic Kernel|[semantic-kernel-multiturn](https://github.com/microsoft/Agents/blob/main/samples/python/semantic-kernel-multiturn/README.md)| -|Streaming Agent|Streams OpenAI responses|[azure-ai-streaming](https://github.com/microsoft/Agents/blob/main/samples/python/azureai-streaming/README.md)| -|Copilot Studio Client|Console app to consume a Copilot Studio Agent|[copilotstudio-client](https://github.com/microsoft/Agents/blob/main/samples/python/copilotstudio-client/README.md)| -|Cards Agent|Agent that uses rich cards to enhance conversation design |[cards](https://github.com/microsoft/Agents/blob/main/samples/python/cards/README.md)| \ No newline at end of file diff --git a/test_samples/hosting_msteams/conversation-agent/README.md b/test_samples/hosting_msteams/conversation-agent/README.md new file mode 100644 index 000000000..00948565f --- /dev/null +++ b/test_samples/hosting_msteams/conversation-agent/README.md @@ -0,0 +1,46 @@ +# Teams Conversation Agent Sample + +Python port of the .NET `ConversationAgent` sample +(`src/samples/Teams/ConversationAgent` in microsoft/Agents-for-net), built on +`AgentApplication` + `TeamsAgentExtension`. + +## What it demonstrates + +Conversation-update and lifecycle events: + +| Event | Decorator | +|-------|-----------| +| installation update | `@teams.activity("installationUpdate")` | +| members added / removed | `@teams.conversation_update("membersAdded" / "membersRemoved")` | +| channel created / renamed / deleted | `@teams.channels.created` / `.renamed` / `.deleted` | +| team renamed | `@teams.teams.renamed` | + +Message commands (driven by the welcome card buttons, all `@teams.message(...)`): + +| Command | Behaviour | +|---------|-----------| +| (any message) | Sends the welcome card. | +| `update` | Updates the triggering card with an incremented counter. | +| `delete` | Deletes the triggering card. | +| `whoami` | Looks up the caller via the Teams API client. | +| `mentionme` | Replies with an Adaptive Card that @-mentions the caller. | +| `atmention` | Replies with a text message that @-mentions the caller. | +| `messageall` | Proactively sends a 1:1 greeting to every team member. | +| `targeted` | Sends a 1:1 message to every member of the conversation. | + +Member lookups use `teams.get_teams_api_client(context).conversations.members`, +and proactive messages use `adapter.create_conversation(...)`. + +## Running + +1. Copy `env.TEMPLATE` to `.env` and fill in your Azure Bot registration + (`CLIENTID`, `CLIENTSECRET`, `TENANTID`). +2. Install the SDK libraries (see the repository `README.md`). +3. Start the agent: + + ```bash + python -m src.main + ``` + +The server listens on `http://localhost:3978/api/messages`. Expose it with a +dev tunnel and side-load the app manifest into Teams. diff --git a/test_samples/hosting_msteams/conversation-agent/env.TEMPLATE b/test_samples/hosting_msteams/conversation-agent/env.TEMPLATE new file mode 100644 index 000000000..187ec681c --- /dev/null +++ b/test_samples/hosting_msteams/conversation-agent/env.TEMPLATE @@ -0,0 +1,3 @@ +CONNECTIONS__SERVICE_CONNECTION__SETTINGS__CLIENTID= +CONNECTIONS__SERVICE_CONNECTION__SETTINGS__CLIENTSECRET= +CONNECTIONS__SERVICE_CONNECTION__SETTINGS__TENANTID= \ No newline at end of file diff --git a/test_samples/hosting_msteams/conversation-agent/pyproject.toml b/test_samples/hosting_msteams/conversation-agent/pyproject.toml new file mode 100644 index 000000000..483a02db5 --- /dev/null +++ b/test_samples/hosting_msteams/conversation-agent/pyproject.toml @@ -0,0 +1,20 @@ +[build-system] +requires = ["setuptools"] +build-backend = "setuptools.build_meta" + +[project] +name = "conversation-agent" +version = "0.1.0" +description = "Teams Conversation Agent sample — demonstrates channel/team lifecycle, member events, and message commands" +authors = [{name = "Microsoft Corporation"}] +license = "MIT" +requires-python = ">=3.11" +dependencies = [ + "microsoft-agents-activity", + "microsoft-agents-hosting-core", + "microsoft-agents-authentication-msal", + "microsoft-agents-hosting-aiohttp", + "microsoft-agents-hosting-msteams", + "python-dotenv", + "aiohttp", +] diff --git a/tests/hosting_teams/__init__.py b/test_samples/hosting_msteams/conversation-agent/src/__init__.py similarity index 100% rename from tests/hosting_teams/__init__.py rename to test_samples/hosting_msteams/conversation-agent/src/__init__.py diff --git a/test_samples/hosting_msteams/conversation-agent/src/agent.py b/test_samples/hosting_msteams/conversation-agent/src/agent.py new file mode 100644 index 000000000..119dc1133 --- /dev/null +++ b/test_samples/hosting_msteams/conversation-agent/src/agent.py @@ -0,0 +1,421 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""Teams Conversation Agent — Python port of the .NET ConversationAgent sample. + +Demonstrates Teams conversation events and message commands: installation and +channel/team lifecycle events, member add/remove, card update/delete, mentions, +member lookups via the Teams API client, and proactive 1:1 messaging. Mirrors +src/samples/Teams/ConversationAgent from the microsoft/Agents-for-net repository. +""" + +import json +import logging +from os import environ +from typing import Optional + +from dotenv import load_dotenv + +from microsoft_teams.api.models import ( + ChannelData, + ChannelInfo, + TeamInfo, + TeamsChannelAccount, +) + +from microsoft_agents.activity import ( + ActionTypes, + CardAction, + ChannelAccount, + Channels, + ConversationParameters, + HeroCard, + Mention, + 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, + CardFactory, + MemoryStorage, + MessageFactory, + TurnContext, + TurnState, +) +from microsoft_agents.hosting.core.storage import ( + ConsoleTranscriptLogger, + TranscriptLoggerMiddleware, +) +from microsoft_agents.hosting.msteams import TeamsAgentExtension +from microsoft_agents.hosting.msteams.teams_turn_context import TeamsTurnContext + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) +load_dotenv() + +agents_sdk_config = load_configuration_from_env(environ) + +STORAGE = MemoryStorage() +CONNECTION_MANAGER = MsalConnectionManager(**agents_sdk_config) +ADAPTER = CloudAdapter(connection_manager=CONNECTION_MANAGER) +ADAPTER.use(TranscriptLoggerMiddleware(ConsoleTranscriptLogger())) +AUTHORIZATION = Authorization(STORAGE, CONNECTION_MANAGER, **agents_sdk_config) + +AGENT_APP = AgentApplication[TurnState]( + storage=STORAGE, + adapter=ADAPTER, + authorization=AUTHORIZATION, + **agents_sdk_config, +) + +teams = TeamsAgentExtension[TurnState](AGENT_APP) + +_MEMBER_NOT_FOUND = "MemberNotFoundInConversation" +_DEFAULT_AUDIENCE = "https://api.botframework.com" + + +def _welcome_card(title: str, count: int = 0) -> HeroCard: + """Build the demo welcome card with one button per message command.""" + return HeroCard( + title=title, + buttons=[ + CardAction( + type=ActionTypes.message_back, + title="Message all members", + text="messageall", + ), + CardAction(type=ActionTypes.message_back, title="Who am I?", text="whoami"), + CardAction( + type=ActionTypes.message_back, title="Mention Me", text="mentionme" + ), + CardAction( + type=ActionTypes.message_back, title="Delete Card", text="delete" + ), + CardAction( + type=ActionTypes.message_back, title="Send Targeted", text="targeted" + ), + CardAction( + type=ActionTypes.message_back, + title="Update Card", + text="update", + value=json.dumps({"count": count}), + ), + ], + ) + + +def _scope_id(context: TeamsTurnContext) -> str: + """Return the team id when in a team channel, otherwise the conversation id. + + The Teams member APIs are scoped to a roster; in a team channel that roster + is keyed by the team id, while in 1:1 / group chats it is the conversation id. + """ + channel_data = context.activity.channel_data + if ( + isinstance(channel_data, ChannelData) + and channel_data.team + and channel_data.team.id + ): + return channel_data.team.id + return context.activity.conversation.id + + +def _app_id(context: TeamsTurnContext) -> str: + return context.identity.get_app_id() if context.identity else "" + + +def _audience(context: TeamsTurnContext) -> str: + if context.identity: + return context.identity.get_token_audience() or _DEFAULT_AUDIENCE + return _DEFAULT_AUDIENCE + + +async def _get_member( + context: TeamsTurnContext, member_id: str +) -> Optional[TeamsChannelAccount]: + """Look up a single member, returning None if not found in the conversation.""" + api = teams.get_teams_api_client(context) + try: + return await api.conversations.members(context.activity.conversation.id).get(member_id) + except Exception as exc: # noqa: BLE001 - surface only the known "not found" case + if _MEMBER_NOT_FOUND in str(exc): + return None + raise + + +async def _create_one_on_one( + context: TeamsTurnContext, + app_id: str, + audience: str, + member: TeamsChannelAccount, + callback, +) -> None: + """Create a proactive 1:1 conversation with *member* and run *callback*.""" + tenant_id = ( + context.activity.conversation.tenant_id + if context.activity.conversation + else None + ) + params = ConversationParameters( + is_group=False, + members=[ChannelAccount(id=member.id, name=member.name)], + tenant_id=tenant_id, + agent=context.activity.recipient, + ) + await context.adapter.create_conversation( + app_id, + Channels.ms_teams, + context.activity.service_url, + audience, + params, + callback, + ) + + +# ── Installation update ────────────────────────────────────────────────────── + + +@teams.activity("installationUpdate") +async def on_installation_update(context: TeamsTurnContext, state: TurnState) -> None: + conv = context.activity.conversation + if conv and conv.conversation_type == "channel": + name = conv.name or "this channel" + await context.send_activity( + "Welcome to Microsoft Teams conversationUpdate events demo. " + f"This agent is configured in {name}" + ) + else: + await context.send_activity( + "Welcome to Microsoft Teams conversationUpdate events demo." + ) + + +# ── Member lifecycle ───────────────────────────────────────────────────────── + + +@teams.conversation_update("membersAdded") +async def on_members_added(context: TeamsTurnContext, state: TurnState) -> None: + conv = context.activity.conversation + recipient_id = context.activity.recipient.id if context.activity.recipient else None + for member in context.activity.members_added or []: + if member.id != recipient_id and ( + not conv or conv.conversation_type != "personal" + ): + await context.send_activity( + MessageFactory.text(f"Welcome to the team {member.name}.") + ) + + +@teams.conversation_update("membersRemoved") +async def on_members_removed(context: TeamsTurnContext, state: TurnState) -> None: + channel_data = context.activity.channel_data + team_name = "the team" + if ( + isinstance(channel_data, ChannelData) + and channel_data.team + and channel_data.team.name + ): + team_name = channel_data.team.name + + recipient_id = context.activity.recipient.id if context.activity.recipient else None + for member in context.activity.members_removed or []: + if member.id == recipient_id: + # The agent itself was removed — clear any cached team data here. + continue + card = HeroCard(text=f"{member.name} was removed from {team_name}") + await context.send_activity( + MessageFactory.attachment(CardFactory.hero_card(card)) + ) + + +# ── Channel events ─────────────────────────────────────────────────────────── + + +@teams.channels.created +async def on_channel_created( + context: TeamsTurnContext, state: TurnState, channel_info: ChannelInfo +) -> None: + name = channel_info.name or "Unknown" + card = HeroCard(text=f"{name} is the Channel created") + await context.send_activity(MessageFactory.attachment(CardFactory.hero_card(card))) + + +@teams.channels.renamed +async def on_channel_renamed( + context: TeamsTurnContext, state: TurnState, channel_info: ChannelInfo +) -> None: + name = channel_info.name or "Unknown" + card = HeroCard(text=f"{name} is the new Channel name") + await context.send_activity(MessageFactory.attachment(CardFactory.hero_card(card))) + + +@teams.channels.deleted +async def on_channel_deleted( + context: TeamsTurnContext, state: TurnState, channel_info: ChannelInfo +) -> None: + name = channel_info.name or "Unknown" + card = HeroCard(text=f"{name} is the Channel deleted") + await context.send_activity(MessageFactory.attachment(CardFactory.hero_card(card))) + + +# ── Team events ────────────────────────────────────────────────────────────── + + +@teams.teams.renamed +async def on_team_renamed( + context: TeamsTurnContext, state: TurnState, team_info: TeamInfo +) -> None: + name = team_info.name or "Unknown" + card = HeroCard(text=f"{name} is the new Team name") + await context.send_activity(MessageFactory.attachment(CardFactory.hero_card(card))) + + +# ── Message commands ───────────────────────────────────────────────────────── + + +@teams.message("targeted") +async def on_targeted(context: TeamsTurnContext, state: TurnState) -> None: + """Send a 1:1 message to every member of the current conversation.""" + app_id = _app_id(context) + audience = _audience(context) + continuation_token: Optional[str] = None + while True: + paged = await teams.get_teams_api_client( + context + ).conversations.members(context.activity.conversation.id).get_paged( + 100, continuation_token + ) + for member in paged.members or []: + + async def _send(ctx: TurnContext, _name=member.name) -> None: + await ctx.send_activity( + f"{_name}, this is a **targeted message** — only you can see this." + ) + + await _create_one_on_one(context, app_id, audience, member, _send) + + continuation_token = paged.continuation_token + if not continuation_token: + break + + +@teams.message("update") +async def on_update_card(context: TeamsTurnContext, state: TurnState) -> None: + """Update the card that triggered this message with an incremented counter.""" + value = context.activity.value + if isinstance(value, str): + try: + value = json.loads(value) + except (json.JSONDecodeError, TypeError): + value = {} + count = (int(value.get("count") or 0) + 1) if isinstance(value, dict) else 1 + + card = _welcome_card("I've been updated", count=count) + card.text = f"Update count - {count}" + + activity = MessageFactory.attachment(CardFactory.hero_card(card)) + activity.id = context.activity.reply_to_id + await context.update_activity(activity) + + +@teams.message("whoami") +async def on_who_am_i(context: TeamsTurnContext, state: TurnState) -> None: + """Fetch and report the caller's Teams member profile.""" + member = await _get_member(context, context.activity.from_property.id) + if member is None: + await context.send_activity("Member not found.") + return + await context.send_activity(f"You are: {member.name}.") + + +@teams.message("delete") +async def on_delete_card(context: TeamsTurnContext, state: TurnState) -> None: + await context.delete_activity(context.activity.reply_to_id) + + +@teams.message("messageall") +async def on_message_all(context: TeamsTurnContext, state: TurnState) -> None: + """Proactively send a 1:1 greeting to every member of the team.""" + app_id = _app_id(context) + audience = _audience(context) + continuation_token: Optional[str] = None + while True: + paged = await teams.get_teams_api_client( + context + ).conversations.members(context.activity.conversation.id).get_paged(100, continuation_token) + for member in paged.members or []: + + async def _greet(ctx: TurnContext, _name=member.name) -> None: + await ctx.send_activity(f"Hello {_name}. I'm a Teams agent.") + + await _create_one_on_one(context, app_id, audience, member, _greet) + + continuation_token = paged.continuation_token + if not continuation_token: + break + + await context.send_activity("All messages have been sent.") + + +@teams.message("mentionme") +async def on_mention_me(context: TeamsTurnContext, state: TurnState) -> None: + """Reply with an Adaptive Card that @-mentions the caller (UPN and AAD).""" + member = await _get_member(context, context.activity.from_property.id) + if member is None: + await context.send_activity("Member not found.") + return + + card = { + "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", + "type": "AdaptiveCard", + "version": "1.5", + "body": [ + { + "type": "TextBlock", + "text": f"Mention by UPN: Hello {member.name} UPN", + }, + { + "type": "TextBlock", + "text": f"Mention by AAD Object Id: Hello {member.name} AAD", + }, + ], + "msteams": { + "entities": [ + { + "type": "mention", + "text": f"{member.name} UPN", + "mentioned": {"id": member.id, "name": member.name}, + }, + { + "type": "mention", + "text": f"{member.name} AAD", + "mentioned": {"id": member.aad_object_id, "name": member.name}, + }, + ] + }, + } + await context.send_activity( + MessageFactory.attachment(CardFactory.adaptive_card(card)) + ) + + +@teams.message("atmention") +async def on_at_mention(context: TeamsTurnContext, state: TurnState) -> None: + """Reply with a text message that @-mentions the caller.""" + from_account = context.activity.from_property + mention = Mention(mentioned=from_account, text=f"{from_account.name}") + reply = MessageFactory.text(f"Hello {mention.text}.") + reply.entities = [mention] + await context.send_activity(reply) + + +# ── Default message — send the welcome card ────────────────────────────────── + + +@teams.activity("message") +async def on_message(context: TeamsTurnContext, state: TurnState) -> None: + await context.send_activity( + MessageFactory.attachment(CardFactory.hero_card(_welcome_card("Welcome!"))) + ) diff --git a/test_samples/hosting_msteams/conversation-agent/src/main.py b/test_samples/hosting_msteams/conversation-agent/src/main.py new file mode 100644 index 000000000..d2c005a4c --- /dev/null +++ b/test_samples/hosting_msteams/conversation-agent/src/main.py @@ -0,0 +1,11 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from .agent import AGENT_APP, CONNECTION_MANAGER +from .start_server import start_server + +if __name__ == "__main__": + start_server( + agent_application=AGENT_APP, + auth_configuration=CONNECTION_MANAGER.get_default_connection_configuration(), + ) diff --git a/test_samples/hosting_msteams/conversation-agent/src/start_server.py b/test_samples/hosting_msteams/conversation-agent/src/start_server.py new file mode 100644 index 000000000..97792f47d --- /dev/null +++ b/test_samples/hosting_msteams/conversation-agent/src/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 + + +def start_server( + agent_application: AgentApplication, + auth_configuration, +) -> None: + async def entry_point(req: Request) -> Response: + agent: AgentApplication = req.app["agent_app"] + adapter: CloudAdapter = req.app["adapter"] + return await start_agent_process(req, agent, adapter) + + app = Application(middlewares=[jwt_authorization_middleware]) + app.router.add_post("/api/messages", entry_point) + app.router.add_get("/api/messages", lambda _: Response(status=200)) + app["agent_configuration"] = auth_configuration + app["agent_app"] = agent_application + app["adapter"] = agent_application.adapter + + run_app(app, host="localhost", port=int(environ.get("PORT", 3978))) diff --git a/test_samples/hosting_msteams/message-extensions/README.md b/test_samples/hosting_msteams/message-extensions/README.md new file mode 100644 index 000000000..43cca06d4 --- /dev/null +++ b/test_samples/hosting_msteams/message-extensions/README.md @@ -0,0 +1,44 @@ +# Teams Message Extensions Sample + +Python port of the .NET `MessageExtensions` sample +(`src/samples/Teams/MessageExtensions` in microsoft/Agents-for-net), built on +`AgentApplication` + `TeamsAgentExtension`. + +## What it demonstrates + +| Command | Decorator | Behaviour | +|---------|-----------|-----------| +| `searchQuery` | `@teams.message_extensions.query("searchQuery")` | Search command. On `initialRun` shows a hint; otherwise returns 5 Adaptive Card results, each with a tappable thumbnail preview. | +| select item | `@teams.message_extensions.select_item` | Handles a tap on a search result preview and returns a detail card. | +| `createCard` | `@teams.message_extensions.submit_action("createCard")` | Action command that builds a card from a submitted title/description. | +| link unfurling | `@teams.message_extensions.query_link` | Returns a preview card for a pasted link. | +| settings url | `@teams.message_extensions.query_setting_url` | Returns a config result pointing at `SETTINGS_URL`. | +| settings saved | `@teams.message_extensions.setting` | Handles applied settings (no-op on `CancelledByUser`). | +| fetch task | `@teams.message_extensions.fetch_action()` | Returns a "not implemented" task module dialog. | +| message | `@teams.activity("message")` | Echoes text and explains how to use the extension. | + +## Environment variables + +| Variable | Required | Default | Description | +|----------|----------|---------|-------------| +| `CONNECTIONS__SERVICE_CONNECTION__SETTINGS__CLIENTID` | Yes | — | Azure Bot / Entra app registration client ID | +| `CONNECTIONS__SERVICE_CONNECTION__SETTINGS__CLIENTSECRET` | Yes | — | Client secret for the above registration | +| `CONNECTIONS__SERVICE_CONNECTION__SETTINGS__TENANTID` | Yes | — | Entra tenant ID | +| `SETTINGS_URL` | No | `http://localhost:3978/settings` | Public URL of the settings page served by this agent. Teams loads this URL in the message-extension configuration pane, so it must be reachable from the internet — use your dev tunnel URL (e.g. `https://my-tunnel.devtunnels.ms/settings`). | + +## Running + +1. Copy `env.TEMPLATE` to `.env` and fill in your Azure Bot registration + (`CLIENTID`, `CLIENTSECRET`, `TENANTID`). +2. If you want the **settings** command to work, set `SETTINGS_URL` to the + `/settings` path on your dev tunnel (e.g. + `SETTINGS_URL=https://my-tunnel.devtunnels.ms/settings`). +3. Install the SDK libraries (see the repository `README.md`). +4. Start the agent: + + ```bash + python -m src.main + ``` + +The server listens on `http://localhost:3978/api/messages`. Expose it with a +dev tunnel and side-load the app manifest into Teams to exercise the commands. diff --git a/test_samples/hosting_msteams/message-extensions/env.TEMPLATE b/test_samples/hosting_msteams/message-extensions/env.TEMPLATE new file mode 100644 index 000000000..21e33d342 --- /dev/null +++ b/test_samples/hosting_msteams/message-extensions/env.TEMPLATE @@ -0,0 +1,5 @@ +CONNECTIONS__SERVICE_CONNECTION__SETTINGS__CLIENTID= +CONNECTIONS__SERVICE_CONNECTION__SETTINGS__CLIENTSECRET= +CONNECTIONS__SERVICE_CONNECTION__SETTINGS__TENANTID= + +SETTINGS_URL \ No newline at end of file diff --git a/test_samples/hosting_msteams/message-extensions/pyproject.toml b/test_samples/hosting_msteams/message-extensions/pyproject.toml new file mode 100644 index 000000000..b01578412 --- /dev/null +++ b/test_samples/hosting_msteams/message-extensions/pyproject.toml @@ -0,0 +1,20 @@ +[build-system] +requires = ["setuptools"] +build-backend = "setuptools.build_meta" + +[project] +name = "message-extensions" +version = "0.1.0" +description = "Teams Message Extensions sample — search/action commands, link unfurling, settings, and fetch task" +authors = [{name = "Microsoft Corporation"}] +license = "MIT" +requires-python = ">=3.11" +dependencies = [ + "microsoft-agents-activity", + "microsoft-agents-hosting-core", + "microsoft-agents-authentication-msal", + "microsoft-agents-hosting-aiohttp", + "microsoft-agents-hosting-msteams", + "python-dotenv", + "aiohttp", +] diff --git a/test_samples/hosting_msteams/message-extensions/src/__init__.py b/test_samples/hosting_msteams/message-extensions/src/__init__.py new file mode 100644 index 000000000..5b7f7a925 --- /dev/null +++ b/test_samples/hosting_msteams/message-extensions/src/__init__.py @@ -0,0 +1,2 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. diff --git a/test_samples/hosting_msteams/message-extensions/src/agent.py b/test_samples/hosting_msteams/message-extensions/src/agent.py new file mode 100644 index 000000000..2d7f9c33f --- /dev/null +++ b/test_samples/hosting_msteams/message-extensions/src/agent.py @@ -0,0 +1,300 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""Teams Message Extensions agent — Python port of the .NET MessageExtensions sample. + +Demonstrates the composeExtension (message extension) surface: search-based +queries, item selection, action commands, link unfurling, settings, and a +fetch-task command. Mirrors src/samples/Teams/MessageExtensions from the +microsoft/Agents-for-net repository. +""" + +import logging +from os import environ + +from dotenv import load_dotenv + +from microsoft_teams.api.models import ( + AppBasedLinkQuery, + CardAction, + MessagingExtensionAction, + MessagingExtensionActionResponse, + MessagingExtensionAttachment, + MessagingExtensionAttachmentLayout, + MessagingExtensionQuery, + MessagingExtensionResponse, + MessagingExtensionResult, + MessagingExtensionResultType, + MessagingExtensionSuggestedAction, +) + +from microsoft_agents.activity import 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, + TurnState, +) +from microsoft_agents.hosting.core.storage import ( + ConsoleTranscriptLogger, + TranscriptLoggerMiddleware, +) +from microsoft_agents.hosting.msteams import TeamsAgentExtension +from microsoft_agents.hosting.msteams.teams_turn_context import TeamsTurnContext + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) +load_dotenv() + +agents_sdk_config = load_configuration_from_env(environ) + +# URL exposed by the agent (dev tunnel / public host) used by the settings command. +SETTINGS_URL = environ.get("SETTINGS_URL", "http://localhost:3978/settings") + +STORAGE = MemoryStorage() +CONNECTION_MANAGER = MsalConnectionManager(**agents_sdk_config) +ADAPTER = CloudAdapter(connection_manager=CONNECTION_MANAGER) +ADAPTER.use(TranscriptLoggerMiddleware(ConsoleTranscriptLogger())) +AUTHORIZATION = Authorization(STORAGE, CONNECTION_MANAGER, **agents_sdk_config) + +AGENT_APP = AgentApplication[TurnState]( + storage=STORAGE, + adapter=ADAPTER, + authorization=AUTHORIZATION, + **agents_sdk_config, +) + +teams = TeamsAgentExtension[TurnState](AGENT_APP) + +_ADAPTIVE_CONTENT_TYPE = "application/vnd.microsoft.card.adaptive" +_THUMBNAIL_CONTENT_TYPE = "application/vnd.microsoft.card.thumbnail" + + +def _adaptive_card(title: str, body_text: str) -> dict: + """Build a minimal Adaptive Card with a bold title and a wrapped body line.""" + return { + "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", + "type": "AdaptiveCard", + "version": "1.5", + "body": [ + {"type": "TextBlock", "text": title, "weight": "Bolder", "size": "Large"}, + {"type": "TextBlock", "text": body_text, "wrap": True, "isSubtle": True}, + ], + } + + +def _thumbnail(title: str, text: str, tap_value: dict) -> dict: + """Build a thumbnail-card preview whose tap fires a selectItem invoke.""" + return { + "title": title, + "text": text, + "tap": {"type": "invoke", "value": tap_value}, + } + + +def _list_result( + *attachments: MessagingExtensionAttachment, +) -> MessagingExtensionResponse: + """Wrap attachments in a list-layout composeExtension result.""" + return MessagingExtensionResponse( + compose_extension=MessagingExtensionResult( + type=MessagingExtensionResultType.RESULT, + attachment_layout=MessagingExtensionAttachmentLayout.LIST, + attachments=list(attachments), + ) + ) + + +# ── Default message — usage hint ───────────────────────────────────────────── + + +@teams.activity("message") +async def on_message(context: TeamsTurnContext, state: TurnState) -> None: + await context.send_activity( + f"Echo: {context.activity.text}\n\n" + "This is a message extension sample. Use the message extension commands " + "in Teams to test the functionality." + ) + + +# ── composeExtension/query (search command) ────────────────────────────────── + + +@teams.message_extensions.query("searchQuery") +async def on_search_query( + context: TeamsTurnContext, state: TurnState, query: MessagingExtensionQuery +) -> MessagingExtensionResponse: + params = {p.name: p.value for p in (query.parameters or [])} + + if str(params.get("initialRun", "")).lower() == "true": + return MessagingExtensionResponse( + compose_extension=MessagingExtensionResult( + type=MessagingExtensionResultType.MESSAGE, + text="Enter a search query to see results.", + ) + ) + + search_text = str(params.get("searchQuery", "") or "") + logger.info("Search query received: %s", search_text) + + attachments = [] + for i in range(1, 6): + card = _adaptive_card( + f"Search Result {i}", + f"Query: '{search_text}' — result description for item {i}.", + ) + preview = _thumbnail( + title=f"Result {i}", + text=f"Preview of result {i} for query '{search_text}'.", + tap_value={"index": str(i), "query": search_text}, + ) + attachments.append( + MessagingExtensionAttachment( + content_type=_ADAPTIVE_CONTENT_TYPE, + content=card, + preview=MessagingExtensionAttachment( + content_type=_THUMBNAIL_CONTENT_TYPE, + content=preview, + ), + ) + ) + + return _list_result(*attachments) + + +# ── composeExtension/selectItem (tap on a search result preview) ───────────── + + +@teams.message_extensions.select_item +async def on_select_item( + context: TeamsTurnContext, state: TurnState, item +) -> MessagingExtensionResponse: + item = item or {} + index = item.get("index", "No Index") + query = item.get("query", "No Query") + logger.info("Item selected: index=%s query=%s", index, query) + + card = _adaptive_card( + "Item Selected", + f"You selected item {index} for query '{query}'.", + ) + return _list_result( + MessagingExtensionAttachment(content_type=_ADAPTIVE_CONTENT_TYPE, content=card) + ) + + +# ── composeExtension/submitAction ("createCard") ───────────────────────────── + + +@teams.message_extensions.submit_action("createCard") +async def on_create_card( + context: TeamsTurnContext, state: TurnState, action: MessagingExtensionAction +) -> MessagingExtensionResponse: + data = action.data if isinstance(action.data, dict) else {} + title = data.get("title") or "Default Title" + description = data.get("description") or "Default Description" + logger.info("Creating card: title=%s description=%s", title, description) + + card = _adaptive_card(title, description) + return _list_result( + MessagingExtensionAttachment(content_type=_ADAPTIVE_CONTENT_TYPE, content=card) + ) + + +# ── composeExtension/queryLink (link unfurling) ────────────────────────────── + + +@teams.message_extensions.query_link +async def on_query_link( + context: TeamsTurnContext, state: TurnState, query: AppBasedLinkQuery +) -> MessagingExtensionResponse: + url = query.url or "" + logger.info("Link query: %s", url) + + if not url: + return MessagingExtensionResponse( + compose_extension=MessagingExtensionResult( + type=MessagingExtensionResultType.MESSAGE, + text="No URL provided.", + ) + ) + + card = _adaptive_card("Link Preview", f"URL: {url}") + return _list_result( + MessagingExtensionAttachment( + content_type=_ADAPTIVE_CONTENT_TYPE, + content=card, + preview=MessagingExtensionAttachment( + content_type=_THUMBNAIL_CONTENT_TYPE, + content=_thumbnail("Link Preview", url, {"url": url}), + ), + ) + ) + + +# ── composeExtension/querySettingUrl ───────────────────────────────────────── + + +@teams.message_extensions.query_setting_url +async def on_query_settings_url( + context: TeamsTurnContext, state: TurnState, query: MessagingExtensionQuery +) -> MessagingExtensionResponse: + logger.info("Settings URL requested") + return MessagingExtensionResponse( + compose_extension=MessagingExtensionResult( + type=MessagingExtensionResultType.CONFIG, + suggested_actions=MessagingExtensionSuggestedAction( + actions=[ + CardAction( + type="openUrl", + title="Configure", + value=SETTINGS_URL, + ) + ] + ), + ) + ) + + +# ── composeExtension/setting (settings applied) ────────────────────────────── + + +@teams.message_extensions.setting +async def on_configure_settings( + context: TeamsTurnContext, state: TurnState, settings: MessagingExtensionQuery +) -> None: + if settings.state == "CancelledByUser": + return + logger.info("Settings saved: %s", settings.state) + + +# ── composeExtension/fetchTask ─────────────────────────────────────────────── + + +@teams.message_extensions.fetch_action() +async def on_fetch_task( + context: TeamsTurnContext, state: TurnState, action: MessagingExtensionAction +) -> MessagingExtensionActionResponse: + logger.info("FetchTask: command=%s", action.command_id) + card = _adaptive_card( + "Conversation Members", + "Conversation Members is not implemented in this sample.", + ) + return MessagingExtensionActionResponse.model_validate( + { + "task": { + "type": "continue", + "value": { + "title": "Conversation Members", + "height": "small", + "width": "small", + "card": { + "contentType": _ADAPTIVE_CONTENT_TYPE, + "content": card, + }, + }, + } + } + ) diff --git a/test_samples/hosting_msteams/message-extensions/src/main.py b/test_samples/hosting_msteams/message-extensions/src/main.py new file mode 100644 index 000000000..d2c005a4c --- /dev/null +++ b/test_samples/hosting_msteams/message-extensions/src/main.py @@ -0,0 +1,11 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from .agent import AGENT_APP, CONNECTION_MANAGER +from .start_server import start_server + +if __name__ == "__main__": + start_server( + agent_application=AGENT_APP, + auth_configuration=CONNECTION_MANAGER.get_default_connection_configuration(), + ) diff --git a/test_samples/hosting_msteams/message-extensions/src/start_server.py b/test_samples/hosting_msteams/message-extensions/src/start_server.py new file mode 100644 index 000000000..39b9f9039 --- /dev/null +++ b/test_samples/hosting_msteams/message-extensions/src/start_server.py @@ -0,0 +1,43 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from os import environ +from pathlib import Path + +from aiohttp.web import Application, FileResponse, Request, Response, run_app + +from microsoft_agents.hosting.aiohttp import ( + CloudAdapter, + jwt_authorization_decorator, + start_agent_process, +) +from microsoft_agents.hosting.core import AgentApplication + +_STATIC_DIR = Path(__file__).parent.parent / "static" + + +def start_server( + agent_application: AgentApplication, + auth_configuration, +) -> None: + @jwt_authorization_decorator + async def entry_point(req: Request): + agent: AgentApplication = req.app["agent_app"] + adapter: CloudAdapter = req.app["adapter"] + return await start_agent_process(req, agent, adapter) + + async def serve_settings(_: Request) -> FileResponse: + return FileResponse(_STATIC_DIR / "settings.html") + + async def health_check(_: Request) -> Response: + return Response(status=200) + + app = Application() + app.router.add_post("/api/messages", entry_point) + app.router.add_get("/api/messages", health_check) + app.router.add_get("/settings", serve_settings) + app["agent_configuration"] = auth_configuration + app["agent_app"] = agent_application + app["adapter"] = agent_application.adapter + + run_app(app, host="localhost", port=int(environ.get("PORT", 3978))) diff --git a/test_samples/hosting_msteams/message-extensions/static/settings.html b/test_samples/hosting_msteams/message-extensions/static/settings.html new file mode 100644 index 000000000..c4f0aaaa4 --- /dev/null +++ b/test_samples/hosting_msteams/message-extensions/static/settings.html @@ -0,0 +1,118 @@ + + + + Message Extension Settings + + + + + + + +
+

Message Extension Settings

+
+
+ + +
+ +
+ + +
+ +
+ + +
+
+
+ + + + diff --git a/test_samples/hosting_msteams/task-modules/README.md b/test_samples/hosting_msteams/task-modules/README.md new file mode 100644 index 000000000..1c2088e64 --- /dev/null +++ b/test_samples/hosting_msteams/task-modules/README.md @@ -0,0 +1,55 @@ +# Teams Task Modules Sample + +Python port of the .NET `TaskModules` sample +(`src/samples/Teams/TaskModules` in microsoft/Agents-for-net), built on +`AgentApplication` + `TeamsAgentExtension`. + +## What it demonstrates + +Sending the agent any message returns a launcher Adaptive Card with four +buttons. Each button opens a task module (dialog) via a `task/fetch` invoke: + +| Verb | Fetch | Submit | +|------|-------|--------| +| `simple_form` | Adaptive Card form (`simple-form-card.json`) | greets the name and closes | +| `webpage_dialog` | hosted webpage at `{APP_BASE_URL}/dialog-form` | greets name + email | +| `multi_step_form` | name card → `multi_step_form_submit_name` | email card → `multi_step_form_submit_email` → greets and closes | +| `mixed_example` | deep-link task module (`https://teams.microsoft.com/l/task/example-mixed`) | — | + +Card definitions live in `src/resources/` and are loaded (with `{{token}}` +substitution) by `src/card_loader.py`, mirroring the .NET `CardLoader`. + +> **Note:** task module verbs are read from `value.data.verb`, so the card +> `Action.Submit` payloads use a `verb` key (the .NET sample uses `task`). + +## Webpage dialog + +The `/dialog-form` route serves `src/resources/dialog-form.html`. It is exempt +from JWT authorization because Teams loads it in an iframe without a bearer +token. The page calls `microsoftTeams.dialog.url.submit(...)` which Teams +delivers back as a `task/submit` invoke with verb `webpage_dialog`. + +## Environment variables + +| Variable | Required | Default | Description | +|----------|----------|---------|-------------| +| `CONNECTIONS__SERVICE_CONNECTION__SETTINGS__CLIENTID` | Yes | — | Azure Bot / Entra app registration client ID | +| `CONNECTIONS__SERVICE_CONNECTION__SETTINGS__CLIENTSECRET` | Yes | — | Client secret for the above registration | +| `CONNECTIONS__SERVICE_CONNECTION__SETTINGS__TENANTID` | Yes | — | Entra tenant ID | +| `APP_BASE_URL` | No | `http://localhost:3978` | Public base URL of this agent. Used to build the webpage dialog URL (`{APP_BASE_URL}/dialog-form`). Teams loads that URL in an iframe, so it must be reachable from the internet — use your dev tunnel URL (e.g. `https://my-tunnel.devtunnels.ms`). | + +## Running + +1. Copy `env.TEMPLATE` to `.env` and fill in your Azure Bot registration + (`CLIENTID`, `CLIENTSECRET`, `TENANTID`). +2. Set `APP_BASE_URL` to your dev tunnel URL (e.g. + `APP_BASE_URL=https://my-tunnel.devtunnels.ms`) so Teams can load the + **Webpage Dialog** task module in an iframe. +3. Install the SDK libraries (see the repository `README.md`). +4. Start the agent: + + ```bash + python -m src.main + ``` + +The server listens on `http://localhost:3978/api/messages`. diff --git a/test_samples/hosting_msteams/task-modules/env.TEMPLATE b/test_samples/hosting_msteams/task-modules/env.TEMPLATE new file mode 100644 index 000000000..62963754c --- /dev/null +++ b/test_samples/hosting_msteams/task-modules/env.TEMPLATE @@ -0,0 +1,7 @@ +CONNECTIONS__SERVICE_CONNECTION__SETTINGS__CLIENTID= +CONNECTIONS__SERVICE_CONNECTION__SETTINGS__CLIENTSECRET= +CONNECTIONS__SERVICE_CONNECTION__SETTINGS__TENANTID= + +# Public base URL of this agent (e.g. your dev tunnel). Used to build the +# "Webpage Dialog" task module URL ({APP_BASE_URL}/dialog-form). +APP_BASE_URL=http://localhost:3978 diff --git a/test_samples/hosting_msteams/task-modules/pyproject.toml b/test_samples/hosting_msteams/task-modules/pyproject.toml new file mode 100644 index 000000000..1e815dfb7 --- /dev/null +++ b/test_samples/hosting_msteams/task-modules/pyproject.toml @@ -0,0 +1,20 @@ +[build-system] +requires = ["setuptools"] +build-backend = "setuptools.build_meta" + +[project] +name = "task-modules" +version = "0.1.0" +description = "Teams Task Modules sample — card dialog, webpage dialog, multi-step form, and deep-link task module" +authors = [{name = "Microsoft Corporation"}] +license = "MIT" +requires-python = ">=3.11" +dependencies = [ + "microsoft-agents-activity", + "microsoft-agents-hosting-core", + "microsoft-agents-authentication-msal", + "microsoft-agents-hosting-aiohttp", + "microsoft-agents-hosting-msteams", + "python-dotenv", + "aiohttp", +] diff --git a/test_samples/hosting_msteams/task-modules/src/__init__.py b/test_samples/hosting_msteams/task-modules/src/__init__.py new file mode 100644 index 000000000..5b7f7a925 --- /dev/null +++ b/test_samples/hosting_msteams/task-modules/src/__init__.py @@ -0,0 +1,2 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. diff --git a/test_samples/hosting_msteams/task-modules/src/agent.py b/test_samples/hosting_msteams/task-modules/src/agent.py new file mode 100644 index 000000000..c4288035a --- /dev/null +++ b/test_samples/hosting_msteams/task-modules/src/agent.py @@ -0,0 +1,224 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""Teams Task Modules agent — Python port of the .NET TaskModules sample. + +Demonstrates the task module (dialog) surface through ``task/fetch`` and +``task/submit`` invokes: an Adaptive Card form, a webpage dialog, a multi-step +form, and a deep-link "mixed" example. Mirrors src/samples/Teams/TaskModules +from the microsoft/Agents-for-net repository. +""" + +import logging +from os import environ + +from dotenv import load_dotenv + +from microsoft_teams.api.models import TaskModuleRequest, TaskModuleResponse + +from microsoft_agents.activity import Attachment, 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, + MessageFactory, + TurnState, +) +from microsoft_agents.hosting.core.storage import ( + ConsoleTranscriptLogger, + TranscriptLoggerMiddleware, +) +from microsoft_agents.hosting.msteams import TeamsAgentExtension +from microsoft_agents.hosting.msteams.teams_turn_context import TeamsTurnContext + +from .card_loader import load_card_json + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) +load_dotenv() + +agents_sdk_config = load_configuration_from_env(environ) + +# Public base URL of this agent (dev tunnel / host) used for the webpage dialog. +APP_BASE_URL = environ.get("APP_BASE_URL", "http://localhost:3978") + +STORAGE = MemoryStorage() +CONNECTION_MANAGER = MsalConnectionManager(**agents_sdk_config) +ADAPTER = CloudAdapter(connection_manager=CONNECTION_MANAGER) +ADAPTER.use(TranscriptLoggerMiddleware(ConsoleTranscriptLogger())) +AUTHORIZATION = Authorization(STORAGE, CONNECTION_MANAGER, **agents_sdk_config) + +AGENT_APP = AgentApplication[TurnState]( + storage=STORAGE, + adapter=ADAPTER, + authorization=AUTHORIZATION, + **agents_sdk_config, +) + +teams = TeamsAgentExtension[TurnState](AGENT_APP) + +_ADAPTIVE_CONTENT_TYPE = "application/vnd.microsoft.card.adaptive" + + +def _continue_card_response( + title: str, card_content: dict, *, height: str = "small", width: str = "small" +) -> TaskModuleResponse: + """Build a ``continue`` task response that renders an Adaptive Card dialog.""" + return TaskModuleResponse.model_validate( + { + "task": { + "type": "continue", + "value": { + "title": title, + "height": height, + "width": width, + "card": { + "contentType": _ADAPTIVE_CONTENT_TYPE, + "content": card_content, + }, + }, + } + } + ) + + +def _continue_url_response( + title: str, url: str, *, height: int = 500, width: int = 800 +) -> TaskModuleResponse: + """Build a ``continue`` task response that renders a hosted webpage dialog.""" + return TaskModuleResponse.model_validate( + { + "task": { + "type": "continue", + "value": { + "title": title, + "height": height, + "width": width, + "url": url, + "fallbackUrl": url, + }, + } + } + ) + + +def _message_response(text: str) -> TaskModuleResponse: + """Build a ``message`` task response that closes the dialog with a toast.""" + return TaskModuleResponse.model_validate( + {"task": {"type": "message", "value": text}} + ) + + +def _launcher_card() -> Attachment: + """Welcome card whose buttons launch each task module via task/fetch.""" + return Attachment( + content_type=_ADAPTIVE_CONTENT_TYPE, + content=load_card_json("launcher-card.json"), + ) + + +# ── Default message — send the launcher card ───────────────────────────────── + + +@teams.activity("message") +async def on_message(context: TeamsTurnContext, state: TurnState) -> None: + await context.send_activity(MessageFactory.attachment(_launcher_card())) + + +# ── Simple Form ────────────────────────────────────────────────────────────── + + +@teams.task_modules.fetch("simple_form") +async def on_simple_form_fetch( + context: TeamsTurnContext, state: TurnState, request: TaskModuleRequest +) -> TaskModuleResponse: + return _continue_card_response( + "Simple Form", load_card_json("simple-form-card.json") + ) + + +@teams.task_modules.submit("simple_form") +async def on_simple_form_submit( + context: TeamsTurnContext, state: TurnState, request: TaskModuleRequest +) -> TaskModuleResponse: + data = request.data if isinstance(request.data, dict) else {} + name = data.get("name", "Unknown") + await context.send_activity(f"Hi {name}, thanks for submitting the form!") + return _message_response("Form was submitted") + + +# ── Webpage Dialog ─────────────────────────────────────────────────────────── + + +@teams.task_modules.fetch("webpage_dialog") +async def on_webpage_dialog_fetch( + context: TeamsTurnContext, state: TurnState, request: TaskModuleRequest +) -> TaskModuleResponse: + return _continue_url_response("Webpage Dialog", f"{APP_BASE_URL}/dialog-form") + + +@teams.task_modules.submit("webpage_dialog") +async def on_webpage_dialog_submit( + context: TeamsTurnContext, state: TurnState, request: TaskModuleRequest +) -> TaskModuleResponse: + data = request.data if isinstance(request.data, dict) else {} + name = data.get("name", "Unknown") + email = data.get("email", "No email provided") + await context.send_activity( + f"Hi {name}, thanks for submitting the form! We got that your email is {email}" + ) + return _message_response("Form submitted successfully") + + +# ── Multi-Step Form ────────────────────────────────────────────────────────── + + +@teams.task_modules.fetch("multi_step_form") +async def on_multi_step_fetch( + context: TeamsTurnContext, state: TurnState, request: TaskModuleRequest +) -> TaskModuleResponse: + return _continue_card_response( + "Multi-step Form Dialog", load_card_json("multi-step-name-card.json") + ) + + +@teams.task_modules.submit("multi_step_form_submit_name") +async def on_multi_step_submit_name( + context: TeamsTurnContext, state: TurnState, request: TaskModuleRequest +) -> TaskModuleResponse: + data = request.data if isinstance(request.data, dict) else {} + name = data.get("name", "Unknown") + return _continue_card_response( + f"Thanks {name} - Get Email", + load_card_json("multi-step-email-card.json", tokens={"name": name}), + ) + + +@teams.task_modules.submit("multi_step_form_submit_email") +async def on_multi_step_submit_email( + context: TeamsTurnContext, state: TurnState, request: TaskModuleRequest +) -> TaskModuleResponse: + data = request.data if isinstance(request.data, dict) else {} + name = data.get("name", "Unknown") + email = data.get("email", "No email provided") + await context.send_activity( + f"Hi {name}, thanks for submitting the form! We got that your email is {email}" + ) + return _message_response("Multi-step form completed successfully") + + +# ── Mixed Example (deep-link task module) ──────────────────────────────────── + + +@teams.task_modules.fetch("mixed_example") +async def on_mixed_example_fetch( + context: TeamsTurnContext, state: TurnState, request: TaskModuleRequest +) -> TaskModuleResponse: + return _continue_url_response( + "Mixed Example", + "https://teams.microsoft.com/l/task/example-mixed", + height=600, + width=800, + ) diff --git a/test_samples/hosting_msteams/task-modules/src/card_loader.py b/test_samples/hosting_msteams/task-modules/src/card_loader.py new file mode 100644 index 000000000..92ed98de3 --- /dev/null +++ b/test_samples/hosting_msteams/task-modules/src/card_loader.py @@ -0,0 +1,43 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""Helpers for loading Adaptive Card JSON resources bundled with this sample. + +Mirrors the .NET ``CardLoader`` utility: it reads a card definition from the +``resources`` directory and optionally substitutes ``{{token}}`` placeholders +before parsing the JSON. +""" + +import json +from os import path +from typing import Optional + +_RESOURCES_DIR = path.join(path.dirname(__file__), "resources") + + +def load_card_json(file_name: str, tokens: Optional[dict[str, str]] = None) -> dict: + """Load a card definition from the resources directory. + + :param file_name: File name of the card JSON (e.g. ``"launcher-card.json"``). + :param tokens: Optional ``{{token}}`` replacements applied to the raw JSON + text before parsing. + :return: The parsed Adaptive Card as a dict. + """ + with open(path.join(_RESOURCES_DIR, file_name), encoding="utf-8") as handle: + raw = handle.read() + + if tokens: + for key, value in tokens.items(): + raw = raw.replace(f"{{{{{key}}}}}", value) + + return json.loads(raw) + + +def load_resource_text(file_name: str) -> str: + """Load a raw text resource (e.g. an HTML page) from the resources directory. + + :param file_name: File name of the resource. + :return: The file contents as text. + """ + with open(path.join(_RESOURCES_DIR, file_name), encoding="utf-8") as handle: + return handle.read() diff --git a/test_samples/hosting_msteams/task-modules/src/main.py b/test_samples/hosting_msteams/task-modules/src/main.py new file mode 100644 index 000000000..d2c005a4c --- /dev/null +++ b/test_samples/hosting_msteams/task-modules/src/main.py @@ -0,0 +1,11 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from .agent import AGENT_APP, CONNECTION_MANAGER +from .start_server import start_server + +if __name__ == "__main__": + start_server( + agent_application=AGENT_APP, + auth_configuration=CONNECTION_MANAGER.get_default_connection_configuration(), + ) diff --git a/test_samples/hosting_msteams/task-modules/src/resources/dialog-form.html b/test_samples/hosting_msteams/task-modules/src/resources/dialog-form.html new file mode 100644 index 000000000..c882778bd --- /dev/null +++ b/test_samples/hosting_msteams/task-modules/src/resources/dialog-form.html @@ -0,0 +1,44 @@ + + + + Teams Dialog Form + + + + + + + +
+

Webpage Dialog Form

+
+
+ + +
+
+ + +
+ +
+
+ + + diff --git a/test_samples/hosting_msteams/task-modules/src/resources/launcher-card.json b/test_samples/hosting_msteams/task-modules/src/resources/launcher-card.json new file mode 100644 index 000000000..d4d540452 --- /dev/null +++ b/test_samples/hosting_msteams/task-modules/src/resources/launcher-card.json @@ -0,0 +1,55 @@ +{ + "type": "AdaptiveCard", + "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", + "version": "1.5", + "body": [ + { + "type": "TextBlock", + "text": "Select the examples you want to see!", + "size": "Large", + "weight": "Bolder" + } + ], + "actions": [ + { + "type": "Action.Submit", + "title": "Simple Form", + "data": { + "msteams": { + "type": "task/fetch" + }, + "verb": "simple_form" + } + }, + { + "type": "Action.Submit", + "title": "Webpage Dialog", + "data": { + "msteams": { + "type": "task/fetch" + }, + "verb": "webpage_dialog" + } + }, + { + "type": "Action.Submit", + "title": "Multi-Step Form", + "data": { + "msteams": { + "type": "task/fetch" + }, + "verb": "multi_step_form" + } + }, + { + "type": "Action.Submit", + "title": "Mixed Example", + "data": { + "msteams": { + "type": "task/fetch" + }, + "verb": "mixed_example" + } + } + ] +} diff --git a/test_samples/hosting_msteams/task-modules/src/resources/multi-step-email-card.json b/test_samples/hosting_msteams/task-modules/src/resources/multi-step-email-card.json new file mode 100644 index 000000000..4a3c01658 --- /dev/null +++ b/test_samples/hosting_msteams/task-modules/src/resources/multi-step-email-card.json @@ -0,0 +1,30 @@ +{ + "type": "AdaptiveCard", + "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", + "version": "1.5", + "body": [ + { + "type": "TextBlock", + "text": "Email, {{name}}!", + "size": "Large", + "weight": "Bolder" + }, + { + "type": "Input.Text", + "id": "email", + "label": "Email", + "isRequired": true, + "placeholder": "Enter your email" + } + ], + "actions": [ + { + "type": "Action.Submit", + "title": "Submit", + "data": { + "verb": "multi_step_form_submit_email", + "name": "{{name}}" + } + } + ] +} diff --git a/test_samples/hosting_msteams/task-modules/src/resources/multi-step-name-card.json b/test_samples/hosting_msteams/task-modules/src/resources/multi-step-name-card.json new file mode 100644 index 000000000..cefe29b27 --- /dev/null +++ b/test_samples/hosting_msteams/task-modules/src/resources/multi-step-name-card.json @@ -0,0 +1,29 @@ +{ + "type": "AdaptiveCard", + "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", + "version": "1.5", + "body": [ + { + "type": "TextBlock", + "text": "This is a multi-step form", + "size": "Large", + "weight": "Bolder" + }, + { + "type": "Input.Text", + "id": "name", + "label": "Name", + "isRequired": true, + "placeholder": "Enter your name" + } + ], + "actions": [ + { + "type": "Action.Submit", + "title": "Submit", + "data": { + "verb": "multi_step_form_submit_name" + } + } + ] +} diff --git a/test_samples/hosting_msteams/task-modules/src/resources/simple-form-card.json b/test_samples/hosting_msteams/task-modules/src/resources/simple-form-card.json new file mode 100644 index 000000000..e453865f3 --- /dev/null +++ b/test_samples/hosting_msteams/task-modules/src/resources/simple-form-card.json @@ -0,0 +1,29 @@ +{ + "type": "AdaptiveCard", + "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", + "version": "1.5", + "body": [ + { + "type": "TextBlock", + "text": "Simple Form", + "size": "Large", + "weight": "Bolder" + }, + { + "type": "Input.Text", + "id": "name", + "label": "Your Name", + "isRequired": true, + "placeholder": "Enter your name" + } + ], + "actions": [ + { + "type": "Action.Submit", + "title": "Submit", + "data": { + "verb": "simple_form" + } + } + ] +} diff --git a/test_samples/hosting_msteams/task-modules/src/start_server.py b/test_samples/hosting_msteams/task-modules/src/start_server.py new file mode 100644 index 000000000..6d6202776 --- /dev/null +++ b/test_samples/hosting_msteams/task-modules/src/start_server.py @@ -0,0 +1,53 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from os import environ + +from aiohttp.web import Application, Request, Response, middleware, run_app + +from microsoft_agents.hosting.aiohttp import ( + CloudAdapter, + jwt_authorization_middleware, + start_agent_process, +) +from microsoft_agents.hosting.core import AgentApplication + +from .card_loader import load_resource_text + +# The webpage dialog is loaded by Teams in an iframe without a bearer token, so +# it must be reachable without JWT authorization. +_PUBLIC_PATHS = {"/dialog-form"} + + +@middleware +async def _auth_unless_public(request: Request, handler): + if request.path in _PUBLIC_PATHS: + return await handler(request) + return await jwt_authorization_middleware(request, handler) + + +def start_server( + agent_application: AgentApplication, + auth_configuration, +) -> None: + async def entry_point(req: Request) -> Response: + agent: AgentApplication = req.app["agent_app"] + adapter: CloudAdapter = req.app["adapter"] + return await start_agent_process(req, agent, adapter) + + async def dialog_form(_req: Request) -> Response: + return Response( + text=load_resource_text("dialog-form.html"), + content_type="text/html", + ) + + app = Application(middlewares=[_auth_unless_public]) + app.router.add_post("/api/messages", entry_point) + app.router.add_get("/api/messages", lambda _: Response(status=200)) + # Served as the URL content for the "Webpage Dialog" task module. + app.router.add_get("/dialog-form", dialog_form) + app["agent_configuration"] = auth_configuration + app["agent_app"] = agent_application + app["adapter"] = agent_application.adapter + + run_app(app, host="localhost", port=int(environ.get("PORT", 3978))) diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/teams_cloud_adapter.py b/tests/hosting_msteams/__init__.py similarity index 100% rename from libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/teams_cloud_adapter.py rename to tests/hosting_msteams/__init__.py diff --git a/tests/hosting_msteams/helpers.py b/tests/hosting_msteams/helpers.py new file mode 100644 index 000000000..0b5dbbff9 --- /dev/null +++ b/tests/hosting_msteams/helpers.py @@ -0,0 +1,90 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""Shared test helpers for microsoft-agents-hosting-teams tests.""" + +import sys +from typing import Any +from unittest.mock import AsyncMock, MagicMock + +from microsoft_agents.activity import Activity, ActivityTypes +from microsoft_agents.hosting.core import TurnContext +from microsoft_agents.hosting.core.app import AgentApplication, RouteRank + +is_supported_version = sys.version_info >= (3, 11) + +if is_supported_version: + from microsoft_agents.hosting.msteams.teams_turn_context import TeamsTurnContext + + +def _make_app() -> Any: + app = MagicMock(spec=AgentApplication) + app._routes = [] + + def _add_route( + selector, handler, is_invoke=False, rank=RouteRank.DEFAULT, auth_handlers=None + ): + app._routes.append( + dict( + selector=selector, + handler=handler, + is_invoke=is_invoke, + rank=rank, + auth_handlers=auth_handlers, + ) + ) + + app.add_route.side_effect = _add_route + return app + + +def _make_context( + activity_type: str, + name: str = None, + value=None, + channel_id: str = "msteams", + channel_data: dict = None, + members_added=None, + members_removed=None, +) -> TurnContext: + context = MagicMock(spec=TurnContext) + activity = MagicMock(spec=Activity) + activity.type = activity_type + activity.name = name + activity.value = value + activity.service_url = "https://smba.trafficmanager.net/teams/" + activity.channel_id = channel_id + activity.channel_data = channel_data + activity.members_added = members_added + activity.members_removed = members_removed + context.activity = activity + context.turn_state = {} + context.send_activity = AsyncMock() + + mock_adapter = MagicMock() + context.adapter = mock_adapter + context._responded = False + context._services = {} + context._on_send_activities = [] + context._on_update_activity = [] + context._on_delete_activity = [] + context.identity = MagicMock() + + def _copy_to(target): + target.adapter = mock_adapter + target._activity = activity + target._responded = False + target._services = {} + target._on_send_activities = [] + target._on_update_activity = [] + target._on_delete_activity = [] + + context.copy_to.side_effect = _copy_to + return context + + +def _make_teams_context() -> "TeamsTurnContext": + """Return a MagicMock shaped like a TeamsTurnContext for use in unit tests.""" + ctx = MagicMock(spec=TeamsTurnContext) + ctx.send_activity = AsyncMock() + return ctx diff --git a/tests/hosting_msteams/test_channels.py b/tests/hosting_msteams/test_channels.py new file mode 100644 index 000000000..b8e8b81b5 --- /dev/null +++ b/tests/hosting_msteams/test_channels.py @@ -0,0 +1,545 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""Tests for TeamsAgentExtension.channels (channel and membership conversation update events).""" + +import pytest +from unittest.mock import AsyncMock, MagicMock + +from microsoft_agents.activity import ActivityTypes + +from .helpers import _make_app, _make_context, is_supported_version + +pytestmark = pytest.mark.skipif( + not is_supported_version, + reason="microsoft-agents-hosting-teams tests require Python 3.11+", +) + +if is_supported_version: + from microsoft_teams.api.models.channel_data import ChannelInfo + + from microsoft_agents.hosting.msteams import TeamsAgentExtension + + +class TestChannelLifecycle: + + def setup_method(self): + self.app = _make_app() + self.ext = TeamsAgentExtension(self.app) + + def test_created_selector(self): + @self.ext.channels.created() + async def handler(ctx, state, data): ... + + selector = self.app._routes[0]["selector"] + assert ( + selector( + _make_context( + ActivityTypes.conversation_update, + channel_data={"eventType": "channelCreated"}, + ) + ) + is True + ) + assert ( + selector( + _make_context( + ActivityTypes.conversation_update, + channel_data={"eventType": "channelDeleted"}, + ) + ) + is False + ) + + def test_deleted_selector(self): + @self.ext.channels.deleted() + async def handler(ctx, state, data): ... + + selector = self.app._routes[0]["selector"] + assert ( + selector( + _make_context( + ActivityTypes.conversation_update, + channel_data={"eventType": "channelDeleted"}, + ) + ) + is True + ) + assert ( + selector( + _make_context( + ActivityTypes.conversation_update, + channel_data={"eventType": "channelCreated"}, + ) + ) + is False + ) + + def test_renamed_selector(self): + @self.ext.channels.renamed() + async def handler(ctx, state, data): ... + + selector = self.app._routes[0]["selector"] + assert ( + selector( + _make_context( + ActivityTypes.conversation_update, + channel_data={"eventType": "channelRenamed"}, + ) + ) + is True + ) + + def test_restored_selector(self): + @self.ext.channels.restored() + async def handler(context, state, data): ... + + selector = self.app._routes[0]["selector"] + assert ( + selector( + _make_context( + ActivityTypes.conversation_update, + channel_data={"eventType": "channelRestored"}, + ) + ) + is True + ) + assert ( + selector( + _make_context( + ActivityTypes.conversation_update, + channel_data={"eventType": "channelRenamed"}, + ) + ) + is False + ) + + def test_shared_selector(self): + @self.ext.channels.shared() + async def handler(context, state, data): ... + + selector = self.app._routes[0]["selector"] + assert ( + selector( + _make_context( + ActivityTypes.conversation_update, + channel_data={"eventType": "channelShared"}, + ) + ) + is True + ) + assert ( + selector( + _make_context( + ActivityTypes.conversation_update, + channel_data={"eventType": "channelUnshared"}, + ) + ) + is False + ) + + def test_unshared_selector(self): + @self.ext.channels.unshared() + async def handler(context, state, data): ... + + selector = self.app._routes[0]["selector"] + assert ( + selector( + _make_context( + ActivityTypes.conversation_update, + channel_data={"eventType": "channelUnshared"}, + ) + ) + is True + ) + assert ( + selector( + _make_context( + ActivityTypes.conversation_update, + channel_data={"eventType": "channelShared"}, + ) + ) + is False + ) + + def test_channel_event_requires_msteams_channel(self): + @self.ext.channels.created() + async def handler(ctx, state, data): ... + + selector = self.app._routes[0]["selector"] + assert ( + selector( + _make_context( + ActivityTypes.conversation_update, + channel_id="webchat", + channel_data={"eventType": "channelCreated"}, + ) + ) + is False + ) + + def test_channel_event_is_not_invoke(self): + @self.ext.channels.created() + async def handler(ctx, state, data): ... + + assert self.app._routes[0]["is_invoke"] is False + + @pytest.mark.asyncio + async def test_created_handler_passes_channel_info(self): + user_handler = AsyncMock() + + @self.ext.channels.created() + async def handler(ctx, state, data): + await user_handler(ctx, state, data) + + route_handler = self.app._routes[0]["handler"] + ctx = _make_context( + ActivityTypes.conversation_update, + channel_data={ + "eventType": "channelCreated", + "channel": {"id": "c1"}, + }, + ) + + await route_handler(ctx, MagicMock()) + + assert isinstance(user_handler.call_args[0][2], ChannelInfo) + assert user_handler.call_args[0][2].id == "c1" + + +class TestChannelMembers: + + def setup_method(self): + self.app = _make_app() + self.ext = TeamsAgentExtension(self.app) + + def test_members_added_selector(self): + @self.ext.channels.members_added() + async def handler(ctx, state, data): ... + + selector = self.app._routes[0]["selector"] + member = MagicMock() + assert ( + selector( + _make_context(ActivityTypes.conversation_update, members_added=[member]) + ) + is True + ) + assert ( + selector(_make_context(ActivityTypes.conversation_update, members_added=[])) + is False + ) + + def test_members_added_requires_msteams_channel(self): + @self.ext.channels.members_added() + async def handler(ctx, state, data): ... + + selector = self.app._routes[0]["selector"] + member = MagicMock() + assert ( + selector( + _make_context( + ActivityTypes.conversation_update, + channel_id="webchat", + members_added=[member], + ) + ) + is False + ) + + def test_members_removed_selector(self): + @self.ext.channels.members_removed() + async def handler(ctx, state, data): ... + + selector = self.app._routes[0]["selector"] + member = MagicMock() + assert ( + selector( + _make_context( + ActivityTypes.conversation_update, members_removed=[member] + ) + ) + is True + ) + assert ( + selector( + _make_context(ActivityTypes.conversation_update, members_removed=[]) + ) + is False + ) + + def test_members_removed_requires_msteams_channel(self): + @self.ext.channels.members_removed() + async def handler(ctx, state, data): ... + + selector = self.app._routes[0]["selector"] + member = MagicMock() + assert ( + selector( + _make_context( + ActivityTypes.conversation_update, + channel_id="webchat", + members_removed=[member], + ) + ) + is False + ) + + @pytest.mark.asyncio + async def test_members_added_handler_passes_channel_info(self): + user_handler = AsyncMock() + + @self.ext.channels.members_added() + async def handler(ctx, state, data): + await user_handler(ctx, state, data) + + route_handler = self.app._routes[0]["handler"] + member = MagicMock() + ctx = _make_context( + ActivityTypes.conversation_update, + members_added=[member], + channel_data={ + "eventType": "membersAdded", + "channel": {"id": "c1"}, + }, + ) + await route_handler(ctx, MagicMock()) + assert isinstance(user_handler.call_args[0][2], ChannelInfo) + assert user_handler.call_args[0][2].id == "c1" + + @pytest.mark.asyncio + async def test_members_removed_handler_passes_channel_info(self): + user_handler = AsyncMock() + + @self.ext.channels.members_removed() + async def handler(ctx, state, data): + await user_handler(ctx, state, data) + + route_handler = self.app._routes[0]["handler"] + member = MagicMock() + ctx = _make_context( + ActivityTypes.conversation_update, + members_removed=[member], + channel_data={ + "eventType": "membersRemoved", + "channel": {"id": "c1"}, + }, + ) + await route_handler(ctx, MagicMock()) + assert isinstance(user_handler.call_args[0][2], ChannelInfo) + assert user_handler.call_args[0][2].id == "c1" + + @pytest.mark.asyncio + async def test_members_added_raises_when_channel_data_missing(self): + @self.ext.channels.members_added() + async def handler(ctx, state, data): ... + + route_handler = self.app._routes[0]["handler"] + member = MagicMock() + ctx = _make_context(ActivityTypes.conversation_update, members_added=[member]) + with pytest.raises(ValueError, match="channel_data"): + await route_handler(ctx, MagicMock()) + + @pytest.mark.asyncio + async def test_members_added_raises_when_channel_info_missing(self): + @self.ext.channels.members_added() + async def handler(ctx, state, data): ... + + route_handler = self.app._routes[0]["handler"] + member = MagicMock() + ctx = _make_context( + ActivityTypes.conversation_update, + members_added=[member], + channel_data={"eventType": "membersAdded"}, + ) + with pytest.raises(ValueError, match="channel_data.channel"): + await route_handler(ctx, MagicMock()) + + +class TestChannelEvent: + + def setup_method(self): + self.app = _make_app() + self.ext = TeamsAgentExtension(self.app) + + def _selector(self): + @self.ext.channels.event() + async def handler(context, state, data): ... + + return self.app._routes[0]["selector"] + + def test_event_matches_channel_created(self): + selector = self._selector() + assert ( + selector( + _make_context( + ActivityTypes.conversation_update, + channel_data={"eventType": "channelCreated"}, + ) + ) + is True + ) + + def test_event_matches_channel_deleted(self): + selector = self._selector() + assert ( + selector( + _make_context( + ActivityTypes.conversation_update, + channel_data={"eventType": "channelDeleted"}, + ) + ) + is True + ) + + def test_event_matches_channel_renamed(self): + selector = self._selector() + assert ( + selector( + _make_context( + ActivityTypes.conversation_update, + channel_data={"eventType": "channelRenamed"}, + ) + ) + is True + ) + + def test_event_matches_channel_restored(self): + selector = self._selector() + assert ( + selector( + _make_context( + ActivityTypes.conversation_update, + channel_data={"eventType": "channelRestored"}, + ) + ) + is True + ) + + def test_event_matches_channel_shared(self): + selector = self._selector() + assert ( + selector( + _make_context( + ActivityTypes.conversation_update, + channel_data={"eventType": "channelShared"}, + ) + ) + is True + ) + + def test_event_matches_channel_unshared(self): + selector = self._selector() + assert ( + selector( + _make_context( + ActivityTypes.conversation_update, + channel_data={"eventType": "channelUnshared"}, + ) + ) + is True + ) + + def test_event_no_match_non_channel_event(self): + selector = self._selector() + assert ( + selector( + _make_context( + ActivityTypes.conversation_update, + channel_data={"eventType": "teamArchived"}, + ) + ) + is False + ) + + def test_event_requires_msteams_channel(self): + selector = self._selector() + assert ( + selector( + _make_context( + ActivityTypes.conversation_update, + channel_id="webchat", + channel_data={"eventType": "channelCreated"}, + ) + ) + is False + ) + + def test_event_requires_conversation_update(self): + selector = self._selector() + assert ( + selector( + _make_context( + ActivityTypes.message, + channel_data={"eventType": "channelCreated"}, + ) + ) + is False + ) + + def test_event_is_not_invoke(self): + @self.ext.channels.event() + async def handler(context, state, data): ... + + assert self.app._routes[0]["is_invoke"] is False + + +class TestChannelDirectDecoratorStyle: + + def setup_method(self): + self.app = _make_app() + self.ext = TeamsAgentExtension(self.app) + + def test_created_direct(self): + @self.ext.channels.created # type: ignore[arg-type] + async def handler(ctx, state, data): ... + + assert self.app._routes[0]["selector"] is not None + + def test_deleted_direct(self): + @self.ext.channels.deleted # type: ignore[arg-type] + async def handler(ctx, state, data): ... + + assert self.app._routes[0]["selector"] is not None + + def test_renamed_direct(self): + @self.ext.channels.renamed # type: ignore[arg-type] + async def handler(ctx, state, data): ... + + assert self.app._routes[0]["selector"] is not None + + def test_restored_direct(self): + @self.ext.channels.restored # type: ignore[arg-type] + async def handler(ctx, state, data): ... + + assert self.app._routes[0]["selector"] is not None + + def test_shared_direct(self): + @self.ext.channels.shared # type: ignore[arg-type] + async def handler(ctx, state, data): ... + + assert self.app._routes[0]["selector"] is not None + + def test_unshared_direct(self): + @self.ext.channels.unshared # type: ignore[arg-type] + async def handler(ctx, state, data): ... + + assert self.app._routes[0]["selector"] is not None + + def test_event_direct(self): + @self.ext.channels.event # type: ignore[arg-type] + async def handler(ctx, state, data): ... + + assert self.app._routes[0]["selector"] is not None + + def test_members_added_direct(self): + @self.ext.channels.members_added # type: ignore[arg-type] + async def handler(ctx, state, data): ... + + assert self.app._routes[0]["selector"] is not None + + def test_members_removed_direct(self): + @self.ext.channels.members_removed # type: ignore[arg-type] + async def handler(ctx, state, data): ... + + assert self.app._routes[0]["selector"] is not None diff --git a/tests/hosting_msteams/test_config.py b/tests/hosting_msteams/test_config.py new file mode 100644 index 000000000..931eb0cb7 --- /dev/null +++ b/tests/hosting_msteams/test_config.py @@ -0,0 +1,202 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""Tests for TeamsAgentExtension.config (config/fetch and config/submit invokes).""" + +import pytest +from unittest.mock import AsyncMock, MagicMock, patch + +from microsoft_agents.activity import ActivityTypes + +from .helpers import _make_app, _make_context, is_supported_version + +pytestmark = pytest.mark.skipif( + not is_supported_version, + reason="microsoft-agents-hosting-teams tests require Python 3.11+", +) + +if is_supported_version: + from microsoft_agents.hosting.msteams import TeamsAgentExtension + +_PATCH = "microsoft_agents.hosting.msteams.config.config._send_invoke_response" + + +class TestConfig: + + def setup_method(self): + self.app = _make_app() + self.ext = TeamsAgentExtension(self.app) + + def test_fetch_selector(self): + @self.ext.config.fetch() + async def handler(ctx, state, data): ... + + selector = self.app._routes[0]["selector"] + assert ( + selector(_make_context(ActivityTypes.invoke, name="config/fetch")) is True + ) + assert ( + selector(_make_context(ActivityTypes.invoke, name="config/submit")) is False + ) + + def test_fetch_is_invoke(self): + @self.ext.config.fetch() + async def handler(ctx, state, data): ... + + assert self.app._routes[0]["is_invoke"] is True + + def test_submit_selector(self): + @self.ext.config.submit() + async def handler(ctx, state, data): ... + + selector = self.app._routes[0]["selector"] + assert ( + selector(_make_context(ActivityTypes.invoke, name="config/submit")) is True + ) + assert ( + selector(_make_context(ActivityTypes.invoke, name="config/fetch")) is False + ) + + def test_submit_is_invoke(self): + @self.ext.config.submit() + async def handler(ctx, state, data): ... + + assert self.app._routes[0]["is_invoke"] is True + + @pytest.mark.asyncio + async def test_fetch_handler_sends_response_when_returned(self): + response = {"type": "auth", "suggestedActions": {}} + user_handler = AsyncMock(return_value=response) + + @self.ext.config.fetch() + async def handler(ctx, state, data): + return await user_handler(ctx, state, data) + + route_handler = self.app._routes[0]["handler"] + ctx = _make_context( + ActivityTypes.invoke, name="config/fetch", value={"setting": "value"} + ) + with patch(_PATCH, new_callable=AsyncMock) as mock_send: + await route_handler(ctx, MagicMock()) + assert user_handler.called + mock_send.assert_awaited_once_with(ctx, response) + + @pytest.mark.asyncio + async def test_fetch_handler_skips_send_when_none_returned(self): + @self.ext.config.fetch() + async def handler(ctx, state, data): ... + + route_handler = self.app._routes[0]["handler"] + ctx = _make_context(ActivityTypes.invoke, name="config/fetch", value={}) + with patch(_PATCH, new_callable=AsyncMock) as mock_send: + await route_handler(ctx, MagicMock()) + mock_send.assert_not_awaited() + + @pytest.mark.asyncio + async def test_fetch_handler_receives_raw_activity_value(self): + user_handler = AsyncMock(return_value=None) + + @self.ext.config.fetch() + async def handler(ctx, state, data): + return await user_handler(ctx, state, data) + + route_handler = self.app._routes[0]["handler"] + payload = {"key": "val"} + ctx = _make_context(ActivityTypes.invoke, name="config/fetch", value=payload) + with patch(_PATCH, new_callable=AsyncMock): + await route_handler(ctx, MagicMock()) + assert user_handler.call_args[0][2] == payload + + # --- direct decoration (no parentheses) --- + + def test_fetch_direct_decoration_registers_route(self): + @self.ext.config.fetch + async def handler(ctx, state, data): ... + + assert len(self.app._routes) == 1 + + def test_fetch_direct_decoration_selector(self): + @self.ext.config.fetch + async def handler(ctx, state, data): ... + + selector = self.app._routes[0]["selector"] + assert ( + selector(_make_context(ActivityTypes.invoke, name="config/fetch")) is True + ) + assert ( + selector(_make_context(ActivityTypes.invoke, name="config/submit")) is False + ) + + def test_fetch_direct_decoration_is_invoke(self): + @self.ext.config.fetch + async def handler(ctx, state, data): ... + + assert self.app._routes[0]["is_invoke"] is True + + def test_fetch_direct_decoration_returns_handler(self): + async def handler(ctx, state, data): ... + + result = self.ext.config.fetch(handler) + assert result is handler + + def test_submit_direct_decoration_registers_route(self): + @self.ext.config.submit + async def handler(ctx, state, data): ... + + assert len(self.app._routes) == 1 + + def test_submit_direct_decoration_selector(self): + @self.ext.config.submit + async def handler(ctx, state, data): ... + + selector = self.app._routes[0]["selector"] + assert ( + selector(_make_context(ActivityTypes.invoke, name="config/submit")) is True + ) + assert ( + selector(_make_context(ActivityTypes.invoke, name="config/fetch")) is False + ) + + def test_submit_direct_decoration_is_invoke(self): + @self.ext.config.submit + async def handler(ctx, state, data): ... + + assert self.app._routes[0]["is_invoke"] is True + + def test_submit_direct_decoration_returns_handler(self): + async def handler(ctx, state, data): ... + + result = self.ext.config.submit(handler) + assert result is handler + + @pytest.mark.asyncio + async def test_fetch_direct_decoration_handler_sends_response(self): + response = {"type": "auth"} + user_handler = AsyncMock(return_value=response) + + async def _handler(ctx, state, data): + return await user_handler(ctx, state, data) + + self.ext.config.fetch(_handler) + + route_handler = self.app._routes[0]["handler"] + ctx = _make_context(ActivityTypes.invoke, name="config/fetch", value={}) + with patch(_PATCH, new_callable=AsyncMock) as mock_send: + await route_handler(ctx, MagicMock()) + mock_send.assert_awaited_once_with(ctx, response) + + @pytest.mark.asyncio + async def test_submit_direct_decoration_handler_sends_response(self): + response = {"type": "message"} + user_handler = AsyncMock(return_value=response) + + async def _handler(ctx, state, data): + return await user_handler(ctx, state, data) + + self.ext.config.submit(_handler) + + route_handler = self.app._routes[0]["handler"] + ctx = _make_context(ActivityTypes.invoke, name="config/submit", value={}) + with patch(_PATCH, new_callable=AsyncMock) as mock_send: + await route_handler(ctx, MagicMock()) + mock_send.assert_awaited_once_with(ctx, response) diff --git a/tests/hosting_msteams/test_file_consent.py b/tests/hosting_msteams/test_file_consent.py new file mode 100644 index 000000000..e654d8a32 --- /dev/null +++ b/tests/hosting_msteams/test_file_consent.py @@ -0,0 +1,147 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""Tests for TeamsAgentExtension.file_consent (fileConsent/invoke activities).""" + +import pytest +from unittest.mock import AsyncMock, MagicMock, patch + +from microsoft_agents.activity import ActivityTypes + +from .helpers import _make_app, _make_context, is_supported_version + +pytestmark = pytest.mark.skipif( + not is_supported_version, + reason="microsoft-agents-hosting-teams tests require Python 3.11+", +) + +if is_supported_version: + from microsoft_teams.api.models import FileConsentCardResponse + from microsoft_agents.hosting.msteams import TeamsAgentExtension + +_PATCH = ( + "microsoft_agents.hosting.msteams.file_consent.file_consent._send_invoke_response" +) + + +class TestFileConsent: + + def setup_method(self): + self.app = _make_app() + self.ext = TeamsAgentExtension(self.app) + + def test_accept_selector(self): + @self.ext.file_consent.accept() + async def handler(ctx, state, data): ... + + selector = self.app._routes[0]["selector"] + assert ( + selector( + _make_context( + ActivityTypes.invoke, + name="fileConsent/invoke", + value={"action": "accept"}, + ) + ) + is True + ) + assert ( + selector( + _make_context( + ActivityTypes.invoke, + name="fileConsent/invoke", + value={"action": "decline"}, + ) + ) + is False + ) + + def test_decline_selector(self): + @self.ext.file_consent.decline() + async def handler(ctx, state, data): ... + + selector = self.app._routes[0]["selector"] + assert ( + selector( + _make_context( + ActivityTypes.invoke, + name="fileConsent/invoke", + value={"action": "decline"}, + ) + ) + is True + ) + assert ( + selector( + _make_context( + ActivityTypes.invoke, + name="fileConsent/invoke", + value={"action": "accept"}, + ) + ) + is False + ) + + def test_accept_is_invoke(self): + @self.ext.file_consent.accept() + async def handler(ctx, state, data): ... + + assert self.app._routes[0]["is_invoke"] is True + + def test_decline_is_invoke(self): + @self.ext.file_consent.decline() + async def handler(ctx, state, data): ... + + assert self.app._routes[0]["is_invoke"] is True + + @pytest.mark.asyncio + async def test_accept_handler_parses_response_and_sends_empty_invoke_response(self): + user_handler = AsyncMock() + + @self.ext.file_consent.accept() + async def handler(ctx, state, data: FileConsentCardResponse): + await user_handler(ctx, state, data) + + route_handler = self.app._routes[0]["handler"] + ctx = _make_context( + ActivityTypes.invoke, + name="fileConsent/invoke", + value={"action": "accept", "context": None, "uploadInfo": None}, + ) + with patch(_PATCH, new_callable=AsyncMock) as mock_send: + await route_handler(ctx, MagicMock()) + assert isinstance(user_handler.call_args[0][2], FileConsentCardResponse) + mock_send.assert_awaited_once_with(ctx) + + @pytest.mark.asyncio + async def test_decline_handler_parses_response_and_sends_empty_invoke_response( + self, + ): + user_handler = AsyncMock() + + @self.ext.file_consent.decline() + async def handler(ctx, state, data: FileConsentCardResponse): + await user_handler(ctx, state, data) + + route_handler = self.app._routes[0]["handler"] + ctx = _make_context( + ActivityTypes.invoke, + name="fileConsent/invoke", + value={"action": "decline", "context": None, "uploadInfo": None}, + ) + with patch(_PATCH, new_callable=AsyncMock) as mock_send: + await route_handler(ctx, MagicMock()) + assert isinstance(user_handler.call_args[0][2], FileConsentCardResponse) + mock_send.assert_awaited_once_with(ctx) + + def test_accept_direct_decorator_style(self): + @self.ext.file_consent.accept # type: ignore[arg-type] + async def handler(ctx, state, data): ... + + assert self.app._routes[0]["selector"] is not None + + def test_decline_direct_decorator_style(self): + @self.ext.file_consent.decline # type: ignore[arg-type] + async def handler(ctx, state, data): ... + + assert self.app._routes[0]["selector"] is not None diff --git a/tests/hosting_msteams/test_internal.py b/tests/hosting_msteams/test_internal.py new file mode 100644 index 000000000..4b840d993 --- /dev/null +++ b/tests/hosting_msteams/test_internal.py @@ -0,0 +1,68 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""Tests for internal helpers: the Teams API client accessor and error resources.""" + +import pytest + +from .helpers import is_supported_version + +pytestmark = pytest.mark.skipif( + not is_supported_version, + reason="microsoft-agents-hosting-teams tests require Python 3.11+", +) + +if is_supported_version: + from microsoft_teams.api import ApiClient + + from microsoft_agents.hosting.msteams._teams_api_client import ( + _TEAMS_API_CLIENT_KEY, + _get_teams_api_client, + ) + from microsoft_agents.hosting.msteams.errors.error_resources import ( + TeamsErrorResources, + ) + + +class _FakeContext: + """Minimal stand-in exposing only the ``turn_state`` dict the accessor reads.""" + + def __init__(self, turn_state): + self.turn_state = turn_state + + +class TestGetTeamsApiClient: + + def test_returns_cached_api_client(self): + client = ApiClient("https://smba.trafficmanager.net/teams/") + ctx = _FakeContext({_TEAMS_API_CLIENT_KEY: client}) + assert _get_teams_api_client(ctx) is client + + def test_raises_when_missing(self): + ctx = _FakeContext({}) + with pytest.raises(ValueError, match="Teams API client"): + _get_teams_api_client(ctx) + + def test_raises_when_wrong_type(self): + ctx = _FakeContext({_TEAMS_API_CLIENT_KEY: object()}) + with pytest.raises(ValueError, match="Teams API client"): + _get_teams_api_client(ctx) + + +class TestTeamsErrorResources: + + def _error_messages(self): + return [ + value + for name, value in vars(TeamsErrorResources).items() + if not name.startswith("_") + ] + + def test_error_codes_are_unique(self): + codes = [msg.error_code for msg in self._error_messages()] + assert len(codes) == len(set(codes)) + + def test_error_codes_within_reserved_range(self): + # The module documents the reserved range as -62000 to -62999. + for msg in self._error_messages(): + assert -62999 <= msg.error_code <= -62000 diff --git a/tests/hosting_msteams/test_meetings.py b/tests/hosting_msteams/test_meetings.py new file mode 100644 index 000000000..cea09d000 --- /dev/null +++ b/tests/hosting_msteams/test_meetings.py @@ -0,0 +1,254 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""Tests for TeamsAgentExtension.meetings (meeting lifecycle events).""" + +import pytest +from unittest.mock import AsyncMock, MagicMock + +from microsoft_agents.activity import ActivityTypes + +from .helpers import _make_app, _make_context, is_supported_version + +pytestmark = pytest.mark.skipif( + not is_supported_version, + reason="microsoft-agents-hosting-teams tests require Python 3.11+", +) + +if is_supported_version: + from microsoft_teams.api.models.meetings import MeetingDetails + from microsoft_agents.activity.teams import MeetingParticipantsEventDetails + from microsoft_agents.hosting.msteams import TeamsAgentExtension + + +def _meeting_details_value() -> dict: + return { + "id": "meeting-id", + "type": "scheduled", + "joinUrl": "https://example.com/meet", + "title": "Test Meeting", + "msGraphResourceId": "graph-id", + } + + +class TestMeetingStartEnd: + + def setup_method(self): + self.app = _make_app() + self.ext = TeamsAgentExtension(self.app) + + def test_start_selector(self): + @self.ext.meetings.start() + async def handler(ctx, state, meeting): ... + + selector = self.app._routes[0]["selector"] + assert ( + selector( + _make_context( + ActivityTypes.event, name="application/vnd.microsoft.meetingStart" + ) + ) + is True + ) + assert ( + selector( + _make_context( + ActivityTypes.event, name="application/vnd.microsoft.meetingEnd" + ) + ) + is False + ) + + def test_end_selector(self): + @self.ext.meetings.end() + async def handler(ctx, state, meeting): ... + + selector = self.app._routes[0]["selector"] + assert ( + selector( + _make_context( + ActivityTypes.event, name="application/vnd.microsoft.meetingEnd" + ) + ) + is True + ) + assert ( + selector( + _make_context( + ActivityTypes.event, name="application/vnd.microsoft.meetingStart" + ) + ) + is False + ) + + def test_start_is_not_invoke(self): + @self.ext.meetings.start() + async def handler(ctx, state, meeting): ... + + assert self.app._routes[0]["is_invoke"] is False + + def test_end_is_not_invoke(self): + @self.ext.meetings.end() + async def handler(ctx, state, meeting): ... + + assert self.app._routes[0]["is_invoke"] is False + + @pytest.mark.asyncio + async def test_start_handler_parses_meeting_details(self): + user_handler = AsyncMock() + + @self.ext.meetings.start() + async def handler(ctx, state, meeting: MeetingDetails): + await user_handler(ctx, state, meeting) + + route_handler = self.app._routes[0]["handler"] + ctx = _make_context( + ActivityTypes.event, + name="application/vnd.microsoft.meetingStart", + value=_meeting_details_value(), + ) + await route_handler(ctx, MagicMock()) + assert isinstance(user_handler.call_args[0][2], MeetingDetails) + + @pytest.mark.asyncio + async def test_end_handler_parses_meeting_details(self): + user_handler = AsyncMock() + + @self.ext.meetings.end() + async def handler(ctx, state, meeting: MeetingDetails): + await user_handler(ctx, state, meeting) + + route_handler = self.app._routes[0]["handler"] + ctx = _make_context( + ActivityTypes.event, + name="application/vnd.microsoft.meetingEnd", + value=_meeting_details_value(), + ) + await route_handler(ctx, MagicMock()) + assert isinstance(user_handler.call_args[0][2], MeetingDetails) + + +class TestMeetingParticipants: + + def setup_method(self): + self.app = _make_app() + self.ext = TeamsAgentExtension(self.app) + + def test_participants_join_selector(self): + @self.ext.meetings.participants_join() + async def handler(ctx, state, details): ... + + selector = self.app._routes[0]["selector"] + assert ( + selector( + _make_context( + ActivityTypes.event, + name="application/vnd.microsoft.meetingParticipantJoin", + ) + ) + is True + ) + assert ( + selector( + _make_context( + ActivityTypes.event, + name="application/vnd.microsoft.meetingParticipantLeave", + ) + ) + is False + ) + + def test_participants_leave_selector(self): + @self.ext.meetings.participants_leave() + async def handler(ctx, state, details): ... + + selector = self.app._routes[0]["selector"] + assert ( + selector( + _make_context( + ActivityTypes.event, + name="application/vnd.microsoft.meetingParticipantLeave", + ) + ) + is True + ) + assert ( + selector( + _make_context( + ActivityTypes.event, + name="application/vnd.microsoft.meetingParticipantJoin", + ) + ) + is False + ) + + def test_participants_join_is_not_invoke(self): + @self.ext.meetings.participants_join() + async def handler(ctx, state, details): ... + + assert self.app._routes[0]["is_invoke"] is False + + @pytest.mark.asyncio + async def test_participants_join_handler_parses_details(self): + user_handler = AsyncMock() + + @self.ext.meetings.participants_join() + async def handler(ctx, state, details: MeetingParticipantsEventDetails): + await user_handler(ctx, state, details) + + route_handler = self.app._routes[0]["handler"] + ctx = _make_context( + ActivityTypes.event, + name="application/vnd.microsoft.meetingParticipantJoin", + value={}, + ) + await route_handler(ctx, MagicMock()) + assert isinstance(user_handler.call_args[0][2], MeetingParticipantsEventDetails) + + @pytest.mark.asyncio + async def test_participants_leave_handler_parses_details(self): + user_handler = AsyncMock() + + @self.ext.meetings.participants_leave() + async def handler(ctx, state, details: MeetingParticipantsEventDetails): + await user_handler(ctx, state, details) + + route_handler = self.app._routes[0]["handler"] + ctx = _make_context( + ActivityTypes.event, + name="application/vnd.microsoft.meetingParticipantLeave", + value={}, + ) + await route_handler(ctx, MagicMock()) + assert isinstance(user_handler.call_args[0][2], MeetingParticipantsEventDetails) + + +class TestMeetingDirectDecoratorStyle: + + def setup_method(self): + self.app = _make_app() + self.ext = TeamsAgentExtension(self.app) + + def test_start_direct(self): + @self.ext.meetings.start # type: ignore[arg-type] + async def handler(ctx, state, meeting): ... + + assert self.app._routes[0]["selector"] is not None + + def test_end_direct(self): + @self.ext.meetings.end # type: ignore[arg-type] + async def handler(ctx, state, meeting): ... + + assert self.app._routes[0]["selector"] is not None + + def test_participants_join_direct(self): + @self.ext.meetings.participants_join # type: ignore[arg-type] + async def handler(ctx, state, details): ... + + assert self.app._routes[0]["selector"] is not None + + def test_participants_leave_direct(self): + @self.ext.meetings.participants_leave # type: ignore[arg-type] + async def handler(ctx, state, details): ... + + assert self.app._routes[0]["selector"] is not None diff --git a/tests/hosting_msteams/test_message_extensions.py b/tests/hosting_msteams/test_message_extensions.py new file mode 100644 index 000000000..ec2cf149b --- /dev/null +++ b/tests/hosting_msteams/test_message_extensions.py @@ -0,0 +1,783 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""Tests for TeamsAgentExtension.message_extensions (composeExtension invokes).""" + +import pytest +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock, patch + +from microsoft_agents.activity import ActivityTypes + +from .helpers import _make_app, _make_context, is_supported_version + +pytestmark = pytest.mark.skipif( + not is_supported_version, + reason="microsoft-agents-hosting-teams tests require Python 3.11+", +) + +if is_supported_version: + from microsoft_teams.api.models import ( + AppBasedLinkQuery, + MessagingExtensionAction, + MessagingExtensionQuery, + MessagingExtensionResponse, + ) + from microsoft_agents.hosting.msteams import TeamsAgentExtension + from microsoft_agents.hosting.msteams.message_extension import ( + route_handlers as message_extension_route_handlers, + ) + +_PATCH = "microsoft_agents.hosting.msteams.message_extension.message_extension._send_invoke_response" + + +class TestMessageExtensionQuery: + + def setup_method(self): + self.app = _make_app() + self.ext = TeamsAgentExtension(self.app) + + def test_query_matches_by_command_id(self): + @self.ext.message_extensions.query("searchCmd") + async def handler(ctx, state, query): ... + + selector = self.app._routes[0]["selector"] + assert ( + selector( + _make_context( + ActivityTypes.invoke, + name="composeExtension/query", + value={"commandId": "searchCmd"}, + ) + ) + is True + ) + assert ( + selector( + _make_context( + ActivityTypes.invoke, + name="composeExtension/query", + value={"commandId": "other"}, + ) + ) + is False + ) + + def test_query_no_command_id_matches_all(self): + @self.ext.message_extensions.query() + async def handler(ctx, state, query): ... + + selector = self.app._routes[0]["selector"] + assert ( + selector( + _make_context( + ActivityTypes.invoke, + name="composeExtension/query", + value={"commandId": "anything"}, + ) + ) + is True + ) + assert ( + selector( + _make_context( + ActivityTypes.invoke, name="composeExtension/query", value={} + ) + ) + is True + ) + + def test_query_wrong_invoke_name(self): + @self.ext.message_extensions.query() + async def handler(ctx, state, query): ... + + selector = self.app._routes[0]["selector"] + assert ( + selector( + _make_context( + ActivityTypes.invoke, name="composeExtension/submitAction", value={} + ) + ) + is False + ) + + def test_query_is_invoke(self): + @self.ext.message_extensions.query() + async def handler(ctx, state, query): ... + + assert self.app._routes[0]["is_invoke"] is True + + @pytest.mark.asyncio + async def test_query_handler_parses_query_and_sends_response(self): + response = MessagingExtensionResponse() + user_handler = AsyncMock(return_value=response) + + @self.ext.message_extensions.query() + async def handler(ctx, state, query: MessagingExtensionQuery): + return await user_handler(ctx, state, query) + + route_handler = self.app._routes[0]["handler"] + ctx = _make_context( + ActivityTypes.invoke, + name="composeExtension/query", + value={"commandId": "search"}, + ) + with patch(_PATCH, new_callable=AsyncMock) as mock_send: + await route_handler(ctx, MagicMock()) + assert isinstance(user_handler.call_args[0][2], MessagingExtensionQuery) + mock_send.assert_awaited_once_with(ctx, response) + + +class TestMessageExtensionSubmitAction: + + def setup_method(self): + self.app = _make_app() + self.ext = TeamsAgentExtension(self.app) + + def test_submit_action_excludes_preview(self): + @self.ext.message_extensions.submit_action() + async def handler(ctx, state, action): ... + + selector = self.app._routes[0]["selector"] + assert ( + selector( + _make_context( + ActivityTypes.invoke, + name="composeExtension/submitAction", + value={"commandId": "cmd"}, + ) + ) + is True + ) + assert ( + selector( + _make_context( + ActivityTypes.invoke, + name="composeExtension/submitAction", + value={"commandId": "cmd", "botMessagePreviewAction": "edit"}, + ) + ) + is False + ) + + def test_message_preview_edit_selector(self): + @self.ext.message_extensions.message_preview_edit() + async def handler(ctx, state, action): ... + + selector = self.app._routes[0]["selector"] + assert ( + selector( + _make_context( + ActivityTypes.invoke, + name="composeExtension/submitAction", + value={"commandId": "cmd", "botMessagePreviewAction": "edit"}, + ) + ) + is True + ) + assert ( + selector( + _make_context( + ActivityTypes.invoke, + name="composeExtension/submitAction", + value={"commandId": "cmd", "botMessagePreviewAction": "send"}, + ) + ) + is False + ) + + def test_message_preview_send_selector(self): + @self.ext.message_extensions.message_preview_send() + async def handler(ctx, state, action): ... + + selector = self.app._routes[0]["selector"] + assert ( + selector( + _make_context( + ActivityTypes.invoke, + name="composeExtension/submitAction", + value={"commandId": "cmd", "botMessagePreviewAction": "send"}, + ) + ) + is True + ) + assert ( + selector( + _make_context( + ActivityTypes.invoke, + name="composeExtension/submitAction", + value={"commandId": "cmd", "botMessagePreviewAction": "edit"}, + ) + ) + is False + ) + + def test_submit_action_is_invoke(self): + @self.ext.message_extensions.submit_action() + async def handler(ctx, state, action): ... + + assert self.app._routes[0]["is_invoke"] is True + + +class TestMessagePreviewEdit: + + def setup_method(self): + self.app = _make_app() + self.ext = TeamsAgentExtension(self.app) + + @pytest.mark.asyncio + async def test_handler_receives_deserialized_activity(self): + from microsoft_agents.activity import Activity + + user_handler = AsyncMock() + + @self.ext.message_extensions.message_preview_edit() + async def handler(ctx, state, preview): + await user_handler(ctx, state, preview) + + route_handler = self.app._routes[0]["handler"] + ctx = _make_context( + ActivityTypes.invoke, + name="composeExtension/submitAction", + value={ + "commandId": "cmd", + "botMessagePreviewAction": "edit", + "botActivityPreview": [{"type": "message", "text": "preview text"}], + }, + ) + with patch(_PATCH, new_callable=AsyncMock): + await route_handler(ctx, MagicMock()) + preview_arg = user_handler.call_args[0][2] + assert isinstance(preview_arg, Activity) + assert preview_arg.text == "preview text" + + @pytest.mark.asyncio + async def test_raises_when_value_is_not_dict(self): + @self.ext.message_extensions.message_preview_edit() + async def handler(ctx, state, preview): ... + + route_handler = self.app._routes[0]["handler"] + ctx = _make_context( + ActivityTypes.invoke, + name="composeExtension/submitAction", + value=None, + ) + with pytest.raises(ValueError, match="botActivityPreview"): + await route_handler(ctx, MagicMock()) + + @pytest.mark.asyncio + async def test_raises_when_bot_activity_preview_is_empty(self): + @self.ext.message_extensions.message_preview_edit() + async def handler(ctx, state, preview): ... + + route_handler = self.app._routes[0]["handler"] + ctx = _make_context( + ActivityTypes.invoke, + name="composeExtension/submitAction", + value={ + "commandId": "cmd", + "botMessagePreviewAction": "edit", + "botActivityPreview": [], + }, + ) + with pytest.raises(ValueError, match="botActivityPreview"): + await route_handler(ctx, MagicMock()) + + +class TestMessagePreviewSend: + + def setup_method(self): + self.app = _make_app() + self.ext = TeamsAgentExtension(self.app) + + @pytest.mark.asyncio + async def test_handler_receives_deserialized_activity(self): + from microsoft_agents.activity import Activity + + user_handler = AsyncMock(return_value=None) + + @self.ext.message_extensions.message_preview_send() + async def handler(ctx, state, preview): + return await user_handler(ctx, state, preview) + + route_handler = self.app._routes[0]["handler"] + ctx = _make_context( + ActivityTypes.invoke, + name="composeExtension/submitAction", + value={ + "commandId": "cmd", + "botMessagePreviewAction": "send", + "botActivityPreview": [{"type": "message", "text": "send text"}], + }, + ) + with patch(_PATCH, new_callable=AsyncMock): + await route_handler(ctx, MagicMock()) + preview_arg = user_handler.call_args[0][2] + assert isinstance(preview_arg, Activity) + assert preview_arg.text == "send text" + + @pytest.mark.asyncio + async def test_raises_when_value_is_not_dict(self): + @self.ext.message_extensions.message_preview_send() + async def handler(ctx, state, preview): ... + + route_handler = self.app._routes[0]["handler"] + ctx = _make_context( + ActivityTypes.invoke, + name="composeExtension/submitAction", + value=None, + ) + with pytest.raises(ValueError, match="botActivityPreview"): + await route_handler(ctx, MagicMock()) + + @pytest.mark.asyncio + async def test_raises_when_bot_activity_preview_missing(self): + @self.ext.message_extensions.message_preview_send() + async def handler(ctx, state, preview): ... + + route_handler = self.app._routes[0]["handler"] + ctx = _make_context( + ActivityTypes.invoke, + name="composeExtension/submitAction", + value={"commandId": "cmd", "botMessagePreviewAction": "send"}, + ) + with pytest.raises(ValueError, match="botActivityPreview"): + await route_handler(ctx, MagicMock()) + + +class TestMessageExtensionFetchAction: + + def setup_method(self): + self.app = _make_app() + self.ext = TeamsAgentExtension(self.app) + + def test_fetch_action_matches_command_id(self): + @self.ext.message_extensions.fetch_action("myCmd") + async def handler(ctx, state, action): ... + + selector = self.app._routes[0]["selector"] + assert ( + selector( + _make_context( + ActivityTypes.invoke, + name="composeExtension/fetchTask", + value={"commandId": "myCmd"}, + ) + ) + is True + ) + assert ( + selector( + _make_context( + ActivityTypes.invoke, + name="composeExtension/fetchTask", + value={"commandId": "other"}, + ) + ) + is False + ) + + def test_fetch_action_no_command_id_matches_all(self): + @self.ext.message_extensions.fetch_action() + async def handler(ctx, state, action): ... + + selector = self.app._routes[0]["selector"] + assert ( + selector( + _make_context( + ActivityTypes.invoke, + name="composeExtension/fetchTask", + value={"commandId": "anything"}, + ) + ) + is True + ) + + def test_fetch_action_is_invoke(self): + @self.ext.message_extensions.fetch_action() + async def handler(ctx, state, action): ... + + assert self.app._routes[0]["is_invoke"] is True + + +class TestMessageExtensionLinkAndItem: + + def setup_method(self): + self.app = _make_app() + self.ext = TeamsAgentExtension(self.app) + + def test_query_link_selector(self): + @self.ext.message_extensions.query_link() + async def handler(ctx, state, query): ... + + selector = self.app._routes[0]["selector"] + assert ( + selector( + _make_context(ActivityTypes.invoke, name="composeExtension/queryLink") + ) + is True + ) + assert ( + selector(_make_context(ActivityTypes.invoke, name="composeExtension/query")) + is False + ) + + def test_anonymous_query_link_selector(self): + @self.ext.message_extensions.anonymous_query_link() + async def handler(ctx, state, query): ... + + selector = self.app._routes[0]["selector"] + assert ( + selector( + _make_context( + ActivityTypes.invoke, name="composeExtension/anonymousQueryLink" + ) + ) + is True + ) + assert ( + selector( + _make_context(ActivityTypes.invoke, name="composeExtension/queryLink") + ) + is False + ) + + def test_select_item_selector(self): + @self.ext.message_extensions.select_item() + async def handler(ctx, state, item): ... + + selector = self.app._routes[0]["selector"] + assert ( + selector( + _make_context(ActivityTypes.invoke, name="composeExtension/selectItem") + ) + is True + ) + assert ( + selector(_make_context(ActivityTypes.invoke, name="composeExtension/query")) + is False + ) + + def test_query_link_is_invoke(self): + @self.ext.message_extensions.query_link() + async def handler(ctx, state, query): ... + + assert self.app._routes[0]["is_invoke"] is True + + +class TestMessageExtensionSetting: + + def setup_method(self): + self.app = _make_app() + self.ext = TeamsAgentExtension(self.app) + + def test_setting_handler_protocol_name(self): + assert ( + message_extension_route_handlers.SettingHandler.__name__ == "SettingHandler" + ) + + def test_setting_selector(self): + @self.ext.message_extensions.setting() + async def handler(ctx, state, settings): ... + + selector = self.app._routes[0]["selector"] + assert ( + selector( + _make_context(ActivityTypes.invoke, name="composeExtension/setting") + ) + is True + ) + assert ( + selector(_make_context(ActivityTypes.invoke, name="composeExtension/query")) + is False + ) + + def test_setting_is_invoke(self): + @self.ext.message_extensions.setting() + async def handler(ctx, state, settings): ... + + assert self.app._routes[0]["is_invoke"] is True + + @pytest.mark.asyncio + async def test_setting_always_sends_empty_response(self): + @self.ext.message_extensions.setting() + async def handler(ctx, state, settings): ... + + route_handler = self.app._routes[0]["handler"] + ctx = _make_context( + ActivityTypes.invoke, name="composeExtension/setting", value={} + ) + with patch(_PATCH, new_callable=AsyncMock) as mock_send: + await route_handler(ctx, MagicMock()) + mock_send.assert_awaited_once_with(ctx, None) + + +class TestMessageExtensionCardButtonClicked: + + def setup_method(self): + self.app = _make_app() + self.ext = TeamsAgentExtension(self.app) + + def test_card_button_clicked_selector(self): + @self.ext.message_extensions.card_button_clicked() + async def handler(ctx, state, data): ... + + selector = self.app._routes[0]["selector"] + assert ( + selector( + _make_context( + ActivityTypes.invoke, name="composeExtension/onCardButtonClicked" + ) + ) + is True + ) + assert ( + selector(_make_context(ActivityTypes.invoke, name="composeExtension/query")) + is False + ) + + def test_card_button_clicked_is_invoke(self): + @self.ext.message_extensions.card_button_clicked() + async def handler(ctx, state, data): ... + + assert self.app._routes[0]["is_invoke"] is True + + +class TestMessageExtensionDirectDecoratorStyle: + + def setup_method(self): + self.app = _make_app() + self.ext = TeamsAgentExtension(self.app) + + def test_select_item_direct(self): + @self.ext.message_extensions.select_item # type: ignore[arg-type] + async def handler(ctx, state, value): ... + + assert self.app._routes[0]["selector"] is not None + + def test_query_link_direct(self): + @self.ext.message_extensions.query_link # type: ignore[arg-type] + async def handler(ctx, state, query): ... + + assert self.app._routes[0]["selector"] is not None + + def test_anonymous_query_link_direct(self): + @self.ext.message_extensions.anonymous_query_link # type: ignore[arg-type] + async def handler(ctx, state, query): ... + + assert self.app._routes[0]["selector"] is not None + + def test_query_setting_url_direct(self): + @self.ext.message_extensions.query_setting_url # type: ignore[arg-type] + async def handler(ctx, state, query): ... + + assert self.app._routes[0]["selector"] is not None + + def test_setting_direct(self): + @self.ext.message_extensions.setting # type: ignore[arg-type] + async def handler(ctx, state, value): ... + + assert self.app._routes[0]["selector"] is not None + + def test_card_button_clicked_direct(self): + @self.ext.message_extensions.card_button_clicked # type: ignore[arg-type] + async def handler(ctx, state, value): ... + + assert self.app._routes[0]["selector"] is not None + + +class TestMessageExtensionHandlerExecution: + """Exercises the handler closures (payload parsing + response sending), not + just the selectors, for the message-extension methods that lacked such tests.""" + + def setup_method(self): + self.app = _make_app() + self.ext = TeamsAgentExtension(self.app) + + @pytest.mark.asyncio + async def test_select_item_passes_raw_value_and_sends_response(self): + response = MessagingExtensionResponse() + user_handler = AsyncMock(return_value=response) + + @self.ext.message_extensions.select_item() + async def handler(ctx, state, value): + return await user_handler(ctx, state, value) + + route_handler = self.app._routes[0]["handler"] + ctx = _make_context( + ActivityTypes.invoke, + name="composeExtension/selectItem", + value={"item": "42"}, + ) + with patch(_PATCH, new_callable=AsyncMock) as mock_send: + await route_handler(ctx, MagicMock()) + # select_item forwards the raw, unvalidated value + assert user_handler.call_args[0][2] == {"item": "42"} + mock_send.assert_awaited_once_with(ctx, response) + + @pytest.mark.asyncio + async def test_select_item_skips_send_when_handler_returns_none(self): + @self.ext.message_extensions.select_item() + async def handler(ctx, state, value): + return None + + route_handler = self.app._routes[0]["handler"] + ctx = _make_context( + ActivityTypes.invoke, name="composeExtension/selectItem", value={} + ) + with patch(_PATCH, new_callable=AsyncMock) as mock_send: + await route_handler(ctx, MagicMock()) + mock_send.assert_not_awaited() + + @pytest.mark.asyncio + async def test_submit_action_parses_action_and_sends_response(self): + response = MessagingExtensionResponse() + user_handler = AsyncMock(return_value=response) + + @self.ext.message_extensions.submit_action() + async def handler(ctx, state, action): + return await user_handler(ctx, state, action) + + route_handler = self.app._routes[0]["handler"] + ctx = _make_context( + ActivityTypes.invoke, + name="composeExtension/submitAction", + value={"commandId": "c", "commandContext": "compose"}, + ) + with patch(_PATCH, new_callable=AsyncMock) as mock_send: + await route_handler(ctx, MagicMock()) + assert isinstance(user_handler.call_args[0][2], MessagingExtensionAction) + mock_send.assert_awaited_once_with(ctx, response) + + def test_submit_action_selector_handles_non_dict_value(self): + # The selector reads botMessagePreviewAction via getattr when value is an + # object rather than a dict; a preview action must exclude this route. + @self.ext.message_extensions.submit_action() + async def handler(ctx, state, action): ... + + selector = self.app._routes[0]["selector"] + + preview_value = SimpleNamespace(botMessagePreviewAction="edit", commandId="c") + assert ( + selector( + _make_context( + ActivityTypes.invoke, + name="composeExtension/submitAction", + value=preview_value, + ) + ) + is False + ) + + non_preview_value = SimpleNamespace(botMessagePreviewAction=None, commandId="c") + assert ( + selector( + _make_context( + ActivityTypes.invoke, + name="composeExtension/submitAction", + value=non_preview_value, + ) + ) + is True + ) + + @pytest.mark.asyncio + async def test_fetch_action_parses_action_and_sends_response(self): + response = MessagingExtensionResponse() + user_handler = AsyncMock(return_value=response) + + @self.ext.message_extensions.fetch_action() + async def handler(ctx, state, action): + return await user_handler(ctx, state, action) + + route_handler = self.app._routes[0]["handler"] + ctx = _make_context( + ActivityTypes.invoke, + name="composeExtension/fetchTask", + value={"commandId": "c", "commandContext": "compose"}, + ) + with patch(_PATCH, new_callable=AsyncMock) as mock_send: + await route_handler(ctx, MagicMock()) + assert isinstance(user_handler.call_args[0][2], MessagingExtensionAction) + mock_send.assert_awaited_once_with(ctx, response) + + @pytest.mark.asyncio + async def test_query_link_parses_query_and_sends_response(self): + response = MessagingExtensionResponse() + user_handler = AsyncMock(return_value=response) + + @self.ext.message_extensions.query_link() + async def handler(ctx, state, query): + return await user_handler(ctx, state, query) + + route_handler = self.app._routes[0]["handler"] + ctx = _make_context( + ActivityTypes.invoke, + name="composeExtension/queryLink", + value={"url": "https://example.com"}, + ) + with patch(_PATCH, new_callable=AsyncMock) as mock_send: + await route_handler(ctx, MagicMock()) + assert isinstance(user_handler.call_args[0][2], AppBasedLinkQuery) + assert user_handler.call_args[0][2].url == "https://example.com" + mock_send.assert_awaited_once_with(ctx, response) + + @pytest.mark.asyncio + async def test_anonymous_query_link_parses_query_and_sends_response(self): + response = MessagingExtensionResponse() + user_handler = AsyncMock(return_value=response) + + @self.ext.message_extensions.anonymous_query_link() + async def handler(ctx, state, query): + return await user_handler(ctx, state, query) + + route_handler = self.app._routes[0]["handler"] + ctx = _make_context( + ActivityTypes.invoke, + name="composeExtension/anonymousQueryLink", + value={"url": "https://example.com"}, + ) + with patch(_PATCH, new_callable=AsyncMock) as mock_send: + await route_handler(ctx, MagicMock()) + assert isinstance(user_handler.call_args[0][2], AppBasedLinkQuery) + mock_send.assert_awaited_once_with(ctx, response) + + @pytest.mark.asyncio + async def test_query_setting_url_parses_query_and_sends_response(self): + response = MessagingExtensionResponse() + user_handler = AsyncMock(return_value=response) + + @self.ext.message_extensions.query_setting_url() + async def handler(ctx, state, query): + return await user_handler(ctx, state, query) + + route_handler = self.app._routes[0]["handler"] + ctx = _make_context( + ActivityTypes.invoke, + name="composeExtension/querySettingUrl", + value={"commandId": "c"}, + ) + with patch(_PATCH, new_callable=AsyncMock) as mock_send: + await route_handler(ctx, MagicMock()) + assert isinstance(user_handler.call_args[0][2], MessagingExtensionQuery) + mock_send.assert_awaited_once_with(ctx, response) + + @pytest.mark.asyncio + async def test_card_button_clicked_forwards_value_and_sends_empty_response(self): + user_handler = AsyncMock() + + @self.ext.message_extensions.card_button_clicked() + async def handler(ctx, state, value): + await user_handler(ctx, state, value) + + route_handler = self.app._routes[0]["handler"] + ctx = _make_context( + ActivityTypes.invoke, + name="composeExtension/onCardButtonClicked", + value={"id": "btn-1"}, + ) + with patch(_PATCH, new_callable=AsyncMock) as mock_send: + await route_handler(ctx, MagicMock()) + # raw value is forwarded; an empty invoke response is always sent + assert user_handler.call_args[0][2] == {"id": "btn-1"} + mock_send.assert_awaited_once_with(ctx) diff --git a/tests/hosting_msteams/test_messages.py b/tests/hosting_msteams/test_messages.py new file mode 100644 index 000000000..b9c39c225 --- /dev/null +++ b/tests/hosting_msteams/test_messages.py @@ -0,0 +1,318 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""Tests for TeamsAgentExtension.messages (message update and actionable message events).""" + +import pytest +from unittest.mock import AsyncMock, MagicMock, patch + +from microsoft_agents.activity import ActivityTypes + +from .helpers import _make_app, _make_context, is_supported_version + +pytestmark = pytest.mark.skipif( + not is_supported_version, + reason="microsoft-agents-hosting-teams tests require Python 3.11+", +) + +if is_supported_version: + from microsoft_teams.api.models import O365ConnectorCardActionQuery + from microsoft_agents.hosting.msteams import TeamsAgentExtension + +_PATCH = "microsoft_agents.hosting.msteams.message.message._send_invoke_response" + + +class TestMessageEdit: + + def setup_method(self): + self.app = _make_app() + self.ext = TeamsAgentExtension(self.app) + + def test_edit_selector(self): + @self.ext.messages.edit() + async def handler(ctx, state): ... + + selector = self.app._routes[0]["selector"] + assert ( + selector( + _make_context( + ActivityTypes.message_update, + channel_data={"eventType": "editMessage"}, + ) + ) + is True + ) + + def test_edit_wrong_event_type(self): + @self.ext.messages.edit() + async def handler(ctx, state): ... + + selector = self.app._routes[0]["selector"] + assert ( + selector( + _make_context( + ActivityTypes.message_update, + channel_data={"eventType": "undeleteMessage"}, + ) + ) + is False + ) + + def test_edit_wrong_channel(self): + @self.ext.messages.edit() + async def handler(ctx, state): ... + + selector = self.app._routes[0]["selector"] + assert ( + selector( + _make_context( + ActivityTypes.message_update, + channel_id="webchat", + channel_data={"eventType": "editMessage"}, + ) + ) + is False + ) + + def test_edit_is_not_invoke(self): + @self.ext.messages.edit() + async def handler(ctx, state): ... + + assert self.app._routes[0]["is_invoke"] is False + + +class TestMessageUndelete: + + def setup_method(self): + self.app = _make_app() + self.ext = TeamsAgentExtension(self.app) + + def test_undelete_selector(self): + @self.ext.messages.undelete() + async def handler(ctx, state): ... + + selector = self.app._routes[0]["selector"] + assert ( + selector( + _make_context( + ActivityTypes.message_update, + channel_data={"eventType": "undeleteMessage"}, + ) + ) + is True + ) + assert ( + selector( + _make_context( + ActivityTypes.message_update, + channel_data={"eventType": "editMessage"}, + ) + ) + is False + ) + + +class TestMessageSoftDelete: + + def setup_method(self): + self.app = _make_app() + self.ext = TeamsAgentExtension(self.app) + + def test_soft_delete_selector(self): + @self.ext.messages.delete() + async def handler(ctx, state): ... + + selector = self.app._routes[0]["selector"] + assert ( + selector( + _make_context( + ActivityTypes.message_delete, + channel_data={"eventType": "softDeleteMessage"}, + ) + ) + is True + ) + assert ( + selector( + _make_context( + ActivityTypes.message_update, + channel_data={"eventType": "softDeleteMessage"}, + ) + ) + is False + ) + assert ( + selector( + _make_context( + ActivityTypes.message_delete, + channel_data={"eventType": "editMessage"}, + ) + ) + is False + ) + + def test_soft_delete_wrong_channel(self): + @self.ext.messages.delete() + async def handler(ctx, state): ... + + selector = self.app._routes[0]["selector"] + assert ( + selector( + _make_context( + ActivityTypes.message_delete, + channel_id="webchat", + channel_data={"eventType": "softDeleteMessage"}, + ) + ) + is False + ) + + +class TestMessageReadReceipt: + + def setup_method(self): + self.app = _make_app() + self.ext = TeamsAgentExtension(self.app) + + def test_read_receipt_selector(self): + @self.ext.messages.read_receipt() + async def handler(ctx, state, receipt): ... + + selector = self.app._routes[0]["selector"] + assert ( + selector( + _make_context( + ActivityTypes.event, name="application/vnd.microsoft.readReceipt" + ) + ) + is True + ) + assert ( + selector( + _make_context( + ActivityTypes.event, name="application/vnd.microsoft.meetingStart" + ) + ) + is False + ) + + def test_read_receipt_is_not_invoke(self): + @self.ext.messages.read_receipt() + async def handler(ctx, state, receipt): ... + + assert self.app._routes[0]["is_invoke"] is False + + @pytest.mark.asyncio + async def test_read_receipt_handler_receives_dict(self): + user_handler = AsyncMock() + + @self.ext.messages.read_receipt() + async def handler(ctx, state, receipt): + await user_handler(ctx, state, receipt) + + route_handler = self.app._routes[0]["handler"] + ctx = _make_context( + ActivityTypes.event, + name="application/vnd.microsoft.readReceipt", + value={"lastReadMessageId": "msg123"}, + ) + await route_handler(ctx, MagicMock()) + assert user_handler.call_args[0][2] == {"lastReadMessageId": "msg123"} + + @pytest.mark.asyncio + async def test_read_receipt_raises_on_non_dict_value(self): + @self.ext.messages.read_receipt() + async def handler(ctx, state, receipt): ... + + route_handler = self.app._routes[0]["handler"] + ctx = _make_context( + ActivityTypes.event, + name="application/vnd.microsoft.readReceipt", + value='{"lastReadMessageId": "msg123"}', + ) + with pytest.raises(TypeError, match="read_receipt"): + await route_handler(ctx, MagicMock()) + + +class TestMessageExecuteAction: + + def setup_method(self): + self.app = _make_app() + self.ext = TeamsAgentExtension(self.app) + + def test_execute_action_selector(self): + @self.ext.messages.execute_action() + async def handler(ctx, state, query): ... + + selector = self.app._routes[0]["selector"] + assert ( + selector( + _make_context( + ActivityTypes.invoke, name="actionableMessage/executeAction" + ) + ) + is True + ) + assert selector(_make_context(ActivityTypes.invoke, name="task/fetch")) is False + + def test_execute_action_is_invoke(self): + @self.ext.messages.execute_action() + async def handler(ctx, state, query): ... + + assert self.app._routes[0]["is_invoke"] is True + + @pytest.mark.asyncio + async def test_execute_action_parses_query_and_sends_empty_response(self): + user_handler = AsyncMock() + + @self.ext.messages.execute_action() + async def handler(ctx, state, query: O365ConnectorCardActionQuery): + await user_handler(ctx, state, query) + + route_handler = self.app._routes[0]["handler"] + ctx = _make_context( + ActivityTypes.invoke, name="actionableMessage/executeAction", value={} + ) + with patch(_PATCH, new_callable=AsyncMock) as mock_send: + await route_handler(ctx, MagicMock()) + assert isinstance( + user_handler.call_args[0][2], O365ConnectorCardActionQuery + ) + mock_send.assert_awaited_once_with(ctx) + + +class TestMessageDirectDecoratorStyle: + + def setup_method(self): + self.app = _make_app() + self.ext = TeamsAgentExtension(self.app) + + def test_edit_direct(self): + @self.ext.messages.edit # type: ignore[arg-type] + async def handler(ctx, state): ... + + assert self.app._routes[0]["selector"] is not None + + def test_undelete_direct(self): + @self.ext.messages.undelete # type: ignore[arg-type] + async def handler(ctx, state): ... + + assert self.app._routes[0]["selector"] is not None + + def test_soft_delete_direct(self): + @self.ext.messages.delete # type: ignore[arg-type] + async def handler(ctx, state): ... + + assert self.app._routes[0]["selector"] is not None + + def test_read_receipt_direct(self): + @self.ext.messages.read_receipt # type: ignore[arg-type] + async def handler(ctx, state, value): ... + + assert self.app._routes[0]["selector"] is not None + + def test_execute_action_direct(self): + @self.ext.messages.execute_action # type: ignore[arg-type] + async def handler(ctx, state, query): ... + + assert self.app._routes[0]["selector"] is not None diff --git a/tests/hosting_msteams/test_routing_integration.py b/tests/hosting_msteams/test_routing_integration.py new file mode 100644 index 000000000..a8fc602c8 --- /dev/null +++ b/tests/hosting_msteams/test_routing_integration.py @@ -0,0 +1,271 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""End-to-end routing integration tests for ``TeamsAgentExtension``. + +Unlike the other ``hosting_msteams`` test modules (which register routes on a +mocked ``AgentApplication`` and invoke handlers/selectors directly), these tests +wire a **real** :class:`AgentApplication` to a real :class:`TeamsAgentExtension` +and drive activities through :meth:`AgentApplication.on_turn`. They verify that +Teams routes are actually registered, selected, and executed by the core routing +engine -- and that non-matching activities do not trigger them. + +The only test double is a minimal adapter that records sent activities; the turn +context, routing, state initialization, and Teams context upgrade all run for +real. +""" + +import pytest + +from microsoft_agents.activity import Activity, ActivityTypes, ResourceResponse +from microsoft_agents.hosting.core import MemoryStorage, TurnContext +from microsoft_agents.hosting.core.app import ( + AgentApplication, + ApplicationOptions, + TurnState, +) +from tests._common.testing_objects import TestingConnectionManager as _ConnectionManager + +from .helpers import is_supported_version + +pytestmark = pytest.mark.skipif( + not is_supported_version, + reason="microsoft-agents-hosting-teams tests require Python 3.11+", +) + +if is_supported_version: + from microsoft_agents.hosting.msteams import TeamsAgentExtension + from microsoft_agents.hosting.msteams.teams_turn_context import TeamsTurnContext + + +class _StubAdapter: + """Minimal adapter that records activities sent during a turn.""" + + def __init__(self): + self.sent_activities: list[Activity] = [] + + async def send_activities(self, context, activities): + self.sent_activities.extend(activities) + return [ResourceResponse()] * len(activities) + + +def _make_app() -> "AgentApplication[TurnState]": + """Build a real AgentApplication wired for on_turn integration tests.""" + storage = MemoryStorage() + app = AgentApplication[TurnState]( + options=ApplicationOptions( + storage=storage, + start_typing_timer=False, + remove_recipient_mention=False, + ), + connection_manager=_ConnectionManager(), + ) + # Shadow class-level middleware lists with fresh instance-level lists so the + # Teams before_turn hook registered below does not leak across app instances. + app._internal_before_turn = [] + app._internal_after_turn = [] + return app + + +def _make_activity(**kwargs) -> Activity: + """Create an Activity with the fields required for state loading/routing.""" + return Activity( + channel_id=kwargs.pop("channel_id", "msteams"), + conversation={"id": "conv1"}, + from_property={"id": "user1"}, + recipient={"id": "bot1"}, + service_url="https://smba.trafficmanager.net/teams/", + **kwargs, + ) + + +def _make_context(activity: Activity) -> TurnContext: + return TurnContext(_StubAdapter(), activity) + + +class TestMessageRouteIntegration: + def setup_method(self): + self.app = _make_app() + self.ext = TeamsAgentExtension(self.app) + self.calls: list[str] = [] + + @pytest.mark.asyncio + async def test_message_route_fires_when_text_matches(self): + @self.ext.message("hello") + async def handler(context, state): + self.calls.append(context.activity.text) + + await self.app.on_turn( + _make_context(_make_activity(type=ActivityTypes.message, text="hello")) + ) + assert self.calls == ["hello"] + + @pytest.mark.asyncio + async def test_message_handler_receives_teams_turn_context(self): + captured: list[object] = [] + + @self.ext.message("hello") + async def handler(context, state): + captured.append(context) + + await self.app.on_turn( + _make_context(_make_activity(type=ActivityTypes.message, text="hello")) + ) + assert len(captured) == 1 + assert isinstance(captured[0], TeamsTurnContext) + + @pytest.mark.asyncio + async def test_message_route_not_fired_when_text_differs(self): + @self.ext.message("hello") + async def handler(context, state): + self.calls.append(context.activity.text) + + await self.app.on_turn( + _make_context(_make_activity(type=ActivityTypes.message, text="goodbye")) + ) + assert self.calls == [] + + @pytest.mark.asyncio + async def test_non_message_activity_does_not_fire_message_route(self): + @self.ext.message("hello") + async def handler(context, state): + self.calls.append("called") + + await self.app.on_turn( + _make_context(_make_activity(type=ActivityTypes.event, text="hello")) + ) + assert self.calls == [] + + +class TestActivityRouteIntegration: + def setup_method(self): + self.app = _make_app() + self.ext = TeamsAgentExtension(self.app) + self.calls: list[str] = [] + + @pytest.mark.asyncio + async def test_activity_route_fires_for_matching_type(self): + @self.ext.activity(ActivityTypes.event) + async def handler(context, state): + self.calls.append(context.activity.type) + + await self.app.on_turn(_make_context(_make_activity(type=ActivityTypes.event))) + assert self.calls == [ActivityTypes.event] + + @pytest.mark.asyncio + async def test_activity_route_not_fired_for_other_type(self): + @self.ext.activity(ActivityTypes.event) + async def handler(context, state): + self.calls.append("called") + + await self.app.on_turn( + _make_context(_make_activity(type=ActivityTypes.message, text="hi")) + ) + assert self.calls == [] + + +class TestConversationUpdateRouteIntegration: + """Drives a Teams-specific conversation update route (message edit).""" + + def setup_method(self): + self.app = _make_app() + self.ext = TeamsAgentExtension(self.app) + self.calls: list[str] = [] + + def _register_edit(self): + @self.ext.messages.edit() + async def handler(context, state): + self.calls.append(type(context).__name__) + + @pytest.mark.asyncio + async def test_edit_route_fires_for_edit_event(self): + self._register_edit() + await self.app.on_turn( + _make_context( + _make_activity( + type=ActivityTypes.message_update, + channel_data={"eventType": "editMessage"}, + ) + ) + ) + assert self.calls == ["TeamsTurnContext"] + + @pytest.mark.asyncio + async def test_edit_route_not_fired_for_other_event(self): + self._register_edit() + await self.app.on_turn( + _make_context( + _make_activity( + type=ActivityTypes.message_update, + channel_data={"eventType": "undeleteMessage"}, + ) + ) + ) + assert self.calls == [] + + @pytest.mark.asyncio + async def test_edit_route_not_fired_on_non_teams_channel(self): + self._register_edit() + await self.app.on_turn( + _make_context( + _make_activity( + type=ActivityTypes.message_update, + channel_id="webchat", + channel_data={"eventType": "editMessage"}, + ) + ) + ) + assert self.calls == [] + + +class TestRouteSelectionOrderingIntegration: + def setup_method(self): + self.app = _make_app() + self.ext = TeamsAgentExtension(self.app) + self.call_order: list[str] = [] + + @pytest.mark.asyncio + async def test_only_first_matching_route_fires(self): + @self.ext.message("hello") + async def first(context, state): + self.call_order.append("first") + + @self.ext.message("hello") + async def second(context, state): + self.call_order.append("second") + + await self.app.on_turn( + _make_context(_make_activity(type=ActivityTypes.message, text="hello")) + ) + assert self.call_order == ["first"] + + @pytest.mark.asyncio + async def test_non_matching_route_is_skipped(self): + @self.ext.message("bye") + async def first(context, state): + self.call_order.append("first") + + @self.ext.message("hello") + async def second(context, state): + self.call_order.append("second") + + await self.app.on_turn( + _make_context(_make_activity(type=ActivityTypes.message, text="hello")) + ) + assert self.call_order == ["second"] + + @pytest.mark.asyncio + async def test_distinct_routes_each_fire_for_their_own_activity(self): + @self.ext.message("hello") + async def on_message(context, state): + self.call_order.append("message") + + @self.ext.activity(ActivityTypes.event) + async def on_event(context, state): + self.call_order.append("event") + + await self.app.on_turn(_make_context(_make_activity(type=ActivityTypes.event))) + await self.app.on_turn( + _make_context(_make_activity(type=ActivityTypes.message, text="hello")) + ) + assert self.call_order == ["event", "message"] diff --git a/tests/hosting_msteams/test_task_modules.py b/tests/hosting_msteams/test_task_modules.py new file mode 100644 index 000000000..9f3bfdce7 --- /dev/null +++ b/tests/hosting_msteams/test_task_modules.py @@ -0,0 +1,247 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""Tests for TeamsAgentExtension.task_modules (task/fetch and task/submit invokes).""" + +import re +import pytest +from unittest.mock import AsyncMock, MagicMock, patch + +from microsoft_agents.activity import ActivityTypes + +from .helpers import _make_app, _make_context, is_supported_version + +pytestmark = pytest.mark.skipif( + not is_supported_version, + reason="microsoft-agents-hosting-teams tests require Python 3.11+", +) + +if is_supported_version: + from microsoft_teams.api.models import TaskModuleRequest, TaskModuleResponse + from microsoft_agents.hosting.msteams import TeamsAgentExtension + +_PATCH = ( + "microsoft_agents.hosting.msteams.task_module.task_module._send_invoke_response" +) + + +class TestTaskModuleFetch: + + def setup_method(self): + self.app = _make_app() + self.ext = TeamsAgentExtension(self.app) + + # ── Selector tests ────────────────────────────────────────────────────── + + def test_fetch_no_verb_matches_any(self): + @self.ext.task_modules.fetch() + async def handler(ctx, state, req): ... + + selector = self.app._routes[0]["selector"] + ctx = _make_context(ActivityTypes.invoke, name="task/fetch", value={}) + assert selector(ctx) is True + + def test_fetch_verb_matches_exact(self): + @self.ext.task_modules.fetch("myVerb") + async def handler(ctx, state, req): ... + + selector = self.app._routes[0]["selector"] + assert ( + selector( + _make_context( + ActivityTypes.invoke, + name="task/fetch", + value={"data": {"verb": "myVerb"}}, + ) + ) + is True + ) + assert ( + selector( + _make_context( + ActivityTypes.invoke, + name="task/fetch", + value={"data": {"verb": "otherVerb"}}, + ) + ) + is False + ) + + def test_fetch_verb_matches_regex(self): + @self.ext.task_modules.fetch(re.compile(r"my.*")) + async def handler(ctx, state, req): ... + + selector = self.app._routes[0]["selector"] + assert ( + selector( + _make_context( + ActivityTypes.invoke, + name="task/fetch", + value={"data": {"verb": "mySpecialVerb"}}, + ) + ) + is True + ) + assert ( + selector( + _make_context( + ActivityTypes.invoke, + name="task/fetch", + value={"data": {"verb": "otherVerb"}}, + ) + ) + is False + ) + + def test_fetch_wrong_invoke_name(self): + @self.ext.task_modules.fetch() + async def handler(ctx, state, req): ... + + selector = self.app._routes[0]["selector"] + assert ( + selector(_make_context(ActivityTypes.invoke, name="task/submit", value={})) + is False + ) + + def test_fetch_wrong_activity_type(self): + @self.ext.task_modules.fetch() + async def handler(ctx, state, req): ... + + selector = self.app._routes[0]["selector"] + assert ( + selector(_make_context(ActivityTypes.message, name="task/fetch")) is False + ) + + def test_fetch_is_invoke(self): + @self.ext.task_modules.fetch() + async def handler(ctx, state, req): ... + + assert self.app._routes[0]["is_invoke"] is True + + # ── Handler tests ─────────────────────────────────────────────────────── + + @pytest.mark.asyncio + async def test_fetch_handler_passes_request_and_sends_response(self): + response = TaskModuleResponse() + user_handler = AsyncMock(return_value=response) + + @self.ext.task_modules.fetch() + async def handler(ctx, state, req: TaskModuleRequest): + return await user_handler(ctx, state, req) + + route_handler = self.app._routes[0]["handler"] + ctx = _make_context( + ActivityTypes.invoke, + name="task/fetch", + value={"data": {"verb": "myVerb"}, "context": None}, + ) + with patch(_PATCH, new_callable=AsyncMock) as mock_send: + await route_handler(ctx, MagicMock()) + assert user_handler.called + assert isinstance(user_handler.call_args[0][2], TaskModuleRequest) + mock_send.assert_awaited_once_with(ctx, response) + + @pytest.mark.asyncio + async def test_fetch_handler_skips_send_when_none_returned(self): + @self.ext.task_modules.fetch() + async def handler(ctx, state, req): ... + + route_handler = self.app._routes[0]["handler"] + ctx = _make_context( + ActivityTypes.invoke, + name="task/fetch", + value={"data": None, "context": None}, + ) + with patch(_PATCH, new_callable=AsyncMock) as mock_send: + await route_handler(ctx, MagicMock()) + mock_send.assert_not_awaited() + + +class TestTaskModuleSubmit: + + def setup_method(self): + self.app = _make_app() + self.ext = TeamsAgentExtension(self.app) + + def test_submit_no_verb_matches_any(self): + @self.ext.task_modules.submit() + async def handler(ctx, state, req): ... + + selector = self.app._routes[0]["selector"] + assert ( + selector(_make_context(ActivityTypes.invoke, name="task/submit", value={})) + is True + ) + + def test_submit_verb_matches_exact(self): + @self.ext.task_modules.submit("submitVerb") + async def handler(ctx, state, req): ... + + selector = self.app._routes[0]["selector"] + assert ( + selector( + _make_context( + ActivityTypes.invoke, + name="task/submit", + value={"data": {"verb": "submitVerb"}}, + ) + ) + is True + ) + assert ( + selector( + _make_context( + ActivityTypes.invoke, + name="task/submit", + value={"data": {"verb": "other"}}, + ) + ) + is False + ) + + def test_submit_wrong_invoke_name(self): + @self.ext.task_modules.submit() + async def handler(ctx, state, req): ... + + selector = self.app._routes[0]["selector"] + assert ( + selector(_make_context(ActivityTypes.invoke, name="task/fetch", value={})) + is False + ) + + def test_submit_is_invoke(self): + @self.ext.task_modules.submit() + async def handler(ctx, state, req): ... + + assert self.app._routes[0]["is_invoke"] is True + + @pytest.mark.asyncio + async def test_submit_handler_passes_request_and_sends_response(self): + response = TaskModuleResponse() + user_handler = AsyncMock(return_value=response) + + @self.ext.task_modules.submit() + async def handler(ctx, state, req: TaskModuleRequest): + return await user_handler(ctx, state, req) + + route_handler = self.app._routes[0]["handler"] + ctx = _make_context( + ActivityTypes.invoke, + name="task/submit", + value={"data": {"verb": "v"}, "context": None}, + ) + with patch(_PATCH, new_callable=AsyncMock) as mock_send: + await route_handler(ctx, MagicMock()) + assert isinstance(user_handler.call_args[0][2], TaskModuleRequest) + mock_send.assert_awaited_once_with(ctx, response) + + @pytest.mark.asyncio + async def test_submit_handler_skips_send_when_none_returned(self): + @self.ext.task_modules.submit() + async def handler(ctx, state, req): ... + + route_handler = self.app._routes[0]["handler"] + ctx = _make_context(ActivityTypes.invoke, name="task/submit", value={}) + with patch(_PATCH, new_callable=AsyncMock) as mock_send: + await route_handler(ctx, MagicMock()) + mock_send.assert_not_awaited() diff --git a/tests/hosting_msteams/test_team_lifecycle.py b/tests/hosting_msteams/test_team_lifecycle.py new file mode 100644 index 000000000..903d85261 --- /dev/null +++ b/tests/hosting_msteams/test_team_lifecycle.py @@ -0,0 +1,382 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""Tests for TeamsAgentExtension.teams (team lifecycle conversation update events).""" + +import pytest +from unittest.mock import AsyncMock, MagicMock + +from microsoft_agents.activity import ActivityTypes + +from .helpers import _make_app, _make_context, is_supported_version + +pytestmark = pytest.mark.skipif( + not is_supported_version, + reason="microsoft-agents-hosting-teams tests require Python 3.11+", +) + +if is_supported_version: + from microsoft_teams.api.models.channel_data import TeamInfo + + from microsoft_agents.hosting.msteams import TeamsAgentExtension + + +class TestTeamLifecycle: + + def setup_method(self): + self.app = _make_app() + self.ext = TeamsAgentExtension(self.app) + + def _selector_for(self, event_type: str): + """Register a no-op handler and return the registered selector.""" + self.app._routes.clear() + + async def handler(ctx, state, data): ... + + return handler + + def test_archived_selector(self): + @self.ext.teams.archived() + async def handler(ctx, state, data): ... + + selector = self.app._routes[0]["selector"] + assert ( + selector( + _make_context( + ActivityTypes.conversation_update, + channel_data={"eventType": "teamArchived"}, + ) + ) + is True + ) + assert ( + selector( + _make_context( + ActivityTypes.conversation_update, + channel_data={"eventType": "teamDeleted"}, + ) + ) + is False + ) + + def test_deleted_selector(self): + @self.ext.teams.deleted() + async def handler(ctx, state, data): ... + + selector = self.app._routes[0]["selector"] + assert ( + selector( + _make_context( + ActivityTypes.conversation_update, + channel_data={"eventType": "teamDeleted"}, + ) + ) + is True + ) + + def test_hard_deleted_selector(self): + @self.ext.teams.hard_deleted() + async def handler(ctx, state, data): ... + + selector = self.app._routes[0]["selector"] + assert ( + selector( + _make_context( + ActivityTypes.conversation_update, + channel_data={"eventType": "teamHardDeleted"}, + ) + ) + is True + ) + assert ( + selector( + _make_context( + ActivityTypes.conversation_update, + channel_data={"eventType": "teamDeleted"}, + ) + ) + is False + ) + + def test_renamed_selector(self): + @self.ext.teams.renamed() + async def handler(ctx, state, data): ... + + selector = self.app._routes[0]["selector"] + assert ( + selector( + _make_context( + ActivityTypes.conversation_update, + channel_data={"eventType": "teamRenamed"}, + ) + ) + is True + ) + + def test_restored_selector(self): + @self.ext.teams.restored() + async def handler(ctx, state, data): ... + + selector = self.app._routes[0]["selector"] + assert ( + selector( + _make_context( + ActivityTypes.conversation_update, + channel_data={"eventType": "teamRestored"}, + ) + ) + is True + ) + assert ( + selector( + _make_context( + ActivityTypes.conversation_update, + channel_data={"eventType": "teamArchived"}, + ) + ) + is False + ) + + def test_unarchived_selector(self): + @self.ext.teams.unarchived() + async def handler(ctx, state, data): ... + + selector = self.app._routes[0]["selector"] + assert ( + selector( + _make_context( + ActivityTypes.conversation_update, + channel_data={"eventType": "teamUnarchived"}, + ) + ) + is True + ) + + def test_team_event_requires_msteams_channel(self): + @self.ext.teams.archived() + async def handler(ctx, state, data): ... + + selector = self.app._routes[0]["selector"] + assert ( + selector( + _make_context( + ActivityTypes.conversation_update, + channel_id="webchat", + channel_data={"eventType": "teamArchived"}, + ) + ) + is False + ) + + def test_team_event_is_not_invoke(self): + @self.ext.teams.archived() + async def handler(ctx, state, data): ... + + assert self.app._routes[0]["is_invoke"] is False + + @pytest.mark.asyncio + async def test_archived_handler_passes_team_info(self): + user_handler = AsyncMock() + + @self.ext.teams.archived() + async def handler(ctx, state, data): + await user_handler(ctx, state, data) + + route_handler = self.app._routes[0]["handler"] + ctx = _make_context( + ActivityTypes.conversation_update, + channel_data={ + "eventType": "teamArchived", + "team": {"id": "t1"}, + }, + ) + + await route_handler(ctx, MagicMock()) + + assert isinstance(user_handler.call_args[0][2], TeamInfo) + assert user_handler.call_args[0][2].id == "t1" + + @pytest.mark.asyncio + async def test_archived_raises_when_team_info_missing(self): + @self.ext.teams.archived() + async def handler(ctx, state, data): ... + + route_handler = self.app._routes[0]["handler"] + ctx = _make_context( + ActivityTypes.conversation_update, + channel_data={"eventType": "teamArchived"}, + ) + + with pytest.raises(ValueError, match="channel_data.team"): + await route_handler(ctx, MagicMock()) + + +class TestTeamEvent: + + def setup_method(self): + self.app = _make_app() + self.ext = TeamsAgentExtension(self.app) + + def _selector(self): + @self.ext.teams.event() + async def handler(context, state, data): ... + + return self.app._routes[0]["selector"] + + def test_event_matches_team_archived(self): + selector = self._selector() + assert ( + selector( + _make_context( + ActivityTypes.conversation_update, + channel_data={"eventType": "teamArchived"}, + ) + ) + is True + ) + + def test_event_matches_team_renamed(self): + selector = self._selector() + assert ( + selector( + _make_context( + ActivityTypes.conversation_update, + channel_data={"eventType": "teamRenamed"}, + ) + ) + is True + ) + + def test_event_matches_team_deleted(self): + selector = self._selector() + assert ( + selector( + _make_context( + ActivityTypes.conversation_update, + channel_data={"eventType": "teamDeleted"}, + ) + ) + is True + ) + + def test_event_matches_team_hard_deleted(self): + selector = self._selector() + assert ( + selector( + _make_context( + ActivityTypes.conversation_update, + channel_data={"eventType": "teamHardDeleted"}, + ) + ) + is True + ) + + def test_event_matches_team_restored(self): + selector = self._selector() + assert ( + selector( + _make_context( + ActivityTypes.conversation_update, + channel_data={"eventType": "teamRestored"}, + ) + ) + is True + ) + + def test_event_matches_team_unarchived(self): + selector = self._selector() + assert ( + selector( + _make_context( + ActivityTypes.conversation_update, + channel_data={"eventType": "teamUnarchived"}, + ) + ) + is True + ) + + def test_event_no_match_non_team_event(self): + selector = self._selector() + assert ( + selector( + _make_context( + ActivityTypes.conversation_update, + channel_data={"eventType": "membersAdded"}, + ) + ) + is False + ) + + def test_event_requires_msteams_channel(self): + selector = self._selector() + assert ( + selector( + _make_context( + ActivityTypes.conversation_update, + channel_id="webchat", + channel_data={"eventType": "teamArchived"}, + ) + ) + is False + ) + + def test_event_requires_conversation_update(self): + selector = self._selector() + assert ( + selector( + _make_context( + ActivityTypes.message, + channel_data={"eventType": "teamArchived"}, + ) + ) + is False + ) + + def test_event_is_not_invoke(self): + @self.ext.teams.event() + async def handler(ctx, state, data): ... + + assert self.app._routes[0]["is_invoke"] is False + + +class TestTeamDirectDecoratorStyle: + + def setup_method(self): + self.app = _make_app() + self.ext = TeamsAgentExtension(self.app) + + def test_archived_direct(self): + @self.ext.teams.archived # type: ignore[arg-type] + async def handler(ctx, state, data): ... + + assert self.app._routes[0]["selector"] is not None + + def test_deleted_direct(self): + @self.ext.teams.deleted # type: ignore[arg-type] + async def handler(ctx, state, data): ... + + assert self.app._routes[0]["selector"] is not None + + def test_hard_deleted_direct(self): + @self.ext.teams.hard_deleted # type: ignore[arg-type] + async def handler(ctx, state, data): ... + + assert self.app._routes[0]["selector"] is not None + + def test_renamed_direct(self): + @self.ext.teams.renamed # type: ignore[arg-type] + async def handler(ctx, state, data): ... + + assert self.app._routes[0]["selector"] is not None + + def test_restored_direct(self): + @self.ext.teams.restored # type: ignore[arg-type] + async def handler(ctx, state, data): ... + + assert self.app._routes[0]["selector"] is not None + + def test_unarchived_direct(self): + @self.ext.teams.unarchived # type: ignore[arg-type] + async def handler(ctx, state, data): ... + + assert self.app._routes[0]["selector"] is not None diff --git a/tests/hosting_msteams/test_teams_activity.py b/tests/hosting_msteams/test_teams_activity.py new file mode 100644 index 000000000..0a535c272 --- /dev/null +++ b/tests/hosting_msteams/test_teams_activity.py @@ -0,0 +1,163 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""Tests for the TeamsActivity helper methods. + +These tests operate on real ``TeamsActivity`` / ``ChannelData`` objects rather +than mocks, so they exercise the actual parsing and mutation logic. +""" + +import pytest + +from .helpers import is_supported_version + +pytestmark = pytest.mark.skipif( + not is_supported_version, + reason="microsoft-agents-hosting-teams tests require Python 3.11+", +) + +if is_supported_version: + from microsoft_teams.api.models import ( + ChannelData, + ChannelInfo, + FeedbackLoop, + MeetingInfo, + TeamInfo, + ) + from microsoft_teams.api.models.channel_data.settings import ChannelDataSettings + + from microsoft_agents.hosting.msteams import TeamsActivity + + +def _activity(channel_data=None) -> "TeamsActivity": + return TeamsActivity(type="message", channel_data=channel_data) + + +class TestGetChannelData: + + def test_returns_none_when_channel_data_absent(self): + assert _activity()._get_channel_data() is None + + def test_returns_channel_data_instance_unchanged(self): + cd = ChannelData(channel=ChannelInfo(id="c1")) + assert _activity(cd)._get_channel_data() is cd + + def test_model_validates_dict_channel_data(self): + result = _activity({"channel": {"id": "c1"}})._get_channel_data() + assert isinstance(result, ChannelData) + assert result.channel.id == "c1" + + +class TestGetSelectedChannelId: + + def test_returns_selected_channel_id(self): + cd = ChannelData( + settings=ChannelDataSettings(selected_channel=ChannelInfo(id="sel-1")) + ) + assert _activity(cd).get_selected_channel_id() == "sel-1" + + def test_returns_none_when_no_channel_data(self): + assert _activity().get_selected_channel_id() is None + + def test_returns_none_when_settings_absent(self): + cd = ChannelData(channel=ChannelInfo(id="c1")) + assert _activity(cd).get_selected_channel_id() is None + + +class TestGetChannelId: + + def test_returns_channel_id(self): + cd = ChannelData(channel=ChannelInfo(id="c1")) + assert _activity(cd).get_channel_id() == "c1" + + def test_returns_none_when_no_channel_data(self): + assert _activity().get_channel_id() is None + + def test_returns_none_when_channel_absent(self): + cd = ChannelData(team=TeamInfo(id="t1")) + assert _activity(cd).get_channel_id() is None + + +class TestGetMeetingInfo: + + def test_returns_meeting(self): + meeting = MeetingInfo(id="m1") + cd = ChannelData(meeting=meeting) + assert _activity(cd).get_meeting_info() is meeting + + def test_returns_none_when_no_channel_data(self): + assert _activity().get_meeting_info() is None + + def test_returns_none_when_meeting_absent(self): + cd = ChannelData(channel=ChannelInfo(id="c1")) + assert _activity(cd).get_meeting_info() is None + + +class TestGetTeamInfo: + + def test_returns_team(self): + team = TeamInfo(id="t1") + cd = ChannelData(team=team) + assert _activity(cd).get_team_info() is team + + def test_returns_none_when_no_channel_data(self): + assert _activity().get_team_info() is None + + def test_returns_none_when_team_absent(self): + cd = ChannelData(channel=ChannelInfo(id="c1")) + assert _activity(cd).get_team_info() is None + + +class TestNotifyUser: + + def test_creates_channel_data_when_absent(self): + activity = _activity() + activity.notify_user() + assert isinstance(activity.channel_data, ChannelData) + assert activity.channel_data.notification is not None + + def test_alert_true_when_not_in_meeting(self): + activity = _activity() + activity.notify_user(alert_in_meeting=False) + notification = activity.channel_data.notification + assert notification.alert is True + assert notification.alert_in_meeting is False + + def test_alert_false_when_in_meeting(self): + activity = _activity() + activity.notify_user(alert_in_meeting=True) + notification = activity.channel_data.notification + assert notification.alert is False + assert notification.alert_in_meeting is True + + def test_external_resource_url_is_propagated(self): + activity = _activity() + activity.notify_user(external_resource_url="https://example.com/card") + assert ( + activity.channel_data.notification.external_resource_url + == "https://example.com/card" + ) + + def test_persists_notification_when_channel_data_is_dict(self): + # _try_get_channel_data returns a NEW ChannelData parsed from the dict; + # the notification must be written back so it is not lost. + activity = _activity({"channel": {"id": "c1"}}) + activity.notify_user(alert_in_meeting=True) + assert isinstance(activity.channel_data, ChannelData) + assert activity.channel_data.notification.alert_in_meeting is True + # existing channel data is preserved + assert activity.channel_data.channel.id == "c1" + + +class TestEnableFeedbackLoop: + + def test_enables_when_no_channel_data(self): + activity = _activity() + assert activity.enable_feedback_loop() is True + assert isinstance(activity.channel_data.feedback_loop, FeedbackLoop) + assert activity.channel_data.feedback_loop.type == "default" + + def test_uses_supplied_feedback_loop_type(self): + activity = _activity() + assert activity.enable_feedback_loop("custom") is True + assert activity.channel_data.feedback_loop.type == "custom" diff --git a/tests/hosting_msteams/test_teams_agent_extension.py b/tests/hosting_msteams/test_teams_agent_extension.py new file mode 100644 index 000000000..fac8fae1a --- /dev/null +++ b/tests/hosting_msteams/test_teams_agent_extension.py @@ -0,0 +1,133 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""Tests for TeamsAgentExtension property accessors and top-level wrapping.""" + +import pytest + +from .helpers import _make_app, is_supported_version + +pytestmark = pytest.mark.skipif( + not is_supported_version, + reason="microsoft-agents-hosting-teams tests require Python 3.11+", +) + +if is_supported_version: + from microsoft_agents.activity import Activity, Channels + from microsoft_teams.api.models import ChannelData + + from microsoft_agents.hosting.msteams import TeamsAgentExtension + from microsoft_agents.hosting.msteams._teams_api_client import ( + _TEAMS_API_CLIENT_KEY, + ) + from microsoft_agents.hosting.msteams.channel import Channel + from microsoft_agents.hosting.msteams.config import Config + from microsoft_agents.hosting.msteams.file_consent import FileConsent + from microsoft_agents.hosting.msteams.meeting import Meeting + from microsoft_agents.hosting.msteams.message import Message + from microsoft_agents.hosting.msteams.message_extension import MessageExtension + from microsoft_agents.hosting.msteams.task_module import TaskModule + from microsoft_agents.hosting.msteams.team import Team + + +class _FakeContext: + """Minimal context stand-in for exercising the before_turn hook directly.""" + + def __init__(self, activity, identity=None): + self.activity = activity + self.identity = identity + self.turn_state = {} + + +class TestTeamsAgentExtensionProperties: + + def setup_method(self): + self.app = _make_app() + self.ext = TeamsAgentExtension(self.app) + + def test_channels_property_returns_channel(self): + assert isinstance(self.ext.channels, Channel) + + def test_config_property_returns_config(self): + assert isinstance(self.ext.config, Config) + + def test_file_consent_property_returns_file_consent(self): + assert isinstance(self.ext.file_consent, FileConsent) + + def test_meetings_property_returns_meeting(self): + assert isinstance(self.ext.meetings, Meeting) + + def test_messages_property_returns_message(self): + assert isinstance(self.ext.messages, Message) + + def test_message_extensions_property_returns_message_extension(self): + assert isinstance(self.ext.message_extensions, MessageExtension) + + def test_task_modules_property_returns_task_module(self): + assert isinstance(self.ext.task_modules, TaskModule) + + def test_teams_property_returns_team(self): + assert isinstance(self.ext.teams, Team) + + def test_properties_return_same_instance_each_call(self): + assert self.ext.channels is self.ext.channels + assert self.ext.meetings is self.ext.meetings + assert self.ext.message_extensions is self.ext.message_extensions + assert self.ext.task_modules is self.ext.task_modules + + +class TestBeforeTurnHook: + + def setup_method(self): + self.app = _make_app() + self.ext = TeamsAgentExtension(self.app) + # The extension registers exactly one before_turn hook during construction. + self.hook = self.app.before_turn.call_args[0][0] + + def test_hook_is_registered(self): + self.app.before_turn.assert_called_once() + + @pytest.mark.asyncio + async def test_non_teams_channel_is_untouched(self): + activity = Activity( + type="message", channel_id="webchat", channel_data={"channel": {"id": "c"}} + ) + ctx = _FakeContext(activity) + + result = await self.hook(ctx, None) + + assert result is True + # channel_data left as the raw dict; no Teams API client cached + assert activity.channel_data == {"channel": {"id": "c"}} + assert _TEAMS_API_CLIENT_KEY not in ctx.turn_state + + @pytest.mark.asyncio + async def test_teams_channel_deserializes_channel_data(self): + activity = Activity( + type="conversationUpdate", + channel_id=Channels.ms_teams, + service_url="https://smba.trafficmanager.net/teams/", + channel_data={"channel": {"id": "c1"}}, + ) + ctx = _FakeContext(activity, identity=None) + + result = await self.hook(ctx, None) + + assert result is True + assert isinstance(activity.channel_data, ChannelData) + assert activity.channel_data.channel.id == "c1" + assert _TEAMS_API_CLIENT_KEY in ctx.turn_state + + @pytest.mark.asyncio + async def test_teams_channel_without_channel_data_sets_none(self): + activity = Activity( + type="conversationUpdate", + channel_id=Channels.ms_teams, + service_url="https://smba.trafficmanager.net/teams/", + ) + ctx = _FakeContext(activity, identity=None) + + result = await self.hook(ctx, None) + + assert result is True + assert activity.channel_data is None diff --git a/tests/hosting_msteams/test_teams_turn_context.py b/tests/hosting_msteams/test_teams_turn_context.py new file mode 100644 index 000000000..73df57206 --- /dev/null +++ b/tests/hosting_msteams/test_teams_turn_context.py @@ -0,0 +1,54 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""Tests for TeamsTurnContext helpers that can be exercised without a live adapter.""" + +import pytest + +from .helpers import is_supported_version + +pytestmark = pytest.mark.skipif( + not is_supported_version, + reason="microsoft-agents-hosting-teams tests require Python 3.11+", +) + +if is_supported_version: + from microsoft_agents.activity import ( + Activity, + ActivityTreatmentTypes, + Entity, + ) + + from microsoft_agents.hosting.msteams import TeamsTurnContext + + +class TestMakeTargetedActivity: + """``_make_targeted_activity`` mutates the supplied activity in place (returns + None) by appending a TARGETED activity-treatment entity.""" + + def test_appends_targeted_treatment_when_no_entities(self): + activity = Activity(type="message") + result = TeamsTurnContext._make_targeted_activity(activity) + assert result is None + assert len(activity.entities) == 1 + assert activity.entities[0].treatment == ActivityTreatmentTypes.TARGETED + + def test_preserves_existing_entities(self): + activity = Activity(type="message", entities=[Entity(type="mention")]) + TeamsTurnContext._make_targeted_activity(activity) + assert len(activity.entities) == 2 + assert activity.entities[0].type == "mention" + assert activity.entities[1].treatment == ActivityTreatmentTypes.TARGETED + + def test_each_call_appends_another_treatment(self): + # The helper is not idempotent: repeated calls accumulate treatments + # (see BUGS.md #4). This pins the current behaviour. + activity = Activity(type="message") + TeamsTurnContext._make_targeted_activity(activity) + TeamsTurnContext._make_targeted_activity(activity) + treatments = [ + e + for e in activity.entities + if getattr(e, "treatment", None) == ActivityTreatmentTypes.TARGETED + ] + assert len(treatments) == 2 diff --git a/tests/hosting_msteams/test_utils.py b/tests/hosting_msteams/test_utils.py new file mode 100644 index 000000000..78320f530 --- /dev/null +++ b/tests/hosting_msteams/test_utils.py @@ -0,0 +1,196 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""Tests for Teams hosting internal utilities (_utils.py).""" + +import re +import pytest +from unittest.mock import AsyncMock, MagicMock + +from .helpers import _make_context, is_supported_version + +pytestmark = pytest.mark.skipif( + not is_supported_version, + reason="microsoft-agents-hosting-teams tests require Python 3.11+", +) + +if is_supported_version: + from microsoft_agents.activity import Activity, ActivityTypes + from microsoft_teams.api.models.channel_data import ( + ChannelData, + ChannelInfo, + TeamInfo, + ) + from microsoft_agents.hosting.msteams._utils import ( + _get_channel_data, + _get_channel_event_type, + _get_channel_info, + _get_team_info, + _match_selector, + _send_invoke_response, + ) + + +class TestMatchSelector: + + def test_none_selector_matches_any_value(self): + assert _match_selector(None, "anything") is True + + def test_none_selector_matches_none_value(self): + assert _match_selector(None, None) is True + + def test_string_exact_match(self): + assert _match_selector("cmd", "cmd") is True + + def test_string_no_match(self): + assert _match_selector("cmd", "other") is False + + def test_string_does_not_partial_match(self): + assert _match_selector("cmd", "cmdExtra") is False + + def test_none_value_does_not_match_non_none_selector(self): + assert _match_selector("cmd", None) is False + + def test_regex_full_match(self): + assert _match_selector(re.compile(r"cmd\d+"), "cmd42") is True + + def test_regex_no_match(self): + assert _match_selector(re.compile(r"cmd\d+"), "other") is False + + def test_regex_requires_full_match(self): + assert _match_selector(re.compile(r"cmd"), "cmdExtra") is False + + +class TestGetChannelEventType: + + def test_returns_none_when_channel_data_absent(self): + ctx = _make_context(ActivityTypes.conversation_update) + assert _get_channel_event_type(ctx) is None + + def test_reads_camel_case_eventType_from_dict(self): + ctx = _make_context( + ActivityTypes.conversation_update, + channel_data={"eventType": "channelCreated"}, + ) + assert _get_channel_event_type(ctx) == "channelCreated" + + def test_reads_snake_case_event_type_from_dict(self): + ctx = _make_context( + ActivityTypes.conversation_update, + channel_data={"event_type": "channelDeleted"}, + ) + assert _get_channel_event_type(ctx) == "channelDeleted" + + def test_reads_event_type_from_object(self): + data = MagicMock() + data.event_type = "teamArchived" + ctx = _make_context(ActivityTypes.conversation_update) + ctx.activity.channel_data = data + assert _get_channel_event_type(ctx) == "teamArchived" + + def test_returns_none_when_object_has_no_event_type(self): + data = MagicMock(spec=[]) + ctx = _make_context(ActivityTypes.conversation_update) + ctx.activity.channel_data = data + assert _get_channel_event_type(ctx) is None + + +class TestGetChannelData: + + def test_raises_when_channel_data_is_none(self): + ctx = _make_context(ActivityTypes.conversation_update) + with pytest.raises(ValueError, match="channel_data"): + _get_channel_data(ctx.activity) + + def test_returns_existing_channel_data_unchanged(self): + channel_data = ChannelData() + ctx = _make_context(ActivityTypes.conversation_update) + ctx.activity.channel_data = channel_data + assert _get_channel_data(ctx.activity) is channel_data + + def test_model_validates_from_dict(self): + ctx = _make_context( + ActivityTypes.conversation_update, + channel_data={"eventType": "channelCreated"}, + ) + result = _get_channel_data(ctx.activity) + assert isinstance(result, ChannelData) + + def test_model_validates_camel_case_dict_keys(self): + ctx = _make_context( + ActivityTypes.conversation_update, + channel_data={"eventType": "teamArchived"}, + ) + result = _get_channel_data(ctx.activity) + assert isinstance(result, ChannelData) + + +class TestGetChannelInfo: + + def test_returns_channel_info_from_channel_data(self): + ctx = _make_context( + ActivityTypes.conversation_update, + channel_data={"channel": {"id": "c1"}}, + ) + result = _get_channel_info(ctx.activity) + assert isinstance(result, ChannelInfo) + assert result.id == "c1" + + def test_raises_when_channel_info_is_missing(self): + ctx = _make_context( + ActivityTypes.conversation_update, + channel_data={"team": {"id": "t1"}}, + ) + with pytest.raises(ValueError, match="channel_data.channel"): + _get_channel_info(ctx.activity) + + +class TestGetTeamInfo: + + def test_returns_team_info_from_channel_data(self): + ctx = _make_context( + ActivityTypes.conversation_update, + channel_data={"team": {"id": "t1"}}, + ) + result = _get_team_info(ctx.activity) + assert isinstance(result, TeamInfo) + assert result.id == "t1" + + def test_raises_when_team_info_is_missing(self): + ctx = _make_context( + ActivityTypes.conversation_update, + channel_data={"channel": {"id": "c1"}}, + ) + with pytest.raises(ValueError, match="channel_data.team"): + _get_team_info(ctx.activity) + + +class TestSendInvokeResponse: + + @pytest.mark.asyncio + async def test_sends_invoke_response_without_body(self): + ctx = _make_context(ActivityTypes.invoke) + await _send_invoke_response(ctx) + ctx.send_activity.assert_awaited_once() + sent = ctx.send_activity.call_args[0][0] + assert sent.type == ActivityTypes.invoke_response + assert sent.value.status == 200 + assert sent.value.body is None + + @pytest.mark.asyncio + async def test_serializes_pydantic_body(self): + ctx = _make_context(ActivityTypes.invoke) + body = Activity(type="message", text="hello") + await _send_invoke_response(ctx, body) + sent = ctx.send_activity.call_args[0][0] + assert sent.value.body == body.model_dump( + mode="json", by_alias=True, exclude_none=True + ) + + @pytest.mark.asyncio + async def test_passes_plain_dict_body_through(self): + ctx = _make_context(ActivityTypes.invoke) + body = {"key": "value"} + await _send_invoke_response(ctx, body) + sent = ctx.send_activity.call_args[0][0] + assert sent.value.body == {"key": "value"} diff --git a/tests/hosting_teams/test_teams_agent_extension.py b/tests/hosting_teams/test_teams_agent_extension.py deleted file mode 100644 index f16b514dd..000000000 --- a/tests/hosting_teams/test_teams_agent_extension.py +++ /dev/null @@ -1,1022 +0,0 @@ -""" -Copyright (c) Microsoft Corporation. All rights reserved. -Licensed under the MIT License. -""" - -import re -import sys -import pytest -from unittest.mock import AsyncMock, MagicMock, patch - -is_supported_version = sys.version_info >= (3, 12) - -pytestmark = pytest.mark.skipif( - not is_supported_version, - reason="microsoft-agents-hosting-teams tests require Python 3.12+", -) - -from microsoft_agents.activity import Activity, ActivityTypes -from microsoft_agents.hosting.core import TurnContext -from microsoft_agents.hosting.core.app import AgentApplication, RouteRank - -if is_supported_version: - from microsoft_agents.activity.teams import ( - MeetingParticipantsEventDetails, - ReadReceiptInfo, - ) - from microsoft_teams.api.models import ( - MeetingDetails, - MessagingExtensionQuery, - MessagingExtensionResponse, - O365ConnectorCardActionQuery, - TaskModuleRequest, - TaskModuleResponse, - ) - from microsoft_agents.hosting.teams import TeamsAgentExtension - - -def _make_app() -> AgentApplication: - app = MagicMock(spec=AgentApplication) - app._routes = [] - - def _add_route( - selector, handler, is_invoke=False, rank=RouteRank.DEFAULT, auth_handlers=None - ): - app._routes.append( - dict( - selector=selector, - handler=handler, - is_invoke=is_invoke, - rank=rank, - auth_handlers=auth_handlers, - ) - ) - - app.add_route.side_effect = _add_route - return app - - -def _meeting_details_value() -> dict: - return { - "id": "meeting-id", - "type": "scheduled", - "joinUrl": "https://example.com/meet", - "title": "Test Meeting", - "msGraphResourceId": "graph-id", - } - - -def _make_context( - activity_type: str, - name: str = None, - value: dict = None, - channel_id: str = "msteams", - channel_data: dict = None, - members_added=None, - members_removed=None, -) -> TurnContext: - context = MagicMock(spec=TurnContext) - activity = MagicMock(spec=Activity) - activity.type = activity_type - activity.name = name - activity.value = value - activity.channel_id = channel_id - activity.channel_data = channel_data - activity.members_added = members_added - activity.members_removed = members_removed - context.activity = activity - context.send_activity = AsyncMock() - return context - - -class TestTaskModule: - - def setup_method(self): - self.app = _make_app() - self.teams = TeamsAgentExtension(self.app) - - # ── Selector tests ────────────────────────────────────────────────────── - - @pytestmark - def test_on_fetch_no_verb_matches_any(self): - @self.teams.task_module.on_fetch() - async def handler(ctx, state, req): ... - - selector = self.app._routes[0]["selector"] - ctx = _make_context(ActivityTypes.invoke, name="task/fetch", value={}) - assert selector(ctx) is True - - @pytestmark - def test_on_fetch_verb_matches_exact(self): - @self.teams.task_module.on_fetch("myVerb") - async def handler(ctx, state, req): ... - - selector = self.app._routes[0]["selector"] - ctx_match = _make_context( - ActivityTypes.invoke, name="task/fetch", value={"data": {"verb": "myVerb"}} - ) - ctx_no_match = _make_context( - ActivityTypes.invoke, - name="task/fetch", - value={"data": {"verb": "otherVerb"}}, - ) - assert selector(ctx_match) is True - assert selector(ctx_no_match) is False - - @pytestmark - def test_on_fetch_verb_matches_regex(self): - @self.teams.task_module.on_fetch(re.compile(r"my.*")) - async def handler(ctx, state, req): ... - - selector = self.app._routes[0]["selector"] - ctx_match = _make_context( - ActivityTypes.invoke, - name="task/fetch", - value={"data": {"verb": "mySpecialVerb"}}, - ) - ctx_no_match = _make_context( - ActivityTypes.invoke, - name="task/fetch", - value={"data": {"verb": "otherVerb"}}, - ) - assert selector(ctx_match) is True - assert selector(ctx_no_match) is False - - @pytestmark - def test_on_fetch_wrong_invoke_name(self): - @self.teams.task_module.on_fetch() - async def handler(ctx, state, req): ... - - selector = self.app._routes[0]["selector"] - ctx = _make_context(ActivityTypes.invoke, name="task/submit", value={}) - assert selector(ctx) is False - - @pytestmark - def test_on_fetch_wrong_activity_type(self): - @self.teams.task_module.on_fetch() - async def handler(ctx, state, req): ... - - selector = self.app._routes[0]["selector"] - ctx = _make_context(ActivityTypes.message, name="task/fetch") - assert selector(ctx) is False - - @pytestmark - def test_on_submit_selector(self): - @self.teams.task_module.on_submit("submitVerb") - async def handler(ctx, state, req): ... - - selector = self.app._routes[0]["selector"] - ctx_match = _make_context( - ActivityTypes.invoke, - name="task/submit", - value={"data": {"verb": "submitVerb"}}, - ) - ctx_no_match = _make_context( - ActivityTypes.invoke, name="task/submit", value={"data": {"verb": "other"}} - ) - assert selector(ctx_match) is True - assert selector(ctx_no_match) is False - - @pytestmark - def test_on_fetch_is_invoke(self): - @self.teams.task_module.on_fetch() - async def handler(ctx, state, req): ... - - assert self.app._routes[0]["is_invoke"] is True - - @pytestmark - def test_on_submit_is_invoke(self): - @self.teams.task_module.on_submit() - async def handler(ctx, state, req): ... - - assert self.app._routes[0]["is_invoke"] is True - - # ── Handler tests ─────────────────────────────────────────────────────── - - @pytestmark - @pytest.mark.asyncio - async def test_on_fetch_handler_passes_request_and_sends_response(self): - response = TaskModuleResponse() - user_handler = AsyncMock(return_value=response) - - @self.teams.task_module.on_fetch() - async def handler(ctx, state, req: TaskModuleRequest): - return await user_handler(ctx, state, req) - - route_handler = self.app._routes[0]["handler"] - state = MagicMock() - ctx = _make_context( - ActivityTypes.invoke, - name="task/fetch", - value={"data": {"verb": "myVerb"}, "context": None}, - ) - with patch( - "microsoft_agents.hosting.teams.teams_agent_extension._send_invoke_response", - new_callable=AsyncMock, - ) as mock_send: - await route_handler(ctx, state) - assert user_handler.called - args = user_handler.call_args[0] - assert isinstance(args[2], TaskModuleRequest) - mock_send.assert_awaited_once_with(ctx, response) - - @pytestmark - @pytest.mark.asyncio - async def test_on_fetch_handler_skips_send_when_none_returned(self): - @self.teams.task_module.on_fetch() - async def handler(ctx, state, req): ... - - route_handler = self.app._routes[0]["handler"] - ctx = _make_context( - ActivityTypes.invoke, - name="task/fetch", - value={"data": None, "context": None}, - ) - with patch( - "microsoft_agents.hosting.teams.teams_agent_extension._send_invoke_response", - new_callable=AsyncMock, - ) as mock_send: - await route_handler(ctx, MagicMock()) - mock_send.assert_not_awaited() - - -class TestMessageExtension: - - def setup_method(self): - self.app = _make_app() - self.teams = TeamsAgentExtension(self.app) - - @pytestmark - def test_on_query_matches_by_command_id(self): - @self.teams.message_extension.on_query("searchCmd") - async def handler(ctx, state, query): ... - - selector = self.app._routes[0]["selector"] - ctx_match = _make_context( - ActivityTypes.invoke, - name="composeExtension/query", - value={ - "commandId": "searchCmd", - "parameters": [{"name": "searchQuery", "value": "pizza"}], - }, - ) - ctx_no_match = _make_context( - ActivityTypes.invoke, - name="composeExtension/query", - value={ - "commandId": "other", - "parameters": [{"name": "searchQuery", "value": "searchCmd"}], - }, - ) - assert selector(ctx_match) is True - assert selector(ctx_no_match) is False - - @pytestmark - def test_on_query_no_selector_matches_all(self): - @self.teams.message_extension.on_query() - async def handler(ctx, state, query): ... - - selector = self.app._routes[0]["selector"] - ctx = _make_context( - ActivityTypes.invoke, - name="composeExtension/query", - value={"parameters": [{"name": "searchQuery", "value": "anything"}]}, - ) - ctx_no_params = _make_context( - ActivityTypes.invoke, - name="composeExtension/query", - value={}, - ) - assert selector(ctx) is True - assert selector(ctx_no_params) is True - - @pytestmark - def test_on_query_is_invoke(self): - @self.teams.message_extension.on_query() - async def handler(ctx, state, query): ... - - assert self.app._routes[0]["is_invoke"] is True - - @pytestmark - def test_on_submit_action_excludes_preview(self): - @self.teams.message_extension.on_submit_action() - async def handler(ctx, state, action): ... - - selector = self.app._routes[0]["selector"] - ctx_normal = _make_context( - ActivityTypes.invoke, - name="composeExtension/submitAction", - value={"commandId": "cmd"}, - ) - ctx_preview = _make_context( - ActivityTypes.invoke, - name="composeExtension/submitAction", - value={"commandId": "cmd", "botMessagePreviewAction": "edit"}, - ) - assert selector(ctx_normal) is True - assert selector(ctx_preview) is False - - @pytestmark - def test_on_agent_message_preview_edit_selector(self): - @self.teams.message_extension.on_agent_message_preview_edit() - async def handler(ctx, state, action): ... - - selector = self.app._routes[0]["selector"] - ctx_edit = _make_context( - ActivityTypes.invoke, - name="composeExtension/submitAction", - value={"commandId": "cmd", "botMessagePreviewAction": "edit"}, - ) - ctx_send = _make_context( - ActivityTypes.invoke, - name="composeExtension/submitAction", - value={"commandId": "cmd", "botMessagePreviewAction": "send"}, - ) - assert selector(ctx_edit) is True - assert selector(ctx_send) is False - - @pytestmark - def test_on_agent_message_preview_send_selector(self): - @self.teams.message_extension.on_agent_message_preview_send() - async def handler(ctx, state, action): ... - - selector = self.app._routes[0]["selector"] - ctx_send = _make_context( - ActivityTypes.invoke, - name="composeExtension/submitAction", - value={"commandId": "cmd", "botMessagePreviewAction": "send"}, - ) - ctx_edit = _make_context( - ActivityTypes.invoke, - name="composeExtension/submitAction", - value={"commandId": "cmd", "botMessagePreviewAction": "edit"}, - ) - assert selector(ctx_send) is True - assert selector(ctx_edit) is False - - @pytestmark - def test_on_fetch_task_matches_command_id(self): - @self.teams.message_extension.on_fetch_task("myCmd") - async def handler(ctx, state, action): ... - - selector = self.app._routes[0]["selector"] - ctx_match = _make_context( - ActivityTypes.invoke, - name="composeExtension/fetchTask", - value={"commandId": "myCmd"}, - ) - ctx_no_match = _make_context( - ActivityTypes.invoke, - name="composeExtension/fetchTask", - value={"commandId": "other"}, - ) - assert selector(ctx_match) is True - assert selector(ctx_no_match) is False - - @pytestmark - def test_on_query_link_selector(self): - @self.teams.message_extension.on_query_link - async def handler(ctx, state, query): ... - - selector = self.app._routes[0]["selector"] - ctx = _make_context(ActivityTypes.invoke, name="composeExtension/queryLink") - ctx_other = _make_context(ActivityTypes.invoke, name="composeExtension/query") - assert selector(ctx) is True - assert selector(ctx_other) is False - - @pytestmark - def test_on_anonymous_query_link_selector(self): - @self.teams.message_extension.on_anonymous_query_link - async def handler(ctx, state, query): ... - - selector = self.app._routes[0]["selector"] - ctx = _make_context( - ActivityTypes.invoke, name="composeExtension/anonymousQueryLink" - ) - assert selector(ctx) is True - - @pytestmark - def test_on_select_item_selector(self): - @self.teams.message_extension.on_select_item - async def handler(ctx, state, item): ... - - selector = self.app._routes[0]["selector"] - ctx = _make_context(ActivityTypes.invoke, name="composeExtension/selectItem") - ctx_other = _make_context(ActivityTypes.invoke, name="composeExtension/query") - assert selector(ctx) is True - assert selector(ctx_other) is False - - @pytestmark - def test_on_configure_settings_sends_empty_response(self): - """on_configure_settings always sends a 200 regardless of handler return value.""" - - @self.teams.message_extension.on_configure_settings - async def handler(ctx, state, settings): ... - - assert self.app._routes[0]["is_invoke"] is True - - @pytestmark - def test_on_card_button_clicked_selector(self): - @self.teams.message_extension.on_card_button_clicked - async def handler(ctx, state, data): ... - - selector = self.app._routes[0]["selector"] - ctx = _make_context( - ActivityTypes.invoke, name="composeExtension/onCardButtonClicked" - ) - assert selector(ctx) is True - - @pytestmark - @pytest.mark.asyncio - async def test_on_query_handler_parses_query_and_sends_response(self): - response = MessagingExtensionResponse() - user_handler = AsyncMock(return_value=response) - - @self.teams.message_extension.on_query() - async def handler(ctx, state, query: MessagingExtensionQuery): - return await user_handler(ctx, state, query) - - route_handler = self.app._routes[0]["handler"] - ctx = _make_context( - ActivityTypes.invoke, - name="composeExtension/query", - value={"commandId": "search"}, - ) - with patch( - "microsoft_agents.hosting.teams.teams_agent_extension._send_invoke_response", - new_callable=AsyncMock, - ) as mock_send: - await route_handler(ctx, MagicMock()) - args = user_handler.call_args[0] - assert isinstance(args[2], MessagingExtensionQuery) - mock_send.assert_awaited_once_with(ctx, response) - - @pytestmark - @pytest.mark.asyncio - async def test_on_configure_settings_always_sends_response(self): - @self.teams.message_extension.on_configure_settings - async def handler(ctx, state, settings): ... - - route_handler = self.app._routes[0]["handler"] - ctx = _make_context( - ActivityTypes.invoke, name="composeExtension/setting", value={} - ) - with patch( - "microsoft_agents.hosting.teams.teams_agent_extension._send_invoke_response", - new_callable=AsyncMock, - ) as mock_send: - await route_handler(ctx, MagicMock()) - mock_send.assert_awaited_once_with(ctx) - - -class TestMeeting: - - def setup_method(self): - self.app = _make_app() - self.teams = TeamsAgentExtension(self.app) - - @pytestmark - def test_on_start_selector(self): - @self.teams.meeting.on_start - async def handler(ctx, state, meeting): ... - - selector = self.app._routes[0]["selector"] - ctx = _make_context( - ActivityTypes.event, name="application/vnd.microsoft.meetingStart" - ) - ctx_other = _make_context( - ActivityTypes.event, name="application/vnd.microsoft.meetingEnd" - ) - assert selector(ctx) is True - assert selector(ctx_other) is False - - @pytestmark - def test_on_end_selector(self): - @self.teams.meeting.on_end - async def handler(ctx, state, meeting): ... - - selector = self.app._routes[0]["selector"] - ctx = _make_context( - ActivityTypes.event, name="application/vnd.microsoft.meetingEnd" - ) - assert selector(ctx) is True - - @pytestmark - def test_on_participants_join_selector(self): - @self.teams.meeting.on_participants_join - async def handler(ctx, state, details): ... - - selector = self.app._routes[0]["selector"] - ctx = _make_context( - ActivityTypes.event, - name="application/vnd.microsoft.meetingParticipantJoin", - ) - assert selector(ctx) is True - - @pytestmark - def test_on_participants_leave_selector(self): - @self.teams.meeting.on_participants_leave - async def handler(ctx, state, details): ... - - selector = self.app._routes[0]["selector"] - ctx = _make_context( - ActivityTypes.event, - name="application/vnd.microsoft.meetingParticipantLeave", - ) - assert selector(ctx) is True - - @pytestmark - def test_on_start_is_not_invoke(self): - @self.teams.meeting.on_start - async def handler(ctx, state, meeting): ... - - assert self.app._routes[0]["is_invoke"] is False - - @pytestmark - @pytest.mark.asyncio - async def test_on_start_handler_parses_meeting_details(self): - user_handler = AsyncMock() - - @self.teams.meeting.on_start - async def handler(ctx, state, meeting: MeetingDetails): - await user_handler(ctx, state, meeting) - - route_handler = self.app._routes[0]["handler"] - ctx = _make_context( - ActivityTypes.event, - name="application/vnd.microsoft.meetingStart", - value=_meeting_details_value(), - ) - await route_handler(ctx, MagicMock()) - args = user_handler.call_args[0] - assert isinstance(args[2], MeetingDetails) - - @pytestmark - @pytest.mark.asyncio - async def test_on_end_handler_parses_meeting_details(self): - user_handler = AsyncMock() - - @self.teams.meeting.on_end - async def handler(ctx, state, meeting: MeetingDetails): - await user_handler(ctx, state, meeting) - - route_handler = self.app._routes[0]["handler"] - ctx = _make_context( - ActivityTypes.event, - name="application/vnd.microsoft.meetingEnd", - value=_meeting_details_value(), - ) - await route_handler(ctx, MagicMock()) - args = user_handler.call_args[0] - assert isinstance(args[2], MeetingDetails) - - @pytestmark - @pytest.mark.asyncio - async def test_on_participants_join_handler_parses_details(self): - user_handler = AsyncMock() - - @self.teams.meeting.on_participants_join - async def handler(ctx, state, details: MeetingParticipantsEventDetails): - await user_handler(ctx, state, details) - - route_handler = self.app._routes[0]["handler"] - ctx = _make_context( - ActivityTypes.event, - name="application/vnd.microsoft.meetingParticipantJoin", - value={}, - ) - await route_handler(ctx, MagicMock()) - args = user_handler.call_args[0] - assert isinstance(args[2], MeetingParticipantsEventDetails) - - -class TestTeamsAgentExtensionTopLevel: - - def setup_method(self): - self.app = _make_app() - self.teams = TeamsAgentExtension(self.app) - - # ── Message edit / undelete / soft delete ─────────────────────────────── - - @pytestmark - def test_on_message_edit_selector(self): - @self.teams.on_message_edit - async def handler(ctx, state): ... - - selector = self.app._routes[0]["selector"] - ctx = _make_context( - ActivityTypes.message_update, - channel_data={"eventType": "editMessage"}, - ) - assert selector(ctx) is True - - @pytestmark - def test_on_message_edit_wrong_event_type(self): - @self.teams.on_message_edit - async def handler(ctx, state): ... - - selector = self.app._routes[0]["selector"] - ctx = _make_context( - ActivityTypes.message_update, - channel_data={"eventType": "undeleteMessage"}, - ) - assert selector(ctx) is False - - @pytestmark - def test_on_message_edit_wrong_channel(self): - @self.teams.on_message_edit - async def handler(ctx, state): ... - - selector = self.app._routes[0]["selector"] - ctx = _make_context( - ActivityTypes.message_update, - channel_id="webchat", - channel_data={"eventType": "editMessage"}, - ) - assert selector(ctx) is False - - @pytestmark - def test_on_message_undelete_selector(self): - @self.teams.on_message_undelete - async def handler(ctx, state): ... - - selector = self.app._routes[0]["selector"] - ctx = _make_context( - ActivityTypes.message_update, - channel_data={"eventType": "undeleteMessage"}, - ) - assert selector(ctx) is True - - @pytestmark - def test_on_message_soft_delete_selector(self): - @self.teams.on_message_soft_delete - async def handler(ctx, state): ... - - selector = self.app._routes[0]["selector"] - ctx = _make_context( - ActivityTypes.message_delete, - channel_data={"eventType": "softDeleteMessage"}, - ) - assert selector(ctx) is True - - # ── Read receipt ─────────────────────────────────────────────────────── - - @pytestmark - def test_on_read_receipt_selector(self): - @self.teams.on_read_receipt - async def handler(ctx, state, receipt): ... - - selector = self.app._routes[0]["selector"] - ctx = _make_context( - ActivityTypes.event, name="application/vnd.microsoft.readReceipt" - ) - assert selector(ctx) is True - - @pytestmark - @pytest.mark.asyncio - async def test_on_read_receipt_handler_parses_receipt(self): - user_handler = AsyncMock() - - @self.teams.on_read_receipt - async def handler(ctx, state, receipt: ReadReceiptInfo): - await user_handler(ctx, state, receipt) - - route_handler = self.app._routes[0]["handler"] - ctx = _make_context( - ActivityTypes.event, - name="application/vnd.microsoft.readReceipt", - value={}, - ) - await route_handler(ctx, MagicMock()) - args = user_handler.call_args[0] - assert isinstance(args[2], ReadReceiptInfo) - - # ── Config ───────────────────────────────────────────────────────────── - - @pytestmark - def test_on_config_fetch_selector(self): - @self.teams.on_config_fetch - async def handler(ctx, state, data): ... - - selector = self.app._routes[0]["selector"] - ctx = _make_context(ActivityTypes.invoke, name="config/fetch") - ctx_other = _make_context(ActivityTypes.invoke, name="config/submit") - assert selector(ctx) is True - assert selector(ctx_other) is False - - @pytestmark - def test_on_config_fetch_is_invoke(self): - @self.teams.on_config_fetch - async def handler(ctx, state, data): ... - - assert self.app._routes[0]["is_invoke"] is True - - @pytestmark - def test_on_config_submit_selector(self): - @self.teams.on_config_submit - async def handler(ctx, state, data): ... - - selector = self.app._routes[0]["selector"] - ctx = _make_context(ActivityTypes.invoke, name="config/submit") - assert selector(ctx) is True - - # ── File consent ─────────────────────────────────────────────────────── - - @pytestmark - def test_on_file_consent_accept_selector(self): - @self.teams.on_file_consent_accept - async def handler(ctx, state, data): ... - - selector = self.app._routes[0]["selector"] - ctx_accept = _make_context( - ActivityTypes.invoke, - name="fileConsent/invoke", - value={"action": "accept"}, - ) - ctx_decline = _make_context( - ActivityTypes.invoke, - name="fileConsent/invoke", - value={"action": "decline"}, - ) - assert selector(ctx_accept) is True - assert selector(ctx_decline) is False - - @pytestmark - def test_on_file_consent_decline_selector(self): - @self.teams.on_file_consent_decline - async def handler(ctx, state, data): ... - - selector = self.app._routes[0]["selector"] - ctx_decline = _make_context( - ActivityTypes.invoke, - name="fileConsent/invoke", - value={"action": "decline"}, - ) - ctx_accept = _make_context( - ActivityTypes.invoke, - name="fileConsent/invoke", - value={"action": "accept"}, - ) - assert selector(ctx_decline) is True - assert selector(ctx_accept) is False - - @pytestmark - @pytest.mark.asyncio - async def test_on_file_consent_accept_sends_empty_response(self): - @self.teams.on_file_consent_accept - async def handler(ctx, state, data): ... - - route_handler = self.app._routes[0]["handler"] - ctx = _make_context( - ActivityTypes.invoke, - name="fileConsent/invoke", - value={"action": "accept", "context": None, "uploadInfo": None}, - ) - with patch( - "microsoft_agents.hosting.teams.teams_agent_extension._send_invoke_response", - new_callable=AsyncMock, - ) as mock_send: - await route_handler(ctx, MagicMock()) - mock_send.assert_awaited_once_with(ctx) - - # ── O365 ─────────────────────────────────────────────────────────────── - - @pytestmark - def test_on_o365_connector_card_action_selector(self): - @self.teams.on_o365_connector_card_action - async def handler(ctx, state, query): ... - - selector = self.app._routes[0]["selector"] - ctx = _make_context( - ActivityTypes.invoke, name="actionableMessage/executeAction" - ) - assert selector(ctx) is True - - @pytestmark - @pytest.mark.asyncio - async def test_on_o365_connector_card_action_parses_query(self): - user_handler = AsyncMock() - - @self.teams.on_o365_connector_card_action - async def handler(ctx, state, query: O365ConnectorCardActionQuery): - await user_handler(ctx, state, query) - - route_handler = self.app._routes[0]["handler"] - ctx = _make_context( - ActivityTypes.invoke, - name="actionableMessage/executeAction", - value={}, - ) - with patch( - "microsoft_agents.hosting.teams.teams_agent_extension._send_invoke_response", - new_callable=AsyncMock, - ): - await route_handler(ctx, MagicMock()) - args = user_handler.call_args[0] - assert isinstance(args[2], O365ConnectorCardActionQuery) - - # ── Conversation update events ───────────────────────────────────────── - - @pytestmark - def test_on_members_added_selector(self): - @self.teams.on_members_added - async def handler(ctx, state): ... - - selector = self.app._routes[0]["selector"] - member = MagicMock() - ctx = _make_context( - ActivityTypes.conversation_update, - members_added=[member], - ) - ctx_empty = _make_context( - ActivityTypes.conversation_update, - members_added=[], - ) - assert selector(ctx) is True - assert selector(ctx_empty) is False - - @pytestmark - def test_on_members_added_requires_teams_channel(self): - @self.teams.on_members_added - async def handler(ctx, state): ... - - selector = self.app._routes[0]["selector"] - member = MagicMock() - ctx = _make_context( - ActivityTypes.conversation_update, - channel_id="webchat", - members_added=[member], - ) - assert selector(ctx) is False - - @pytestmark - def test_on_members_removed_selector(self): - @self.teams.on_members_removed - async def handler(ctx, state): ... - - selector = self.app._routes[0]["selector"] - member = MagicMock() - ctx = _make_context( - ActivityTypes.conversation_update, - members_removed=[member], - ) - assert selector(ctx) is True - - @pytestmark - def test_on_channel_created_selector(self): - @self.teams.on_channel_created - async def handler(ctx, state): ... - - selector = self.app._routes[0]["selector"] - ctx = _make_context( - ActivityTypes.conversation_update, - channel_data={"eventType": "channelCreated"}, - ) - ctx_other = _make_context( - ActivityTypes.conversation_update, - channel_data={"eventType": "channelDeleted"}, - ) - assert selector(ctx) is True - assert selector(ctx_other) is False - - @pytestmark - def test_on_channel_deleted_selector(self): - @self.teams.on_channel_deleted - async def handler(ctx, state): ... - - selector = self.app._routes[0]["selector"] - ctx = _make_context( - ActivityTypes.conversation_update, - channel_data={"eventType": "channelDeleted"}, - ) - assert selector(ctx) is True - - @pytestmark - def test_on_channel_renamed_selector(self): - @self.teams.on_channel_renamed - async def handler(ctx, state): ... - - selector = self.app._routes[0]["selector"] - ctx = _make_context( - ActivityTypes.conversation_update, - channel_data={"eventType": "channelRenamed"}, - ) - assert selector(ctx) is True - - @pytestmark - def test_on_channel_restored_selector(self): - @self.teams.on_channel_restored - async def handler(ctx, state): ... - - selector = self.app._routes[0]["selector"] - ctx = _make_context( - ActivityTypes.conversation_update, - channel_data={"eventType": "channelRestored"}, - ) - assert selector(ctx) is True - - @pytestmark - def test_on_team_archived_selector(self): - @self.teams.on_team_archived - async def handler(ctx, state): ... - - selector = self.app._routes[0]["selector"] - ctx = _make_context( - ActivityTypes.conversation_update, - channel_data={"eventType": "teamArchived"}, - ) - assert selector(ctx) is True - - @pytestmark - def test_on_team_deleted_selector(self): - @self.teams.on_team_deleted - async def handler(ctx, state): ... - - selector = self.app._routes[0]["selector"] - ctx = _make_context( - ActivityTypes.conversation_update, - channel_data={"eventType": "teamDeleted"}, - ) - assert selector(ctx) is True - - @pytestmark - def test_on_team_hard_deleted_selector(self): - @self.teams.on_team_hard_deleted - async def handler(ctx, state): ... - - selector = self.app._routes[0]["selector"] - ctx = _make_context( - ActivityTypes.conversation_update, - channel_data={"eventType": "teamHardDeleted"}, - ) - assert selector(ctx) is True - - @pytestmark - def test_on_team_renamed_selector(self): - @self.teams.on_team_renamed - async def handler(ctx, state): ... - - selector = self.app._routes[0]["selector"] - ctx = _make_context( - ActivityTypes.conversation_update, - channel_data={"eventType": "teamRenamed"}, - ) - assert selector(ctx) is True - - @pytestmark - def test_on_team_restored_selector(self): - @self.teams.on_team_restored - async def handler(ctx, state): ... - - selector = self.app._routes[0]["selector"] - ctx = _make_context( - ActivityTypes.conversation_update, - channel_data={"eventType": "teamRestored"}, - ) - assert selector(ctx) is True - - @pytestmark - def test_on_team_unarchived_selector(self): - @self.teams.on_team_unarchived - async def handler(ctx, state): ... - - selector = self.app._routes[0]["selector"] - ctx = _make_context( - ActivityTypes.conversation_update, - channel_data={"eventType": "teamUnarchived"}, - ) - assert selector(ctx) is True - - # ── Sub-object accessors ──────────────────────────────────────────────── - - @pytestmark - def test_properties_return_sub_objects(self): - from microsoft_agents.hosting.teams.teams_agent_extension import ( - MessageExtension, - TaskModule, - Meeting, - ) - - assert isinstance(self.teams.message_extension, MessageExtension) - assert isinstance(self.teams.task_module, TaskModule) - assert isinstance(self.teams.meeting, Meeting) - - # ── Decorator style without args ──────────────────────────────────────── - - @pytestmark - def test_on_message_edit_direct_decorator(self): - """Verify on_message_edit works as @teams.on_message_edit (no call parens).""" - - @self.teams.on_message_edit - async def handler(ctx, state): ... - - assert len(self.app._routes) == 1 - - @pytestmark - def test_on_message_edit_factory_decorator(self): - """Verify on_message_edit works as @teams.on_message_edit() (with call parens).""" - - @self.teams.on_message_edit() - async def handler(ctx, state): ... - - assert len(self.app._routes) == 1