From 1cef3d52ce2e2a9d56c39db6525e8f0aec63b298 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Thu, 16 Jul 2026 13:09:05 -0700 Subject: [PATCH 1/6] Adding back teams --- .../microsoft-agents-hosting-teams/LICENSE | 21 + .../MANIFEST.in | 1 + .../hosting/teams/__init__.py | 17 + .../hosting/teams/errors/__init__.py | 15 + .../hosting/teams/errors/error_resources.py | 72 + .../hosting/teams/teams_activity_handler.py | 983 +++++++++++++ .../hosting/teams/teams_agent_extension.py | 1285 +++++++++++++++++ .../hosting/teams/teams_cloud_adapter.py | 0 .../hosting/teams/teams_info.py | 666 +++++++++ .../pyproject.toml | 23 + .../microsoft-agents-hosting-teams/readme.md | 169 +++ .../microsoft-agents-hosting-teams/setup.py | 19 + 12 files changed, 3271 insertions(+) create mode 100644 libraries/microsoft-agents-hosting-teams/LICENSE create mode 100644 libraries/microsoft-agents-hosting-teams/MANIFEST.in create mode 100644 libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/__init__.py create mode 100644 libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/errors/__init__.py create mode 100644 libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/errors/error_resources.py create mode 100644 libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/teams_activity_handler.py create mode 100644 libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/teams_agent_extension.py create mode 100644 libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/teams_cloud_adapter.py create mode 100644 libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/teams_info.py create mode 100644 libraries/microsoft-agents-hosting-teams/pyproject.toml create mode 100644 libraries/microsoft-agents-hosting-teams/readme.md create mode 100644 libraries/microsoft-agents-hosting-teams/setup.py diff --git a/libraries/microsoft-agents-hosting-teams/LICENSE b/libraries/microsoft-agents-hosting-teams/LICENSE new file mode 100644 index 000000000..9e841e7a2 --- /dev/null +++ b/libraries/microsoft-agents-hosting-teams/LICENSE @@ -0,0 +1,21 @@ + MIT License + + Copyright (c) Microsoft Corporation. + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE diff --git a/libraries/microsoft-agents-hosting-teams/MANIFEST.in b/libraries/microsoft-agents-hosting-teams/MANIFEST.in new file mode 100644 index 000000000..43a71d9ed --- /dev/null +++ b/libraries/microsoft-agents-hosting-teams/MANIFEST.in @@ -0,0 +1 @@ +include VERSION.txt \ No newline at end of file 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 new file mode 100644 index 000000000..2723ff546 --- /dev/null +++ b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/__init__.py @@ -0,0 +1,17 @@ +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/errors/__init__.py b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/errors/__init__.py new file mode 100644 index 000000000..8d767abf6 --- /dev/null +++ b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/errors/__init__.py @@ -0,0 +1,15 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +""" +Error resources for Microsoft Agents Hosting Teams package. +""" + +from microsoft_agents.activity.errors import ErrorMessage + +from .error_resources import TeamsErrorResources + +# Singleton 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-teams/microsoft_agents/hosting/teams/errors/error_resources.py new file mode 100644 index 000000000..f324224f2 --- /dev/null +++ b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/errors/error_resources.py @@ -0,0 +1,72 @@ +# 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. +""" + +from microsoft_agents.activity.errors import ErrorMessage + + +class TeamsErrorResources: + """ + Error messages for Teams operations. + + Error codes are organized in the range -62000 to -62999. + """ + + TeamsBadRequest = ErrorMessage( + "BadRequest", + -62000, + ) + + TeamsNotImplemented = ErrorMessage( + "NotImplemented", + -62001, + ) + + TeamsContextRequired = ErrorMessage( + "context is required.", + -62002, + ) + + TeamsMeetingIdRequired = ErrorMessage( + "meeting_id is required.", + -62003, + ) + + TeamsParticipantIdRequired = ErrorMessage( + "participant_id is required.", + -62004, + ) + + TeamsTeamIdRequired = ErrorMessage( + "team_id is required.", + -62005, + ) + + TeamsTurnContextRequired = ErrorMessage( + "TurnContext cannot be None", + -62006, + ) + + TeamsActivityRequired = ErrorMessage( + "Activity cannot be None", + -62007, + ) + + TeamsChannelIdRequired = ErrorMessage( + "The teams_channel_id cannot be None or empty", + -62008, + ) + + TeamsConversationIdRequired = ErrorMessage( + "conversation_id is required.", + -62009, + ) + + def __init__(self): + """Initialize TeamsErrorResources.""" + pass 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 new file mode 100644 index 000000000..2c171d5f3 --- /dev/null +++ b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/teams_activity_handler.py @@ -0,0 +1,983 @@ +""" +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": + card_response = FileConsentCardResponse.model_validate(value) + return self._create_invoke_response( + await self.on_teams_file_consent(turn_context, card_response) + ) + elif name == "actionableMessage/executeAction": + query = O365ConnectorCardActionQuery.model_validate(value) + await self.on_teams_o365_connector_card_action(turn_context, query) + return self._create_invoke_response() + elif name == "composeExtension/queryLink": + query = AppBasedLinkQuery.model_validate(value) + return self._create_invoke_response( + await self.on_teams_app_based_link_query(turn_context, query) + ) + elif name == "composeExtension/anonymousQueryLink": + query = AppBasedLinkQuery.model_validate(value) + return self._create_invoke_response( + await self.on_teams_anonymous_app_based_link_query( + turn_context, query + ) + ) + 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": + action = MessagingExtensionAction.model_validate(value) + return self._create_invoke_response( + await self.on_teams_messaging_extension_submit_action_dispatch( + turn_context, action + ) + ) + elif name == "composeExtension/fetchTask": + action = MessagingExtensionAction.model_validate(value) + return self._create_invoke_response( + await self.on_teams_messaging_extension_fetch_task( + turn_context, action + ) + ) + elif name == "composeExtension/querySettingUrl": + query = MessagingExtensionQuery.model_validate(value) + return self._create_invoke_response( + await self.on_teams_messaging_extension_configuration_query_setting_url( + turn_context, query + ) + ) + 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": + tab_request = TabRequest.model_validate(value) + return self._create_invoke_response( + await self.on_teams_tab_fetch(turn_context, tab_request) + ) + elif name == "tab/submit": + tab_submit = TabSubmit.model_validate(value) + return self._create_invoke_response( + await self.on_teams_tab_submit(turn_context, tab_submit) + ) + 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.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, turn_context + ) + + if ( + turn_context.activity.members_removed + and len(turn_context.activity.members_removed) > 0 + ): + return await self.on_teams_members_removed_dispatch( + turn_context.activity.members_removed, + channel_data.team, + 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( + channel_data.channel, channel_data.team, turn_context + ) + elif event_type == "channelDeleted": + return await self.on_teams_channel_deleted( + channel_data.channel, channel_data.team, turn_context + ) + elif event_type == "channelRenamed": + return await self.on_teams_channel_renamed( + channel_data.channel, channel_data.team, turn_context + ) + elif event_type == "teamArchived": + return await self.on_teams_team_archived( + channel_data.team, turn_context + ) + elif event_type == "teamDeleted": + return await self.on_teams_team_deleted(channel_data.team, turn_context) + elif event_type == "teamHardDeleted": + return await self.on_teams_team_hard_deleted( + channel_data.team, turn_context + ) + elif event_type == "channelRestored": + return await self.on_teams_channel_restored( + channel_data.channel, channel_data.team, turn_context + ) + elif event_type == "teamRenamed": + return await self.on_teams_team_renamed(channel_data.team, turn_context) + elif event_type == "teamRestored": + return await self.on_teams_team_restored( + channel_data.team, turn_context + ) + elif event_type == "teamUnarchived": + return await self.on_teams_team_unarchived( + channel_data.team, 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_members_added_activity(members_added, turn_context) + 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 teams_members_added: The list of TeamsChannelAccount objects representing the members added to the conversation. + :param team_info: The team info object. + :param turn_context: The context object for the turn. + :return: None + """ + return + + 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) + ) + ) + + await self.on_members_removed_activity(members_removed, turn_context) + 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 teams_members_removed: The list of TeamsChannelAccount objects representing the members removed from the conversation. + :param team_info: The team info object. + :param turn_context: The context object for the turn. + :return: None + """ + return + + async def on_teams_channel_created( + self, channel_info: ChannelInfo, team_info: TeamInfo, turn_context: TurnContext + ) -> None: + """ + Handles Teams channel created. + + :param channel_info: The channel info object. + :param team_info: The team info object. + :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 channel_info: The channel info object. + :param team_info: The team info object. + :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 channel_info: The channel info object. + :param team_info: The team info object. + :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 team_info: The team info object. + :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 team_info: The team info object. + :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 team_info: The team info object. + :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 channel_info: The channel info object. + :param team_info: The team info object. + :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 team_info: The team info object. + :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 team_info: The team info object. + :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 team_info: The team info object. + :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( + ReadReceiptInfo.model_validate(turn_context.activity.value), + turn_context, + ) + elif turn_context.activity.name == "application/vnd.microsoft.meetingStart": + return await self.on_teams_meeting_start( + MeetingStartEventDetails.model_validate( + turn_context.activity.value + ), + turn_context, + ) + elif turn_context.activity.name == "application/vnd.microsoft.meetingEnd": + return await self.on_teams_meeting_end( + MeetingEndEventDetails.model_validate(turn_context.activity.value), + turn_context, + ) + elif ( + turn_context.activity.name + == "application/vnd.microsoft.meetingParticipantJoin" + ): + return await self.on_teams_meeting_participants_join( + MeetingParticipantsEventDetails.model_validate( + turn_context.activity.value + ), + turn_context, + ) + elif ( + turn_context.activity.name + == "application/vnd.microsoft.meetingParticipantLeave" + ): + return await self.on_teams_meeting_participants_leave( + MeetingParticipantsEventDetails.model_validate( + turn_context.activity.value + ), + 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 meeting: The meeting start event details. + :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 meeting: The meeting end event details. + :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 read_receipt: The read receipt info. + :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 meeting: The meeting participants event details. + :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 meeting: The meeting participants event details. + :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 new file mode 100644 index 000000000..34cdef2fd --- /dev/null +++ b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/teams_agent_extension.py @@ -0,0 +1,1285 @@ +""" +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_cloud_adapter.py b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/teams_cloud_adapter.py new file mode 100644 index 000000000..e69de29bb 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 new file mode 100644 index 000000000..b90eb6f84 --- /dev/null +++ b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/teams_info.py @@ -0,0 +1,666 @@ +# 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/pyproject.toml b/libraries/microsoft-agents-hosting-teams/pyproject.toml new file mode 100644 index 000000000..3ab2c1459 --- /dev/null +++ b/libraries/microsoft-agents-hosting-teams/pyproject.toml @@ -0,0 +1,23 @@ +[build-system] +requires = ["setuptools"] +build-backend = "setuptools.build_meta" + +[project] +name = "microsoft-agents-hosting-teams" +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" +classifiers = [ + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Operating System :: OS Independent", +] + +[project.urls] +"Homepage" = "https://github.com/microsoft/Agents" diff --git a/libraries/microsoft-agents-hosting-teams/readme.md b/libraries/microsoft-agents-hosting-teams/readme.md new file mode 100644 index 000000000..9ed79b7a0 --- /dev/null +++ b/libraries/microsoft-agents-hosting-teams/readme.md @@ -0,0 +1,169 @@ +# 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/libraries/microsoft-agents-hosting-teams/setup.py b/libraries/microsoft-agents-hosting-teams/setup.py new file mode 100644 index 000000000..62611d341 --- /dev/null +++ b/libraries/microsoft-agents-hosting-teams/setup.py @@ -0,0 +1,19 @@ +from os import environ, path +from setuptools import setup + +# Try to read from VERSION.txt file first, fall back to environment variable +version_file = path.join(path.dirname(__file__), "VERSION.txt") +if path.exists(version_file): + with open(version_file, "r", encoding="utf-8") as f: + package_version = f.read().strip() +else: + package_version = environ.get("PackageVersion", "0.0.0") + +setup( + version=package_version, + install_requires=[ + f"microsoft-agents-hosting-core=={package_version}", + "aiohttp>=3.11.11", + "microsoft-teams-api>=2.0.0,<3", + ], +) From 7223732d8808f539c9814d41e970f12f77f6ce3c Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Thu, 16 Jul 2026 13:22:59 -0700 Subject: [PATCH 2/6] Adding back tests --- tests/hosting_teams/__init__.py | 0 .../test_teams_agent_extension.py | 1022 +++++++++++++++++ 2 files changed, 1022 insertions(+) create mode 100644 tests/hosting_teams/__init__.py create mode 100644 tests/hosting_teams/test_teams_agent_extension.py diff --git a/tests/hosting_teams/__init__.py b/tests/hosting_teams/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/hosting_teams/test_teams_agent_extension.py b/tests/hosting_teams/test_teams_agent_extension.py new file mode 100644 index 000000000..f16b514dd --- /dev/null +++ b/tests/hosting_teams/test_teams_agent_extension.py @@ -0,0 +1,1022 @@ +""" +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 From b99315c350746cfa1d8d243e59da925b335a24bd Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Thu, 16 Jul 2026 13:27:41 -0700 Subject: [PATCH 3/6] Update to install wheels step --- .azdo/ci-pr.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.azdo/ci-pr.yaml b/.azdo/ci-pr.yaml index d03c6d025..82cd0e406 100644 --- a/.azdo/ci-pr.yaml +++ b/.azdo/ci-pr.yaml @@ -79,6 +79,7 @@ steps: else echo "Skipping microsoft_agents_hosting_msteams: requires Python 3.11+" fi + python -m pip install ./dist/microsoft_agents_hosting_teams*.whl python -m pip install ./dist/microsoft_agents_hosting_slack*.whl python -m pip install ./dist/microsoft_agents_storage_blob*.whl python -m pip install ./dist/microsoft_agents_storage_cosmos*.whl From 0506d0d5b004bd68d14a9c547311ec0edd769dfa Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Thu, 16 Jul 2026 13:37:53 -0700 Subject: [PATCH 4/6] Update to install wheels step --- .github/workflows/python-package.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index 9aa8f7723..6d64b628a 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -68,6 +68,7 @@ jobs: else echo "Skipping microsoft_agents_hosting_msteams: requires Python 3.11+" fi + python -m pip install ./dist/microsoft_agents_hosting_teams*.whl python -m pip install ./dist/microsoft_agents_hosting_slack*.whl python -m pip install ./dist/microsoft_agents_storage_blob*.whl python -m pip install ./dist/microsoft_agents_storage_cosmos*.whl From 516640a032d67b221cb1815ac672b8ea9c0f4d5f Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Thu, 16 Jul 2026 13:41:15 -0700 Subject: [PATCH 5/6] Updating pipelines to not install hosting-teams for python <= 3.11 --- .azdo/ci-pr.yaml | 2 +- .github/workflows/python-package.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.azdo/ci-pr.yaml b/.azdo/ci-pr.yaml index 82cd0e406..1f627a48c 100644 --- a/.azdo/ci-pr.yaml +++ b/.azdo/ci-pr.yaml @@ -76,10 +76,10 @@ steps: python -m pip install ./dist/microsoft_agents_hosting_dialogs*.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 + python -m pip install ./dist/microsoft_agents_hosting_teams*.whl else echo "Skipping microsoft_agents_hosting_msteams: requires Python 3.11+" fi - python -m pip install ./dist/microsoft_agents_hosting_teams*.whl python -m pip install ./dist/microsoft_agents_hosting_slack*.whl python -m pip install ./dist/microsoft_agents_storage_blob*.whl python -m pip install ./dist/microsoft_agents_storage_cosmos*.whl diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index 6d64b628a..22c267f2e 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -65,10 +65,10 @@ jobs: python -m pip install ./dist/microsoft_agents_hosting_dialogs*.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 + python -m pip install ./dist/microsoft_agents_hosting_teams*.whl else echo "Skipping microsoft_agents_hosting_msteams: requires Python 3.11+" fi - python -m pip install ./dist/microsoft_agents_hosting_teams*.whl python -m pip install ./dist/microsoft_agents_hosting_slack*.whl python -m pip install ./dist/microsoft_agents_storage_blob*.whl python -m pip install ./dist/microsoft_agents_storage_cosmos*.whl From e6820c17cea0d44c814e88aa60931aa90bd1c8da Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Thu, 16 Jul 2026 13:43:35 -0700 Subject: [PATCH 6/6] Updating pipelines to not install hosting-teams for python <= 3.11 --- .azdo/ci-pr.yaml | 6 +++++- .github/workflows/python-package.yml | 6 +++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/.azdo/ci-pr.yaml b/.azdo/ci-pr.yaml index 1f627a48c..36efb1a9e 100644 --- a/.azdo/ci-pr.yaml +++ b/.azdo/ci-pr.yaml @@ -76,10 +76,14 @@ steps: python -m pip install ./dist/microsoft_agents_hosting_dialogs*.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 - python -m pip install ./dist/microsoft_agents_hosting_teams*.whl else echo "Skipping microsoft_agents_hosting_msteams: requires Python 3.11+" fi + 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 + else + echo "Skipping microsoft_agents_hosting_teams: requires Python 3.12+" + fi python -m pip install ./dist/microsoft_agents_hosting_slack*.whl python -m pip install ./dist/microsoft_agents_storage_blob*.whl python -m pip install ./dist/microsoft_agents_storage_cosmos*.whl diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index 22c267f2e..0b860917d 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -65,10 +65,14 @@ jobs: python -m pip install ./dist/microsoft_agents_hosting_dialogs*.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 - python -m pip install ./dist/microsoft_agents_hosting_teams*.whl else echo "Skipping microsoft_agents_hosting_msteams: requires Python 3.11+" fi + 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 + else + echo "Skipping microsoft_agents_hosting_teams: requires Python 3.12+" + fi python -m pip install ./dist/microsoft_agents_hosting_slack*.whl python -m pip install ./dist/microsoft_agents_storage_blob*.whl python -m pip install ./dist/microsoft_agents_storage_cosmos*.whl