diff --git a/.azdo/ci-pr.yaml b/.azdo/ci-pr.yaml index d03c6d025..36efb1a9e 100644 --- a/.azdo/ci-pr.yaml +++ b/.azdo/ci-pr.yaml @@ -79,6 +79,11 @@ steps: 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 9aa8f7723..0b860917d 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -68,6 +68,11 @@ jobs: 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/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 + +[](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 +
| Version | +Date | +Release Notes | +
|---|---|---|
| 1.1.0 | +2026-06-19 | ++ + 1.1.0 Release Notes + + | +
| 1.0.0 | +2026-05-22 | ++ + 1.0.0 Release Notes + + | +
| 0.9.0 | +2026-04-15 | ++ + 0.9.0 Release Notes + + | +
| 0.8.0 | +2026-02-23 | ++ + 0.8.0 Release Notes + + | +
| 0.7.0 | +2026-01-21 | ++ + 0.7.0 Release Notes + + | +
| 0.6.1 | +2025-12-01 | ++ + 0.6.1 Release Notes + + | +
| 0.6.0 | +2025-11-18 | ++ + 0.6.0 Release Notes + + | +
| 0.5.0 | +2025-10-22 | ++ + 0.5.0 Release Notes + + | +