From 1bfd426b03e24b755e4e83ee8b5d2f8f66d6027a Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Tue, 16 Jun 2026 08:54:13 -0700 Subject: [PATCH 01/46] Updating typing annotations --- .../microsoft_agents/activity/activity.py | 13 ++++++-- .../activity/channel_adapter_protocol.py | 8 ++--- .../activity/entity/ai_entity.py | 8 ++--- .../activity/teams/conversation_list.py | 5 ++- .../meeting_notification_channel_data.py | 3 +- .../teams/meeting_notification_response.py | 3 +- .../meeting_participants_event_details.py | 3 +- .../activity/teams/message_actions_payload.py | 14 ++++---- .../teams/messaging_extension_action.py | 5 ++- .../teams/messaging_extension_query.py | 6 ++-- .../teams/messaging_extension_result.py | 6 ++-- .../messaging_extension_suggested_action.py | 5 ++- .../activity/teams/o365_connector_card.py | 10 +++--- .../teams/o365_connector_card_action_card.py | 9 +++--- .../o365_connector_card_multichoice_input.py | 4 +-- .../teams/o365_connector_card_open_uri.py | 5 ++- .../teams/o365_connector_card_section.py | 14 ++++---- .../activity/teams/tab_response_cards.py | 3 +- .../activity/teams/tab_suggested_actions.py | 4 +-- .../targeted_meeting_notification_value.py | 7 ++-- .../activity/teams/teams_channel_account.py | 2 +- .../activity/teams/teams_channel_data.py | 5 ++- .../teams/teams_paged_members_result.py | 3 +- .../activity/turn_context_protocol.py | 12 ++++--- .../msal/msal_connection_manager.py | 18 +++++------ .../client/connection_settings.py | 4 +-- .../hosting/core/_oauth/_oauth_flow.py | 2 +- .../hosting/core/app/agent_application.py | 9 +++--- .../hosting/core/app/app_options.py | 4 +-- .../hosting/core/app/input_file.py | 4 +-- .../core/app/state/conversation_state.py | 2 +- .../hosting/core/app/state/state.py | 13 +++----- .../hosting/core/app/state/temp_state.py | 10 +++--- .../hosting/core/app/state/turn_state.py | 6 ++-- .../core/app/streaming/citation_util.py | 6 ++-- .../core/app/streaming/streaming_response.py | 15 ++++----- .../hosting/core/app/typing_indicator.py | 4 +-- .../hosting/core/channel_adapter.py | 14 ++++---- .../hosting/core/channel_service_adapter.py | 4 +-- .../core/client/channels_configuration.py | 12 +++---- .../core/client/configuration_channel_host.py | 4 +-- .../hosting/core/client/http_agent_channel.py | 2 +- .../core/connector/agent_sign_in_base.py | 12 +++---- .../hosting/core/connector/user_token_base.py | 21 ++++++++---- .../core/http/_channel_service_routes.py | 4 +-- .../hosting/core/http/_http_adapter_base.py | 4 +-- .../core/http/_http_request_protocol.py | 6 ++-- .../hosting/core/http/_http_response.py | 4 +-- .../hosting/core/message_factory.py | 32 +++++++++---------- .../hosting/core/state/agent_state.py | 18 +++++------ .../core/state/state_property_accessor.py | 6 ++-- .../hosting/core/storage/memory_storage.py | 2 +- .../hosting/core/storage/storage.py | 19 ++++------- .../core/storage/transcript_file_store.py | 16 +++++----- .../hosting/core/storage/transcript_logger.py | 7 ++-- .../core/storage/transcript_memory_store.py | 4 +-- .../hosting/core/storage/transcript_store.py | 4 +-- .../hosting/core/turn_context.py | 14 +++++--- .../hosting/dialogs/object_path.py | 8 ++--- .../prompts/prompt_validator_context.py | 5 +-- .../hosting/slack/_path_navigator.py | 6 ++-- .../hosting/slack/api/slack_stream.py | 6 ++-- .../hosting/slack/slack_agent_extension.py | 4 +-- .../hosting/teams/teams_activity_handler.py | 10 +++--- .../hosting/teams/teams_agent_extension.py | 4 +-- .../hosting/teams/teams_info.py | 10 +++--- .../storage/blob/blob_storage.py | 6 ++-- .../storage/blob/blob_storage_config.py | 6 ++-- .../storage/cosmos/cosmos_db_storage.py | 11 +++---- .../cosmos/cosmos_db_storage_config.py | 7 ++-- 70 files changed, 269 insertions(+), 277 deletions(-) diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/activity.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/activity.py index cad64ed24..d5f960e0a 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/activity.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/activity.py @@ -497,7 +497,7 @@ def create_message_activity(): """ return Activity(type=ActivityTypes.message) - def create_reply(self, text: str = None, locale: str = None): + def create_reply(self, text: str | None = None, locale: str | None = None): """ Creates a new message activity as a response to this activity. @@ -539,7 +539,11 @@ def create_reply(self, text: str = None, locale: str = None): ) def create_trace( - self, name: str, value: object = None, value_type: str = None, label: str = None + self, + name: str, + value: object = None, + value_type: str | None = None, + label: str | None = None, ): """ Creates a new trace activity based on this activity. @@ -585,7 +589,10 @@ def create_trace( @staticmethod def create_trace_activity( - name: str, value: object = None, value_type: str = None, label: str = None + name: str, + value: object = None, + value_type: str | None = None, + label: str | None = None, ): """ Creates an instance of the :class:`microsoft_agents.activity.Activity` class as a TraceActivity object. diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/channel_adapter_protocol.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/channel_adapter_protocol.py index ce07f2481..811d694f1 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/channel_adapter_protocol.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/channel_adapter_protocol.py @@ -2,7 +2,7 @@ # Licensed under the MIT License. from abc import abstractmethod -from typing import Protocol, List, Callable, Awaitable, Optional +from typing import Protocol, Callable, Awaitable, Optional from .turn_context_protocol import TurnContextProtocol from microsoft_agents.activity import ( @@ -18,8 +18,8 @@ class ChannelAdapterProtocol(Protocol): @abstractmethod async def send_activities( - self, context: TurnContextProtocol, activities: List[Activity] - ) -> List[ResourceResponse]: + self, context: TurnContextProtocol, activities: list[Activity] + ) -> list[ResourceResponse]: pass @abstractmethod @@ -54,7 +54,7 @@ async def continue_conversation_with_claims( claims_identity: dict, continuation_activity: Activity, callback: Callable[[TurnContextProtocol], Awaitable], - audience: str = None, + audience: str | None = None, ): pass diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/ai_entity.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/ai_entity.py index 68f1a6bf1..45235092c 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/ai_entity.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/ai_entity.py @@ -2,7 +2,7 @@ # Licensed under the MIT License. from enum import Enum -from typing import List, Optional, Literal +from typing import Optional, Literal from pydantic import Field from ..agents_model import AgentsModel @@ -79,7 +79,7 @@ class ClientCitationAppearance(AgentsModel, _SchemaMixin): abstract: str = "" encoding_format: Optional[str] = None image: Optional[ClientCitationImage] = None - keywords: Optional[List[str]] = None + keywords: Optional[list[str]] = None usage_info: Optional[SensitivityUsageInfo] = None @@ -107,6 +107,6 @@ class AIEntity(Entity): type: str = "https://schema.org/Message" id: str = "" - additional_type: List[str] = Field(default_factory=lambda: ["AIGeneratedContent"]) - citation: Optional[List[ClientCitation]] = None + additional_type: list[str] = Field(default_factory=lambda: ["AIGeneratedContent"]) + citation: Optional[list[ClientCitation]] = None usage_info: Optional[SensitivityUsageInfo] = None diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/teams/conversation_list.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/teams/conversation_list.py index 52ddadf57..f417fa6b9 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/teams/conversation_list.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/teams/conversation_list.py @@ -2,7 +2,6 @@ # Licensed under the MIT License. from ..agents_model import AgentsModel -from typing import List from .channel_info import ChannelInfo @@ -11,7 +10,7 @@ class ConversationList(AgentsModel): """List of channels under a team. :param conversations: List of ChannelInfo objects. - :type conversations: List[ChannelInfo] + :type conversations: list[ChannelInfo] """ - conversations: List[ChannelInfo] + conversations: list[ChannelInfo] diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/teams/meeting_notification_channel_data.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/teams/meeting_notification_channel_data.py index b7aaee69a..1dd3ced65 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/teams/meeting_notification_channel_data.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/teams/meeting_notification_channel_data.py @@ -2,7 +2,6 @@ # Licensed under the MIT License. from ..agents_model import AgentsModel -from typing import List from .on_behalf_of import OnBehalfOf @@ -13,4 +12,4 @@ class MeetingNotificationChannelData(AgentsModel): :type on_behalf_of_list: list[OnBehalfOf] """ - on_behalf_of_list: List[OnBehalfOf] = None + on_behalf_of_list: list[OnBehalfOf] = None diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/teams/meeting_notification_response.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/teams/meeting_notification_response.py index d08dd55e7..3b8b779e4 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/teams/meeting_notification_response.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/teams/meeting_notification_response.py @@ -2,7 +2,6 @@ # Licensed under the MIT License. from ..agents_model import AgentsModel -from typing import List from .meeting_notification_recipient_failure_info import ( MeetingNotificationRecipientFailureInfo, ) @@ -17,4 +16,4 @@ class MeetingNotificationResponse(AgentsModel): :type recipients_failure_info: list[MeetingNotificationRecipientFailureInfo] """ - recipients_failure_info: List[MeetingNotificationRecipientFailureInfo] = None + recipients_failure_info: list[MeetingNotificationRecipientFailureInfo] = None diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/teams/meeting_participants_event_details.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/teams/meeting_participants_event_details.py index 6d55f14fb..e946252a2 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/teams/meeting_participants_event_details.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/teams/meeting_participants_event_details.py @@ -2,7 +2,6 @@ # Licensed under the MIT License. from ..agents_model import AgentsModel -from typing import List from .teams_meeting_member import TeamsMeetingMember @@ -13,4 +12,4 @@ class MeetingParticipantsEventDetails(AgentsModel): :type members: list[TeamsMeetingMember] """ - members: List[TeamsMeetingMember] = None + members: list[TeamsMeetingMember] = None diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/teams/message_actions_payload.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/teams/message_actions_payload.py index e0a07db55..ab45b415c 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/teams/message_actions_payload.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/teams/message_actions_payload.py @@ -3,7 +3,7 @@ from pydantic import Field from ..agents_model import AgentsModel -from typing import Annotated, List +from typing import Annotated from .message_actions_payload_from import MessageActionsPayloadFrom from .message_actions_payload_body import MessageActionsPayloadBody @@ -44,11 +44,11 @@ class MessageActionsPayload(AgentsModel): :param attachment_layout: How the attachment(s) are displayed in the message. :type attachment_layout: str :param attachments: Attachments in the message - card, image, file, etc. - :type attachments: List[MessageActionsPayloadAttachment] + :type attachments: list[MessageActionsPayloadAttachment] :param mentions: List of entities mentioned in the message. - :type mentions: List[MessageActionsPayloadMention] + :type mentions: list[MessageActionsPayloadMention] :param reactions: Reactions for the message. - :type reactions: List[MessageActionsPayloadReaction] + :type reactions: list[MessageActionsPayloadReaction] """ id: str = None @@ -65,6 +65,6 @@ class MessageActionsPayload(AgentsModel): from_property: MessageActionsPayloadFrom = None body: MessageActionsPayloadBody = None attachment_layout: str = None - attachments: List[MessageActionsPayloadAttachment] = None - mentions: List[MessageActionsPayloadMention] = None - reactions: List[MessageActionsPayloadReaction] = None + attachments: list[MessageActionsPayloadAttachment] = None + mentions: list[MessageActionsPayloadMention] = None + reactions: list[MessageActionsPayloadReaction] = None diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/teams/messaging_extension_action.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/teams/messaging_extension_action.py index 2e47c7d36..dbd828601 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/teams/messaging_extension_action.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/teams/messaging_extension_action.py @@ -2,7 +2,6 @@ # Licensed under the MIT License. from ..agents_model import AgentsModel -from typing import Any, List from .task_module_request_context import TaskModuleRequestContext from .message_actions_payload import MessageActionsPayload @@ -23,7 +22,7 @@ class MessagingExtensionAction(AgentsModel): :param bot_message_preview_action: Bot message preview action taken by user. Possible values include: 'edit', 'send' :type bot_message_preview_action: str :param bot_activity_preview: List of bot activity previews. - :type bot_activity_preview: List[Activity] + :type bot_activity_preview: list[Activity] :param message_payload: Message content sent as part of the command request. :type message_payload: MessageActionsPayload """ @@ -33,5 +32,5 @@ class MessagingExtensionAction(AgentsModel): command_id: str = None command_context: str = None bot_message_preview_action: str = None - bot_activity_preview: List[Activity] = None + bot_activity_preview: list[Activity] = None message_payload: MessageActionsPayload = None diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/teams/messaging_extension_query.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/teams/messaging_extension_query.py index c784ec9c8..7ebd6b745 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/teams/messaging_extension_query.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/teams/messaging_extension_query.py @@ -2,7 +2,7 @@ # Licensed under the MIT License. from ..agents_model import AgentsModel -from typing import List, Optional +from typing import Optional from .messaging_extension_parameter import MessagingExtensionParameter from .messaging_extension_query_options import MessagingExtensionQueryOptions @@ -14,7 +14,7 @@ class MessagingExtensionQuery(AgentsModel): :param command_id: Id of the command assigned by Bot :type command_id: str :param parameters: Parameters for the query - :type parameters: List["MessagingExtensionParameter"] + :type parameters: list["MessagingExtensionParameter"] :param query_options: Query options for the extension :type query_options: Optional["MessagingExtensionQueryOptions"] :param state: State parameter passed back to the bot after authentication/configuration flow @@ -22,6 +22,6 @@ class MessagingExtensionQuery(AgentsModel): """ command_id: str = None - parameters: List[MessagingExtensionParameter] = None + parameters: list[MessagingExtensionParameter] = None query_options: Optional[MessagingExtensionQueryOptions] = None state: str = None diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/teams/messaging_extension_result.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/teams/messaging_extension_result.py index 24f4d8568..ab8c32caf 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/teams/messaging_extension_result.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/teams/messaging_extension_result.py @@ -2,7 +2,7 @@ # Licensed under the MIT License. from ..agents_model import AgentsModel -from typing import List, Optional +from typing import Optional from .messaging_extension_attachment import MessagingExtensionAttachment from .messaging_extension_suggested_action import MessagingExtensionSuggestedAction @@ -17,7 +17,7 @@ class MessagingExtensionResult(AgentsModel): :param type: The type of the result. Possible values include: 'result', 'auth', 'config', 'message', 'botMessagePreview' :type type: str :param attachments: (Only when type is result) Attachments - :type attachments: List["MessagingExtensionAttachment"] + :type attachments: list["MessagingExtensionAttachment"] :param suggested_actions: Suggested actions for the result. :type suggested_actions: Optional["MessagingExtensionSuggestedAction"] :param text: (Only when type is message) Text @@ -28,7 +28,7 @@ class MessagingExtensionResult(AgentsModel): attachment_layout: str = None type: str = None - attachments: List[MessagingExtensionAttachment] = None + attachments: list[MessagingExtensionAttachment] = None suggested_actions: Optional[MessagingExtensionSuggestedAction] = None text: Optional[str] = None activity_preview: Optional["Activity"] = None diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/teams/messaging_extension_suggested_action.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/teams/messaging_extension_suggested_action.py index 6829cccc4..fac981fa2 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/teams/messaging_extension_suggested_action.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/teams/messaging_extension_suggested_action.py @@ -2,7 +2,6 @@ # Licensed under the MIT License. from ..agents_model import AgentsModel -from typing import List from ..card_action import CardAction @@ -11,7 +10,7 @@ class MessagingExtensionSuggestedAction(AgentsModel): """Messaging extension suggested actions. :param actions: List of suggested actions. - :type actions: List["CardAction"] + :type actions: list["CardAction"] """ - actions: List[CardAction] = None + actions: list[CardAction] = None diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/teams/o365_connector_card.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/teams/o365_connector_card.py index afa2ffedd..e36e3e9be 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/teams/o365_connector_card.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/teams/o365_connector_card.py @@ -2,7 +2,7 @@ # Licensed under the MIT License. from ..agents_model import AgentsModel -from typing import List, Optional +from typing import Optional from .o365_connector_card_section import O365ConnectorCardSection from .o365_connector_card_action_base import O365ConnectorCardActionBase @@ -19,14 +19,14 @@ class O365ConnectorCard(AgentsModel): :param theme_color: Theme color for the card :type theme_color: Optional[str] :param sections: Set of sections for the current card - :type sections: Optional[List["O365ConnectorCardSection"]] + :type sections: Optional[list["O365ConnectorCardSection"]] :param potential_action: Set of actions for the current card - :type potential_action: Optional[List["O365ConnectorCardActionBase"]] + :type potential_action: Optional[list["O365ConnectorCardActionBase"]] """ title: str = None text: Optional[str] = None summary: Optional[str] = None theme_color: Optional[str] = None - sections: Optional[List[O365ConnectorCardSection]] = None - potential_action: Optional[List[O365ConnectorCardActionBase]] = None + sections: Optional[list[O365ConnectorCardSection]] = None + potential_action: Optional[list[O365ConnectorCardActionBase]] = None diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/teams/o365_connector_card_action_card.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/teams/o365_connector_card_action_card.py index a72528aad..3dc6f045f 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/teams/o365_connector_card_action_card.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/teams/o365_connector_card_action_card.py @@ -3,7 +3,6 @@ from pydantic import Field from ..agents_model import AgentsModel -from typing import List from .o365_connector_card_input_base import O365ConnectorCardInputBase from .o365_connector_card_action_base import O365ConnectorCardActionBase @@ -18,13 +17,13 @@ class O365ConnectorCardActionCard(AgentsModel): :param id: Action Id :type id: str :param inputs: Set of inputs contained in this ActionCard - :type inputs: List["O365ConnectorCardInputBase"] + :type inputs: list["O365ConnectorCardInputBase"] :param actions: Set of actions contained in this ActionCard - :type actions: List["O365ConnectorCardActionBase"] + :type actions: list["O365ConnectorCardActionBase"] """ type: str = Field(None, alias="@type") name: str = None id: str = Field(None, alias="@id") - inputs: List[O365ConnectorCardInputBase] = None - actions: List[O365ConnectorCardActionBase] = None + inputs: list[O365ConnectorCardInputBase] = None + actions: list[O365ConnectorCardActionBase] = None diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/teams/o365_connector_card_multichoice_input.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/teams/o365_connector_card_multichoice_input.py index efb41ead9..bbbf23440 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/teams/o365_connector_card_multichoice_input.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/teams/o365_connector_card_multichoice_input.py @@ -23,7 +23,7 @@ class O365ConnectorCardMultichoiceInput(AgentsModel): :param value: Default value for this input field :type value: Optional[str] :param choices: Set of choices for this input field. - :type choices: List["O365ConnectorCardMultichoiceInputChoice"] + :type choices: list["O365ConnectorCardMultichoiceInputChoice"] :param style: Choice style. Possible values include: 'compact', 'expanded' :type style: Optional[str] :param is_multi_select: Define if this input field allows multiple selections. Default value is false. @@ -35,6 +35,6 @@ class O365ConnectorCardMultichoiceInput(AgentsModel): is_required: Optional[bool] = None title: Optional[str] = None value: Optional[str] = None - choices: List[O365ConnectorCardMultichoiceInputChoice] = None + choices: list[O365ConnectorCardMultichoiceInputChoice] = None style: Optional[str] = None is_multi_select: Optional[bool] = None diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/teams/o365_connector_card_open_uri.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/teams/o365_connector_card_open_uri.py index 7d466a017..bb09af084 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/teams/o365_connector_card_open_uri.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/teams/o365_connector_card_open_uri.py @@ -3,7 +3,6 @@ from pydantic import Field from ..agents_model import AgentsModel -from typing import List from .o365_connector_card_open_uri_target import O365ConnectorCardOpenUriTarget @@ -17,10 +16,10 @@ class O365ConnectorCardOpenUri(AgentsModel): :param id: Id of the OpenUri action. :type id: str :param targets: List of targets for the OpenUri action. - :type targets: List["O365ConnectorCardOpenUriTarget"] + :type targets: list["O365ConnectorCardOpenUriTarget"] """ type: str = Field(None, alias="@type") name: str = None id: str = Field(None, alias="@id") - targets: List[O365ConnectorCardOpenUriTarget] = None + targets: list[O365ConnectorCardOpenUriTarget] = None diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/teams/o365_connector_card_section.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/teams/o365_connector_card_section.py index 6bb0d700b..55496a5e8 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/teams/o365_connector_card_section.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/teams/o365_connector_card_section.py @@ -2,7 +2,7 @@ # Licensed under the MIT License. from ..agents_model import AgentsModel -from typing import List, Optional +from typing import Optional from .o365_connector_card_fact import O365ConnectorCardFact from .o365_connector_card_image import O365ConnectorCardImage from .o365_connector_card_action_base import O365ConnectorCardActionBase @@ -24,11 +24,11 @@ class O365ConnectorCardSection(AgentsModel): :param activity_text: Activity text. :type activity_text: Optional[str] :param facts: List of facts for the section. - :type facts: Optional[List["O365ConnectorCardFact"]] + :type facts: Optional[list["O365ConnectorCardFact"]] :param images: List of images for the section. - :type images: Optional[List["O365ConnectorCardImage"]] + :type images: Optional[list["O365ConnectorCardImage"]] :param potential_action: List of actions for the section. - :type potential_action: Optional[List["O365ConnectorCardActionBase"]] + :type potential_action: Optional[list["O365ConnectorCardActionBase"]] """ title: Optional[str] = None @@ -37,6 +37,6 @@ class O365ConnectorCardSection(AgentsModel): activity_subtitle: Optional[str] = None activity_image: Optional[str] = None activity_text: Optional[str] = None - facts: Optional[List[O365ConnectorCardFact]] = None - images: Optional[List[O365ConnectorCardImage]] = None - potential_action: Optional[List[O365ConnectorCardActionBase]] = None + facts: Optional[list[O365ConnectorCardFact]] = None + images: Optional[list[O365ConnectorCardImage]] = None + potential_action: Optional[list[O365ConnectorCardActionBase]] = None diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/teams/tab_response_cards.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/teams/tab_response_cards.py index e5afbbf11..8cdceb29b 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/teams/tab_response_cards.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/teams/tab_response_cards.py @@ -2,7 +2,6 @@ # Licensed under the MIT License.from ..agents_model import AgentsModel from ..agents_model import AgentsModel -from typing import List from .tab_response_card import TabResponseCard @@ -14,4 +13,4 @@ class TabResponseCards(AgentsModel): :type cards: list[TabResponseCard] """ - cards: List[TabResponseCard] = None + cards: list[TabResponseCard] = None diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/teams/tab_suggested_actions.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/teams/tab_suggested_actions.py index aa285a440..0f45be211 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/teams/tab_suggested_actions.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/teams/tab_suggested_actions.py @@ -2,8 +2,6 @@ # Licensed under the MIT License. from ..agents_model import AgentsModel -from typing import List - from ..card_action import CardAction @@ -14,4 +12,4 @@ class TabSuggestedActions(AgentsModel): :type actions: list[CardAction] """ - actions: List[CardAction] = None + actions: list[CardAction] = None diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/teams/targeted_meeting_notification_value.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/teams/targeted_meeting_notification_value.py index 0ba96d61e..65be92eb1 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/teams/targeted_meeting_notification_value.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/teams/targeted_meeting_notification_value.py @@ -2,7 +2,6 @@ # Licensed under the MIT License. from ..agents_model import AgentsModel -from typing import List from .surface import Surface @@ -10,10 +9,10 @@ class TargetedMeetingNotificationValue(AgentsModel): """Specifies the value for targeted meeting notifications. :param recipients: List of recipient MRIs for the notification. - :type recipients: List[str] + :type recipients: list[str] :param message: The message content of the notification. :type message: str """ - recipients: List[str] = None - surfaces: List[Surface] = None + recipients: list[str] = None + surfaces: list[Surface] = None diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/teams/teams_channel_account.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/teams/teams_channel_account.py index da810e1bb..0f06fa120 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/teams/teams_channel_account.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/teams/teams_channel_account.py @@ -39,6 +39,6 @@ class TeamsChannelAccount(AgentsModel): user_role: str = None @property - def properties(self) -> dict[str, Any]: + def properties(self) -> dict[str, Any] | None: """Returns the set of properties that are not None.""" return self.model_extra diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/teams/teams_channel_data.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/teams/teams_channel_data.py index 14fb5cdc2..16e8dffe6 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/teams/teams_channel_data.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/teams/teams_channel_data.py @@ -2,7 +2,6 @@ # Licensed under the MIT License. from ..agents_model import AgentsModel -from typing import List from .channel_info import ChannelInfo from .team_info import TeamInfo from .notification_info import NotificationInfo @@ -30,7 +29,7 @@ class TeamsChannelData(AgentsModel): :param settings: Information about the settings in which the message was sent :type settings: TeamsChannelDataSettings :param on_behalf_of: The OnBehalfOf list for user attribution - :type on_behalf_of: List[OnBehalfOf] + :type on_behalf_of: list[OnBehalfOf] """ channel: ChannelInfo = None @@ -40,4 +39,4 @@ class TeamsChannelData(AgentsModel): tenant: TenantInfo = None meeting: TeamsMeetingInfo = None settings: TeamsChannelDataSettings = None - on_behalf_of: List[OnBehalfOf] = None + on_behalf_of: list[OnBehalfOf] = None diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/teams/teams_paged_members_result.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/teams/teams_paged_members_result.py index b523376a6..1c4fed5a3 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/teams/teams_paged_members_result.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/teams/teams_paged_members_result.py @@ -2,7 +2,6 @@ # Licensed under the MIT License. from ..agents_model import AgentsModel -from typing import List from .teams_channel_account import TeamsChannelAccount @@ -16,4 +15,4 @@ class TeamsPagedMembersResult(AgentsModel): """ continuation_token: str = None - members: List[TeamsChannelAccount] = None + members: list[TeamsChannelAccount] = None diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/turn_context_protocol.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/turn_context_protocol.py index 945cbc71a..e9e613f00 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/turn_context_protocol.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/turn_context_protocol.py @@ -3,7 +3,7 @@ from __future__ import annotations -from typing import Protocol, List, Callable, Optional, Generic, TypeVar +from typing import Protocol, Callable, Optional, Generic, TypeVar from abc import abstractmethod from microsoft_agents.activity import ( @@ -35,8 +35,8 @@ async def send_activity( @abstractmethod async def send_activities( - self, activities: List[Activity] - ) -> List[ResourceResponse]: + self, activities: list[Activity] + ) -> list[ResourceResponse]: pass @abstractmethod @@ -63,6 +63,10 @@ def on_delete_activity(self, handler: Callable) -> "TurnContextProtocol": @abstractmethod async def send_trace_activity( - self, name: str, value: object = None, value_type: str = None, label: str = None + self, + name: str, + value: object = None, + value_type: str | None = None, + label: str | None = None, ) -> ResourceResponse: pass diff --git a/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/msal_connection_manager.py b/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/msal_connection_manager.py index a1424014e..7ee323918 100644 --- a/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/msal_connection_manager.py +++ b/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/msal_connection_manager.py @@ -2,7 +2,7 @@ # Licensed under the MIT License. import re -from typing import Dict, List, Optional +from typing import Dict, Optional from microsoft_agents.hosting.core import ( AgentAuthConfiguration, AccessTokenProviderBase, @@ -14,27 +14,27 @@ class MsalConnectionManager(Connections): - _connections: Dict[str, MsalAuth] - _connections_map: List[Dict[str, str]] + _connections: dict[str, MsalAuth] + _connections_map: list[dict[str, str]] _service_connection_configuration: AgentAuthConfiguration def __init__( self, - connections_configurations: Optional[Dict[str, AgentAuthConfiguration]] = None, - connections_map: Optional[List[Dict[str, str]]] = None, + connections_configurations: Optional[dict[str, AgentAuthConfiguration]] = None, + connections_map: Optional[list[dict[str, str]]] = None, **kwargs, ): """ Initialize the MSAL connection manager. :arg connections_configurations: A dictionary of connection configurations. - :type connections_configurations: Dict[str, :class:`microsoft_agents.hosting.core.AgentAuthConfiguration`] + :type connections_configurations: dict[str, :class:`microsoft_agents.hosting.core.AgentAuthConfiguration`] :arg connections_map: A list of connection mappings. - :type connections_map: List[Dict[str, str]] + :type connections_map: list[dict[str, str]] :raises ValueError: If no service connection configuration is provided. """ - self._connections: Dict[str, MsalAuth] = {} + self._connections: dict[str, MsalAuth] = {} self._connections_map = connections_map or kwargs.get("CONNECTIONSMAP", {}) self._config_map: dict[str, AgentAuthConfiguration] = {} @@ -46,7 +46,7 @@ def __init__( self._connections[connection_name] = MsalAuth(agent_auth_config) self._config_map[connection_name] = agent_auth_config else: - raw_configurations: Dict[str, Dict] = kwargs.get("CONNECTIONS", {}) + raw_configurations: dict[str, dict] = kwargs.get("CONNECTIONS", {}) for connection_name, connection_settings in raw_configurations.items(): parsed_configuration = AgentAuthConfiguration( **connection_settings.get("SETTINGS", {}) diff --git a/libraries/microsoft-agents-copilotstudio-client/microsoft_agents/copilotstudio/client/connection_settings.py b/libraries/microsoft-agents-copilotstudio-client/microsoft_agents/copilotstudio/client/connection_settings.py index 96909a10e..90bec29fc 100644 --- a/libraries/microsoft-agents-copilotstudio-client/microsoft_agents/copilotstudio/client/connection_settings.py +++ b/libraries/microsoft-agents-copilotstudio-client/microsoft_agents/copilotstudio/client/connection_settings.py @@ -2,7 +2,7 @@ # Licensed under the MIT License. from os import environ -from typing import Dict, Optional, Any +from typing import Optional, Any from .direct_to_engine_connection_settings_protocol import ( DirectToEngineConnectionSettingsProtocol, ) @@ -67,7 +67,7 @@ def populate_from_environment( direct_connect_url: Optional[str] = None, use_experimental_endpoint: Optional[bool] = None, enable_diagnostics: Optional[bool] = None, - ) -> Dict[str, Any]: + ) -> dict[str, Any]: """ Populate connection settings from environment variables. diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/_oauth/_oauth_flow.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/_oauth/_oauth_flow.py index b3c0bc110..6100c4c10 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/_oauth/_oauth_flow.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/_oauth/_oauth_flow.py @@ -107,7 +107,7 @@ def __init__( def flow_state(self) -> _FlowState: return self._flow_state.model_copy() - async def get_user_token(self, magic_code: str = None) -> TokenResponse: + async def get_user_token(self, magic_code: str | None = None) -> TokenResponse: """Get the user token based on the context. Args: diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/agent_application.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/agent_application.py index f46d699c8..1b76103ba 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/agent_application.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/agent_application.py @@ -18,7 +18,6 @@ Optional, Pattern, TypeVar, - Union, cast, ) @@ -289,7 +288,7 @@ def add_route( def activity( self, - activity_type: Union[str, ActivityTypes, list[Union[str, ActivityTypes]]], + activity_type: str | ActivityTypes | list[str | ActivityTypes], *, auth_handlers: Optional[list[str]] = None, **kwargs, @@ -306,7 +305,7 @@ async def on_event(context: TurnContext, state: TurnState): return True :param activity_type: Activity type or collection of types that should trigger the handler. - :type activity_type: Union[str, microsoft_agents.activity.ActivityTypes, list[Union[str, microsoft_agents.activity.ActivityTypes]]] + :type activity_type: str | microsoft_agents.activity.ActivityTypes | list[str | microsoft_agents.activity.ActivityTypes] :param auth_handlers: Optional list of authorization handler IDs for the route. :type auth_handlers: Optional[list[str]] :param kwargs: Additional route configuration passed to :meth:`microsoft_agents.hosting.core.AgentApplication.add_route`. @@ -326,7 +325,7 @@ def __call(func: RouteHandler[StateT]) -> RouteHandler[StateT]: def message( self, - select: Union[str, Pattern[str], list[Union[str, Pattern[str]]]], + select: str | Pattern[str] | list[str | Pattern[str]], *, auth_handlers: Optional[list[str]] = None, **kwargs, @@ -343,7 +342,7 @@ async def on_hi_message(context: TurnContext, state: TurnState): return True :param select: Literal text, compiled regex, or list of either used to match the incoming message. - :type select: Union[str, Pattern[str], list[Union[str, Pattern[str]]]] + :type select: str | Pattern[str] | list[str | Pattern[str]] :param auth_handlers: Optional list of authorization handler IDs for the route. :type auth_handlers: Optional[list[str]] :param kwargs: Additional route configuration passed to :meth:`microsoft_agents.hosting.core.AgentApplication.add_route`. diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/app_options.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/app_options.py index 376548b79..27a2013f7 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/app_options.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/app_options.py @@ -7,7 +7,7 @@ from dataclasses import dataclass, field from logging import Logger -from typing import Callable, List, Optional +from typing import Callable, Optional from microsoft_agents.hosting.core.app.oauth import AuthHandler from microsoft_agents.hosting.core.storage import Storage @@ -81,7 +81,7 @@ class ApplicationOptions: will mark the bot's process as idle and shut it down. """ - file_downloaders: List[InputFileDownloader] = field(default_factory=list) + file_downloaders: list[InputFileDownloader] = field(default_factory=list) """ Optional. Array of input file download plugins to use. """ diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/input_file.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/input_file.py index 4e44627c3..1a6589d93 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/input_file.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/input_file.py @@ -7,7 +7,7 @@ from abc import ABC, abstractmethod from dataclasses import dataclass -from typing import List, Optional +from typing import Optional from microsoft_agents.hosting.core import TurnContext @@ -38,7 +38,7 @@ class InputFileDownloader(ABC): """ @abstractmethod - async def download_files(self, context: TurnContext) -> List[InputFile]: + async def download_files(self, context: TurnContext) -> list[InputFile]: """ Download any files referenced by the incoming activity for the current turn. diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/state/conversation_state.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/state/conversation_state.py index d7436ab36..4287862e9 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/state/conversation_state.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/state/conversation_state.py @@ -33,7 +33,7 @@ def __init__(self, storage: Storage) -> None: super().__init__(storage=storage, context_service_key=self.CONTEXT_SERVICE_KEY) def get_storage_key( - self, turn_context: TurnContext, *, target_cls: Type[StoreItem] = None + self, turn_context: TurnContext, *, target_cls: Type[StoreItem] | None = None ): channel_id = turn_context.activity.channel_id if not channel_id: diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/state/state.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/state/state.py index ce4c70646..0b9987a71 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/state/state.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/state/state.py @@ -6,10 +6,9 @@ from __future__ import annotations import logging -import json from abc import ABC, abstractmethod from copy import deepcopy -from typing import Any, Callable, List, Optional, Type, TypeVar, Union, overload +from typing import Any, Callable, Optional, Type, TypeVar, overload from microsoft_agents.hosting.core.state.state_property_accessor import ( StatePropertyAccessor as _StatePropertyAccessor, @@ -32,7 +31,7 @@ def state(_cls: Type[T]) -> Type[T]: ... def state( _cls: Optional[Type[T]] = None, -) -> Union[Callable[[Type[T]], Type[T]], Type[T]]: +) -> Callable[[Type[T]], Type[T]] | Type[T]: """ @state\n class Example(State): @@ -71,7 +70,7 @@ class State(dict[str, StoreItem], ABC): The Storage Key """ - __deleted__: List[str] + __deleted__: list[str] """ Deleted Keys """ @@ -206,9 +205,7 @@ def __init__(self, state: State, name: str) -> None: async def get( self, turn_context: TurnContext, - default_value_or_factory: Optional[ - Union[Any, Callable[[], Optional[Any]]] - ] = None, + default_value_or_factory: Optional[Any | Callable[[], Optional[Any]]] = None, ) -> Optional[Any]: """ Get the property value from the state. @@ -216,7 +213,7 @@ async def get( :param turn_context: The turn context. :type turn_context: :class:`microsoft_agents.hosting.core.turn_context.TurnContext` :param default_value_or_factory: Default value or factory function to use if property doesn't exist. - :type default_value_or_factory: Optional[Union[Any, Callable[[], Optional[Any]]]] + :type default_value_or_factory: Optional[Any | Callable[[], Optional[Any]]] :return: The property value or default value if not found. :rtype: Optional[Any] """ diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/state/temp_state.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/state/temp_state.py index cb954c721..03e22379c 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/state/temp_state.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/state/temp_state.py @@ -5,9 +5,7 @@ from __future__ import annotations -from typing import Dict, List, Optional, TypeVar, Callable, Any, Generic - -from microsoft_agents.hosting.core.storage import Storage +from typing import Optional, TypeVar, Callable, Any from microsoft_agents.hosting.core.turn_context import TurnContext from microsoft_agents.hosting.core.app.input_file import InputFile @@ -32,7 +30,7 @@ class TempState(AgentState): def __init__(self): super().__init__(None, context_service_key=self.SCOPE_NAME) - self._state: Dict[str, Any] = {} + self._state: dict[str, Any] = {} @property def name(self) -> str: @@ -40,12 +38,12 @@ def name(self) -> str: return self.SCOPE_NAME @property - def input_files(self) -> List[InputFile]: + def input_files(self) -> list[InputFile]: """Downloaded files included in the Activity""" return self.get_value(self.INPUT_FILES_KEY, lambda: []) @input_files.setter - def input_files(self, value: List[InputFile]) -> None: + def input_files(self, value: list[InputFile]) -> None: self.set_value(self.INPUT_FILES_KEY, value) def clear(self, turn_context: TurnContext) -> None: diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/state/turn_state.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/state/turn_state.py index be1f8339f..0f17366b0 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/state/turn_state.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/state/turn_state.py @@ -6,7 +6,7 @@ from __future__ import annotations import logging -from typing import Any, Dict, Optional, Type, TypeVar, cast, Callable, Awaitable +from typing import Any, Optional, Type, TypeVar, Callable import asyncio from microsoft_agents.hosting.core.storage import Storage @@ -41,7 +41,7 @@ def __init__(self, *agent_states: AgentState) -> None: Args: agent_states: Initial list of AgentState objects to manage. """ - self._scopes: Dict[str, AgentState] = {} + self._scopes: dict[str, AgentState] = {} # Add all provided agent states for agent_state in agent_states: @@ -115,7 +115,7 @@ def get_value( name: str, default_value_factory: Optional[Callable[[], T]] = None, *, - target_cls: Type[T] = None, + target_cls: Type[T] | None = None, ) -> T: """ Gets a value from state. diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/streaming/citation_util.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/streaming/citation_util.py index 1ec923dc9..e849b899e 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/streaming/citation_util.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/streaming/citation_util.py @@ -2,7 +2,7 @@ # Licensed under the MIT License. import re -from typing import List, Optional +from typing import Optional from microsoft_agents.activity import ClientCitation @@ -45,8 +45,8 @@ def format_citations_response(text: str) -> str: @staticmethod def get_used_citations( - text: str, citations: List[ClientCitation] - ) -> Optional[List[ClientCitation]]: + text: str, citations: list[ClientCitation] + ) -> Optional[list[ClientCitation]]: """ Get the citations used in the text. This will remove any citations that are included in the citations array from the response but not referenced in the text. diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/streaming/streaming_response.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/streaming/streaming_response.py index f5229d3d6..34db4eb24 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/streaming/streaming_response.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/streaming/streaming_response.py @@ -4,12 +4,11 @@ import uuid import asyncio import logging -from typing import List, Optional, Callable, Literal, cast +from typing import Optional, Callable, Literal, cast from microsoft_agents.activity import ( Activity, AIEntity, - Entity, EntityTypes, Attachment, Channels, @@ -51,15 +50,15 @@ def __init__(self, context: "TurnContext"): self._sequence_number = 1 self._stream_id: Optional[str] = None self._message = "" - self._queue: List[Callable[[], Activity | None]] = [] + self._queue: list[Callable[[], Activity | None]] = [] self._queue_sync: Optional[asyncio.Task] = None self._chunk_queued = False self._ended = False self._cancelled = False self._is_streaming_channel = False self._interval = 0.1 - self._attachments: Optional[List[Attachment]] = None - self._citations: List[ClientCitation] = [] + self._attachments: Optional[list[Attachment]] = None + self._citations: list[ClientCitation] = [] self._sensitivity_label: Optional[SensitivityUsageInfo] = None self._enable_feedback_loop = False self._feedback_loop_type: Optional[Literal["default", "custom"]] = None @@ -102,7 +101,7 @@ def create_activity(): self._queue_activity(create_activity) def queue_text_chunk( - self, text: str, citations: Optional[List[Citation]] = None + self, text: str, citations: Optional[list[Citation]] = None ) -> None: """ Queues a chunk of partial message text to be sent to the client. @@ -142,7 +141,7 @@ async def end_stream(self) -> None: # Wait for the queue to drain await self.wait_for_queue() - def set_attachments(self, attachments: List[Attachment]) -> None: + def set_attachments(self, attachments: list[Attachment]) -> None: """ Sets the attachments to attach to the final chunk. @@ -160,7 +159,7 @@ def set_sensitivity_label(self, sensitivity_label: SensitivityUsageInfo) -> None """ self._sensitivity_label = sensitivity_label - def set_citations(self, citations: List[Citation]) -> None: + def set_citations(self, citations: list[Citation]) -> None: """ Sets the citations for the full message. diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/typing_indicator.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/typing_indicator.py index cedab4342..a99ddc30e 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/typing_indicator.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/typing_indicator.py @@ -8,7 +8,7 @@ import asyncio import logging from dataclasses import dataclass, field -from typing import Dict, Optional +from typing import Optional from microsoft_agents.hosting.core import TurnContext from microsoft_agents.activity import Activity, ActivityTypes, Channels, EntityTypes @@ -42,7 +42,7 @@ class TypingOptions: initial_delay_ms: int = DEFAULT_INITIAL_DELAY_MS interval_ms: int = DEFAULT_INTERVAL_MS - channel_strategies: Dict[str, TypingChannelStrategy] = field(default_factory=dict) + channel_strategies: dict[str, TypingChannelStrategy] = field(default_factory=dict) def __post_init__(self): # Apply default channel overrides (matching .NET's M365Copilot default) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/channel_adapter.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/channel_adapter.py index 2592d9580..d8768d551 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/channel_adapter.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/channel_adapter.py @@ -3,7 +3,7 @@ from abc import ABC, abstractmethod from collections.abc import Callable -from typing import List, Awaitable +from typing import Awaitable from microsoft_agents.hosting.core.authorization import ClaimsIdentity from microsoft_agents.activity import ChannelAdapterProtocol from microsoft_agents.activity import ( @@ -27,15 +27,15 @@ class ChannelAdapter(ABC, ChannelAdapterProtocol): AGENT_CALLBACK_HANDLER_KEY = "AgentCallbackHandler" CHANNEL_SERVICE_FACTORY_KEY = "ChannelServiceClientFactory" - on_turn_error: Callable[[TurnContext, Exception], Awaitable] = None + on_turn_error: Callable[[TurnContext, Exception], Awaitable] | None = None def __init__(self): self.middleware_set = MiddlewareSet() @abstractmethod async def send_activities( - self, context: TurnContext, activities: List[Activity] - ) -> List[ResourceResponse]: + self, context: TurnContext, activities: list[Activity] + ) -> list[ResourceResponse]: """ Sends a set of activities to the user. An array of responses from the server will be returned. @@ -119,7 +119,7 @@ async def continue_conversation_with_claims( claims_identity: ClaimsIdentity, continuation_activity: Activity, callback: Callable[[TurnContext], Awaitable], - audience: str = None, + audience: str | None = None, ): """ Sends a proactive message to a conversation. Call this method to proactively send a message to a conversation. @@ -221,7 +221,9 @@ async def create_conversation( return await self.run_pipeline(context, callback) async def run_pipeline( - self, context: TurnContext, callback: Callable[[TurnContext], Awaitable] = None + self, + context: TurnContext, + callback: Callable[[TurnContext], Awaitable] | None = None, ): """ Called by the parent class to run the adapters middleware set and calls the passed in `callback()` handler at diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/channel_service_adapter.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/channel_service_adapter.py index abd8a6f1f..2be89cf01 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/channel_service_adapter.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/channel_service_adapter.py @@ -227,7 +227,7 @@ async def continue_conversation_with_claims( claims_identity: ClaimsIdentity, continuation_activity: Activity, callback: Callable[[TurnContext], Awaitable], - audience: str = None, + audience: str | None = None, ): """ Continue a conversation with the provided claims identity. @@ -393,7 +393,7 @@ async def process_activity( otherwise, `None` is returned. """ scopes: list[str] = claims_identity.get_token_scope() - outgoing_audience: str = None + outgoing_audience: str | None = None if claims_identity.is_agent_claim(): outgoing_audience = claims_identity.get_token_audience() diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/client/channels_configuration.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/client/channels_configuration.py index 935611a94..03547f23d 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/client/channels_configuration.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/client/channels_configuration.py @@ -10,12 +10,12 @@ class ChannelInfo(ChannelInfoProtocol): def __init__( self, - id: str = None, - app_id: str = None, - resource_url: str = None, - token_provider: str = None, - channel_factory: str = None, - endpoint: str = None, + id: str | None = None, + app_id: str | None = None, + resource_url: str | None = None, + token_provider: str | None = None, + channel_factory: str | None = None, + endpoint: str | None = None, **kwargs ): self.id = id diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/client/configuration_channel_host.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/client/configuration_channel_host.py index 0c48dbf15..07dde3dc6 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/client/configuration_channel_host.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/client/configuration_channel_host.py @@ -24,8 +24,8 @@ def __init__( self.connections = connections self.configuration = configuration self.channels: dict[str, ChannelInfoProtocol] = {} - self.host_endpoint: str = None - self.host_app_id: str = None + self.host_endpoint: str | None = None + self.host_app_id: str | None = None channel_host_configuration = configuration.CHANNEL_HOST_CONFIGURATION() diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/client/http_agent_channel.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/client/http_agent_channel.py index ecc1cbbef..b7895baf2 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/client/http_agent_channel.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/client/http_agent_channel.py @@ -31,7 +31,7 @@ async def post_activity( conversation_id: str, activity: Activity, *, - response_body_type: type[AgentsModel] = None, + response_body_type: type[AgentsModel] | None = None, **kwargs, ) -> InvokeResponse: if not endpoint: diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/agent_sign_in_base.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/agent_sign_in_base.py index 75b262ebc..e4f81b006 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/agent_sign_in_base.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/agent_sign_in_base.py @@ -9,9 +9,9 @@ class AgentSignInBase(Protocol): async def get_sign_in_url( self, state: str, - code_challenge: str = None, - emulator_url: str = None, - final_redirect: str = None, + code_challenge: str | None = None, + emulator_url: str | None = None, + final_redirect: str | None = None, ) -> str: raise NotImplementedError() @@ -19,8 +19,8 @@ async def get_sign_in_url( async def get_sign_in_resource( self, state: str, - code_challenge: str = None, - emulator_url: str = None, - final_redirect: str = None, + code_challenge: str | None = None, + emulator_url: str | None = None, + final_redirect: str | None = None, ) -> SignInResource: raise NotImplementedError() diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/user_token_base.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/user_token_base.py index 32c21abe9..3ae669ab9 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/user_token_base.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/user_token_base.py @@ -19,8 +19,8 @@ async def get_token( self, user_id: str, connection_name: str, - channel_id: str = None, - code: str = None, + channel_id: str | None = None, + code: str | None = None, ) -> TokenResponse: """ Get sign-in URL. @@ -63,8 +63,8 @@ async def get_aad_tokens( self, user_id: str, connection_name: str, - channel_id: str = None, - body: dict = None, + channel_id: str | None = None, + body: dict | None = None, ) -> dict[str, TokenResponse]: """ Gets Azure Active Directory tokens for a user and connection. @@ -79,7 +79,10 @@ async def get_aad_tokens( @abstractmethod async def sign_out( - self, user_id: str, connection_name: str = None, channel_id: str = None + self, + user_id: str, + connection_name: str | None = None, + channel_id: str | None = None, ) -> None: """ Signs the user out from the specified connection. @@ -92,7 +95,7 @@ async def sign_out( @abstractmethod async def get_token_status( - self, user_id: str, channel_id: str = None, include: str = None + self, user_id: str, channel_id: str | None = None, include: str | None = None ) -> list[TokenStatus]: """ Gets token status for the user. @@ -106,7 +109,11 @@ async def get_token_status( @abstractmethod async def exchange_token( - self, user_id: str, connection_name: str, channel_id: str, body: dict = None + self, + user_id: str, + connection_name: str, + channel_id: str, + body: dict | None = None, ) -> TokenResponse: """ Exchanges a token. diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/http/_channel_service_routes.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/http/_channel_service_routes.py index 16adf3813..3c7e5bcbe 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/http/_channel_service_routes.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/http/_channel_service_routes.py @@ -3,7 +3,7 @@ """Channel service route definitions (framework-agnostic logic).""" -from typing import Type, List, Union +from typing import Type from microsoft_agents.activity import ( AgentsModel, @@ -47,7 +47,7 @@ async def deserialize_from_body( return target_model.model_validate(body) @staticmethod - def serialize_model(model_or_list: Union[AgentsModel, List[AgentsModel]]) -> dict: + def serialize_model(model_or_list: AgentsModel | list[AgentsModel]) -> dict: """Serialize model or list of models to JSON-compatible dict.""" if isinstance(model_or_list, AgentsModel): return model_or_list.model_dump( diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/http/_http_adapter_base.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/http/_http_adapter_base.py index b2fea448c..4ea5438b6 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/http/_http_adapter_base.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/http/_http_adapter_base.py @@ -32,8 +32,8 @@ class HttpAdapterBase(ChannelServiceAdapter, ABC): def __init__( self, *, - connection_manager: Connections = None, - channel_service_client_factory: ChannelServiceClientFactoryBase = None, + connection_manager: Connections | None = None, + channel_service_client_factory: ChannelServiceClientFactoryBase | None = None, ): """Initialize the HTTP adapter. diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/http/_http_request_protocol.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/http/_http_request_protocol.py index f99dc1d80..c38749871 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/http/_http_request_protocol.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/http/_http_request_protocol.py @@ -3,7 +3,7 @@ """Protocol for abstracting HTTP request objects across frameworks.""" -from typing import Protocol, Dict, Any, Optional +from typing import Protocol, Any, Optional class HttpRequestProtocol(Protocol): @@ -19,11 +19,11 @@ def method(self) -> str: ... @property - def headers(self) -> Dict[str, str]: + def headers(self) -> dict[str, str]: """Request headers.""" ... - async def json(self) -> Dict[str, Any]: + async def json(self) -> dict[str, Any]: """Parse request body as JSON.""" ... diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/http/_http_response.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/http/_http_response.py index d593cdee9..955ff2ad9 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/http/_http_response.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/http/_http_response.py @@ -3,7 +3,7 @@ """HTTP response abstraction.""" -from typing import Any, Optional, Dict +from typing import Any, Optional from dataclasses import dataclass @@ -13,7 +13,7 @@ class HttpResponse: status_code: int body: Optional[Any] = None - headers: Optional[Dict[str, str]] = None + headers: Optional[dict[str, str]] = None content_type: Optional[str] = "application/json" diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/message_factory.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/message_factory.py index 424a4024d..91a557302 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/message_factory.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/message_factory.py @@ -17,8 +17,8 @@ def attachment_activity( attachment_layout: AttachmentLayoutTypes, attachments: list[Attachment], - text: str = None, - speak: str = None, + text: str | None = None, + speak: str | None = None, input_hint: InputHints | str = InputHints.accepting_input, ) -> Activity: message = Activity( @@ -44,7 +44,7 @@ class MessageFactory: @staticmethod def text( text: str, - speak: str = None, + speak: str | None = None, input_hint: InputHints | str = InputHints.accepting_input, ) -> Activity: """ @@ -68,8 +68,8 @@ def text( @staticmethod def suggested_actions( actions: list[CardAction], - text: str = None, - speak: str = None, + text: str | None = None, + speak: str | None = None, input_hint: InputHints | str = InputHints.accepting_input, ) -> Activity: """ @@ -101,9 +101,9 @@ def suggested_actions( @staticmethod def attachment( attachment: Attachment, - text: str = None, - speak: str = None, - input_hint: InputHints | str = None, + text: str | None = None, + speak: str | None = None, + input_hint: InputHints | str | None = None, ): """ Returns a single message activity containing an attachment. @@ -129,8 +129,8 @@ def attachment( @staticmethod def list( attachments: list[Attachment], - text: str = None, - speak: str = None, + text: str | None = None, + speak: str | None = None, input_hint: InputHints | str = None, ) -> Activity: """ @@ -161,8 +161,8 @@ def list( @staticmethod def carousel( attachments: list[Attachment], - text: str = None, - speak: str = None, + text: str | None = None, + speak: str | None = None, input_hint: InputHints | str = None, ) -> Activity: """ @@ -194,10 +194,10 @@ def carousel( def content_url( url: str, content_type: str, - name: str = None, - text: str = None, - speak: str = None, - input_hint: InputHints | str = None, + name: str | None = None, + text: str | None = None, + speak: str | None = None, + input_hint: InputHints | str | None = None, ): """ Returns a message that will display a single image or video to a user. diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/state/agent_state.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/state/agent_state.py index 02df1a6ce..8006e0247 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/state/agent_state.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/state/agent_state.py @@ -5,7 +5,7 @@ from abc import abstractmethod from copy import deepcopy -from typing import Callable, Dict, Union, Type +from typing import Callable, Type from microsoft_agents.hosting.core.storage import Storage, StoreItem @@ -18,7 +18,7 @@ class CachedAgentState(StoreItem): Internal cached Agent state. """ - def __init__(self, state: Dict[str, StoreItem | dict] = None): + def __init__(self, state: dict[str, StoreItem | dict] | None = None): if state: self.state = state self.hash = self.compute_hash() @@ -85,7 +85,7 @@ def __init__(self, storage: Storage, context_service_key: str): self.state_key = "state" self._storage = storage self._context_service_key = context_service_key - self._cached_state: CachedAgentState = None + self._cached_state: CachedAgentState | None = None def get_cached_state(self, turn_context: TurnContext) -> CachedAgentState: """ @@ -113,7 +113,7 @@ def create_property(self, name: str) -> StatePropertyAccessor: ) return BotStatePropertyAccessor(self, name) - def get(self, turn_context: TurnContext) -> Dict[str, StoreItem]: + def get(self, turn_context: TurnContext) -> dict[str, StoreItem]: cached = self.get_cached_state(turn_context) return getattr(cached, "state", None) @@ -148,7 +148,7 @@ async def save(self, turn_context: TurnContext, force: bool = False) -> None: if force or (self._cached_state is not None and self._cached_state.is_changed): storage_key = self.get_storage_key(turn_context) - changes: Dict[str, StoreItem] = {storage_key: self._cached_state} + changes: dict[str, StoreItem] = {storage_key: self._cached_state} await self._storage.write(changes) self._cached_state.hash = self._cached_state.compute_hash() @@ -185,14 +185,14 @@ async def delete(self, turn_context: TurnContext) -> None: @abstractmethod def get_storage_key( - self, turn_context: TurnContext, *, target_cls: Type[StoreItem] = None + self, turn_context: TurnContext, *, target_cls: Type[StoreItem] | None = None ) -> str: raise NotImplementedError() def get_value( self, property_name: str, - default_value_factory: Callable[[], StoreItem] = None, + default_value_factory: Callable[[], StoreItem] | None = None, *, target_cls: Type[StoreItem] = None, ) -> StoreItem: @@ -312,9 +312,9 @@ async def delete(self, turn_context: TurnContext) -> None: async def get( self, turn_context: TurnContext, - default_value_or_factory: Union[Callable, StoreItem] = None, + default_value_or_factory: Callable | StoreItem | None = None, *, - target_cls: Type[StoreItem] = None, + target_cls: Type[StoreItem] | None = None, ) -> StoreItem: """ Gets the property value. diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/state/state_property_accessor.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/state/state_property_accessor.py index 4ad564894..145d26f90 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/state/state_property_accessor.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/state/state_property_accessor.py @@ -3,7 +3,7 @@ from abc import abstractmethod from collections.abc import Callable -from typing import Protocol, Type, Union +from typing import Protocol, Type from microsoft_agents.hosting.core.storage import StoreItem @@ -15,9 +15,9 @@ class StatePropertyAccessor(Protocol): async def get( self, turn_context: TurnContext, - default_value_or_factory: Union[Callable, StoreItem] = None, + default_value_or_factory: Callable | StoreItem | None = None, *, - target_cls: Type[StoreItem] = None + target_cls: Type[StoreItem] | None = None ) -> object: """ Get the property value from the source diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/memory_storage.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/memory_storage.py index 31560b276..17ba2d1fd 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/memory_storage.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/memory_storage.py @@ -12,7 +12,7 @@ class MemoryStorage(Storage): - def __init__(self, state: dict[str, JSON] = None): + def __init__(self, state: dict[str, JSON] | None = None): self._memory: dict[str, JSON] = state or {} self._lock = Lock() diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/storage.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/storage.py index 1e9ddd86e..380def139 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/storage.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/storage.py @@ -1,7 +1,7 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. -from typing import Protocol, TypeVar, Type, Union +from typing import Protocol, TypeVar, Type from abc import abstractmethod from asyncio import gather @@ -13,7 +13,7 @@ class Storage(Protocol): async def read( - self, keys: list[str], *, target_cls: Type[StoreItemT] = None, **kwargs + self, keys: list[str], *, target_cls: Type[StoreItemT] | None = None, **kwargs ) -> dict[str, StoreItemT]: """Reads multiple items from storage. @@ -53,8 +53,8 @@ async def initialize(self) -> None: @abstractmethod async def _read_item( - self, key: str, *, target_cls: Type[StoreItemT] = None, **kwargs - ) -> tuple[Union[str, None], Union[StoreItemT, None]]: + self, key: str, *, target_cls: Type[StoreItemT] | None = None, **kwargs + ) -> tuple[str | None, StoreItemT | None]: """Reads a single item from storage by key. Returns a tuple of (key, StoreItem) if found, or (None, None) if not found. @@ -62,7 +62,7 @@ async def _read_item( pass async def read( - self, keys: list[str], *, target_cls: Type[StoreItemT] = None, **kwargs + self, keys: list[str], *, target_cls: Type[StoreItemT] | None = None, **kwargs ) -> dict[str, StoreItemT]: if not keys: raise ValueError("Storage.read(): Keys are required when reading.") @@ -72,13 +72,8 @@ async def read( with spans.StorageRead(len(keys)): await self.initialize() - items: list[tuple[Union[str, None], Union[StoreItemT, None]]] = ( - await gather( - *[ - self._read_item(key, target_cls=target_cls, **kwargs) - for key in keys - ] - ) + items: list[tuple[str | None, StoreItemT | None]] = await gather( + *[self._read_item(key, target_cls=target_cls, **kwargs) for key in keys] ) return {key: value for key, value in items if key is not None} diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/transcript_file_store.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/transcript_file_store.py index d40bf092b..f69cd20c9 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/transcript_file_store.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/transcript_file_store.py @@ -10,7 +10,7 @@ from datetime import datetime, timezone from pathlib import Path -from typing import Any, Dict, Iterable, List, Optional, Tuple, Union +from typing import Any, Optional from .transcript_logger import TranscriptLogger from .transcript_logger import PagedResult @@ -41,7 +41,7 @@ class FileTranscriptStore(TranscriptLogger): - Microsoft.Agents.Storage.Transcript namespace overview [AGENTS] """ - def __init__(self, root_folder: Union[str, Path]) -> None: + def __init__(self, root_folder: str | Path) -> None: self._root = Path(root_folder).expanduser().resolve() self._root.mkdir(parents=True, exist_ok=True) @@ -87,10 +87,10 @@ async def list_transcripts(self, channel_id: str) -> PagedResult[TranscriptInfo] :param channel_id: The channel ID to list transcripts for.""" channel_dir = self._channel_dir(channel_id) - def _list() -> List[TranscriptInfo]: + def _list() -> list[TranscriptInfo]: if not channel_dir.exists(): return [] - results: List[TranscriptInfo] = [] + results: list[TranscriptInfo] = [] for p in channel_dir.glob("*.transcript"): # mtime is a reasonable proxy for 'created/updated' created = datetime.fromtimestamp(p.stat().st_mtime, tz=timezone.utc) @@ -127,12 +127,12 @@ async def get_transcript_activities( """ file_path = self._file_path(channel_id, conversation_id) - def _read_page() -> Tuple[List[Activity], Optional[str]]: + def _read_page() -> tuple[list[Activity], Optional[str]]: if not file_path.exists(): return [], None offset = int(continuation_token) if continuation_token else 0 - results: List[Activity] = [] + results: list[Activity] = [] with open(file_path, "rb") as f: f.seek(0, os.SEEK_END) @@ -215,7 +215,7 @@ def _sanitize(pattern: re.Pattern[str], value: str) -> str: return value or "unknown" -def _get_ids(activity: Activity) -> Tuple[str, str]: +def _get_ids(activity: Activity) -> tuple[str, str]: # Works with both dict-like and object-like Activity def _get(obj: Any, *path: str) -> Optional[Any]: cur = obj @@ -235,7 +235,7 @@ def _get(obj: Any, *path: str) -> Optional[Any]: return str(channel_id), str(conversation_id) -def _to_plain_dict(activity: Activity) -> Dict[str, Any]: +def _to_plain_dict(activity: Activity) -> dict[str, Any]: if isinstance(activity, dict): return activity diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/transcript_logger.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/transcript_logger.py index 41df1ffda..93b78baea 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/transcript_logger.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/transcript_logger.py @@ -5,11 +5,10 @@ import string import json -from typing import Any, Optional from abc import ABC, abstractmethod from datetime import datetime, timezone from queue import Queue -from typing import Awaitable, Callable, List, Optional +from typing import Awaitable, Callable, Optional from dataclasses import dataclass from microsoft_agents.activity import Activity, ChannelAccount @@ -24,7 +23,7 @@ @dataclass class PagedResult(Generic[T]): - items: List[T] + items: list[T] continuation_token: Optional[str] = None @@ -139,7 +138,7 @@ async def on_turn( # pylint: disable=unused-argument async def send_activities_handler( ctx: TurnContext, - activities: List[Activity], + activities: list[Activity], next_send: Callable[[], Awaitable[None]], ): # Run full pipeline diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/transcript_memory_store.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/transcript_memory_store.py index 6bc170f11..6ae74684f 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/transcript_memory_store.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/transcript_memory_store.py @@ -50,7 +50,7 @@ async def get_transcript_activities( self, channel_id: str, conversation_id: str, - continuation_token: str = None, + continuation_token: str | None = None, start_date: datetime = datetime.min.replace(tzinfo=timezone.utc), ) -> PagedResult[Activity]: """ @@ -127,7 +127,7 @@ async def delete_transcript(self, channel_id: str, conversation_id: str) -> None ] async def list_transcripts( - self, channel_id: str, continuation_token: str = None + self, channel_id: str, continuation_token: str | None = None ) -> PagedResult[TranscriptInfo]: """ Lists all transcripts (unique conversation IDs) for a given channel. diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/transcript_store.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/transcript_store.py index 8e660bf89..4170863be 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/transcript_store.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/transcript_store.py @@ -14,7 +14,7 @@ async def get_transcript_activities( self, channel_id: str, conversation_id: str, - continuation_token: str = None, + continuation_token: str | None = None, start_date: datetime = datetime.min.replace(tzinfo=timezone.utc), ) -> tuple[list[Activity], str]: """ @@ -30,7 +30,7 @@ async def get_transcript_activities( @abstractmethod async def list_transcripts( - self, channel_id: str, continuation_token: str = None + self, channel_id: str, continuation_token: str | None = None ) -> tuple[list[TranscriptInfo, str]]: """ Asynchronously lists transcripts for a given channel. diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/turn_context.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/turn_context.py index 46eeba4ab..af71ffac2 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/turn_context.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/turn_context.py @@ -30,8 +30,8 @@ class TurnContext(TurnContextProtocol): def __init__( self, adapter_or_context, - request: Activity = None, - identity: ClaimsIdentity = None, + request: Activity | None = None, + identity: ClaimsIdentity | None = None, ): """ Creates a new TurnContext instance. @@ -185,8 +185,8 @@ def set(self, key: str, value: object) -> None: async def send_activity( self, activity_or_text: Activity | str, - speak: str = None, - input_hint: str = None, + speak: str | None = None, + input_hint: str | None = None, ) -> ResourceResponse | None: """ Sends a single activity or message to the user. @@ -338,7 +338,11 @@ async def next_handler(): return await logic async def send_trace_activity( - self, name: str, value: object = None, value_type: str = None, label: str = None + self, + name: str, + value: object = None, + value_type: str | None = None, + label: str | None = None, ) -> ResourceResponse: trace_activity = Activity( type=ActivityTypes.trace, diff --git a/libraries/microsoft-agents-hosting-dialogs/microsoft_agents/hosting/dialogs/object_path.py b/libraries/microsoft-agents-hosting-dialogs/microsoft_agents/hosting/dialogs/object_path.py index 0e6a4460c..4b7deb2b7 100644 --- a/libraries/microsoft-agents-hosting-dialogs/microsoft_agents/hosting/dialogs/object_path.py +++ b/libraries/microsoft-agents-hosting-dialogs/microsoft_agents/hosting/dialogs/object_path.py @@ -2,7 +2,7 @@ # Licensed under the MIT License. import copy -from typing import Union, Callable +from typing import Callable class ObjectPath: @@ -11,7 +11,7 @@ class ObjectPath: """ @staticmethod - def assign(start_object, overlay_object, default: Union[Callable, object] = None): + def assign(start_object, overlay_object, default: Callable | object = None): """ Creates a new object by overlaying values in start_object with non-null values from overlay_object. @@ -106,9 +106,7 @@ def set_path_value(obj, path: str, value: object): ObjectPath.__set_object_segment(current, last_segment, value) @staticmethod - def get_path_value( - obj, path: str, default: Union[Callable, object] = None - ) -> object: + def get_path_value(obj, path: str, default: Callable | object = None) -> object: """ Get the value for a path relative to an object. """ diff --git a/libraries/microsoft-agents-hosting-dialogs/microsoft_agents/hosting/dialogs/prompts/prompt_validator_context.py b/libraries/microsoft-agents-hosting-dialogs/microsoft_agents/hosting/dialogs/prompts/prompt_validator_context.py index 7a19231a9..35917975a 100644 --- a/libraries/microsoft-agents-hosting-dialogs/microsoft_agents/hosting/dialogs/prompts/prompt_validator_context.py +++ b/libraries/microsoft-agents-hosting-dialogs/microsoft_agents/hosting/dialogs/prompts/prompt_validator_context.py @@ -1,6 +1,7 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. -from typing import Dict, cast + +from typing import cast from microsoft_agents.hosting.core import TurnContext from .prompt_options import PromptOptions from .prompt_recognizer_result import PromptRecognizerResult @@ -11,7 +12,7 @@ def __init__( self, turn_context: TurnContext, recognized: PromptRecognizerResult, - state: Dict[str, object], + state: dict[str, object], options: PromptOptions, ): """Creates contextual information passed to a custom `PromptValidator`. diff --git a/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/_path_navigator.py b/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/_path_navigator.py index a6a817256..f7cc736ba 100644 --- a/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/_path_navigator.py +++ b/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/_path_navigator.py @@ -5,7 +5,7 @@ from __future__ import annotations -from typing import Any, Optional, Tuple +from typing import Any, Optional def _parse_path(path: str) -> Optional[list[int | str]]: @@ -60,7 +60,7 @@ def emit() -> None: return segments -def _resolve_segment(current: Any, segment: Any) -> Tuple[bool, Any]: +def _resolve_segment(current: Any, segment: Any) -> tuple[bool, Any]: """Resolve one path segment against the current node. Returns ``(found, value)``. ``found`` is False when the segment cannot be @@ -89,7 +89,7 @@ def _resolve_segment(current: Any, segment: Any) -> Tuple[bool, Any]: return False, None -def try_get_path_value(data: Any, path: str) -> Tuple[bool, Any]: +def try_get_path_value(data: Any, path: str) -> tuple[bool, Any]: """Walk ``path`` against ``data``. Returns ``(found, value)``.""" if data is None: return False, None diff --git a/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/api/slack_stream.py b/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/api/slack_stream.py index 9b158a223..ec9f352b2 100644 --- a/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/api/slack_stream.py +++ b/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/api/slack_stream.py @@ -6,7 +6,7 @@ from __future__ import annotations import json -from typing import Any, Optional, Union +from typing import Any, Optional from pydantic import BaseModel @@ -64,7 +64,7 @@ async def start( async def append( self, - chunk_or_text: Union[str, BaseModel, list[BaseModel]], + chunk_or_text: str | BaseModel | list[BaseModel], ) -> "SlackStream": """Append one or more chunks to the stream. @@ -107,7 +107,7 @@ async def append( async def stop( self, chunks: Optional[list[BaseModel]] = None, - blocks: Union[str, list[Any], dict, None] = None, + blocks: str | list[Any] | dict | None = None, ) -> None: """Stop the active stream, optionally finalizing with chunks and/or Block Kit blocks. diff --git a/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/slack_agent_extension.py b/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/slack_agent_extension.py index 29313d590..faa928db0 100644 --- a/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/slack_agent_extension.py +++ b/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/slack_agent_extension.py @@ -6,7 +6,7 @@ from __future__ import annotations import re -from typing import Any, Callable, Generic, Optional, Pattern, TypeVar, Union +from typing import Any, Callable, Generic, Optional, Pattern, TypeVar from microsoft_agents.activity import ActivityTypes, Channels from microsoft_agents.hosting.core import TurnContext @@ -22,7 +22,7 @@ StateT = TypeVar("StateT", bound=TurnState) -TextSelector = Union[str, Pattern[str], None] +TextSelector = str | Pattern[str], None _SLACK_API_SERVICE_KEY = "microsoft_agents.hosting.slack.SlackApi" 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 index ba753bb29..c0511e2d7 100644 --- 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 @@ -4,7 +4,7 @@ """ from http import HTTPStatus -from typing import Any, List +from typing import Any from microsoft_agents.hosting.core import ActivityHandler, TurnContext from microsoft_agents.hosting.teams.errors import teams_errors @@ -621,7 +621,7 @@ async def on_teams_message_soft_delete(self, turn_context: TurnContext) -> None: async def on_teams_members_added_dispatch( self, - members_added: List[ChannelAccount], + members_added: list[ChannelAccount], team_info: TeamInfo, turn_context: TurnContext, ) -> None: @@ -672,7 +672,7 @@ async def on_teams_members_added_dispatch( async def on_teams_members_added( self, - teams_members_added: List[TeamsChannelAccount], + teams_members_added: list[TeamsChannelAccount], team_info: TeamInfo, turn_context: TurnContext, ) -> None: @@ -686,7 +686,7 @@ async def on_teams_members_added( async def on_teams_members_removed_dispatch( self, - members_removed: List[ChannelAccount], + members_removed: list[ChannelAccount], team_info: TeamInfo, turn_context: TurnContext, ) -> None: @@ -706,7 +706,7 @@ async def on_teams_members_removed_dispatch( async def on_teams_members_removed( self, - teams_members_removed: List[TeamsChannelAccount], + teams_members_removed: list[TeamsChannelAccount], team_info: TeamInfo, turn_context: TurnContext, ) -> None: 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 index 6209bdd2a..34cdef2fd 100644 --- 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 @@ -7,7 +7,7 @@ import re from http import HTTPStatus -from typing import Any, Callable, Generic, Optional, Pattern, TypeVar, Union +from typing import Any, Callable, Generic, Optional, Pattern, TypeVar from microsoft_agents.activity import Activity, ActivityTypes, InvokeResponse from microsoft_agents.hosting.core import TurnContext @@ -30,7 +30,7 @@ StateT = TypeVar("StateT", bound=TurnState) -CommandSelector = Union[str, Pattern[str], None] +CommandSelector = str | Pattern[str] | None def _match_selector(selector: CommandSelector, value: Optional[str]) -> bool: 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 index 05cb1ab3a..b90eb6f84 100644 --- 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 @@ -3,7 +3,7 @@ """Teams information utilities for Microsoft Agents.""" -from typing import Optional, Tuple, Dict, Any, List +from typing import Optional, Any from microsoft_agents.activity import Activity, Channels, ConversationParameters @@ -145,7 +145,7 @@ async def send_message_to_teams_channel( activity: Activity, teams_channel_id: str, app_id: Optional[str] = None, - ) -> Tuple[Dict[str, Any], str]: + ) -> tuple[dict[str, Any], str]: """ Sends a message to a Teams channel. @@ -225,7 +225,7 @@ async def _conversation_callback( @staticmethod async def get_team_channels( context: TurnContext, team_id: Optional[str] = None - ) -> List[ChannelInfo]: + ) -> list[ChannelInfo]: """ Gets the channels of a team. @@ -425,7 +425,7 @@ async def send_message_to_list_of_users( context: TurnContext, activity: Activity, tenant_id: str, - members: List[TeamsMember], + members: list[TeamsMember], ) -> TeamsBatchOperationResponse: """ Sends a message to a list of users. @@ -524,7 +524,7 @@ async def send_message_to_list_of_channels( context: TurnContext, activity: Activity, tenant_id: str, - members: List[TeamsMember], + members: list[TeamsMember], ) -> TeamsBatchOperationResponse: """ Sends a message to a list of channels. diff --git a/libraries/microsoft-agents-storage-blob/microsoft_agents/storage/blob/blob_storage.py b/libraries/microsoft-agents-storage-blob/microsoft_agents/storage/blob/blob_storage.py index 595c62fd5..a88c60783 100644 --- a/libraries/microsoft-agents-storage-blob/microsoft_agents/storage/blob/blob_storage.py +++ b/libraries/microsoft-agents-storage-blob/microsoft_agents/storage/blob/blob_storage.py @@ -1,5 +1,5 @@ import json -from typing import TypeVar, Union +from typing import TypeVar from io import BytesIO from azure.storage.blob.aio import ( @@ -63,8 +63,8 @@ async def initialize(self) -> None: self._initialized = True async def _read_item( - self, key: str, *, target_cls: StoreItemT = None, **kwargs - ) -> tuple[Union[str, None], Union[StoreItemT, None]]: + self, key: str, *, target_cls: StoreItemT | None = None, **kwargs + ) -> tuple[str | None, StoreItemT | None]: item = await ignore_error( self._container_client.download_blob(blob=key, timeout=5), is_status_code_error(404), diff --git a/libraries/microsoft-agents-storage-blob/microsoft_agents/storage/blob/blob_storage_config.py b/libraries/microsoft-agents-storage-blob/microsoft_agents/storage/blob/blob_storage_config.py index dec5e206a..bbf9a9056 100644 --- a/libraries/microsoft-agents-storage-blob/microsoft_agents/storage/blob/blob_storage_config.py +++ b/libraries/microsoft-agents-storage-blob/microsoft_agents/storage/blob/blob_storage_config.py @@ -1,5 +1,3 @@ -from typing import Union - from azure.core.credentials_async import AsyncTokenCredential @@ -11,7 +9,7 @@ def __init__( container_name: str, connection_string: str = "", url: str = "", - credential: Union[AsyncTokenCredential, None] = None, + credential: AsyncTokenCredential | None = None, ): """Configuration settings for BlobStorage. @@ -25,4 +23,4 @@ def __init__( self.container_name: str = container_name self.connection_string: str = connection_string self.url: str = url - self.credential: Union[AsyncTokenCredential, None] = credential + self.credential: AsyncTokenCredential | None = credential diff --git a/libraries/microsoft-agents-storage-cosmos/microsoft_agents/storage/cosmos/cosmos_db_storage.py b/libraries/microsoft-agents-storage-cosmos/microsoft_agents/storage/cosmos/cosmos_db_storage.py index 96df0352b..5dc612737 100644 --- a/libraries/microsoft-agents-storage-cosmos/microsoft_agents/storage/cosmos/cosmos_db_storage.py +++ b/libraries/microsoft-agents-storage-cosmos/microsoft_agents/storage/cosmos/cosmos_db_storage.py @@ -1,12 +1,11 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. -from typing import TypeVar, Union +from typing import TypeVar import asyncio from azure.cosmos import ( documents, - http_constants, CosmosDict, ) from azure.cosmos.aio import ( @@ -46,8 +45,8 @@ def __init__(self, config: CosmosDBStorageConfig): self._config: CosmosDBStorageConfig = config self._client: CosmosClient = self._create_client() - self._database: DatabaseProxy = None - self._container: ContainerProxy = None + self._database: DatabaseProxy | None = None + self._container: ContainerProxy | None = None self._compatability_mode_partition_key: bool = False # Lock used for synchronizing container creation self._lock: asyncio.Lock = asyncio.Lock() @@ -88,8 +87,8 @@ def _sanitize(self, key: str) -> str: ) async def _read_item( - self, key: str, *, target_cls: StoreItemT = None, **kwargs - ) -> tuple[Union[str, None], Union[StoreItemT, None]]: + self, key: str, *, target_cls: StoreItemT | None = None, **kwargs + ) -> tuple[str | None, StoreItemT | None]: if key == "": raise ValueError(str(storage_errors.CosmosDbKeyCannotBeEmpty)) diff --git a/libraries/microsoft-agents-storage-cosmos/microsoft_agents/storage/cosmos/cosmos_db_storage_config.py b/libraries/microsoft-agents-storage-cosmos/microsoft_agents/storage/cosmos/cosmos_db_storage_config.py index 4e0e15ac0..596a10360 100644 --- a/libraries/microsoft-agents-storage-cosmos/microsoft_agents/storage/cosmos/cosmos_db_storage_config.py +++ b/libraries/microsoft-agents-storage-cosmos/microsoft_agents/storage/cosmos/cosmos_db_storage_config.py @@ -1,5 +1,4 @@ import json -from typing import Union from azure.core.credentials_async import AsyncTokenCredential from microsoft_agents.storage.cosmos.errors import storage_errors @@ -16,12 +15,12 @@ def __init__( auth_key: str = "", database_id: str = "", container_id: str = "", - cosmos_client_options: dict = None, + cosmos_client_options: dict | None = None, container_throughput: int | None = None, key_suffix: str = "", compatibility_mode: bool = False, url: str = "", - credential: Union[AsyncTokenCredential, None] = None, + credential: AsyncTokenCredential | None = None, **kwargs, ): """Create the Config object. @@ -62,7 +61,7 @@ def __init__( "compatibility_mode", False ) self.url = url or kwargs.get("url", "") - self.credential: Union[AsyncTokenCredential, None] = credential + self.credential: AsyncTokenCredential | None = credential @staticmethod def validate_cosmos_db_config(config: "CosmosDBStorageConfig") -> None: From c8a4dfa75d0141876ca2decb2dc972a2066822eb Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Wed, 17 Jun 2026 12:43:31 -0700 Subject: [PATCH 02/46] Another commit --- .../microsoft_agents/hosting/teams/_utils.py | 45 ++ .../hosting/teams/meeting/meeting.py | 142 ++++ .../__init__.py} | 0 .../message_extension/message_extension.py | 475 ++++++++++++ .../teams/message_extension/route_handlers.py | 114 +++ .../hosting/teams/route_handlers.py | 118 +++ .../hosting/teams/task_module/__init__.py | 0 .../teams/task_module/route_handlers.py | 27 + .../hosting/teams/task_module/task_module.py | 121 +++ .../hosting/teams/teams_agent_extension.py | 714 ------------------ .../hosting/teams/teams_turn_context.py | 12 + .../hosting/teams/type_defs.py | 23 + 12 files changed, 1077 insertions(+), 714 deletions(-) create mode 100644 libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/_utils.py create mode 100644 libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/meeting/meeting.py rename libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/{teams_cloud_adapter.py => message_extension/__init__.py} (100%) create mode 100644 libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/message_extension/message_extension.py create mode 100644 libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/message_extension/route_handlers.py create mode 100644 libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/route_handlers.py create mode 100644 libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/task_module/__init__.py create mode 100644 libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/task_module/route_handlers.py create mode 100644 libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/task_module/task_module.py create mode 100644 libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/teams_turn_context.py create mode 100644 libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/type_defs.py diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/_utils.py b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/_utils.py new file mode 100644 index 000000000..9ba3b0780 --- /dev/null +++ b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/_utils.py @@ -0,0 +1,45 @@ +import re + +from typing import Any, Optional + +from microsoft_agents.activity import Activity +from microsoft_agents.hosting.core import TurnContext + +from .type_defs import ( + CommandSelector +) + +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), + ) + ) diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/meeting/meeting.py b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/meeting/meeting.py new file mode 100644 index 000000000..1ec9cce68 --- /dev/null +++ b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/meeting/meeting.py @@ -0,0 +1,142 @@ +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 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/message_extension/__init__.py similarity index 100% rename from libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/teams_cloud_adapter.py rename to libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/message_extension/__init__.py diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/message_extension/message_extension.py b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/message_extension/message_extension.py new file mode 100644 index 000000000..adaf48c37 --- /dev/null +++ b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/message_extension/message_extension.py @@ -0,0 +1,475 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from typing import Generic, Optional, Callable + +from microsoft_teams.api.models import ( + MessagingExtensionQuery, + MessagingExtensionAction, + MessagingExtensionResponse, + O365ConnectorCardActionQuery, + AppBasedLinkQuery +) + +from microsoft_agents.activity import ( + Activity, + ActivityTypes +) + +from microsoft_agents.hosting.core import ( + AgentApplication, + RouteRank, + TurnContext, +) + +from microsoft_agents.hosting.teams.teams_turn_context import TeamsTurnContext +from microsoft_agents.hosting.teams.type_defs import ( + StateT, + CommandSelector, + _RouteDecorator +) + +from microsoft_agents.hosting.teams._utils import ( + _match_selector, + _send_invoke_response +) + +from .route_handlers import ( + FetchActionHandler, + SubmitActionHandler, + MessagePreviewEditHandler, + QueryHandler, + SelectItemHandler, + QueryLinkHandler, + QueryUrlSettingHandler, + ConfigureSettingsHandler, + CardButtonClickedHandler +) + +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, + ) -> _RouteDecorator[QueryHandler[StateT]]: + """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: QueryHandler[StateT]) -> QueryHandler[StateT]: + async def __handler(context: TurnContext, state: StateT) -> None: + teams_context = TeamsTurnContext(context) + query = MessagingExtensionQuery.model_validate( + context.activity.value or {} + ) + response = await func(teams_context, state, query) + if response is not None: + await _send_invoke_response(context, response) + + self._app.add_route( + __selector, + __handler, + is_invoke=True, + rank=rank, + auth_handlers=auth_handlers, + ) + return func + + return __call + + def on_select_item( + self, + *, + auth_handlers: Optional[list[str]] = None, + rank: RouteRank = RouteRank.DEFAULT, + ) -> _RouteDecorator[SelectItemHandler[StateT]]: + """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 __call(func: SelectItemHandler[StateT]) -> SelectItemHandler[StateT]: + async def __handler(context: TurnContext, state: StateT) -> None: + teams_context = TeamsTurnContext(context) + response = await func(teams_context, state, context.activity.value) + if response is not None: + await _send_invoke_response(context, response) + + self._app.add_route( + __selector, + __handler, + is_invoke=True, + rank=rank, + auth_handlers=auth_handlers, + ) + return func + + return __call + + def on_submit_action( + self, + command_id: CommandSelector = None, + *, + auth_handlers: Optional[list[str]] = None, + rank: RouteRank = RouteRank.DEFAULT, + ) -> _RouteDecorator[SubmitActionHandler[StateT]]: + """Register a handler for composeExtension/submitAction invokes (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: SubmitActionHandler[StateT]) -> SubmitActionHandler[StateT]: + async def __handler(context: TurnContext, state: StateT) -> None: + teams_context = TeamsTurnContext(context) + action = MessagingExtensionAction.model_validate( + context.activity.value or {} + ) + response = await func(teams_context, state, action) + if response is not None: + await _send_invoke_response(context, response) + + self._app.add_route( + __selector, + __handler, + is_invoke=True, + rank=rank, + auth_handlers=auth_handlers, + ) + return func + + return __call + + def on_message_preview_edit( + self, + command_id: CommandSelector = None, + *, + auth_handlers: Optional[list[str]] = None, + rank: RouteRank = RouteRank.DEFAULT, + ) -> _RouteDecorator[MessagePreviewEditHandler[StateT]]: + """Register a handler for composeExtension/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: MessagePreviewEditHandler[StateT]) -> MessagePreviewEditHandler[StateT]: + async def __handler(context: TurnContext, state: StateT) -> None: + teams_context = TeamsTurnContext(context) + action = MessagingExtensionAction.model_validate( + context.activity.value or {} + ) + response = await func(teams_context, state, action) + if response is not None: + await _send_invoke_response(context, response) + + self._app.add_route( + __selector, + __handler, + is_invoke=True, + rank=rank, + auth_handlers=auth_handlers, + ) + return func + + return __call + + def on_message_preview_send( + self, + command_id: CommandSelector = None, + *, + auth_handlers: Optional[list[str]] = None, + rank: RouteRank = RouteRank.DEFAULT, + ) -> _RouteDecorator[MessagePreviewSendHandler[StateT]]: + """Register a handler for composeExtension/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: MessagePreviewSendHandler[StateT]) -> MessagePreviewSendHandler[StateT]: + async def __handler(context: TurnContext, state: StateT) -> None: + teams_context = TeamsTurnContext(context) + action = MessagingExtensionAction.model_validate( + context.activity.value or {} + ) + response = await func(teams_context, state, action) + if response is not None: + await _send_invoke_response(context, response) + + self._app.add_route( + __selector, + __handler, + is_invoke=True, + rank=rank, + auth_handlers=auth_handlers, + ) + return func + + return __call + + def on_task_fetch( + self, + command_id: CommandSelector = None, + *, + auth_handlers: Optional[list[str]] = None, + rank: RouteRank = RouteRank.DEFAULT, + ) -> _RouteDecorator[TaskFetchHandler[StateT]]: + """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: TaskFetchHandler[StateT]) -> TaskFetchHandler[StateT]: + 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, + *, + auth_handlers: Optional[list[str]] = None, + rank: RouteRank = RouteRank.DEFAULT, + ) -> _RouteDecorator[QueryLinkHandler[StateT]]: + """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 __call(func: QueryLinkHandler[StateT]) -> QueryLinkHandler[StateT]: + async def __handler(context: TurnContext, state: StateT) -> None: + teams_context = TeamsTurnContext(context) + query = AppBasedLinkQuery.model_validate(context.activity.value or {}) + response = await func(teams_context, state, query) + if response is not None: + await _send_invoke_response(context, response) + + self._app.add_route( + __selector, + __handler, + is_invoke=True, + rank=rank, + auth_handlers=auth_handlers, + ) + return func + + return __call + + def on_anonymous_query_link( + self, + *, + auth_handlers: Optional[list[str]] = None, + rank: RouteRank = RouteRank.DEFAULT, + ) -> _RouteDecorator[QueryLinkHandler[StateT]]: + """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: QueryLinkHandler[StateT]) -> QueryLinkHandler[StateT]: + async def __handler(context: TurnContext, state: StateT) -> None: + teams_context = TeamsTurnContext(context) + query = AppBasedLinkQuery.model_validate(context.activity.value or {}) + response = await func(teams_context, state, query) + if response is not None: + await _send_invoke_response(context, response) + + self._app.add_route( + __selector, + __handler, + is_invoke=True, + rank=rank, + auth_handlers=auth_handlers, + ) + return func + + return __register + + def on_query_url_setting( + self, + *, + auth_handlers: Optional[list[str]] = None, + rank: RouteRank = RouteRank.DEFAULT, + ) -> _RouteDecorator[QueryUrlSettingHandler[StateT]]: + """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: QueryUrlSettingHandler[StateT]) -> QueryUrlSettingHandler[StateT]: + async def __handler(context: TurnContext, state: StateT) -> None: + teams_context = TeamsTurnContext(context) + query = MessagingExtensionQuery.model_validate( + context.activity.value or {} + ) + response = await func(teams_context, state, query) + if response is not None: + await _send_invoke_response(context, response) + + self._app.add_route( + __selector, + __handler, + is_invoke=True, + rank=rank, + auth_handlers=auth_handlers, + ) + return func + + return __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 diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/message_extension/route_handlers.py b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/message_extension/route_handlers.py new file mode 100644 index 000000000..a37c79fa4 --- /dev/null +++ b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/message_extension/route_handlers.py @@ -0,0 +1,114 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from typing import ( + Any, + Awaitable, + Protocol, +) + +from microsoft_teams.api.models import ( + Channel, + Team, + MeetingDetails, + MeetingParticipantsEventDetails, + MessageExtensionAction, + MessageExtensionQuery, + MessageExtensionActionResponse, + MessageExtensionResponse, + O365ConnectorCardActionQuery, + TaskModuleRequest, + TaskModuleResponse, + AppBasedLinkQuery +) + +from microsoft_agents.activity import Activity + +from microsoft_agents.hosting.teams_turn_context import TeamsTurnContext +from microsoft_agents.hosting.teams.type_defs import StateT + +class FetchActionHandler(Protocol[StateT]): + def __call__( + self, + context: TeamsTurnContext, + state: StateT, + action: MessageExtensionAction + ) -> Awaitable[MessageExtensionActionResponse]: ... + +class SubmitActionHandler(Protocol[StateT]): + def __call__( + self, + context: TeamsTurnContext, + state: StateT, + action: MessageExtensionAction + ) -> Awaitable[MessageExtensionResponse]: ... + +class MessagePreviewEditHandler(Protocol[StateT]): + def __call__( + self, + context: TeamsTurnContext, + state: StateT, + activity_preview: Activity + ) -> Awaitable[MessageExtensionResponse]: + ... + +class MessagePreviewSendHandler(Protocol[StateT]): + def __call__( + self, + context: TeamsTurnContext, + activity_preview: Activity + ) -> Awaitable[None]: + ... + +class QueryHandler(Protocol[StateT]): + def __call__( + self, + context: TeamsTurnContext, + state: StateT, + query: MessageExtensionQuery + ) -> Awaitable[MessageExtensionResponse]: + ... + +class SelectItemHandler(Protocol[StateT]): + def __call__( + self, + context: TeamsTurnContext, + state: StateT, + item: Any + ) -> Awaitable[MessageExtensionResponse]: + ... + +class QueryLinkHandler(Protocol[StateT]): + def __call__( + self, + context: TeamsTurnContext, + state: StateT, + query: AppBasedLinkQuery + ) -> Awaitable[MessageExtensionResponse]: + ... + +class QueryUrlSettingHandler(Protocol[StateT]): + def __call__( + self, + context: TeamsTurnContext, + state: StateT, + ) -> Awaitable[MessageExtensionResponse]: + ... + +class ConfigureSettingsHandler(Protocol[StateT]): + def __call__( + self, + context: TeamsTurnContext, + state: StateT, + query: MessageExtensionQuery + ) -> Awaitable[MessageExtensionResponse]: + ... + +class CardButtonClickedHandler(Protocol[StateT]): + def __call__( + self, + context: TeamsTurnContext, + state: StateT, + card: Any + ) -> Awaitable[None]: + ... \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/route_handlers.py b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/route_handlers.py new file mode 100644 index 000000000..e81e237a6 --- /dev/null +++ b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/route_handlers.py @@ -0,0 +1,118 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from typing import ( + Any, + Awaitable, + Protocol, +) + +from microsoft_agents.activity import Activity + +from microsoft_teams.api.models import ( + Channel, + Team, + MeetingDetails, + MeetingParticipantsEventDetails, + MessagingExtensionAction, + MessagingExtensionQuery, + MessageExtensionActionResponse, + MessagingExtensionResponse, + O365ConnectorCardActionQuery, + TaskModuleRequest, + TaskModuleResponse +) + +from .teams_turn_context import TeamsTurnContext +from .type_defs import StateT + +class TeamsRouteHandler(Protocol[StateT]): + def __call__(self, context: TeamsTurnContext, state: StateT) -> Awaitable[None]: ... + +# Meetings route handlers + +class MeetingStartHandler(Protocol[StateT]): + def __call__( + self, + context: TeamsTurnContext, + state: StateT, + meeting: MeetingDetails + ) -> Awaitable[None]: ... + +class MeetingEndHandler(Protocol[StateT]): + def __call__( + self, + context: TeamsTurnContext, + state: StateT, + meeting: MeetingDetails + ) -> Awaitable[None]: ... + +class MeetingParticipantsEventHandler(Protocol[StateT]): + def __call__( + self, + context: TeamsTurnContext, + state: StateT, + meeting: MeetingParticipantsEventDetails + ) -> Awaitable[None]: ... + +# Message extension route handlers + +# Messages route handler + +class O365ConnectorCardActionHandler(Protocol[StateT]): + def __call__( + self, + context: TeamsTurnContext, + state: StateT, + query: O365ConnectorCardActionQuery + ) -> Awaitable[None]: + ... + +class ReadReceiptHandler(Protocol[StateT]): + def __call__( + self, + context: TeamsTurnContext, + state: StateT, + data: dict + ) -> Awaitable[None]: + ... + +# Task Modules route handlers + +class TaskFetchHandler(Protocol[StateT]): + def __call__( + self, + context: TeamsTurnContext, + state: StateT, + request: TaskModuleRequest, + ) -> Awaitable[TaskModuleResponse]: + ... + +class TaskSubmitHandler(Protocol[StateT]): + def __call__( + self, + context: TeamsTurnContext, + state: StateT, + request: TaskModuleRequest, + ) -> Awaitable[TaskModuleResponse]: + ... + +# Teams Channels route handlers + +class ChannelUpdateHandler(Protocol[StateT]): + def __call__( + self, + context: TeamsTurnContext, + state: StateT, + data: Channel + ) -> Awaitable[None]: + ... + +class TeamUpdateHandler(Protocol[StateT]): + def __call__( + self, + context: TeamsTurnContext, + state: StateT, + data: Team + ) -> Awaitable[None]: + ... \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/task_module/__init__.py b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/task_module/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/task_module/route_handlers.py b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/task_module/route_handlers.py new file mode 100644 index 000000000..c0489cd71 --- /dev/null +++ b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/task_module/route_handlers.py @@ -0,0 +1,27 @@ +from typing import Awaitable, Protocol + +from microsoft_teams.api.models import ( + TaskModuleRequest, + TaskModuleResponse +) + +from microsoft_agents.hosting.teams.teams_turn_context import TeamsTurnContext +from microsoft_agents.hosting.teams.type_defs import StateT + +class TaskFetchHandler(Protocol[StateT]): + def __call__( + self, + context: TeamsTurnContext, + state: StateT, + request: TaskModuleRequest, + ) -> Awaitable[TaskModuleResponse]: + ... + +class TaskSubmitHandler(Protocol[StateT]): + def __call__( + self, + context: TeamsTurnContext, + state: StateT, + request: TaskModuleRequest, + ) -> Awaitable[TaskModuleResponse]: + ... \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/task_module/task_module.py b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/task_module/task_module.py new file mode 100644 index 000000000..f0e776f10 --- /dev/null +++ b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/task_module/task_module.py @@ -0,0 +1,121 @@ +from typing import ( + Any, + Generic, + Optional +) + +from microsoft_agents.hosting.core import ( + AgentApplication, + RouteRank, + TurnContext +) + +from microsoft_agents.hosting.teams.teams_turn_context import TeamsTurnContext +from microsoft_agents.hosting.teams._type_defs import ( + CommandSelector, + RouteDecorator, + StateT, +) +from microsoft_agents.hosting.teams._utils import ( + _match_selector, + _send_invoke_response, +) + +from .route_handlers import ( +) + +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, + ) -> RouteDecorator[]: + """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 \ No newline at end of file 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 index 34cdef2fd..2115b0d66 100644 --- 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 @@ -28,720 +28,6 @@ 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]): """ diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/teams_turn_context.py b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/teams_turn_context.py new file mode 100644 index 000000000..8460aaa56 --- /dev/null +++ b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/teams_turn_context.py @@ -0,0 +1,12 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from microsoft_agents.hosting.core import TurnContext + +class TeamsTurnContext(TurnContext): + """A context object for handling Teams-specific turn functionality.""" + + def __init__(self, context: TurnContext): + super().__init__(context) + + self._context = context \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/type_defs.py b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/type_defs.py new file mode 100644 index 000000000..f53e52060 --- /dev/null +++ b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/type_defs.py @@ -0,0 +1,23 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from typing import ( + Callable, + TypeVar, + Pattern, + Protocol, +) + +from microsoft_agents.hosting.core import TurnState + +from .teams_turn_context import TeamsTurnContext + +TeamsRouteSelector = Callable[[TeamsTurnContext], bool] + +StateT = TypeVar("StateT", bound=TurnState) +RouteHandlerT = TypeVar("RouteHandlerT", bound=Callable) + +CommandSelector = str | Pattern[str] | None + +class _RouteDecorator(Protocol[RouteHandlerT]): + def __call__(self, func: RouteHandlerT) -> RouteHandlerT: ... \ No newline at end of file From 05f00e7431e9c8094c9e21560e34297a0aa91200 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Wed, 17 Jun 2026 15:43:39 -0700 Subject: [PATCH 03/46] Separating route hubs --- .../microsoft_agents/hosting/teams/_utils.py | 2 +- .../hosting/teams/channel/__init__.py | 0 .../hosting/teams/channel/channel.py | 0 .../hosting/teams/channel/route_handlers.py | 0 .../hosting/teams/configuration/__init__.py | 0 .../teams/configuration/configuration.py | 0 .../teams/configuration/route_handlers.py | 0 .../hosting/teams/file_consent/__init__.py | 0 .../teams/file_consent/file_consent.py | 0 .../teams/file_consent/route_handlers.py | 0 .../hosting/teams/meeting/__init__.py | 0 .../hosting/teams/meeting/meeting.py | 94 +++++++---- .../hosting/teams/meeting/route_handlers.py | 36 +++++ .../hosting/teams/message/__init__.py | 0 .../hosting/teams/message/message.py | 150 ++++++++++++++++++ .../hosting/teams/message/route_handlers.py | 24 +++ .../message_extension/message_extension.py | 60 +++---- .../teams/message_extension/route_handlers.py | 3 +- .../hosting/teams/route_handlers.py | 71 ++------- .../teams/task_module/route_handlers.py | 7 +- .../hosting/teams/task_module/task_module.py | 32 ++-- .../hosting/teams/team/__init__.py | 0 .../hosting/teams/team/team.py | 1 + .../hosting/teams/teams_agent_extension.py | 91 ++++++----- 24 files changed, 406 insertions(+), 165 deletions(-) create mode 100644 libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/channel/__init__.py create mode 100644 libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/channel/channel.py create mode 100644 libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/channel/route_handlers.py create mode 100644 libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/configuration/__init__.py create mode 100644 libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/configuration/configuration.py create mode 100644 libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/configuration/route_handlers.py create mode 100644 libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/file_consent/__init__.py create mode 100644 libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/file_consent/file_consent.py create mode 100644 libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/file_consent/route_handlers.py create mode 100644 libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/meeting/__init__.py create mode 100644 libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/meeting/route_handlers.py create mode 100644 libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/message/__init__.py create mode 100644 libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/message/message.py create mode 100644 libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/message/route_handlers.py create mode 100644 libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/team/__init__.py create mode 100644 libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/team/team.py diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/_utils.py b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/_utils.py index 9ba3b0780..bce969664 100644 --- a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/_utils.py +++ b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/_utils.py @@ -42,4 +42,4 @@ async def _send_invoke_response(context: TurnContext, body: Any = None) -> None: type=ActivityTypes.invoke_response, value=InvokeResponse(status=int(HTTPStatus.OK), body=serialized_body), ) - ) + ) \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/channel/__init__.py b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/channel/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/channel/channel.py b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/channel/channel.py new file mode 100644 index 000000000..e69de29bb diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/channel/route_handlers.py b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/channel/route_handlers.py new file mode 100644 index 000000000..e69de29bb diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/configuration/__init__.py b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/configuration/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/configuration/configuration.py b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/configuration/configuration.py new file mode 100644 index 000000000..e69de29bb diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/configuration/route_handlers.py b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/configuration/route_handlers.py new file mode 100644 index 000000000..e69de29bb diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/file_consent/__init__.py b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/file_consent/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/file_consent/file_consent.py b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/file_consent/file_consent.py new file mode 100644 index 000000000..e69de29bb diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/file_consent/route_handlers.py b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/file_consent/route_handlers.py new file mode 100644 index 000000000..e69de29bb diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/meeting/__init__.py b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/meeting/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/meeting/meeting.py b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/meeting/meeting.py index 1ec9cce68..3af6e948c 100644 --- a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/meeting/meeting.py +++ b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/meeting/meeting.py @@ -1,3 +1,39 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from typing import ( + Callable, + Generic, + Optional +) + +from microsoft_teams.api.models import ( + MeetingDetails, + MeetingParticipantsEventDetails, +) + +from microsoft_agents.activity import ( + Activity, + ActivityTypes, +) +from microsoft_agents.hosting.core import ( + AgentApplication, + RouteRank, + TurnContext, +) + +from microsoft_agents.hosting.teams.teams_turn_context import TeamsTurnContext +from microsoft_agents.hosting.teams.type_defs import ( + _RouteDecorator, + StateT, +) + +from .route_handlers import ( + MeetingStartHandler, + MeetingEndHandler, + MeetingParticipantsEventHandler +) + class Meeting(Generic[StateT]): """ Route registration for Teams Meeting event activities. @@ -7,13 +43,20 @@ class Meeting(Generic[StateT]): def __init__(self, app: AgentApplication[StateT]) -> None: self._app = app + + # + # @meeting.on_start + # def on_start_handler(self, context: TeamsTurnContext, state: StateT, meeting: MeetingDetails) -> None: + # pass + + # meeting.on_start()(on_start_handler) + def on_start( self, - handler: Optional[Callable] = None, *, auth_handlers: Optional[list[str]] = None, rank: RouteRank = RouteRank.DEFAULT, - ) -> Callable: + ) -> _RouteDecorator[MeetingStartHandler[StateT]]: """Register a handler for meeting start events.""" def __selector(context: TurnContext) -> bool: @@ -22,10 +65,11 @@ def __selector(context: TurnContext) -> bool: and context.activity.name == "application/vnd.microsoft.meetingStart" ) - def __register(func: Callable) -> Callable: + def __call(func: MeetingStartHandler[StateT]) -> MeetingStartHandler[StateT]: async def __handler(context: TurnContext, state: StateT) -> None: + teams_context = TeamsTurnContext(context) meeting = MeetingDetails.model_validate(context.activity.value or {}) - await func(context, state, meeting) + await func(teams_context, state, meeting) self._app.add_route( __selector, @@ -35,17 +79,14 @@ async def __handler(context: TurnContext, state: StateT) -> None: ) return func - if handler is not None: - return __register(handler) - return __register + return __call def on_end( self, - handler: Optional[Callable] = None, *, auth_handlers: Optional[list[str]] = None, rank: RouteRank = RouteRank.DEFAULT, - ) -> Callable: + ) -> _RouteDecorator[MeetingEndHandler[StateT]]: """Register a handler for meeting end events.""" def __selector(context: TurnContext) -> bool: @@ -54,10 +95,11 @@ def __selector(context: TurnContext) -> bool: and context.activity.name == "application/vnd.microsoft.meetingEnd" ) - def __register(func: Callable) -> Callable: + def __call(func: MeetingEndHandler[StateT]) -> MeetingEndHandler[StateT]: async def __handler(context: TurnContext, state: StateT) -> None: + teams_context = TeamsTurnContext(context) meeting = MeetingDetails.model_validate(context.activity.value or {}) - await func(context, state, meeting) + await func(teams_context, state, meeting) self._app.add_route( __selector, @@ -67,17 +109,14 @@ async def __handler(context: TurnContext, state: StateT) -> None: ) return func - if handler is not None: - return __register(handler) - return __register + return __call def on_participants_join( self, - handler: Optional[Callable] = None, *, auth_handlers: Optional[list[str]] = None, rank: RouteRank = RouteRank.DEFAULT, - ) -> Callable: + ) -> _RouteDecorator[MeetingParticipantsEventHandler[StateT]]: """Register a handler for meeting participant join events.""" def __selector(context: TurnContext) -> bool: @@ -87,12 +126,13 @@ def __selector(context: TurnContext) -> bool: == "application/vnd.microsoft.meetingParticipantJoin" ) - def __register(func: Callable) -> Callable: + def __call(func: MeetingParticipantsEventHandler[StateT]) -> MeetingParticipantsEventHandler[StateT]: async def __handler(context: TurnContext, state: StateT) -> None: + teams_context = TeamsTurnContext(context) details = MeetingParticipantsEventDetails.model_validate( context.activity.value or {} ) - await func(context, state, details) + await func(teams_context, state, details) self._app.add_route( __selector, @@ -102,17 +142,14 @@ async def __handler(context: TurnContext, state: StateT) -> None: ) return func - if handler is not None: - return __register(handler) - return __register + return __call def on_participants_leave( self, - handler: Optional[Callable] = None, *, auth_handlers: Optional[list[str]] = None, rank: RouteRank = RouteRank.DEFAULT, - ) -> Callable: + ) -> _RouteDecorator[MeetingParticipantsEventHandler[StateT]]: """Register a handler for meeting participant leave events.""" def __selector(context: TurnContext) -> bool: @@ -122,12 +159,13 @@ def __selector(context: TurnContext) -> bool: == "application/vnd.microsoft.meetingParticipantLeave" ) - def __register(func: Callable) -> Callable: + def __call(func: MeetingParticipantsEventHandler[StateT]) -> MeetingParticipantsEventHandler[StateT]: async def __handler(context: TurnContext, state: StateT) -> None: + teams_context = TeamsTurnContext(context) details = MeetingParticipantsEventDetails.model_validate( context.activity.value or {} ) - await func(context, state, details) + await func(teams_context, state, details) self._app.add_route( __selector, @@ -136,7 +174,5 @@ async def __handler(context: TurnContext, state: StateT) -> None: auth_handlers=auth_handlers, ) return func - - if handler is not None: - return __register(handler) - return __register + + return __call diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/meeting/route_handlers.py b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/meeting/route_handlers.py new file mode 100644 index 000000000..50a84d5bd --- /dev/null +++ b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/meeting/route_handlers.py @@ -0,0 +1,36 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from typing import Awaitable, Protocol + +from microsoft_teams.api.models import ( + MeetingDetails, + MeetingParticipantsEventDetails +) + +from microsoft_agents.hosting.teams.teams_turn_context import TeamsTurnContext +from microsoft_agents.hosting.teams.type_defs import StateT + +class MeetingStartHandler(Protocol[StateT]): + def __call__( + self, + context: TeamsTurnContext, + state: StateT, + meeting: MeetingDetails + ) -> Awaitable[None]: ... + +class MeetingEndHandler(Protocol[StateT]): + def __call__( + self, + context: TeamsTurnContext, + state: StateT, + meeting: MeetingDetails + ) -> Awaitable[None]: ... + +class MeetingParticipantsEventHandler(Protocol[StateT]): + def __call__( + self, + context: TeamsTurnContext, + state: StateT, + meeting: MeetingParticipantsEventDetails + ) -> Awaitable[None]: ... \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/message/__init__.py b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/message/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/message/message.py b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/message/message.py new file mode 100644 index 000000000..f500e5e5d --- /dev/null +++ b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/message/message.py @@ -0,0 +1,150 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from typing import ( + Callable, + Generic, + Optional +) + +from microsoft_teams.api.models.o365 import O365ConnectorCardActionQuery + +from microsoft_agents.activity import ActivityTypes +from microsoft_agents.hosting.core import ( + AgentApplication, + RouteRank, + TurnContext, +) + +from microsoft_agents.hosting.teams.route_handlers import TeamsRouteHandler +from microsoft_agents.hosting.teams.teams_turn_context import TeamsTurnContext +from microsoft_agents.hosting.teams.type_defs import ( + _RouteDecorator, + StateT, +) +from microsoft_agetns.hosting.teams._utils import _get_channel_event_type + +from .route_handlers import ( + O365ConnectorCardActionHandler, + ReadReceiptHandler +) + +class Message(Generic[StateT]): + + def __init__(self, app: AgentApplication[StateT]): + self._app = app + + def _create_basic_decorator( + self, + event_type: str, + *, + auth_handlers: Optional[list[str]] = None, + rank: RouteRank = RouteRank.DEFAULT, + ) -> _RouteDecorator[TeamsRouteHandler[StateT]]: + def __selector(context: TurnContext) -> bool: + return ( + context.activity.type == ActivityTypes.message_update + and context.activity.channel_id == "msteams" + and _get_channel_event_type(context) == event_type + ) + + def __call(func: TeamsRouteHandler[StateT]) -> TeamsRouteHandler[StateT]: + self._app.add_route( + __selector, + TeamsRouteHandler[StateT].wrap(func), + rank=rank, + auth_handlers=auth_handlers + ) + return func + + return __call + + def edit( + self, + *, + auth_handlers: Optional[list[str]] = None, + rank: RouteRank = RouteRank.DEFAULT, + ) -> _RouteDecorator[TeamsRouteHandler[StateT]]: + """Register a handler for Teams editMessage events.""" + return self._create_basic_decorator("editMessage", auth_handlers=auth_handlers, rank=rank) + + def undelete( + self, + *, + auth_handlers: Optional[list[str]] = None, + rank: RouteRank = RouteRank.DEFAULT, + ) -> _RouteDecorator[TeamsRouteHandler[StateT]]: + """Register a handler for Teams undeleteMessage events.""" + return self._create_basic_decorator("undeleteMessage", auth_handlers=auth_handlers, rank=rank) + + def 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.""" + return self._create_basic_decorator("softDeleteMessage", auth_handlers=auth_handlers, rank=rank) + + # ── Read receipt ─────────────────────────────────────────────────────── + + def on_read_receipt( + self, + *, + auth_handlers: Optional[list[str]] = None, + rank: RouteRank = RouteRank.DEFAULT, + ) -> _RouteDecorator[ReadReceiptHandler[StateT]]: + """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 __call(func: ReadReceiptHandler[StateT]) -> ReadReceiptHandler[StateT]: + async def __handler(context: TurnContext, state: StateT) -> None: + teams_context = TeamsTurnContext(context) + await func(teams_context, state) + + self._app.add_route( + __selector, __handler, rank=rank, auth_handlers=auth_handlers + ) + return func + + return __call + + def o365_connector_card_action( + self, + *, + auth_handlers: Optional[list[str]] = None, + rank: RouteRank = RouteRank.DEFAULT, + ) -> _RouteDecorator[O365ConnectorCardActionHandler[StateT]]: + """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 __call(func: O365ConnectorCardActionHandler[StateT]) -> O365ConnectorCardActionHandler[StateT]: + async def __handler(context: TurnContext, state: StateT) -> None: + teams_context = TeamsTurnContext(context) + query = O365ConnectorCardActionQuery.model_validate( + context.activity.value or {} + ) + await func(teams_context, state, query) + await _send_invoke_response(context) + + self._app.add_route( + __selector, + __handler, + is_invoke=True, + rank=rank, + auth_handlers=auth_handlers, + ) + return func + + return __call \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/message/route_handlers.py b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/message/route_handlers.py new file mode 100644 index 000000000..940606631 --- /dev/null +++ b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/message/route_handlers.py @@ -0,0 +1,24 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from typing import Awaitable, Protocol + +from microsoft_teams.api.models.o365 import ( + O365ConnectorCardActionQuery +) + +from microsoft_agents.hosting.teams.teams_turn_context import TeamsTurnContext +from microsoft_agents.hosting.teams.type_defs import StateT + +class O365ConnectorCardActionHandler(Protocol[StateT]): + def __call__( + self, + context: TeamsTurnContext, + state: StateT, + query: O365ConnectorCardActionQuery) -> Awaitable[None]: ... + +class ReadReceiptHandler(Protocol[StateT]): + def __call__( + self, + context: TeamsTurnContext, + data: dict) -> Awaitable[None]: ... \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/message_extension/message_extension.py b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/message_extension/message_extension.py index adaf48c37..d37beaa03 100644 --- a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/message_extension/message_extension.py +++ b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/message_extension/message_extension.py @@ -35,9 +35,11 @@ ) from .route_handlers import ( - FetchActionHandler, + FetchTaskHandler, + QueryHandler, SubmitActionHandler, MessagePreviewEditHandler, + MessagePreviewSendHandler, QueryHandler, SelectItemHandler, QueryLinkHandler, @@ -55,7 +57,7 @@ class MessageExtension(Generic[StateT]): def __init__(self, app: AgentApplication[StateT]) -> None: self._app = app - def on_query( + def query( self, command_id: CommandSelector = None, *, @@ -103,7 +105,7 @@ async def __handler(context: TurnContext, state: StateT) -> None: return __call - def on_select_item( + def select_item( self, *, auth_handlers: Optional[list[str]] = None, @@ -135,7 +137,7 @@ async def __handler(context: TurnContext, state: StateT) -> None: return __call - def on_submit_action( + def submit_action( self, command_id: CommandSelector = None, *, @@ -186,7 +188,7 @@ async def __handler(context: TurnContext, state: StateT) -> None: return __call - def on_message_preview_edit( + def message_preview_edit( self, command_id: CommandSelector = None, *, @@ -227,7 +229,7 @@ async def __handler(context: TurnContext, state: StateT) -> None: return __call - def on_message_preview_send( + def message_preview_send( self, command_id: CommandSelector = None, *, @@ -253,6 +255,11 @@ async def __handler(context: TurnContext, state: StateT) -> None: action = MessagingExtensionAction.model_validate( context.activity.value or {} ) + # activity_preview: Activity | None = None + # if action.bot_activity_preview: + # activity_preview = Activity.model_validate(action.bot_activity_preview[0]) + # activity_preview = + # action.bot_activity_preview[0] response = await func(teams_context, state, action) if response is not None: await _send_invoke_response(context, response) @@ -268,13 +275,13 @@ async def __handler(context: TurnContext, state: StateT) -> None: return __call - def on_task_fetch( + def fetch_task( self, command_id: CommandSelector = None, *, auth_handlers: Optional[list[str]] = None, rank: RouteRank = RouteRank.DEFAULT, - ) -> _RouteDecorator[TaskFetchHandler[StateT]]: + ) -> _RouteDecorator[FetchTaskHandler[StateT]]: """Register a handler for composeExtension/fetchTask invokes.""" def __selector(context: TurnContext) -> bool: @@ -287,7 +294,7 @@ def __selector(context: TurnContext) -> bool: ) ) - def __call(func: TaskFetchHandler[StateT]) -> TaskFetchHandler[StateT]: + def __call(func: FetchTaskHandler[StateT]) -> FetchTaskHandler[StateT]: async def __handler(context: TurnContext, state: StateT) -> None: action = MessagingExtensionAction.model_validate( context.activity.value or {} @@ -307,7 +314,7 @@ async def __handler(context: TurnContext, state: StateT) -> None: return __call - def on_query_link( + def query_link( self, *, auth_handlers: Optional[list[str]] = None, @@ -340,7 +347,7 @@ async def __handler(context: TurnContext, state: StateT) -> None: return __call - def on_anonymous_query_link( + def anonymous_query_link( self, *, auth_handlers: Optional[list[str]] = None, @@ -354,7 +361,7 @@ def __selector(context: TurnContext) -> bool: and context.activity.name == "composeExtension/anonymousQueryLink" ) - def __register(func: QueryLinkHandler[StateT]) -> QueryLinkHandler[StateT]: + def __call(func: QueryLinkHandler[StateT]) -> QueryLinkHandler[StateT]: async def __handler(context: TurnContext, state: StateT) -> None: teams_context = TeamsTurnContext(context) query = AppBasedLinkQuery.model_validate(context.activity.value or {}) @@ -371,9 +378,9 @@ async def __handler(context: TurnContext, state: StateT) -> None: ) return func - return __register + return __call - def on_query_url_setting( + def query_setting_url( self, *, auth_handlers: Optional[list[str]] = None, @@ -387,7 +394,7 @@ def __selector(context: TurnContext) -> bool: and context.activity.name == "composeExtension/querySettingUrl" ) - def __register(func: QueryUrlSettingHandler[StateT]) -> QueryUrlSettingHandler[StateT]: + def __call(func: QueryUrlSettingHandler[StateT]) -> QueryUrlSettingHandler[StateT]: async def __handler(context: TurnContext, state: StateT) -> None: teams_context = TeamsTurnContext(context) query = MessagingExtensionQuery.model_validate( @@ -406,15 +413,15 @@ async def __handler(context: TurnContext, state: StateT) -> None: ) return func - return __register + return __call - def on_configure_settings( + def configure_settings( self, handler: Optional[Callable] = None, *, auth_handlers: Optional[list[str]] = None, rank: RouteRank = RouteRank.DEFAULT, - ) -> Callable: + ) -> _RouteDecorator[ConfigureSettingsHandler[StateT]]: """Register a handler for composeExtension/setting invokes.""" def __selector(context: TurnContext) -> bool: @@ -423,7 +430,7 @@ def __selector(context: TurnContext) -> bool: and context.activity.name == "composeExtension/setting" ) - def __register(func: Callable) -> Callable: + def __call(func: Callable) -> Callable: async def __handler(context: TurnContext, state: StateT) -> None: await func(context, state, context.activity.value) await _send_invoke_response(context) @@ -438,16 +445,15 @@ async def __handler(context: TurnContext, state: StateT) -> None: return func if handler is not None: - return __register(handler) - return __register + return __call(handler) + return __call - def on_card_button_clicked( + def card_button_clicked( self, - handler: Optional[Callable] = None, *, auth_handlers: Optional[list[str]] = None, rank: RouteRank = RouteRank.DEFAULT, - ) -> Callable: + ) -> _RouteDecorator[CardButtonClickedHandler[StateT]]: """Register a handler for composeExtension/onCardButtonClicked invokes.""" def __selector(context: TurnContext) -> bool: @@ -456,7 +462,7 @@ def __selector(context: TurnContext) -> bool: and context.activity.name == "composeExtension/onCardButtonClicked" ) - def __register(func: Callable) -> Callable: + def __call(func: CardButtonClickedHandler[StateT]) -> CardButtonClickedHandler[StateT]: async def __handler(context: TurnContext, state: StateT) -> None: await func(context, state, context.activity.value) await _send_invoke_response(context) @@ -470,6 +476,4 @@ async def __handler(context: TurnContext, state: StateT) -> None: ) return func - if handler is not None: - return __register(handler) - return __register + return __call diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/message_extension/route_handlers.py b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/message_extension/route_handlers.py index a37c79fa4..69e7e2b3c 100644 --- a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/message_extension/route_handlers.py +++ b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/message_extension/route_handlers.py @@ -27,7 +27,7 @@ from microsoft_agents.hosting.teams_turn_context import TeamsTurnContext from microsoft_agents.hosting.teams.type_defs import StateT -class FetchActionHandler(Protocol[StateT]): +class FetchTaskHandler(Protocol[StateT]): def __call__( self, context: TeamsTurnContext, @@ -92,6 +92,7 @@ def __call__( self, context: TeamsTurnContext, state: StateT, + query: MessageExtensionQuery ) -> Awaitable[MessageExtensionResponse]: ... diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/route_handlers.py b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/route_handlers.py index e81e237a6..3b1734e7b 100644 --- a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/route_handlers.py +++ b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/route_handlers.py @@ -1,26 +1,16 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. +from __future__ import annotations + from typing import ( - Any, Awaitable, Protocol, ) -from microsoft_agents.activity import Activity - -from microsoft_teams.api.models import ( - Channel, - Team, - MeetingDetails, - MeetingParticipantsEventDetails, - MessagingExtensionAction, - MessagingExtensionQuery, - MessageExtensionActionResponse, - MessagingExtensionResponse, - O365ConnectorCardActionQuery, - TaskModuleRequest, - TaskModuleResponse +from microsoft_agents.hosting.core import ( + RouteHandler, + TurnContext, ) from .teams_turn_context import TeamsTurnContext @@ -29,31 +19,14 @@ class TeamsRouteHandler(Protocol[StateT]): def __call__(self, context: TeamsTurnContext, state: StateT) -> Awaitable[None]: ... -# Meetings route handlers - -class MeetingStartHandler(Protocol[StateT]): - def __call__( - self, - context: TeamsTurnContext, - state: StateT, - meeting: MeetingDetails - ) -> Awaitable[None]: ... - -class MeetingEndHandler(Protocol[StateT]): - def __call__( - self, - context: TeamsTurnContext, - state: StateT, - meeting: MeetingDetails - ) -> Awaitable[None]: ... + @staticmethod + def wrap(handler: TeamsRouteHandler[StateT]) -> RouteHandler[StateT]: + async def __func(context: TurnContext, state: StateT) -> None: + teams_context = TeamsTurnContext(context) + await handler(teams_context, state) + return __func -class MeetingParticipantsEventHandler(Protocol[StateT]): - def __call__( - self, - context: TeamsTurnContext, - state: StateT, - meeting: MeetingParticipantsEventDetails - ) -> Awaitable[None]: ... +# Meetings route handlers # Message extension route handlers @@ -77,26 +50,6 @@ def __call__( ) -> Awaitable[None]: ... -# Task Modules route handlers - -class TaskFetchHandler(Protocol[StateT]): - def __call__( - self, - context: TeamsTurnContext, - state: StateT, - request: TaskModuleRequest, - ) -> Awaitable[TaskModuleResponse]: - ... - -class TaskSubmitHandler(Protocol[StateT]): - def __call__( - self, - context: TeamsTurnContext, - state: StateT, - request: TaskModuleRequest, - ) -> Awaitable[TaskModuleResponse]: - ... - # Teams Channels route handlers class ChannelUpdateHandler(Protocol[StateT]): diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/task_module/route_handlers.py b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/task_module/route_handlers.py index c0489cd71..2f5fdf258 100644 --- a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/task_module/route_handlers.py +++ b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/task_module/route_handlers.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + from typing import Awaitable, Protocol from microsoft_teams.api.models import ( @@ -8,7 +11,7 @@ from microsoft_agents.hosting.teams.teams_turn_context import TeamsTurnContext from microsoft_agents.hosting.teams.type_defs import StateT -class TaskFetchHandler(Protocol[StateT]): +class FetchHandler(Protocol[StateT]): def __call__( self, context: TeamsTurnContext, @@ -17,7 +20,7 @@ def __call__( ) -> Awaitable[TaskModuleResponse]: ... -class TaskSubmitHandler(Protocol[StateT]): +class SubmitHandler(Protocol[StateT]): def __call__( self, context: TeamsTurnContext, diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/task_module/task_module.py b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/task_module/task_module.py index f0e776f10..3b8417860 100644 --- a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/task_module/task_module.py +++ b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/task_module/task_module.py @@ -1,9 +1,19 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + + from typing import ( Any, Generic, Optional ) +from microsoft_teams.api.models import ( + TaskModuleRequest, +) + +from microsoft_agents.activity import ActivityTypes + from microsoft_agents.hosting.core import ( AgentApplication, RouteRank, @@ -11,9 +21,9 @@ ) from microsoft_agents.hosting.teams.teams_turn_context import TeamsTurnContext -from microsoft_agents.hosting.teams._type_defs import ( +from microsoft_agents.hosting.teams.type_defs import ( CommandSelector, - RouteDecorator, + _RouteDecorator, StateT, ) from microsoft_agents.hosting.teams._utils import ( @@ -22,6 +32,8 @@ ) from .route_handlers import ( + FetchHandler, + SubmitHandler ) class TaskModule(Generic[StateT]): @@ -42,13 +54,13 @@ def _get_verb(value: Optional[Any]) -> Optional[str]: return data.get("verb") return None - def on_fetch( + def fetch( self, verb: CommandSelector = None, *, auth_handlers: Optional[list[str]] = None, rank: RouteRank = RouteRank.DEFAULT, - ) -> RouteDecorator[]: + ) -> _RouteDecorator[FetchHandler[StateT]]: """Register a handler for task/fetch invokes. :param verb: Optional verb string or regex to match against task data. @@ -63,10 +75,11 @@ def __selector(context: TurnContext) -> bool: return False return _match_selector(verb, TaskModule._get_verb(context.activity.value)) - def __call(func: Callable) -> Callable: + def __call(func: FetchHandler[StateT]) -> FetchHandler[StateT]: async def __handler(context: TurnContext, state: StateT) -> None: + teams_context = TeamsTurnContext(context) request = TaskModuleRequest.model_validate(context.activity.value or {}) - response = await func(context, state, request) + response = await func(teams_context, state, request) if response is not None: await _send_invoke_response(context, response) @@ -87,7 +100,7 @@ def on_submit( *, auth_handlers: Optional[list[str]] = None, rank: RouteRank = RouteRank.DEFAULT, - ) -> Callable: + ) -> _RouteDecorator[SubmitHandler[StateT]]: """Register a handler for task/submit invokes. :param verb: Optional verb string or regex to match against task data. @@ -102,10 +115,11 @@ def __selector(context: TurnContext) -> bool: return False return _match_selector(verb, TaskModule._get_verb(context.activity.value)) - def __call(func: Callable) -> Callable: + def __call(func: SubmitHandler[StateT]) -> SubmitHandler[StateT]: async def __handler(context: TurnContext, state: StateT) -> None: + teams_context = TeamsTurnContext(context) request = TaskModuleRequest.model_validate(context.activity.value or {}) - response = await func(context, state, request) + response = await func(teams_context, state, request) if response is not None: await _send_invoke_response(context, response) diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/team/__init__.py b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/team/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/team/team.py b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/team/team.py new file mode 100644 index 000000000..519cbf67a --- /dev/null +++ b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/team/team.py @@ -0,0 +1 @@ +m. \ No newline at end of file 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 index 2115b0d66..a4f777df9 100644 --- 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 @@ -28,6 +28,17 @@ TaskModuleRequest, ) +from .channel import Channel +from .meeting import Meeting +from .message import Message +from .message_extension import MessageExtension +from .task_module import TaskModule +from .team import Team + +from .type_defs import ( + StateT +) + class TeamsAgentExtension(Generic[StateT]): """ @@ -56,6 +67,9 @@ def __init__(self, app: AgentApplication[StateT]) -> None: self._message_extension: MessageExtension[StateT] = MessageExtension(app) self._task_module: TaskModule[StateT] = TaskModule(app) self._meeting: Meeting[StateT] = Meeting(app) + self._message: Message[StateT] = Message(app) + self._team: Team[StateT] = Team(app) + self._channel: Channel[StateT] = Channel(app) @property def message_extension(self) -> MessageExtension[StateT]: @@ -71,6 +85,11 @@ def task_module(self) -> TaskModule[StateT]: def meeting(self) -> Meeting[StateT]: """Route registration for Meeting lifecycle events.""" return self._meeting + + @property + def message(self) -> Message[StateT]: + """Route registration for messaging activities.""" + return self._message # ── Message update / delete ──────────────────────────────────────────── @@ -90,15 +109,15 @@ def __selector(context: TurnContext) -> bool: and _get_channel_event_type(context) == "editMessage" ) - def __register(func: Callable) -> Callable: + def __call(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 + return __call(handler) + return __call def on_message_undelete( self, @@ -116,15 +135,15 @@ def __selector(context: TurnContext) -> bool: and _get_channel_event_type(context) == "undeleteMessage" ) - def __register(func: Callable) -> Callable: + def __call(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 + return __call(handler) + return __call def on_message_soft_delete( self, @@ -142,15 +161,15 @@ def __selector(context: TurnContext) -> bool: and _get_channel_event_type(context) == "softDeleteMessage" ) - def __register(func: Callable) -> Callable: + def __call(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 + return __call(handler) + return __call # ── Read receipt ─────────────────────────────────────────────────────── @@ -169,7 +188,7 @@ def __selector(context: TurnContext) -> bool: and context.activity.name == "application/vnd.microsoft.readReceipt" ) - def __register(func: Callable) -> Callable: + def __call(func: Callable) -> Callable: async def __handler(context: TurnContext, state: StateT) -> None: receipt = ReadReceiptInfo.model_validate(context.activity.value or {}) await func(context, state, receipt) @@ -180,8 +199,8 @@ async def __handler(context: TurnContext, state: StateT) -> None: return func if handler is not None: - return __register(handler) - return __register + return __call(handler) + return __call # ── Config ───────────────────────────────────────────────────────────── @@ -200,7 +219,7 @@ def __selector(context: TurnContext) -> bool: and context.activity.name == "config/fetch" ) - def __register(func: Callable) -> Callable: + def __call(func: Callable) -> Callable: async def __handler(context: TurnContext, state: StateT) -> None: response = await func(context, state, context.activity.value) if response is not None: @@ -216,8 +235,8 @@ async def __handler(context: TurnContext, state: StateT) -> None: return func if handler is not None: - return __register(handler) - return __register + return __call(handler) + return __call def on_config_submit( self, @@ -234,7 +253,7 @@ def __selector(context: TurnContext) -> bool: and context.activity.name == "config/submit" ) - def __register(func: Callable) -> Callable: + def __call(func: Callable) -> Callable: async def __handler(context: TurnContext, state: StateT) -> None: response = await func(context, state, context.activity.value) if response is not None: @@ -250,8 +269,8 @@ async def __handler(context: TurnContext, state: StateT) -> None: return func if handler is not None: - return __register(handler) - return __register + return __call(handler) + return __call # ── File consent ─────────────────────────────────────────────────────── @@ -272,7 +291,7 @@ def __selector(context: TurnContext) -> bool: and context.activity.value.get("action") == "accept" ) - def __register(func: Callable) -> Callable: + def __call(func: Callable) -> Callable: async def __handler(context: TurnContext, state: StateT) -> None: file_consent = FileConsentCardResponse.model_validate( context.activity.value or {} @@ -290,8 +309,8 @@ async def __handler(context: TurnContext, state: StateT) -> None: return func if handler is not None: - return __register(handler) - return __register + return __call(handler) + return __call def on_file_consent_decline( self, @@ -310,7 +329,7 @@ def __selector(context: TurnContext) -> bool: and context.activity.value.get("action") == "decline" ) - def __register(func: Callable) -> Callable: + def __call(func: Callable) -> Callable: async def __handler(context: TurnContext, state: StateT) -> None: file_consent = FileConsentCardResponse.model_validate( context.activity.value or {} @@ -328,8 +347,8 @@ async def __handler(context: TurnContext, state: StateT) -> None: return func if handler is not None: - return __register(handler) - return __register + return __call(handler) + return __call # ── O365 Connector ───────────────────────────────────────────────────── @@ -348,7 +367,7 @@ def __selector(context: TurnContext) -> bool: and context.activity.name == "actionableMessage/executeAction" ) - def __register(func: Callable) -> Callable: + def __call(func: Callable) -> Callable: async def __handler(context: TurnContext, state: StateT) -> None: query = O365ConnectorCardActionQuery.model_validate( context.activity.value or {} @@ -366,8 +385,8 @@ async def __handler(context: TurnContext, state: StateT) -> None: return func if handler is not None: - return __register(handler) - return __register + return __call(handler) + return __call # ── Conversation update events ───────────────────────────────────────── @@ -388,15 +407,15 @@ def __selector(context: TurnContext) -> bool: and len(context.activity.members_added) > 0 ) - def __register(func: Callable) -> Callable: + def __call(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 + return __call(handler) + return __call def on_members_removed( self, @@ -415,15 +434,15 @@ def __selector(context: TurnContext) -> bool: and len(context.activity.members_removed) > 0 ) - def __register(func: Callable) -> Callable: + def __call(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 + return __call(handler) + return __call def on_channel_created( self, @@ -560,12 +579,12 @@ def __selector(context: TurnContext) -> bool: and _get_channel_event_type(context) == event_type ) - def __register(func: Callable) -> Callable: + def __call(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 + return __call(handler) + return __call From 4290f74e3a0d570651abd42c6f5a08a89d4e19ad Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Wed, 17 Jun 2026 16:02:09 -0700 Subject: [PATCH 04/46] Adding file consent routes --- .../teams/file_consent/file_consent.py | 81 ++++++++++++ .../teams/file_consent/route_handlers.py | 17 +++ .../hosting/teams/message/__init__.py | 8 ++ .../hosting/teams/message/message.py | 5 +- .../hosting/teams/teams_agent_extension.py | 118 +----------------- 5 files changed, 111 insertions(+), 118 deletions(-) diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/file_consent/file_consent.py b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/file_consent/file_consent.py index e69de29bb..127c52bfc 100644 --- a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/file_consent/file_consent.py +++ b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/file_consent/file_consent.py @@ -0,0 +1,81 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from typing import Generic, Optional + +from microsoft_teams.api.models import FileConsentCardResponse + +from microsoft_agents.activity import ActivityTypes +from microsoft_agents.hosting.core import ( + AgentApplication, + RouteRank, + TurnContext, +) + +from microsoft_agents.hosting.teams.teams_turn_context import TeamsTurnContext +from microsoft_agents.hosting.teams.type_defs import ( + _RouteDecorator, + StateT, +) +from microsoft_agents.hosting.teams._utils import _send_invoke_response + +from .route_handlers import FileConsentHandler + +class FileConsent(Generic[StateT]): + + def __init__(self, app: AgentApplication[StateT]): + self._app = app + + def _create_decorator( + self, + action: str, + *, + auth_handlers: Optional[list[str]] = None, + rank: RouteRank = RouteRank.DEFAULT, + ) -> _RouteDecorator[FileConsentHandler[StateT]]: + + 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") == action + ) + + def __call(func: FileConsentHandler[StateT]) -> FileConsentHandler[StateT]: + async def __handler(context: TurnContext, state: StateT) -> None: + teams_context = TeamsTurnContext(context) + file_consent = FileConsentCardResponse.model_validate( + context.activity.value or {} + ) + await func(teams_context, state, file_consent) + await _send_invoke_response(context) + + self._app.add_route( + __selector, + __handler, + is_invoke=True, + rank=rank, + auth_handlers=auth_handlers, + ) + return func + + return __call + + def on_file_consent_accept( + self, + *, + auth_handlers: Optional[list[str]] = None, + rank: RouteRank = RouteRank.DEFAULT, + ) -> _RouteDecorator[FileConsentHandler[StateT]]: + """Register a handler for fileConsent/invoke with action == 'accept'.""" + return self._create_decorator("accept") + + def on_file_consent_decline( + self, + *, + auth_handlers: Optional[list[str]] = None, + rank: RouteRank = RouteRank.DEFAULT, + ) -> _RouteDecorator[FileConsentHandler[StateT]]: + """Register a handler for fileConsent/invoke with action == 'decline'.""" + return self._create_decorator("decline") \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/file_consent/route_handlers.py b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/file_consent/route_handlers.py index e69de29bb..826379b90 100644 --- a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/file_consent/route_handlers.py +++ b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/file_consent/route_handlers.py @@ -0,0 +1,17 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from typing import Awaitable, Protocol + +from microsoft_teams.api.models import FileConsentCardResponse + +from microsoft_agents.hosting.teams.teams_turn_context import TeamsTurnContext +from microsoft_agents.hosting.teams.type_defs import StateT + +class FileConsentHandler(Protocol[StateT]): + def __call__( + self, + context: TeamsTurnContext, + state: StateT, + file_consent: FileConsentCardResponse + ) -> Awaitable[None]: ... \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/message/__init__.py b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/message/__init__.py index e69de29bb..4ea36343a 100644 --- a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/message/__init__.py +++ b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/message/__init__.py @@ -0,0 +1,8 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from .message import Message + +__all__ = [ + "Message", +] \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/message/message.py b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/message/message.py index f500e5e5d..c8c0cf7fb 100644 --- a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/message/message.py +++ b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/message/message.py @@ -22,7 +22,10 @@ _RouteDecorator, StateT, ) -from microsoft_agetns.hosting.teams._utils import _get_channel_event_type +from microsoft_agetns.hosting.teams._utils import ( + _get_channel_event_type, + _send_invoke_response, +) from .route_handlers import ( O365ConnectorCardActionHandler, 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 index a4f777df9..c542d7c79 100644 --- 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 @@ -271,123 +271,7 @@ async def __handler(context: TurnContext, state: StateT) -> None: if handler is not None: return __call(handler) return __call - - # ── 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 __call(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 __call(handler) - return __call - - 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 __call(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 __call(handler) - return __call - - # ── 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 __call(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 __call(handler) - return __call - + # ── Conversation update events ───────────────────────────────────────── def on_members_added( From 7537bc232556281333b35e71a193d0a6dc1508e2 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Wed, 17 Jun 2026 16:09:11 -0700 Subject: [PATCH 05/46] Adding refactored configuration routes --- .../teams/configuration/configuration.py | 74 +++++++++++++++++++ .../teams/configuration/route_handlers.py | 17 +++++ .../teams/file_consent/file_consent.py | 4 +- .../hosting/teams/teams_agent_extension.py | 68 ----------------- 4 files changed, 93 insertions(+), 70 deletions(-) diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/configuration/configuration.py b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/configuration/configuration.py index e69de29bb..24f8224e1 100644 --- a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/configuration/configuration.py +++ b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/configuration/configuration.py @@ -0,0 +1,74 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from typing import Generic, Optional + +from microsoft_agents.activity import ActivityTypes +from microsoft_agents.hosting.core import ( + AgentApplication, + RouteRank, + TurnContext, +) + +from microsoft_agents.hosting.teams.teams_turn_context import TeamsTurnContext +from microsoft_agents.hosting.teams.type_defs import ( + _RouteDecorator, + StateT, +) +from microsoft_agents.hosting.teams._utils import _send_invoke_response + +from .route_handlers import ConfigurationHandler + +class Configuration(Generic[StateT]): + + def __init__(self, app: AgentApplication[StateT]): + self._app = app + + def _create_decorator( + self, + name: str, + *, + auth_handlers: Optional[list[str]] = None, + rank: RouteRank = RouteRank.DEFAULT, + ) -> _RouteDecorator[ConfigurationHandler[StateT]]: + def __selector(context: TurnContext) -> bool: + return ( + context.activity.type == ActivityTypes.invoke + and context.activity.name == name + ) + + def __call(func: ConfigurationHandler[StateT]) -> ConfigurationHandler[StateT]: + async def __handler(context: TurnContext, state: StateT) -> None: + teams_context = TeamsTurnContext(context) + response = await func(teams_context, state, context.activity.value) + if response is not None: + await _send_invoke_response(context, response) + + self._app.add_route( + __selector, + __handler, + is_invoke=True, + rank=rank, + auth_handlers=auth_handlers, + ) + return func + + return __call + + def on_config_fetch( + self, + *, + auth_handlers: Optional[list[str]] = None, + rank: RouteRank = RouteRank.DEFAULT, + ) -> _RouteDecorator[ConfigurationHandler[StateT]]: + """Register a handler for config/fetch invokes.""" + return self._create_decorator("config/fetch", auth_handlers=auth_handlers, rank=rank) + + def on_config_submit( + self, + *, + auth_handlers: Optional[list[str]] = None, + rank: RouteRank = RouteRank.DEFAULT, + ) -> _RouteDecorator[ConfigurationHandler[StateT]]: + """Register a handler for config/submit invokes.""" + return self._create_decorator("config/submit", auth_handlers=auth_handlers, rank=rank) \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/configuration/route_handlers.py b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/configuration/route_handlers.py index e69de29bb..0b999e515 100644 --- a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/configuration/route_handlers.py +++ b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/configuration/route_handlers.py @@ -0,0 +1,17 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from typing import Any, Awaitable, Protocol + +from microsoft_teams.api.models.config import ConfigResponse + +from microsoft_agents.hosting.teams.teams_turn_context import TeamsTurnContext +from microsoft_agents.hosting.teams.type_defs import StateT + +class ConfigurationHandler(Protocol[StateT]): + def __call__( + self, + context: TeamsTurnContext, + state: StateT, + config_data: Any, + ) -> Awaitable[ConfigResponse]: ... \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/file_consent/file_consent.py b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/file_consent/file_consent.py index 127c52bfc..11005514a 100644 --- a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/file_consent/file_consent.py +++ b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/file_consent/file_consent.py @@ -69,7 +69,7 @@ def on_file_consent_accept( rank: RouteRank = RouteRank.DEFAULT, ) -> _RouteDecorator[FileConsentHandler[StateT]]: """Register a handler for fileConsent/invoke with action == 'accept'.""" - return self._create_decorator("accept") + return self._create_decorator("accept", auth_handlers=auth_handlers, rank=rank) def on_file_consent_decline( self, @@ -78,4 +78,4 @@ def on_file_consent_decline( rank: RouteRank = RouteRank.DEFAULT, ) -> _RouteDecorator[FileConsentHandler[StateT]]: """Register a handler for fileConsent/invoke with action == 'decline'.""" - return self._create_decorator("decline") \ No newline at end of file + return self._create_decorator("decline", auth_handlers=auth_handlers, rank=rank) \ No newline at end of file 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 index c542d7c79..9522c5625 100644 --- 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 @@ -203,74 +203,6 @@ async def __handler(context: TurnContext, state: StateT) -> None: return __call # ── 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 __call(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 __call(handler) - return __call - - 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 __call(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 __call(handler) - return __call # ── Conversation update events ───────────────────────────────────────── From 50867ec55b30e1330c79d8ce788c1dd90fbbf269 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Wed, 17 Jun 2026 16:29:36 -0700 Subject: [PATCH 06/46] Refactor Team routes --- .../microsoft_agents/hosting/teams/_utils.py | 2 +- .../hosting/teams/app/route_handlers.py | 0 .../hosting/teams/channel/__init__.py | 6 + .../hosting/teams/channel/channel.py | 111 +++++++ .../hosting/teams/channel/route_handlers.py | 17 + .../hosting/teams/configuration/__init__.py | 6 + .../hosting/teams/file_consent/__init__.py | 6 + .../hosting/teams/meeting/__init__.py | 6 + .../teams/message_extension/__init__.py | 6 + .../hosting/teams/task_module/__init__.py | 6 + .../hosting/teams/team/__init__.py | 6 + .../hosting/teams/team/route_handlers.py | 17 + .../hosting/teams/team/team.py | 139 +++++++- .../hosting/teams/teams_agent_extension.py | 299 ++---------------- 14 files changed, 358 insertions(+), 269 deletions(-) create mode 100644 libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/app/route_handlers.py create mode 100644 libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/team/route_handlers.py diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/_utils.py b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/_utils.py index bce969664..9ba3b0780 100644 --- a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/_utils.py +++ b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/_utils.py @@ -42,4 +42,4 @@ async def _send_invoke_response(context: TurnContext, body: Any = None) -> None: type=ActivityTypes.invoke_response, value=InvokeResponse(status=int(HTTPStatus.OK), body=serialized_body), ) - ) \ No newline at end of file + ) diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/app/route_handlers.py b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/app/route_handlers.py new file mode 100644 index 000000000..e69de29bb diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/channel/__init__.py b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/channel/__init__.py index e69de29bb..d003f17a9 100644 --- a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/channel/__init__.py +++ b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/channel/__init__.py @@ -0,0 +1,6 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from .channel import Channel + +__all__ = ["Channel"] \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/channel/channel.py b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/channel/channel.py index e69de29bb..6437d03cc 100644 --- a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/channel/channel.py +++ b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/channel/channel.py @@ -0,0 +1,111 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from typing import Generic, Optional + +from microsoft_teams.api.models.channel_data import ChannelData + +from microsoft_agents.activity import ActivityTypes +from microsoft_agents.hosting.core import ( + AgentApplication, + RouteRank, + TurnContext, +) + +from microsoft_agents.hosting.teams.teams_turn_context import TeamsTurnContext +from microsoft_agents.hosting.teams.type_defs import ( + _RouteDecorator, + StateT, +) +from microsoft_agents.hosting.teams._utils import ( + _send_invoke_response, + _get_channel_event_type, +) + +from .route_handlers import ChannelUpdateHandler + +def _get_channel_data(context: TurnContext) -> ChannelData: + data = context.activity.channel_data + if data is None: + return ChannelData() + if isinstance(data, dict): + return ChannelData(**data) + return ChannelData.model_validate(data) + +class Channel(Generic[StateT]): + + def __init__(self, app: AgentApplication[StateT]): + self._app = app + + def _create_decorator( + self, + event_type: str, + *, + auth_handlers: Optional[list[str]] = None, + rank: RouteRank = RouteRank.DEFAULT, + ) -> _RouteDecorator[ChannelUpdateHandler[StateT]]: + 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 __call(func: ChannelUpdateHandler[StateT]) -> ChannelUpdateHandler[StateT]: + + async def __handler(context: TurnContext, state: StateT) -> None: + teams_context = TeamsTurnContext(context) + channel_data = _get_channel_data(context) + await func(teams_context, state, channel_data) + + self._app.add_route( + __selector, __handler, rank=rank, auth_handlers=auth_handlers + ) + return func + + return __call + + + def created( + self, + *, + auth_handlers: Optional[list[str]] = None, + rank: RouteRank = RouteRank.DEFAULT, + ) -> _RouteDecorator[ChannelUpdateHandler[StateT]]: + """Register a handler for Teams channelCreated conversation update events.""" + return self._create_decorator( + "channelCreated", auth_handlers=auth_handlers, rank=rank + ) + + def deleted( + self, + *, + auth_handlers: Optional[list[str]] = None, + rank: RouteRank = RouteRank.DEFAULT, + ) -> _RouteDecorator[ChannelUpdateHandler[StateT]]: + """Register a handler for Teams channelDeleted conversation update events.""" + return self._create_decorator( + "channelDeleted", auth_handlers=auth_handlers, rank=rank + ) + + def renamed( + self, + *, + auth_handlers: Optional[list[str]] = None, + rank: RouteRank = RouteRank.DEFAULT, + ) -> _RouteDecorator[ChannelUpdateHandler[StateT]]: + """Register a handler for Teams channelRenamed conversation update events.""" + return self._create_decorator( + "channelRenamed", auth_handlers=auth_handlers, rank=rank + ) + + def restored( + self, + *, + auth_handlers: Optional[list[str]] = None, + rank: RouteRank = RouteRank.DEFAULT, + ) -> _RouteDecorator[ChannelUpdateHandler[StateT]]: + """Register a handler for Teams channelRestored conversation update events.""" + return self._create_decorator( + "channelRestored", auth_handlers=auth_handlers, rank=rank + ) \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/channel/route_handlers.py b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/channel/route_handlers.py index e69de29bb..42c50d060 100644 --- a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/channel/route_handlers.py +++ b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/channel/route_handlers.py @@ -0,0 +1,17 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from typing import Any, Awaitable, Protocol + +from microsoft_teams.api.models.channel_data import ChannelData + +from microsoft_agents.hosting.teams.teams_turn_context import TeamsTurnContext +from microsoft_agents.hosting.teams.type_defs import StateT + +class ChannelUpdateHandler(Protocol[StateT]): + def __call__( + self, + context: TeamsTurnContext, + state: StateT, + data: ChannelData, + ) -> Awaitable[None]: ... \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/configuration/__init__.py b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/configuration/__init__.py index e69de29bb..edbb3adbf 100644 --- a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/configuration/__init__.py +++ b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/configuration/__init__.py @@ -0,0 +1,6 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from .configuration import Configuration + +__all__ = ["Configuration"] \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/file_consent/__init__.py b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/file_consent/__init__.py index e69de29bb..165a7bdf7 100644 --- a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/file_consent/__init__.py +++ b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/file_consent/__init__.py @@ -0,0 +1,6 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from .file_consent import FileConsent + +__all__ = ["FileConsent"] \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/meeting/__init__.py b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/meeting/__init__.py index e69de29bb..e2ae72b4f 100644 --- a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/meeting/__init__.py +++ b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/meeting/__init__.py @@ -0,0 +1,6 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from .meeting import Meeting + +__all__ = ["Meeting"] \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/message_extension/__init__.py b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/message_extension/__init__.py index e69de29bb..5ca78a985 100644 --- a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/message_extension/__init__.py +++ b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/message_extension/__init__.py @@ -0,0 +1,6 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from .message_extension import MessageExtension + +__all__ = ["MessageExtension"] \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/task_module/__init__.py b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/task_module/__init__.py index e69de29bb..102cc9157 100644 --- a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/task_module/__init__.py +++ b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/task_module/__init__.py @@ -0,0 +1,6 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from .task_module import TaskModule + +__all__ = ["TaskModule"] \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/team/__init__.py b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/team/__init__.py index e69de29bb..0451c92bb 100644 --- a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/team/__init__.py +++ b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/team/__init__.py @@ -0,0 +1,6 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from .team import Team + +__all__ = ["Team"] \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/team/route_handlers.py b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/team/route_handlers.py new file mode 100644 index 000000000..a7e9a1678 --- /dev/null +++ b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/team/route_handlers.py @@ -0,0 +1,17 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from typing import Awaitable, Protocol + +from microsoft_teams.api.models import Team + +from microsoft_agents.hosting.teams.teams_turn_context import TeamsTurnContext +from microsoft_agents.hosting.teams.type_defs import StateT + +class TeamUpdateHandler(Protocol[StateT]): + def __call__( + self, + context: TeamsTurnContext, + state: StateT, + data: Team + ) -> Awaitable[None]: ... \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/team/team.py b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/team/team.py index 519cbf67a..0f93f03f6 100644 --- a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/team/team.py +++ b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/team/team.py @@ -1 +1,138 @@ -m. \ No newline at end of file +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from typing import ( + Callable, + Generic, + Optional +) + +from microsoft_teams.api.models import Team + +from microsoft_agents.activity import ActivityTypes +from microsoft_agents.hosting.core import ( + AgentApplication, + RouteRank, + TurnContext, +) + +from microsoft_agents.hosting.teams.route_handlers import TeamsRouteHandler +from microsoft_agents.hosting.teams.teams_turn_context import TeamsTurnContext +from microsoft_agents.hosting.teams.type_defs import ( + _RouteDecorator, + StateT, +) +from microsoft_agetns.hosting.teams._utils import ( + _get_channel_event_type, +) + +from .route_handlers import TeamUpdateHandler + +def _get_team_data(context: TurnContext) -> Team: + data = context.activity.channel_data + if data is None: + raise ValueError("Channel data is required") + if isinstance(data, dict): + return Team(**data) + return Team.model_validate(data) + +class Team(Generic[StateT]): + + def __init__(self, app: AgentApplication[StateT]): + self._app = app + + def _create_decorator( + self, + event_type: str, + *, + auth_handlers: Optional[list[str]] = None, + rank: RouteRank = RouteRank.DEFAULT, + ) -> _RouteDecorator[TeamUpdateHandler[StateT]]: + 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 __call(func: TeamUpdateHandler[StateT]) -> TeamUpdateHandler[StateT]: + + async def __handler(context: TurnContext, state: StateT) -> None: + teams_context = TeamsTurnContext(context) + team_data = _get_team_data(context) + await func(teams_context, state, team_data) + + self._app.add_route( + __selector, __handler, rank=rank, auth_handlers=auth_handlers + ) + return func + + return __call + + + def archived( + self, + *, + auth_handlers: Optional[list[str]] = None, + rank: RouteRank = RouteRank.DEFAULT, + ) -> _RouteDecorator[TeamUpdateHandler[StateT]]: + """Register a handler for Teams teamArchived conversation update events.""" + return self._create_decorator( + "teamArchived", auth_handlers=auth_handlers, rank=rank + ) + + def deleted( + self, + *, + auth_handlers: Optional[list[str]] = None, + rank: RouteRank = RouteRank.DEFAULT, + ) -> _RouteDecorator[TeamUpdateHandler[StateT]]: + """Register a handler for Teams teamDeleted conversation update events.""" + return self._create_decorator( + "teamDeleted", auth_handlers=auth_handlers, rank=rank + ) + + def hard_deleted( + self, + *, + auth_handlers: Optional[list[str]] = None, + rank: RouteRank = RouteRank.DEFAULT, + ) -> _RouteDecorator[TeamUpdateHandler[StateT]]: + """Register a handler for Teams teamHardDeleted conversation update events.""" + return self._create_decorator( + "teamHardDeleted", auth_handlers=auth_handlers, rank=rank + ) + + def renamed( + self, + *, + auth_handlers: Optional[list[str]] = None, + rank: RouteRank = RouteRank.DEFAULT, + ) -> _RouteDecorator[TeamUpdateHandler[StateT]]: + """Register a handler for Teams teamRenamed conversation update events.""" + return self._create_decorator( + "teamRenamed", auth_handlers=auth_handlers, rank=rank + ) + + def restored( + self, + *, + auth_handlers: Optional[list[str]] = None, + rank: RouteRank = RouteRank.DEFAULT, + ) -> _RouteDecorator[TeamUpdateHandler[StateT]]: + """Register a handler for Teams teamRestored conversation update events.""" + return self._create_decorator( + "teamRestored", auth_handlers=auth_handlers, rank=rank + ) + + def unarchived( + self, + handler: Optional[Callable] = None, + *, + auth_handlers: Optional[list[str]] = None, + rank: RouteRank = RouteRank.DEFAULT, + ) -> _RouteDecorator[TeamUpdateHandler[StateT]]: + """Register a handler for Teams teamUnarchived conversation update events.""" + return self._create_decorator( + "teamUnarchived", auth_handlers=auth_handlers, rank=rank + ) \ No newline at end of file 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 index 9522c5625..8ee419e51 100644 --- 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 @@ -29,6 +29,8 @@ ) from .channel import Channel +from .configuration import Configuration +from .file_consent import FileConsent from .meeting import Meeting from .message import Message from .message_extension import MessageExtension @@ -64,145 +66,53 @@ 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._channel: Channel[StateT] = Channel(app) + self._configuration: Configuration[StateT] = Configuration(app) + self._file_consent: FileConsent[StateT] = FileConsent(app) self._meeting: Meeting[StateT] = Meeting(app) self._message: Message[StateT] = Message(app) + self._message_extension: MessageExtension[StateT] = MessageExtension(app) + self._task_module: TaskModule[StateT] = TaskModule(app) self._team: Team[StateT] = Team(app) - self._channel: Channel[StateT] = Channel(app) - + @property - def message_extension(self) -> MessageExtension[StateT]: - """Route registration for Message Extension (composeExtension) invokes.""" - return self._message_extension + def channel(self) -> Channel[StateT]: + """Route registration for Channel events.""" + return self._channel @property - def task_module(self) -> TaskModule[StateT]: - """Route registration for Task Module (task/fetch, task/submit) invokes.""" - return self._task_module + def configuration(self) -> Configuration[StateT]: + """Route registration for Configuration events.""" + return self._configuration + + @property + def file_consent(self) -> FileConsent[StateT]: + """Route registration for File Consent events.""" + return self._file_consent @property def meeting(self) -> Meeting[StateT]: """Route registration for Meeting lifecycle events.""" return self._meeting - + @property def message(self) -> Message[StateT]: """Route registration for messaging activities.""" return self._message - # ── 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 __call(func: Callable) -> Callable: - self._app.add_route( - __selector, func, rank=rank, auth_handlers=auth_handlers - ) - return func - - if handler is not None: - return __call(handler) - return __call - - 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 __call(func: Callable) -> Callable: - self._app.add_route( - __selector, func, rank=rank, auth_handlers=auth_handlers - ) - return func - - if handler is not None: - return __call(handler) - return __call - - 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 __call(func: Callable) -> Callable: - self._app.add_route( - __selector, func, rank=rank, auth_handlers=auth_handlers - ) - return func - - if handler is not None: - return __call(handler) - return __call - - # ── 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 __call(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 + @property + def message_extension(self) -> MessageExtension[StateT]: + """Route registration for Message Extension (composeExtension) invokes.""" + return self._message_extension - if handler is not None: - return __call(handler) - return __call + @property + def task_module(self) -> TaskModule[StateT]: + """Route registration for Task Module (task/fetch, task/submit) invokes.""" + return self._task_module + - # ── Config ───────────────────────────────────────────────────────────── # ── Conversation update events ───────────────────────────────────────── @@ -258,149 +168,4 @@ def __call(func: Callable) -> Callable: if handler is not None: return __call(handler) - return __call - - 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 __call(func: Callable) -> Callable: - self._app.add_route( - __selector, func, rank=rank, auth_handlers=auth_handlers - ) - return func - - if handler is not None: - return __call(handler) - return __call + return __call \ No newline at end of file From 130b7abf5d4eade1966cbc4fba5904c6ee540f58 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Wed, 17 Jun 2026 16:36:08 -0700 Subject: [PATCH 07/46] Cleaned up TeamsAgentExtension --- .../hosting/teams/channel/channel.py | 66 +++++++++++++- .../hosting/teams/teams_agent_extension.py | 89 ++----------------- 2 files changed, 69 insertions(+), 86 deletions(-) diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/channel/channel.py b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/channel/channel.py index 6437d03cc..6f70b2bcc 100644 --- a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/channel/channel.py +++ b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/channel/channel.py @@ -99,7 +99,7 @@ def renamed( "channelRenamed", auth_handlers=auth_handlers, rank=rank ) - def restored( + def rest( self, *, auth_handlers: Optional[list[str]] = None, @@ -108,4 +108,66 @@ def restored( """Register a handler for Teams channelRestored conversation update events.""" return self._create_decorator( "channelRestored", auth_handlers=auth_handlers, rank=rank - ) \ No newline at end of file + ) + + def on_members_added( + self, + *, + auth_handlers: Optional[list[str]] = None, + rank: RouteRank = RouteRank.DEFAULT, + ) -> _RouteDecorator[ChannelUpdateHandler[StateT]]: + """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 __call(func: ChannelUpdateHandler[StateT]) -> ChannelUpdateHandler[StateT]: + + async def __func(context: TurnContext, state: StateT) -> None: + teams_context = TeamsTurnContext(context) + channel_data = _get_channel_data(context) + await func(teams_context, state, channel_data) + + self._app.add_route( + __selector, __func, rank=rank, auth_handlers=auth_handlers + ) + + return func + + return __call + + def on_members_removed( + self, + *, + auth_handlers: Optional[list[str]] = None, + rank: RouteRank = RouteRank.DEFAULT, + ) -> _RouteDecorator[ChannelUpdateHandler[StateT]]: + """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 __call(func: ChannelUpdateHandler[StateT]) -> ChannelUpdateHandler[StateT]: + + async def __func(context: TurnContext, state: StateT) -> None: + teams_context = TeamsTurnContext(context) + channel_data = _get_channel_data(context) + await func(teams_context, state, channel_data) + + self._app.add_route( + __selector, __func, rank=rank, auth_handlers=auth_handlers + ) + + return func + + return __call \ No newline at end of file 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 index 8ee419e51..d45befe0f 100644 --- 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 @@ -5,28 +5,9 @@ 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, -) +from typing import Generic + +from microsoft_agents.hosting.core.app import AgentApplication from .channel import Channel from .configuration import Configuration @@ -37,9 +18,7 @@ from .task_module import TaskModule from .team import Team -from .type_defs import ( - StateT -) +from .type_defs import StateT class TeamsAgentExtension(Generic[StateT]): @@ -110,62 +89,4 @@ def message_extension(self) -> MessageExtension[StateT]: @property def task_module(self) -> TaskModule[StateT]: """Route registration for Task Module (task/fetch, task/submit) invokes.""" - return self._task_module - - - - # ── 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 __call(func: Callable) -> Callable: - self._app.add_route( - __selector, func, rank=rank, auth_handlers=auth_handlers - ) - return func - - if handler is not None: - return __call(handler) - return __call - - 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 __call(func: Callable) -> Callable: - self._app.add_route( - __selector, func, rank=rank, auth_handlers=auth_handlers - ) - return func - - if handler is not None: - return __call(handler) - return __call \ No newline at end of file + return self._task_module \ No newline at end of file From f3d1b31481210ee8fe3d2d2c5c8dd3865f903d0c Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Wed, 17 Jun 2026 16:38:06 -0700 Subject: [PATCH 08/46] Removing duplicate logic --- .../hosting/teams/route_handlers.py | 46 +------------------ 1 file changed, 1 insertion(+), 45 deletions(-) diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/route_handlers.py b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/route_handlers.py index 3b1734e7b..95587c8a7 100644 --- a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/route_handlers.py +++ b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/route_handlers.py @@ -24,48 +24,4 @@ def wrap(handler: TeamsRouteHandler[StateT]) -> RouteHandler[StateT]: async def __func(context: TurnContext, state: StateT) -> None: teams_context = TeamsTurnContext(context) await handler(teams_context, state) - return __func - -# Meetings route handlers - -# Message extension route handlers - -# Messages route handler - -class O365ConnectorCardActionHandler(Protocol[StateT]): - def __call__( - self, - context: TeamsTurnContext, - state: StateT, - query: O365ConnectorCardActionQuery - ) -> Awaitable[None]: - ... - -class ReadReceiptHandler(Protocol[StateT]): - def __call__( - self, - context: TeamsTurnContext, - state: StateT, - data: dict - ) -> Awaitable[None]: - ... - -# Teams Channels route handlers - -class ChannelUpdateHandler(Protocol[StateT]): - def __call__( - self, - context: TeamsTurnContext, - state: StateT, - data: Channel - ) -> Awaitable[None]: - ... - -class TeamUpdateHandler(Protocol[StateT]): - def __call__( - self, - context: TeamsTurnContext, - state: StateT, - data: Team - ) -> Awaitable[None]: - ... \ No newline at end of file + return __func \ No newline at end of file From c0ed7b00f44eb8d501a462f9a5b9055c88f350e0 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Thu, 18 Jun 2026 10:15:31 -0700 Subject: [PATCH 09/46] Cleaning up extension and adding new route handler protocol for Handoff --- .../hosting/core/app/_type_defs.py | 3 + .../hosting/core/app/agent_application.py | 15 ++- .../hosting/teams/app/route_handlers.py | 0 .../hosting/teams/channel/route_handlers.py | 1 + .../teams/configuration/configuration.py | 4 +- .../teams/configuration/route_handlers.py | 1 + .../teams/file_consent/file_consent.py | 4 +- .../hosting/teams/meeting/meeting.py | 16 +-- .../hosting/teams/message/message.py | 14 +- .../hosting/teams/message/route_handlers.py | 3 +- .../message_extension/message_extension.py | 11 +- .../hosting/teams/route_handlers.py | 15 ++- .../hosting/teams/task_module/task_module.py | 2 +- .../hosting/teams/teams_activity_handler.py | 12 +- .../hosting/teams/teams_agent_extension.py | 120 ++++++++++++++++-- .../hosting/teams/type_defs.py | 2 +- 16 files changed, 162 insertions(+), 61 deletions(-) delete mode 100644 libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/app/route_handlers.py diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/_type_defs.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/_type_defs.py index f5ceb61a2..d6a28f209 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/_type_defs.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/_type_defs.py @@ -13,3 +13,6 @@ class RouteHandler(Protocol[StateT]): def __call__(self, context: TurnContext, state: StateT) -> Awaitable[None]: ... + +class HandoffHandler(Protocol[StateT]): + def __call__(self, context: TurnContext, state: StateT, handoff_data: str) -> Awaitable[None]: ... \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/agent_application.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/agent_application.py index 1b76103ba..21b1dca54 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/agent_application.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/agent_application.py @@ -43,7 +43,11 @@ from .typing_indicator import TypingIndicator from .telemetry import spans -from ._type_defs import RouteHandler, RouteSelector +from ._type_defs import ( + RouteHandler, + HandoffHandler, + RouteSelector, +) from ._routes import _RouteList, _Route, RouteRank, _agentic_selector from .proactive import Proactive, ProactiveOptions @@ -537,10 +541,7 @@ def __call(func: RouteHandler[StateT]) -> RouteHandler[StateT]: def handoff( self, *, auth_handlers: Optional[list[str]] = None, **kwargs - ) -> Callable[ - [Callable[[TurnContext, StateT, str], Awaitable[None]]], - Callable[[TurnContext, StateT, str], Awaitable[None]], - ]: + ) -> Callable[[HandoffHandler[StateT]], HandoffHandler[StateT]]: """ Register a handler to hand off conversations from one copilot to another. @@ -563,8 +564,8 @@ def __selector(context: TurnContext) -> bool: ) def __call( - func: Callable[[TurnContext, StateT, str], Awaitable[None]], - ) -> Callable[[TurnContext, StateT, str], Awaitable[None]]: + func: HandoffHandler[StateT], + ) -> HandoffHandler[StateT]: async def __handler(context: TurnContext, state: StateT): if not context.activity.value: return False diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/app/route_handlers.py b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/app/route_handlers.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/channel/route_handlers.py b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/channel/route_handlers.py index 42c50d060..d4c99617f 100644 --- a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/channel/route_handlers.py +++ b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/channel/route_handlers.py @@ -9,6 +9,7 @@ from microsoft_agents.hosting.teams.type_defs import StateT class ChannelUpdateHandler(Protocol[StateT]): + """A protocol for handling Teams channel update requests.""" def __call__( self, context: TeamsTurnContext, diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/configuration/configuration.py b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/configuration/configuration.py index 24f8224e1..d74884ce3 100644 --- a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/configuration/configuration.py +++ b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/configuration/configuration.py @@ -55,7 +55,7 @@ async def __handler(context: TurnContext, state: StateT) -> None: return __call - def on_config_fetch( + def fetch( self, *, auth_handlers: Optional[list[str]] = None, @@ -64,7 +64,7 @@ def on_config_fetch( """Register a handler for config/fetch invokes.""" return self._create_decorator("config/fetch", auth_handlers=auth_handlers, rank=rank) - def on_config_submit( + def submit( self, *, auth_handlers: Optional[list[str]] = None, diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/configuration/route_handlers.py b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/configuration/route_handlers.py index 0b999e515..5a8da499f 100644 --- a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/configuration/route_handlers.py +++ b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/configuration/route_handlers.py @@ -9,6 +9,7 @@ from microsoft_agents.hosting.teams.type_defs import StateT class ConfigurationHandler(Protocol[StateT]): + """A protocol for handling Teams configuration requests.""" def __call__( self, context: TeamsTurnContext, diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/file_consent/file_consent.py b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/file_consent/file_consent.py index 11005514a..5ccf2ca6c 100644 --- a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/file_consent/file_consent.py +++ b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/file_consent/file_consent.py @@ -62,7 +62,7 @@ async def __handler(context: TurnContext, state: StateT) -> None: return __call - def on_file_consent_accept( + def accept( self, *, auth_handlers: Optional[list[str]] = None, @@ -71,7 +71,7 @@ def on_file_consent_accept( """Register a handler for fileConsent/invoke with action == 'accept'.""" return self._create_decorator("accept", auth_handlers=auth_handlers, rank=rank) - def on_file_consent_decline( + def decline( self, *, auth_handlers: Optional[list[str]] = None, diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/meeting/meeting.py b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/meeting/meeting.py index 3af6e948c..83129cc9e 100644 --- a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/meeting/meeting.py +++ b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/meeting/meeting.py @@ -43,15 +43,7 @@ class Meeting(Generic[StateT]): def __init__(self, app: AgentApplication[StateT]) -> None: self._app = app - - # - # @meeting.on_start - # def on_start_handler(self, context: TeamsTurnContext, state: StateT, meeting: MeetingDetails) -> None: - # pass - - # meeting.on_start()(on_start_handler) - - def on_start( + def start( self, *, auth_handlers: Optional[list[str]] = None, @@ -81,7 +73,7 @@ async def __handler(context: TurnContext, state: StateT) -> None: return __call - def on_end( + def end( self, *, auth_handlers: Optional[list[str]] = None, @@ -111,7 +103,7 @@ async def __handler(context: TurnContext, state: StateT) -> None: return __call - def on_participants_join( + def participants_join( self, *, auth_handlers: Optional[list[str]] = None, @@ -144,7 +136,7 @@ async def __handler(context: TurnContext, state: StateT) -> None: return __call - def on_participants_leave( + def participants_leave( self, *, auth_handlers: Optional[list[str]] = None, diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/message/message.py b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/message/message.py index c8c0cf7fb..8e181a181 100644 --- a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/message/message.py +++ b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/message/message.py @@ -22,13 +22,13 @@ _RouteDecorator, StateT, ) -from microsoft_agetns.hosting.teams._utils import ( +from microsoft_agents.hosting.teams._utils import ( _get_channel_event_type, _send_invoke_response, ) from .route_handlers import ( - O365ConnectorCardActionHandler, + ExecuteActionHandler, ReadReceiptHandler ) @@ -90,9 +90,7 @@ def soft_delete( """Register a handler for Teams softDeleteMessage events.""" return self._create_basic_decorator("softDeleteMessage", auth_handlers=auth_handlers, rank=rank) - # ── Read receipt ─────────────────────────────────────────────────────── - - def on_read_receipt( + def read_receipt( self, *, auth_handlers: Optional[list[str]] = None, @@ -118,12 +116,12 @@ async def __handler(context: TurnContext, state: StateT) -> None: return __call - def o365_connector_card_action( + def execute_action( self, *, auth_handlers: Optional[list[str]] = None, rank: RouteRank = RouteRank.DEFAULT, - ) -> _RouteDecorator[O365ConnectorCardActionHandler[StateT]]: + ) -> _RouteDecorator[ExecuteActionHandler[StateT]]: """Register a handler for actionableMessage/executeAction invokes.""" def __selector(context: TurnContext) -> bool: @@ -132,7 +130,7 @@ def __selector(context: TurnContext) -> bool: and context.activity.name == "actionableMessage/executeAction" ) - def __call(func: O365ConnectorCardActionHandler[StateT]) -> O365ConnectorCardActionHandler[StateT]: + def __call(func: ExecuteActionHandler[StateT]) -> ExecuteActionHandler[StateT]: async def __handler(context: TurnContext, state: StateT) -> None: teams_context = TeamsTurnContext(context) query = O365ConnectorCardActionQuery.model_validate( diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/message/route_handlers.py b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/message/route_handlers.py index 940606631..bdd1e38b4 100644 --- a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/message/route_handlers.py +++ b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/message/route_handlers.py @@ -10,7 +10,7 @@ from microsoft_agents.hosting.teams.teams_turn_context import TeamsTurnContext from microsoft_agents.hosting.teams.type_defs import StateT -class O365ConnectorCardActionHandler(Protocol[StateT]): +class ExecuteActionHandler(Protocol[StateT]): def __call__( self, context: TeamsTurnContext, @@ -21,4 +21,5 @@ class ReadReceiptHandler(Protocol[StateT]): def __call__( self, context: TeamsTurnContext, + state: StateT, data: dict) -> Awaitable[None]: ... \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/message_extension/message_extension.py b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/message_extension/message_extension.py index d37beaa03..2f06142dd 100644 --- a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/message_extension/message_extension.py +++ b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/message_extension/message_extension.py @@ -6,15 +6,10 @@ from microsoft_teams.api.models import ( MessagingExtensionQuery, MessagingExtensionAction, - MessagingExtensionResponse, - O365ConnectorCardActionQuery, - AppBasedLinkQuery + AppBasedLinkQuery, ) -from microsoft_agents.activity import ( - Activity, - ActivityTypes -) +from microsoft_agents.activity import ActivityTypes from microsoft_agents.hosting.core import ( AgentApplication, @@ -415,7 +410,7 @@ async def __handler(context: TurnContext, state: StateT) -> None: return __call - def configure_settings( + def setting( self, handler: Optional[Callable] = None, *, diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/route_handlers.py b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/route_handlers.py index 95587c8a7..b034619d7 100644 --- a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/route_handlers.py +++ b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/route_handlers.py @@ -8,9 +8,10 @@ Protocol, ) -from microsoft_agents.hosting.core import ( +from microsoft_agents.hosting.core import TurnContext +from microsoft_agents.hosting.core.app._type_defs import ( RouteHandler, - TurnContext, + HandoffHandler, ) from .teams_turn_context import TeamsTurnContext @@ -24,4 +25,14 @@ def wrap(handler: TeamsRouteHandler[StateT]) -> RouteHandler[StateT]: async def __func(context: TurnContext, state: StateT) -> None: teams_context = TeamsTurnContext(context) await handler(teams_context, state) + return __func + +class TeamsHandoffHandler(Protocol[StateT]): + def __call__(self, context: TeamsTurnContext, state: StateT, handoff_data: str) -> Awaitable[None]: ... + + @staticmethod + def wrap(handler: TeamsHandoffHandler[StateT]) -> HandoffHandler[StateT]: + async def __func(context: TurnContext, state: StateT, handoff_data: str) -> None: + teams_context = TeamsTurnContext(context) + await handler(teams_context, state, handoff_data) return __func \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/task_module/task_module.py b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/task_module/task_module.py index 3b8417860..4c9be1f7e 100644 --- a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/task_module/task_module.py +++ b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/task_module/task_module.py @@ -94,7 +94,7 @@ async def __handler(context: TurnContext, state: StateT) -> None: return __call - def on_submit( + def submit( self, verb: CommandSelector = None, *, 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 index c0511e2d7..4be373524 100644 --- 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 @@ -78,7 +78,7 @@ async def on_invoke_activity(self, turn_context: TurnContext) -> InvokeResponse: await self.on_teams_file_consent(turn_context, value) ) elif name == "actionableMessage/executeAction": - await self.on_teams_o365_connector_card_action(turn_context, value) + await self.on_execute_action(turn_context, value) return self._create_invoke_response() elif name == "composeExtension/queryLink": return self._create_invoke_response( @@ -117,12 +117,12 @@ async def on_invoke_activity(self, turn_context: TurnContext) -> InvokeResponse: ) elif name == "composeExtension/querySettingUrl": return self._create_invoke_response( - await self.on_teams_messaging_extension_configuration_query_setting_url( + await self.on_teams_messaging_extension_config_query_setting_url( turn_context, value ) ) elif name == "composeExtension/setting": - await self.on_teams_messaging_extension_configuration_setting( + await self.on_teams_messaging_extension_config_setting( turn_context, value ) return self._create_invoke_response() @@ -248,7 +248,7 @@ async def on_teams_file_consent_decline( """ raise NotImplementedError(str(teams_errors.TeamsNotImplemented)) - async def on_teams_o365_connector_card_action( + async def on_execute_action( self, turn_context: TurnContext, query: O365ConnectorCardActionQuery ) -> None: """ @@ -406,7 +406,7 @@ async def on_teams_messaging_extension_fetch_task( """ raise NotImplementedError(str(teams_errors.TeamsNotImplemented)) - async def on_teams_messaging_extension_configuration_query_setting_url( + async def on_teams_messaging_extension_config_query_setting_url( self, turn_context: TurnContext, query: MessagingExtensionQuery ) -> MessagingExtensionResponse: """ @@ -418,7 +418,7 @@ async def on_teams_messaging_extension_configuration_query_setting_url( """ raise NotImplementedError(str(teams_errors.TeamsNotImplemented)) - async def on_teams_messaging_extension_configuration_setting( + async def on_teams_messaging_extension_config_setting( self, turn_context: TurnContext, settings: Any ) -> None: """ 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 index d45befe0f..a4337baae 100644 --- 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 @@ -5,9 +5,26 @@ from __future__ import annotations -from typing import Generic - -from microsoft_agents.hosting.core.app import AgentApplication +from typing import ( + Awaitable, + Callable, + Generic, + Optional, + Pattern, + Protocol, +) + +from microsoft_agents.activity import ( + ActivityTypes, + ConversationUpdateTypes, + MessageReactionTypes, + MessageUpdateTypes, +) +from microsoft_agents.hosting.core import AgentApplication +from microsoft_agents.hosting.core.app._type_defs import ( + RouteHandler, + HandoffHandler +) from .channel import Channel from .configuration import Configuration @@ -15,11 +32,14 @@ from .meeting import Meeting from .message import Message from .message_extension import MessageExtension +from .route_handlers import TeamsRouteHandler, TeamsHandoffHandler from .task_module import TaskModule from .team import Team -from .type_defs import StateT +from .type_defs import StateT, _RouteDecorator +class _AppRouteDecorator(Protocol[StateT]): + def __call__(self, func: TeamsRouteHandler[StateT], /) -> RouteHandler[StateT]: ... class TeamsAgentExtension(Generic[StateT]): """ @@ -57,12 +77,12 @@ def __init__(self, app: AgentApplication[StateT]) -> None: self._team: Team[StateT] = Team(app) @property - def channel(self) -> Channel[StateT]: + def channels(self) -> Channel[StateT]: """Route registration for Channel events.""" return self._channel @property - def configuration(self) -> Configuration[StateT]: + def config(self) -> Configuration[StateT]: """Route registration for Configuration events.""" return self._configuration @@ -72,21 +92,99 @@ def file_consent(self) -> FileConsent[StateT]: return self._file_consent @property - def meeting(self) -> Meeting[StateT]: + def meetings(self) -> Meeting[StateT]: """Route registration for Meeting lifecycle events.""" return self._meeting @property - def message(self) -> Message[StateT]: + def messages(self) -> Message[StateT]: """Route registration for messaging activities.""" return self._message @property - def message_extension(self) -> MessageExtension[StateT]: + def message_extensions(self) -> MessageExtension[StateT]: """Route registration for Message Extension (composeExtension) invokes.""" return self._message_extension @property - def task_module(self) -> TaskModule[StateT]: + def task_modules(self) -> TaskModule[StateT]: """Route registration for Task Module (task/fetch, task/submit) invokes.""" - return self._task_module \ No newline at end of file + return self._task_module + + # AgentApplication route hooks + + def _wrap_decorator( + self, + decorator: _RouteDecorator[RouteHandler[StateT]] + ) -> Callable[[TeamsRouteHandler[StateT]], RouteHandler[StateT]]: + """Wrap a core route decorator to create a Teams-specific route decorator.""" + def __call(func: TeamsRouteHandler[StateT]) -> RouteHandler[StateT]: + return decorator(TeamsRouteHandler.wrap(func)) + return __call + + def activity( + self, + activity_type: str | ActivityTypes | list[str | ActivityTypes], + *, + auth_handlers: Optional[list[str]] = None, + **kwargs, + ) -> _AppRouteDecorator[StateT]: + return self._wrap_decorator( + self._app.activity(activity_type, auth_handlers=auth_handlers, **kwargs) + ) + + def message( + self, + select: str | Pattern[str] | list[str | Pattern[str]], + *, + auth_handlers: Optional[list[str]] = None, + **kwargs, + ) -> _AppRouteDecorator[StateT]: + return self._wrap_decorator( + self._app.message(select, auth_handlers=auth_handlers, **kwargs) + ) + + def conversation_update( + self, + type: ConversationUpdateTypes, + *, + auth_handlers: Optional[list[str]] = None, + **kwargs, + ) -> _AppRouteDecorator[StateT]: + return self._wrap_decorator( + self._app.conversation_update(type, auth_handlers=auth_handlers, **kwargs) + ) + + def message_reaction( + self, + type: MessageReactionTypes, + *, + auth_handlers: Optional[list[str]] = None, + **kwargs, + ) -> _AppRouteDecorator[StateT]: + return self._wrap_decorator( + self._app.message_reaction(type, auth_handlers=auth_handlers, **kwargs) + ) + + def message_update( + self, + type: MessageUpdateTypes, + *, + auth_handlers: Optional[list[str]] = None, + **kwargs, + ) -> _AppRouteDecorator[StateT]: + return self._wrap_decorator( + self._app.message_update(type, auth_handlers=auth_handlers, **kwargs) + ) + + def handoff( + self, + *, + auth_handlers: Optional[list[str]] = None, + **kwargs, + ) -> Callable[[TeamsHandoffHandler[StateT]], HandoffHandler[StateT]]: + def __call(func: TeamsHandoffHandler[StateT]) -> HandoffHandler[StateT]: + return self._app.handoff(auth_handlers=auth_handlers, **kwargs)( + TeamsHandoffHandler.wrap(func) + ) + return __call \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/type_defs.py b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/type_defs.py index f53e52060..e807b0e19 100644 --- a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/type_defs.py +++ b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/type_defs.py @@ -20,4 +20,4 @@ CommandSelector = str | Pattern[str] | None class _RouteDecorator(Protocol[RouteHandlerT]): - def __call__(self, func: RouteHandlerT) -> RouteHandlerT: ... \ No newline at end of file + def __call__(self, func: RouteHandlerT, /) -> RouteHandlerT: ... \ No newline at end of file From d1a4371a7e0a9395094bc5fe5dff35a588ae0ddb Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Thu, 18 Jun 2026 14:16:59 -0700 Subject: [PATCH 10/46] Further cleanup and formatting --- .../hosting/core/app/_type_defs.py | 5 +- .../hosting/teams/__init__.py | 31 +- .../microsoft_agents/hosting/teams/_utils.py | 53 +- .../hosting/teams/channel/__init__.py | 2 +- .../hosting/teams/channel/channel.py | 46 +- .../hosting/teams/channel/route_handlers.py | 27 +- .../{configuration => config}/__init__.py | 4 +- .../configuration.py => config/config.py} | 45 +- .../hosting/teams/config/route_handlers.py | 34 + .../teams/configuration/route_handlers.py | 18 - .../hosting/teams/file_consent/__init__.py | 2 +- .../teams/file_consent/file_consent.py | 26 +- .../teams/file_consent/route_handlers.py | 26 +- .../hosting/teams/meeting/__init__.py | 2 +- .../hosting/teams/meeting/meeting.py | 35 +- .../hosting/teams/meeting/route_handlers.py | 64 +- .../hosting/teams/message/__init__.py | 2 +- .../hosting/teams/message/message.py | 81 +- .../hosting/teams/message/route_handlers.py | 46 +- .../teams/message_extension/__init__.py | 2 +- .../message_extension/message_extension.py | 93 +- .../teams/message_extension/route_handlers.py | 222 +- .../hosting/teams/route_handlers.py | 68 +- .../hosting/teams/task_module/__init__.py | 2 +- .../teams/task_module/route_handlers.py | 49 +- .../hosting/teams/task_module/task_module.py | 34 +- .../hosting/teams/team/__init__.py | 2 +- .../hosting/teams/team/route_handlers.py | 24 +- .../hosting/teams/team/team.py | 47 +- .../hosting/teams/teams_activity_handler.py | 1822 ++++++++--------- .../hosting/teams/teams_agent_extension.py | 118 +- .../hosting/teams/teams_turn_context.py | 24 +- .../hosting/teams/type_defs.py | 13 +- .../proactive/test_conversation_builder.py | 1 - .../test_conversation_reference_builder.py | 1 - .../app/proactive/test_proactive.py | 1 - tests/hosting_teams/helpers.py | 57 + tests/hosting_teams/test_channels.py | 265 +++ tests/hosting_teams/test_config.py | 108 + tests/hosting_teams/test_file_consent.py | 135 ++ tests/hosting_teams/test_meetings.py | 225 ++ .../hosting_teams/test_message_extensions.py | 538 +++++ tests/hosting_teams/test_messages.py | 281 +++ tests/hosting_teams/test_task_modules.py | 245 +++ tests/hosting_teams/test_team_lifecycle.py | 172 ++ .../test_teams_agent_extension.py | 1007 +-------- tests/hosting_teams/test_utils.py | 150 ++ 47 files changed, 4009 insertions(+), 2246 deletions(-) rename libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/{configuration => config}/__init__.py (58%) rename libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/{configuration/configuration.py => config/config.py} (57%) create mode 100644 libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/config/route_handlers.py delete mode 100644 libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/configuration/route_handlers.py create mode 100644 tests/hosting_teams/helpers.py create mode 100644 tests/hosting_teams/test_channels.py create mode 100644 tests/hosting_teams/test_config.py create mode 100644 tests/hosting_teams/test_file_consent.py create mode 100644 tests/hosting_teams/test_meetings.py create mode 100644 tests/hosting_teams/test_message_extensions.py create mode 100644 tests/hosting_teams/test_messages.py create mode 100644 tests/hosting_teams/test_task_modules.py create mode 100644 tests/hosting_teams/test_team_lifecycle.py create mode 100644 tests/hosting_teams/test_utils.py diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/_type_defs.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/_type_defs.py index d6a28f209..92e994729 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/_type_defs.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/_type_defs.py @@ -14,5 +14,8 @@ class RouteHandler(Protocol[StateT]): def __call__(self, context: TurnContext, state: StateT) -> Awaitable[None]: ... + class HandoffHandler(Protocol[StateT]): - def __call__(self, context: TurnContext, state: StateT, handoff_data: str) -> Awaitable[None]: ... \ No newline at end of file + def __call__( + self, context: TurnContext, state: StateT, handoff_data: str + ) -> Awaitable[None]: ... 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 index 2723ff546..00ccf8a31 100644 --- a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/__init__.py +++ b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/__init__.py @@ -1,17 +1,30 @@ -from .teams_activity_handler import TeamsActivityHandler -from .teams_agent_extension import ( - TeamsAgentExtension, - MessageExtension, - TaskModule, - Meeting, -) +""" +Microsoft Agents Hosting Teams package. + +Provides Teams-specific activity handlers, extensions, and utilities for building +Microsoft Teams bots and agents using the AgentApplication or ActivityHandler models. +""" + +from .teams_agent_extension import TeamsAgentExtension +from .channel import Channel +from .config import Config +from .file_consent import FileConsent +from .meeting import Meeting +from .message import Message +from .message_extension import MessageExtension +from .task_module import TaskModule +from .team import Team from .teams_info import TeamsInfo __all__ = [ - "TeamsActivityHandler", "TeamsAgentExtension", + "Channel", + "Config", + "FileConsent", + "Meeting", + "Message", "MessageExtension", "TaskModule", - "Meeting", + "Team", "TeamsInfo", ] diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/_utils.py b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/_utils.py index 9ba3b0780..5288efb54 100644 --- a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/_utils.py +++ b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/_utils.py @@ -1,25 +1,60 @@ +"""Internal utility helpers for the Teams hosting layer.""" + import re from typing import Any, Optional -from microsoft_agents.activity import Activity -from microsoft_agents.hosting.core import TurnContext +from http import HTTPStatus -from .type_defs import ( - CommandSelector +from microsoft_teams.api.models.channel_data import ChannelData + +from microsoft_agents.activity import ( + Activity, + ActivityTypes, + InvokeResponse, ) +from microsoft_agents.hosting.core import TurnContext + +from .type_defs import CommandSelector + + +def _get_channel_data(context: TurnContext) -> ChannelData: + """Extract and parse Teams channel data from the activity's channel_data. + + :param context: The current turn context. + :return: A :class:`ChannelData` instance parsed from channel_data. + :raises ValueError: If channel_data is absent. + :raises pydantic.ValidationError: If channel_data cannot be deserialized. + """ + data = context.activity.channel_data + if data is None: + raise ValueError("channel_data is required") + if isinstance(data, ChannelData): + return data + return ChannelData.model_validate(data) + def _match_selector(selector: CommandSelector, value: Optional[str]) -> bool: + """Return True if *value* matches *selector*. + + :param selector: A literal string, compiled regex, or None. None matches anything. + :param value: The string to test. None never matches a non-None selector. + """ if selector is None: return True if value is None: return False if isinstance(selector, str): return selector == value - return bool(re.match(selector, value)) + return bool(re.fullmatch(selector, value)) def _get_channel_event_type(context: TurnContext) -> Optional[str]: + """Extract the Teams channel event type from the activity's channel_data. + + :param context: The current turn context. + :return: The event type string (e.g. ``"channelCreated"``), or None if absent. + """ data = context.activity.channel_data if data is None: return None @@ -29,6 +64,14 @@ def _get_channel_event_type(context: TurnContext) -> Optional[str]: async def _send_invoke_response(context: TurnContext, body: Any = None) -> None: + """Send an invoke response activity with HTTP 200 and an optional body. + + Pydantic models are serialised via ``model_dump``; all other values are passed + through as-is. + + :param context: The current turn context. + :param body: Optional response payload. Pydantic models are auto-serialised to JSON. + """ serialized_body = None if body is not None: if hasattr(body, "model_dump"): diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/channel/__init__.py b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/channel/__init__.py index d003f17a9..49479e345 100644 --- a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/channel/__init__.py +++ b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/channel/__init__.py @@ -3,4 +3,4 @@ from .channel import Channel -__all__ = ["Channel"] \ No newline at end of file +__all__ = ["Channel"] diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/channel/channel.py b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/channel/channel.py index 6f70b2bcc..2317af89b 100644 --- a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/channel/channel.py +++ b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/channel/channel.py @@ -1,9 +1,9 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. -from typing import Generic, Optional +"""Route registration helpers for Teams channel conversation update events.""" -from microsoft_teams.api.models.channel_data import ChannelData +from typing import Generic, Optional from microsoft_agents.activity import ActivityTypes from microsoft_agents.hosting.core import ( @@ -18,23 +18,24 @@ StateT, ) from microsoft_agents.hosting.teams._utils import ( - _send_invoke_response, + _get_channel_data, _get_channel_event_type, ) from .route_handlers import ChannelUpdateHandler -def _get_channel_data(context: TurnContext) -> ChannelData: - data = context.activity.channel_data - if data is None: - return ChannelData() - if isinstance(data, dict): - return ChannelData(**data) - return ChannelData.model_validate(data) class Channel(Generic[StateT]): + """Route registration for Teams channel conversation update events. + + Access via :attr:`TeamsAgentExtension.channels`. + """ - def __init__(self, app: AgentApplication[StateT]): + def __init__(self, app: AgentApplication[StateT]) -> None: + """Initialise with the owning :class:`AgentApplication`. + + :param app: The application to register routes on. + """ self._app = app def _create_decorator( @@ -44,6 +45,14 @@ def _create_decorator( auth_handlers: Optional[list[str]] = None, rank: RouteRank = RouteRank.DEFAULT, ) -> _RouteDecorator[ChannelUpdateHandler[StateT]]: + """Build a route decorator that matches a specific Teams channel event type. + + :param event_type: The ``eventType`` value in channel_data to match (e.g. ``"channelCreated"``). + :param auth_handlers: Optional auth handler names to run before the route. + :param rank: Route priority rank. + :return: A decorator that registers the handler. + """ + def __selector(context: TurnContext) -> bool: return ( context.activity.type == ActivityTypes.conversation_update @@ -54,7 +63,7 @@ def __selector(context: TurnContext) -> bool: def __call(func: ChannelUpdateHandler[StateT]) -> ChannelUpdateHandler[StateT]: async def __handler(context: TurnContext, state: StateT) -> None: - teams_context = TeamsTurnContext(context) + teams_context = TeamsTurnContext(context, self._app) channel_data = _get_channel_data(context) await func(teams_context, state, channel_data) @@ -65,7 +74,6 @@ async def __handler(context: TurnContext, state: StateT) -> None: return __call - def created( self, *, @@ -109,8 +117,8 @@ def rest( return self._create_decorator( "channelRestored", auth_handlers=auth_handlers, rank=rank ) - - def on_members_added( + + def members_added( self, *, auth_handlers: Optional[list[str]] = None, @@ -129,7 +137,7 @@ def __selector(context: TurnContext) -> bool: def __call(func: ChannelUpdateHandler[StateT]) -> ChannelUpdateHandler[StateT]: async def __func(context: TurnContext, state: StateT) -> None: - teams_context = TeamsTurnContext(context) + teams_context = TeamsTurnContext(context, self._app) channel_data = _get_channel_data(context) await func(teams_context, state, channel_data) @@ -141,7 +149,7 @@ async def __func(context: TurnContext, state: StateT) -> None: return __call - def on_members_removed( + def members_removed( self, *, auth_handlers: Optional[list[str]] = None, @@ -160,7 +168,7 @@ def __selector(context: TurnContext) -> bool: def __call(func: ChannelUpdateHandler[StateT]) -> ChannelUpdateHandler[StateT]: async def __func(context: TurnContext, state: StateT) -> None: - teams_context = TeamsTurnContext(context) + teams_context = TeamsTurnContext(context, self._app) channel_data = _get_channel_data(context) await func(teams_context, state, channel_data) @@ -170,4 +178,4 @@ async def __func(context: TurnContext, state: StateT) -> None: return func - return __call \ No newline at end of file + return __call diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/channel/route_handlers.py b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/channel/route_handlers.py index d4c99617f..1d2418f0d 100644 --- a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/channel/route_handlers.py +++ b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/channel/route_handlers.py @@ -1,6 +1,8 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. +"""Protocol definition for Teams channel update route handlers.""" + from typing import Any, Awaitable, Protocol from microsoft_teams.api.models.channel_data import ChannelData @@ -8,11 +10,24 @@ from microsoft_agents.hosting.teams.teams_turn_context import TeamsTurnContext from microsoft_agents.hosting.teams.type_defs import StateT + class ChannelUpdateHandler(Protocol[StateT]): - """A protocol for handling Teams channel update requests.""" + """Protocol for a handler invoked on Teams channel update events. + + Handlers receive the Teams turn context, the current turn state, and the + parsed :class:`ChannelData` from the activity. + """ + def __call__( - self, - context: TeamsTurnContext, - state: StateT, - data: ChannelData, - ) -> Awaitable[None]: ... \ No newline at end of file + self, + context: TeamsTurnContext, + state: StateT, + data: ChannelData, + ) -> Awaitable[None]: + """Handle a channel update event. + + :param context: Teams-aware turn context. + :param state: The current turn state. + :param data: Parsed channel data from the incoming activity. + """ + ... diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/configuration/__init__.py b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/config/__init__.py similarity index 58% rename from libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/configuration/__init__.py rename to libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/config/__init__.py index edbb3adbf..ce912e97f 100644 --- a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/configuration/__init__.py +++ b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/config/__init__.py @@ -1,6 +1,6 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. -from .configuration import Configuration +from .config import Config -__all__ = ["Configuration"] \ No newline at end of file +__all__ = ["Config"] diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/configuration/configuration.py b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/config/config.py similarity index 57% rename from libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/configuration/configuration.py rename to libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/config/config.py index d74884ce3..c41cd43cc 100644 --- a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/configuration/configuration.py +++ b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/config/config.py @@ -1,6 +1,8 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. +"""Route registration helpers for Teams configuration (config/fetch, config/submit) invokes.""" + from typing import Generic, Optional from microsoft_agents.activity import ActivityTypes @@ -17,11 +19,20 @@ ) from microsoft_agents.hosting.teams._utils import _send_invoke_response -from .route_handlers import ConfigurationHandler +from .route_handlers import ConfigHandler + + +class Config(Generic[StateT]): + """Route registration for Teams config invoke activities. + + Access via :attr:`TeamsAgentExtension.config`. + """ -class Configuration(Generic[StateT]): + def __init__(self, app: AgentApplication[StateT]) -> None: + """Initialise with the owning :class:`AgentApplication`. - def __init__(self, app: AgentApplication[StateT]): + :param app: The application to register routes on. + """ self._app = app def _create_decorator( @@ -30,16 +41,24 @@ def _create_decorator( *, auth_handlers: Optional[list[str]] = None, rank: RouteRank = RouteRank.DEFAULT, - ) -> _RouteDecorator[ConfigurationHandler[StateT]]: + ) -> _RouteDecorator[ConfigHandler[StateT]]: + """Build a route decorator for a Teams configuration invoke name. + + :param name: The invoke activity name to match (e.g. ``"config/fetch"``). + :param auth_handlers: Optional auth handler names to run before the route. + :param rank: Route priority rank. + :return: A decorator that registers the handler. + """ + def __selector(context: TurnContext) -> bool: return ( context.activity.type == ActivityTypes.invoke and context.activity.name == name ) - def __call(func: ConfigurationHandler[StateT]) -> ConfigurationHandler[StateT]: + def __call(func: ConfigHandler[StateT]) -> ConfigHandler[StateT]: async def __handler(context: TurnContext, state: StateT) -> None: - teams_context = TeamsTurnContext(context) + teams_context = TeamsTurnContext(context, self._app) response = await func(teams_context, state, context.activity.value) if response is not None: await _send_invoke_response(context, response) @@ -60,15 +79,19 @@ def fetch( *, auth_handlers: Optional[list[str]] = None, rank: RouteRank = RouteRank.DEFAULT, - ) -> _RouteDecorator[ConfigurationHandler[StateT]]: + ) -> _RouteDecorator[ConfigHandler[StateT]]: """Register a handler for config/fetch invokes.""" - return self._create_decorator("config/fetch", auth_handlers=auth_handlers, rank=rank) - + return self._create_decorator( + "config/fetch", auth_handlers=auth_handlers, rank=rank + ) + def submit( self, *, auth_handlers: Optional[list[str]] = None, rank: RouteRank = RouteRank.DEFAULT, - ) -> _RouteDecorator[ConfigurationHandler[StateT]]: + ) -> _RouteDecorator[ConfigHandler[StateT]]: """Register a handler for config/submit invokes.""" - return self._create_decorator("config/submit", auth_handlers=auth_handlers, rank=rank) \ No newline at end of file + return self._create_decorator( + "config/submit", auth_handlers=auth_handlers, rank=rank + ) diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/config/route_handlers.py b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/config/route_handlers.py new file mode 100644 index 000000000..e4a2a0276 --- /dev/null +++ b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/config/route_handlers.py @@ -0,0 +1,34 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""Protocol definition for Teams configuration route handlers.""" + +from typing import Any, Awaitable, Protocol + +from microsoft_teams.api.models.config import ConfigResponse + +from microsoft_agents.hosting.teams.teams_turn_context import TeamsTurnContext +from microsoft_agents.hosting.teams.type_defs import StateT + + +class ConfigHandler(Protocol[StateT]): + """Protocol for a handler invoked on Teams config/fetch or config/submit activities. + + The handler returns a :class:`ConfigResponse` which is automatically sent back + as an invoke response by the routing layer. + """ + + def __call__( + self, + context: TeamsTurnContext, + state: StateT, + config_data: Any, + ) -> Awaitable[ConfigResponse]: + """Handle a configuration invoke. + + :param context: Teams-aware turn context. + :param state: The current turn state. + :param config_data: Raw value payload from the invoke activity. + :return: A :class:`ConfigResponse` to send back to Teams. + """ + ... diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/configuration/route_handlers.py b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/configuration/route_handlers.py deleted file mode 100644 index 5a8da499f..000000000 --- a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/configuration/route_handlers.py +++ /dev/null @@ -1,18 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from typing import Any, Awaitable, Protocol - -from microsoft_teams.api.models.config import ConfigResponse - -from microsoft_agents.hosting.teams.teams_turn_context import TeamsTurnContext -from microsoft_agents.hosting.teams.type_defs import StateT - -class ConfigurationHandler(Protocol[StateT]): - """A protocol for handling Teams configuration requests.""" - def __call__( - self, - context: TeamsTurnContext, - state: StateT, - config_data: Any, - ) -> Awaitable[ConfigResponse]: ... \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/file_consent/__init__.py b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/file_consent/__init__.py index 165a7bdf7..5f890f635 100644 --- a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/file_consent/__init__.py +++ b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/file_consent/__init__.py @@ -3,4 +3,4 @@ from .file_consent import FileConsent -__all__ = ["FileConsent"] \ No newline at end of file +__all__ = ["FileConsent"] diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/file_consent/file_consent.py b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/file_consent/file_consent.py index 5ccf2ca6c..9d7511f2e 100644 --- a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/file_consent/file_consent.py +++ b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/file_consent/file_consent.py @@ -1,6 +1,8 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. +"""Route registration helpers for Teams fileConsent/invoke activities.""" + from typing import Generic, Optional from microsoft_teams.api.models import FileConsentCardResponse @@ -21,9 +23,18 @@ from .route_handlers import FileConsentHandler + class FileConsent(Generic[StateT]): + """Route registration for Teams file consent invoke activities. + + Access via :attr:`TeamsAgentExtension.file_consent`. + """ - def __init__(self, app: AgentApplication[StateT]): + def __init__(self, app: AgentApplication[StateT]) -> None: + """Initialise with the owning :class:`AgentApplication`. + + :param app: The application to register routes on. + """ self._app = app def _create_decorator( @@ -33,6 +44,13 @@ def _create_decorator( auth_handlers: Optional[list[str]] = None, rank: RouteRank = RouteRank.DEFAULT, ) -> _RouteDecorator[FileConsentHandler[StateT]]: + """Build a route decorator for a fileConsent/invoke action. + + :param action: The consent action to match — ``"accept"`` or ``"decline"``. + :param auth_handlers: Optional auth handler names to run before the route. + :param rank: Route priority rank. + :return: A decorator that registers the handler. + """ def __selector(context: TurnContext) -> bool: return ( @@ -44,7 +62,7 @@ def __selector(context: TurnContext) -> bool: def __call(func: FileConsentHandler[StateT]) -> FileConsentHandler[StateT]: async def __handler(context: TurnContext, state: StateT) -> None: - teams_context = TeamsTurnContext(context) + teams_context = TeamsTurnContext(context, self._app) file_consent = FileConsentCardResponse.model_validate( context.activity.value or {} ) @@ -59,7 +77,7 @@ async def __handler(context: TurnContext, state: StateT) -> None: auth_handlers=auth_handlers, ) return func - + return __call def accept( @@ -78,4 +96,4 @@ def decline( rank: RouteRank = RouteRank.DEFAULT, ) -> _RouteDecorator[FileConsentHandler[StateT]]: """Register a handler for fileConsent/invoke with action == 'decline'.""" - return self._create_decorator("decline", auth_handlers=auth_handlers, rank=rank) \ No newline at end of file + return self._create_decorator("decline", auth_handlers=auth_handlers, rank=rank) diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/file_consent/route_handlers.py b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/file_consent/route_handlers.py index 826379b90..41034773e 100644 --- a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/file_consent/route_handlers.py +++ b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/file_consent/route_handlers.py @@ -1,6 +1,8 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. +"""Protocol definition for Teams file consent route handlers.""" + from typing import Awaitable, Protocol from microsoft_teams.api.models import FileConsentCardResponse @@ -8,10 +10,24 @@ from microsoft_agents.hosting.teams.teams_turn_context import TeamsTurnContext from microsoft_agents.hosting.teams.type_defs import StateT + class FileConsentHandler(Protocol[StateT]): + """Protocol for a handler invoked on fileConsent/invoke activities. + + Called for both ``accept`` and ``decline`` actions; the routing layer sends + the invoke response automatically after the handler returns. + """ + def __call__( - self, - context: TeamsTurnContext, - state: StateT, - file_consent: FileConsentCardResponse - ) -> Awaitable[None]: ... \ No newline at end of file + self, + context: TeamsTurnContext, + state: StateT, + file_consent: FileConsentCardResponse, + ) -> Awaitable[None]: + """Handle a file consent invoke. + + :param context: Teams-aware turn context. + :param state: The current turn state. + :param file_consent: Parsed file consent card response from the invoke payload. + """ + ... diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/meeting/__init__.py b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/meeting/__init__.py index e2ae72b4f..dd768ef94 100644 --- a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/meeting/__init__.py +++ b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/meeting/__init__.py @@ -3,4 +3,4 @@ from .meeting import Meeting -__all__ = ["Meeting"] \ No newline at end of file +__all__ = ["Meeting"] diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/meeting/meeting.py b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/meeting/meeting.py index 83129cc9e..3838f8adf 100644 --- a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/meeting/meeting.py +++ b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/meeting/meeting.py @@ -1,15 +1,13 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. -from typing import ( - Callable, - Generic, - Optional -) +"""Route registration helpers for Teams meeting lifecycle event activities.""" + +from typing import Callable, Generic, Optional from microsoft_teams.api.models import ( MeetingDetails, - MeetingParticipantsEventDetails, + MeetingParticipant, ) from microsoft_agents.activity import ( @@ -31,9 +29,10 @@ from .route_handlers import ( MeetingStartHandler, MeetingEndHandler, - MeetingParticipantsEventHandler + MeetingParticipantsEventHandler, ) + class Meeting(Generic[StateT]): """ Route registration for Teams Meeting event activities. @@ -59,7 +58,7 @@ def __selector(context: TurnContext) -> bool: def __call(func: MeetingStartHandler[StateT]) -> MeetingStartHandler[StateT]: async def __handler(context: TurnContext, state: StateT) -> None: - teams_context = TeamsTurnContext(context) + teams_context = TeamsTurnContext(context, self._app) meeting = MeetingDetails.model_validate(context.activity.value or {}) await func(teams_context, state, meeting) @@ -89,7 +88,7 @@ def __selector(context: TurnContext) -> bool: def __call(func: MeetingEndHandler[StateT]) -> MeetingEndHandler[StateT]: async def __handler(context: TurnContext, state: StateT) -> None: - teams_context = TeamsTurnContext(context) + teams_context = TeamsTurnContext(context, self._app) meeting = MeetingDetails.model_validate(context.activity.value or {}) await func(teams_context, state, meeting) @@ -118,10 +117,12 @@ def __selector(context: TurnContext) -> bool: == "application/vnd.microsoft.meetingParticipantJoin" ) - def __call(func: MeetingParticipantsEventHandler[StateT]) -> MeetingParticipantsEventHandler[StateT]: + def __call( + func: MeetingParticipantsEventHandler[StateT], + ) -> MeetingParticipantsEventHandler[StateT]: async def __handler(context: TurnContext, state: StateT) -> None: - teams_context = TeamsTurnContext(context) - details = MeetingParticipantsEventDetails.model_validate( + teams_context = TeamsTurnContext(context, self._app) + details = MeetingParticipant.model_validate( context.activity.value or {} ) await func(teams_context, state, details) @@ -151,10 +152,12 @@ def __selector(context: TurnContext) -> bool: == "application/vnd.microsoft.meetingParticipantLeave" ) - def __call(func: MeetingParticipantsEventHandler[StateT]) -> MeetingParticipantsEventHandler[StateT]: + def __call( + func: MeetingParticipantsEventHandler[StateT], + ) -> MeetingParticipantsEventHandler[StateT]: async def __handler(context: TurnContext, state: StateT) -> None: - teams_context = TeamsTurnContext(context) - details = MeetingParticipantsEventDetails.model_validate( + teams_context = TeamsTurnContext(context, self._app) + details = MeetingParticipant.model_validate( context.activity.value or {} ) await func(teams_context, state, details) @@ -166,5 +169,5 @@ async def __handler(context: TurnContext, state: StateT) -> None: auth_handlers=auth_handlers, ) return func - + return __call diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/meeting/route_handlers.py b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/meeting/route_handlers.py index 50a84d5bd..00f8fad81 100644 --- a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/meeting/route_handlers.py +++ b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/meeting/route_handlers.py @@ -1,36 +1,68 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. +"""Protocol definitions for Teams meeting event route handlers.""" + from typing import Awaitable, Protocol from microsoft_teams.api.models import ( MeetingDetails, - MeetingParticipantsEventDetails + MeetingParticipant, ) from microsoft_agents.hosting.teams.teams_turn_context import TeamsTurnContext from microsoft_agents.hosting.teams.type_defs import StateT + class MeetingStartHandler(Protocol[StateT]): + """Protocol for a handler invoked when a Teams meeting starts.""" + def __call__( - self, - context: TeamsTurnContext, - state: StateT, - meeting: MeetingDetails - ) -> Awaitable[None]: ... + self, + context: TeamsTurnContext, + state: StateT, + meeting: MeetingDetails, + ) -> Awaitable[None]: + """Handle a meeting start event. + + :param context: Teams-aware turn context. + :param state: The current turn state. + :param meeting: Details of the meeting that started. + """ + ... + class MeetingEndHandler(Protocol[StateT]): + """Protocol for a handler invoked when a Teams meeting ends.""" + def __call__( - self, - context: TeamsTurnContext, - state: StateT, - meeting: MeetingDetails - ) -> Awaitable[None]: ... + self, + context: TeamsTurnContext, + state: StateT, + meeting: MeetingDetails, + ) -> Awaitable[None]: + """Handle a meeting end event. + + :param context: Teams-aware turn context. + :param state: The current turn state. + :param meeting: Details of the meeting that ended. + """ + ... + class MeetingParticipantsEventHandler(Protocol[StateT]): + """Protocol for a handler invoked when participants join or leave a Teams meeting.""" + def __call__( - self, - context: TeamsTurnContext, - state: StateT, - meeting: MeetingParticipantsEventDetails - ) -> Awaitable[None]: ... \ No newline at end of file + self, + context: TeamsTurnContext, + state: StateT, + meeting: MeetingParticipant, + ) -> Awaitable[None]: + """Handle a meeting participant join or leave event. + + :param context: Teams-aware turn context. + :param state: The current turn state. + :param meeting: Details of the participants event. + """ + ... diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/message/__init__.py b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/message/__init__.py index 4ea36343a..fe0e05a99 100644 --- a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/message/__init__.py +++ b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/message/__init__.py @@ -5,4 +5,4 @@ __all__ = [ "Message", -] \ No newline at end of file +] diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/message/message.py b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/message/message.py index 8e181a181..aaba1caa8 100644 --- a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/message/message.py +++ b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/message/message.py @@ -1,11 +1,9 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. -from typing import ( - Callable, - Generic, - Optional -) +"""Route registration helpers for Teams message update and actionable message activities.""" + +from typing import Callable, Generic, Optional from microsoft_teams.api.models.o365 import O365ConnectorCardActionQuery @@ -27,26 +25,42 @@ _send_invoke_response, ) -from .route_handlers import ( - ExecuteActionHandler, - ReadReceiptHandler -) +from .route_handlers import ExecuteActionHandler, ReadReceiptHandler + class Message(Generic[StateT]): + """Route registration for Teams message update and actionable message activities. - def __init__(self, app: AgentApplication[StateT]): + Access via :attr:`TeamsAgentExtension.messages`. + """ + + def __init__(self, app: AgentApplication[StateT]) -> None: + """Initialise with the owning :class:`AgentApplication`. + + :param app: The application to register routes on. + """ self._app = app def _create_basic_decorator( self, event_type: str, + message_type: ActivityTypes, *, auth_handlers: Optional[list[str]] = None, rank: RouteRank = RouteRank.DEFAULT, ) -> _RouteDecorator[TeamsRouteHandler[StateT]]: + """Build a route decorator for a Teams messageUpdate channel event type. + + :param event_type: The channel event type to match (e.g. ``"editMessage"``). + :param message_type: The activity type to match. + :param auth_handlers: Optional auth handler names to run before the route. + :param rank: Route priority rank. + :return: A decorator that registers the handler. + """ + def __selector(context: TurnContext) -> bool: return ( - context.activity.type == ActivityTypes.message_update + context.activity.type == message_type and context.activity.channel_id == "msteams" and _get_channel_event_type(context) == event_type ) @@ -54,14 +68,14 @@ def __selector(context: TurnContext) -> bool: def __call(func: TeamsRouteHandler[StateT]) -> TeamsRouteHandler[StateT]: self._app.add_route( __selector, - TeamsRouteHandler[StateT].wrap(func), + TeamsRouteHandler.wrap(func, self._app), rank=rank, - auth_handlers=auth_handlers + auth_handlers=auth_handlers, ) return func return __call - + def edit( self, *, @@ -69,7 +83,12 @@ def edit( rank: RouteRank = RouteRank.DEFAULT, ) -> _RouteDecorator[TeamsRouteHandler[StateT]]: """Register a handler for Teams editMessage events.""" - return self._create_basic_decorator("editMessage", auth_handlers=auth_handlers, rank=rank) + return self._create_basic_decorator( + "editMessage", + ActivityTypes.message_update, + auth_handlers=auth_handlers, + rank=rank, + ) def undelete( self, @@ -78,17 +97,26 @@ def undelete( rank: RouteRank = RouteRank.DEFAULT, ) -> _RouteDecorator[TeamsRouteHandler[StateT]]: """Register a handler for Teams undeleteMessage events.""" - return self._create_basic_decorator("undeleteMessage", auth_handlers=auth_handlers, rank=rank) + return self._create_basic_decorator( + "undeleteMessage", + ActivityTypes.message_update, + auth_handlers=auth_handlers, + rank=rank, + ) def soft_delete( self, - handler: Optional[Callable] = None, *, auth_handlers: Optional[list[str]] = None, rank: RouteRank = RouteRank.DEFAULT, - ) -> Callable: + ) -> _RouteDecorator[TeamsRouteHandler[StateT]]: """Register a handler for Teams softDeleteMessage events.""" - return self._create_basic_decorator("softDeleteMessage", auth_handlers=auth_handlers, rank=rank) + return self._create_basic_decorator( + "softDeleteMessage", + ActivityTypes.message_delete, + auth_handlers=auth_handlers, + rank=rank, + ) def read_receipt( self, @@ -106,8 +134,13 @@ def __selector(context: TurnContext) -> bool: def __call(func: ReadReceiptHandler[StateT]) -> ReadReceiptHandler[StateT]: async def __handler(context: TurnContext, state: StateT) -> None: - teams_context = TeamsTurnContext(context) - await func(teams_context, state) + teams_context = TeamsTurnContext(context, self._app) + value = context.activity.value + if not isinstance(value, dict): + raise TypeError( + f"read_receipt: activity.value must be a dict, got {type(value).__name__}" + ) + await func(teams_context, state, value) self._app.add_route( __selector, __handler, rank=rank, auth_handlers=auth_handlers @@ -115,7 +148,7 @@ async def __handler(context: TurnContext, state: StateT) -> None: return func return __call - + def execute_action( self, *, @@ -132,7 +165,7 @@ def __selector(context: TurnContext) -> bool: def __call(func: ExecuteActionHandler[StateT]) -> ExecuteActionHandler[StateT]: async def __handler(context: TurnContext, state: StateT) -> None: - teams_context = TeamsTurnContext(context) + teams_context = TeamsTurnContext(context, self._app) query = O365ConnectorCardActionQuery.model_validate( context.activity.value or {} ) @@ -148,4 +181,4 @@ async def __handler(context: TurnContext, state: StateT) -> None: ) return func - return __call \ No newline at end of file + return __call diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/message/route_handlers.py b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/message/route_handlers.py index bdd1e38b4..43318643d 100644 --- a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/message/route_handlers.py +++ b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/message/route_handlers.py @@ -1,25 +1,47 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. +"""Protocol definitions for Teams message and actionable message route handlers.""" + from typing import Awaitable, Protocol -from microsoft_teams.api.models.o365 import ( - O365ConnectorCardActionQuery -) +from microsoft_teams.api.models.o365 import O365ConnectorCardActionQuery from microsoft_agents.hosting.teams.teams_turn_context import TeamsTurnContext from microsoft_agents.hosting.teams.type_defs import StateT + class ExecuteActionHandler(Protocol[StateT]): + """Protocol for a handler invoked on actionableMessage/executeAction activities.""" + def __call__( - self, - context: TeamsTurnContext, - state: StateT, - query: O365ConnectorCardActionQuery) -> Awaitable[None]: ... - + self, + context: TeamsTurnContext, + state: StateT, + query: O365ConnectorCardActionQuery, + ) -> Awaitable[None]: + """Handle an O365 connector card action execution. + + :param context: Teams-aware turn context. + :param state: The current turn state. + :param query: The parsed O365 connector card action query. + """ + ... + + class ReadReceiptHandler(Protocol[StateT]): + """Protocol for a handler invoked on Teams read receipt events.""" + def __call__( - self, - context: TeamsTurnContext, - state: StateT, - data: dict) -> Awaitable[None]: ... \ No newline at end of file + self, + context: TeamsTurnContext, + state: StateT, + data: dict, + ) -> Awaitable[None]: + """Handle a read receipt event. + + :param context: Teams-aware turn context. + :param state: The current turn state. + :param data: Raw event payload from the activity value. + """ + ... diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/message_extension/__init__.py b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/message_extension/__init__.py index 5ca78a985..d1f10e06f 100644 --- a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/message_extension/__init__.py +++ b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/message_extension/__init__.py @@ -3,4 +3,4 @@ from .message_extension import MessageExtension -__all__ = ["MessageExtension"] \ No newline at end of file +__all__ = ["MessageExtension"] diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/message_extension/message_extension.py b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/message_extension/message_extension.py index 2f06142dd..cb5ab29bf 100644 --- a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/message_extension/message_extension.py +++ b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/message_extension/message_extension.py @@ -1,6 +1,8 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. +"""Route registration helpers for Teams Message Extension (composeExtension) invokes.""" + from typing import Generic, Optional, Callable from microsoft_teams.api.models import ( @@ -9,7 +11,7 @@ AppBasedLinkQuery, ) -from microsoft_agents.activity import ActivityTypes +from microsoft_agents.activity import Activity, ActivityTypes from microsoft_agents.hosting.core import ( AgentApplication, @@ -21,13 +23,10 @@ from microsoft_agents.hosting.teams.type_defs import ( StateT, CommandSelector, - _RouteDecorator + _RouteDecorator, ) -from microsoft_agents.hosting.teams._utils import ( - _match_selector, - _send_invoke_response -) +from microsoft_agents.hosting.teams._utils import _match_selector, _send_invoke_response from .route_handlers import ( FetchTaskHandler, @@ -35,14 +34,31 @@ SubmitActionHandler, MessagePreviewEditHandler, MessagePreviewSendHandler, - QueryHandler, SelectItemHandler, QueryLinkHandler, QueryUrlSettingHandler, ConfigureSettingsHandler, - CardButtonClickedHandler + CardButtonClickedHandler, ) + +def _extract_activity_preview(value: object) -> Activity: + """Extract and deserialize the first botActivityPreview entry from a composeExtension/submitAction value. + + :raises ValueError: If value is not a dict or no preview entries are present. + """ + if not isinstance(value, dict): + raise ValueError( + f"botActivityPreview: activity.value must be a dict, got {type(value).__name__}" + ) + previews = value.get("botActivityPreview") or [] + if not previews: + raise ValueError( + "botActivityPreview: no preview activity found in activity.value" + ) + return Activity.model_validate(previews[0]) + + class MessageExtension(Generic[StateT]): """ Route registration for Teams Message Extension (composeExtension) invoke activities. @@ -81,7 +97,7 @@ def __selector(context: TurnContext) -> bool: def __call(func: QueryHandler[StateT]) -> QueryHandler[StateT]: async def __handler(context: TurnContext, state: StateT) -> None: - teams_context = TeamsTurnContext(context) + teams_context = TeamsTurnContext(context, self._app) query = MessagingExtensionQuery.model_validate( context.activity.value or {} ) @@ -116,7 +132,7 @@ def __selector(context: TurnContext) -> bool: def __call(func: SelectItemHandler[StateT]) -> SelectItemHandler[StateT]: async def __handler(context: TurnContext, state: StateT) -> None: - teams_context = TeamsTurnContext(context) + teams_context = TeamsTurnContext(context, self._app) response = await func(teams_context, state, context.activity.value) if response is not None: await _send_invoke_response(context, response) @@ -164,7 +180,7 @@ def __selector(context: TurnContext) -> bool: def __call(func: SubmitActionHandler[StateT]) -> SubmitActionHandler[StateT]: async def __handler(context: TurnContext, state: StateT) -> None: - teams_context = TeamsTurnContext(context) + teams_context = TeamsTurnContext(context, self._app) action = MessagingExtensionAction.model_validate( context.activity.value or {} ) @@ -203,13 +219,13 @@ def __selector(context: TurnContext) -> bool: return False return _match_selector(command_id, value.get("commandId")) - def __call(func: MessagePreviewEditHandler[StateT]) -> MessagePreviewEditHandler[StateT]: + def __call( + func: MessagePreviewEditHandler[StateT], + ) -> MessagePreviewEditHandler[StateT]: async def __handler(context: TurnContext, state: StateT) -> None: - teams_context = TeamsTurnContext(context) - action = MessagingExtensionAction.model_validate( - context.activity.value or {} - ) - response = await func(teams_context, state, action) + teams_context = TeamsTurnContext(context, self._app) + activity_preview = _extract_activity_preview(context.activity.value) + response = await func(teams_context, state, activity_preview) if response is not None: await _send_invoke_response(context, response) @@ -244,18 +260,13 @@ def __selector(context: TurnContext) -> bool: return False return _match_selector(command_id, value.get("commandId")) - def __call(func: MessagePreviewSendHandler[StateT]) -> MessagePreviewSendHandler[StateT]: + def __call( + func: MessagePreviewSendHandler[StateT], + ) -> MessagePreviewSendHandler[StateT]: async def __handler(context: TurnContext, state: StateT) -> None: - teams_context = TeamsTurnContext(context) - action = MessagingExtensionAction.model_validate( - context.activity.value or {} - ) - # activity_preview: Activity | None = None - # if action.bot_activity_preview: - # activity_preview = Activity.model_validate(action.bot_activity_preview[0]) - # activity_preview = - # action.bot_activity_preview[0] - response = await func(teams_context, state, action) + teams_context = TeamsTurnContext(context, self._app) + activity_preview = _extract_activity_preview(context.activity.value) + response = await func(teams_context, state, activity_preview) if response is not None: await _send_invoke_response(context, response) @@ -291,10 +302,11 @@ def __selector(context: TurnContext) -> bool: def __call(func: FetchTaskHandler[StateT]) -> FetchTaskHandler[StateT]: async def __handler(context: TurnContext, state: StateT) -> None: + teams_context = TeamsTurnContext(context, self._app) action = MessagingExtensionAction.model_validate( context.activity.value or {} ) - response = await func(context, state, action) + response = await func(teams_context, state, action) if response is not None: await _send_invoke_response(context, response) @@ -325,7 +337,7 @@ def __selector(context: TurnContext) -> bool: def __call(func: QueryLinkHandler[StateT]) -> QueryLinkHandler[StateT]: async def __handler(context: TurnContext, state: StateT) -> None: - teams_context = TeamsTurnContext(context) + teams_context = TeamsTurnContext(context, self._app) query = AppBasedLinkQuery.model_validate(context.activity.value or {}) response = await func(teams_context, state, query) if response is not None: @@ -358,7 +370,7 @@ def __selector(context: TurnContext) -> bool: def __call(func: QueryLinkHandler[StateT]) -> QueryLinkHandler[StateT]: async def __handler(context: TurnContext, state: StateT) -> None: - teams_context = TeamsTurnContext(context) + teams_context = TeamsTurnContext(context, self._app) query = AppBasedLinkQuery.model_validate(context.activity.value or {}) response = await func(teams_context, state, query) if response is not None: @@ -389,9 +401,11 @@ def __selector(context: TurnContext) -> bool: and context.activity.name == "composeExtension/querySettingUrl" ) - def __call(func: QueryUrlSettingHandler[StateT]) -> QueryUrlSettingHandler[StateT]: + def __call( + func: QueryUrlSettingHandler[StateT], + ) -> QueryUrlSettingHandler[StateT]: async def __handler(context: TurnContext, state: StateT) -> None: - teams_context = TeamsTurnContext(context) + teams_context = TeamsTurnContext(context, self._app) query = MessagingExtensionQuery.model_validate( context.activity.value or {} ) @@ -412,7 +426,6 @@ async def __handler(context: TurnContext, state: StateT) -> None: def setting( self, - handler: Optional[Callable] = None, *, auth_handlers: Optional[list[str]] = None, rank: RouteRank = RouteRank.DEFAULT, @@ -427,7 +440,8 @@ def __selector(context: TurnContext) -> bool: def __call(func: Callable) -> Callable: async def __handler(context: TurnContext, state: StateT) -> None: - await func(context, state, context.activity.value) + teams_context = TeamsTurnContext(context, self._app) + await func(teams_context, state, context.activity.value) await _send_invoke_response(context) self._app.add_route( @@ -439,8 +453,6 @@ async def __handler(context: TurnContext, state: StateT) -> None: ) return func - if handler is not None: - return __call(handler) return __call def card_button_clicked( @@ -457,9 +469,12 @@ def __selector(context: TurnContext) -> bool: and context.activity.name == "composeExtension/onCardButtonClicked" ) - def __call(func: CardButtonClickedHandler[StateT]) -> CardButtonClickedHandler[StateT]: + def __call( + func: CardButtonClickedHandler[StateT], + ) -> CardButtonClickedHandler[StateT]: async def __handler(context: TurnContext, state: StateT) -> None: - await func(context, state, context.activity.value) + teams_context = TeamsTurnContext(context, self._app) + await func(teams_context, state, context.activity.value) await _send_invoke_response(context) self._app.add_route( diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/message_extension/route_handlers.py b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/message_extension/route_handlers.py index 69e7e2b3c..9d053ac9f 100644 --- a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/message_extension/route_handlers.py +++ b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/message_extension/route_handlers.py @@ -1,6 +1,8 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. +"""Protocol definitions for Teams Message Extension route handlers.""" + from typing import ( Any, Awaitable, @@ -8,108 +10,202 @@ ) from microsoft_teams.api.models import ( - Channel, - Team, - MeetingDetails, - MeetingParticipantsEventDetails, - MessageExtensionAction, - MessageExtensionQuery, - MessageExtensionActionResponse, - MessageExtensionResponse, - O365ConnectorCardActionQuery, - TaskModuleRequest, - TaskModuleResponse, - AppBasedLinkQuery + MessagingExtensionAction, + MessagingExtensionQuery, + MessagingExtensionActionResponse, + MessagingExtensionResponse, + AppBasedLinkQuery, ) from microsoft_agents.activity import Activity -from microsoft_agents.hosting.teams_turn_context import TeamsTurnContext +from microsoft_agents.hosting.teams.teams_turn_context import TeamsTurnContext from microsoft_agents.hosting.teams.type_defs import StateT + class FetchTaskHandler(Protocol[StateT]): + """Protocol for a handler invoked on composeExtension/fetchTask activities.""" + def __call__( - self, - context: TeamsTurnContext, - state: StateT, - action: MessageExtensionAction - ) -> Awaitable[MessageExtensionActionResponse]: ... + self, + context: TeamsTurnContext, + state: StateT, + action: MessagingExtensionAction, + ) -> Awaitable[MessagingExtensionActionResponse]: + """Handle a fetch task invoke. + + :param context: Teams-aware turn context. + :param state: The current turn state. + :param action: The parsed messaging extension action from the invoke payload. + :return: A response containing the task module to display. + """ + ... + class SubmitActionHandler(Protocol[StateT]): + """Protocol for a handler invoked on composeExtension/submitAction activities.""" + def __call__( - self, - context: TeamsTurnContext, - state: StateT, - action: MessageExtensionAction - ) -> Awaitable[MessageExtensionResponse]: ... + self, + context: TeamsTurnContext, + state: StateT, + action: MessagingExtensionAction, + ) -> Awaitable[MessagingExtensionResponse]: + """Handle a submit action invoke. + + :param context: Teams-aware turn context. + :param state: The current turn state. + :param action: The parsed messaging extension action from the invoke payload. + :return: A messaging extension response. + """ + ... + class MessagePreviewEditHandler(Protocol[StateT]): + """Protocol for a handler invoked on composeExtension/submitAction with botMessagePreviewAction == 'edit'.""" + def __call__( - self, - context: TeamsTurnContext, - state: StateT, - activity_preview: Activity - ) -> Awaitable[MessageExtensionResponse]: + self, + context: TeamsTurnContext, + state: StateT, + activity_preview: Activity, + ) -> Awaitable[MessagingExtensionResponse]: + """Handle a message preview edit request. + + :param context: Teams-aware turn context. + :param state: The current turn state. + :param activity_preview: The raw botActivityPreview[0] dict from the invoke payload. + :return: A messaging extension response. + """ ... + class MessagePreviewSendHandler(Protocol[StateT]): + """Protocol for a handler invoked on composeExtension/submitAction with botMessagePreviewAction == 'send'.""" + def __call__( - self, - context: TeamsTurnContext, - activity_preview: Activity - ) -> Awaitable[None]: + self, + context: TeamsTurnContext, + state: StateT, + activity_preview: Activity, + ) -> Awaitable[MessagingExtensionResponse | None]: + """Handle a message preview send request. + + :param context: Teams-aware turn context. + :param state: The current turn state. + :param activity_preview: The raw botActivityPreview[0] dict from the invoke payload. + """ ... + class QueryHandler(Protocol[StateT]): + """Protocol for a handler invoked on composeExtension/query activities.""" + def __call__( - self, - context: TeamsTurnContext, - state: StateT, - query: MessageExtensionQuery - ) -> Awaitable[MessageExtensionResponse]: + self, + context: TeamsTurnContext, + state: StateT, + query: MessagingExtensionQuery, + ) -> Awaitable[MessagingExtensionResponse]: + """Handle a message extension query invoke. + + :param context: Teams-aware turn context. + :param state: The current turn state. + :param query: The parsed messaging extension query. + :return: A messaging extension response containing search results. + """ ... + class SelectItemHandler(Protocol[StateT]): + """Protocol for a handler invoked on composeExtension/selectItem activities.""" + def __call__( - self, - context: TeamsTurnContext, - state: StateT, - item: Any - ) -> Awaitable[MessageExtensionResponse]: + self, + context: TeamsTurnContext, + state: StateT, + item: Any, + ) -> Awaitable[MessagingExtensionResponse]: + """Handle a select item invoke. + + :param context: Teams-aware turn context. + :param state: The current turn state. + :param item: The raw item selected by the user. + :return: A messaging extension response. + """ ... + class QueryLinkHandler(Protocol[StateT]): + """Protocol for a handler invoked on composeExtension/queryLink or anonymousQueryLink activities.""" + def __call__( - self, - context: TeamsTurnContext, - state: StateT, - query: AppBasedLinkQuery - ) -> Awaitable[MessageExtensionResponse]: + self, + context: TeamsTurnContext, + state: StateT, + query: AppBasedLinkQuery, + ) -> Awaitable[MessagingExtensionResponse]: + """Handle a link unfurling query. + + :param context: Teams-aware turn context. + :param state: The current turn state. + :param query: The app-based link query containing the URL to unfurl. + :return: A messaging extension response with the unfurled card. + """ ... + class QueryUrlSettingHandler(Protocol[StateT]): + """Protocol for a handler invoked on composeExtension/querySettingUrl activities.""" + def __call__( - self, - context: TeamsTurnContext, - state: StateT, - query: MessageExtensionQuery - ) -> Awaitable[MessageExtensionResponse]: + self, + context: TeamsTurnContext, + state: StateT, + query: MessagingExtensionQuery, + ) -> Awaitable[MessagingExtensionResponse]: + """Handle a query setting URL invoke. + + :param context: Teams-aware turn context. + :param state: The current turn state. + :param query: The messaging extension query for the settings URL. + :return: A messaging extension response containing the settings page URL. + """ ... + class ConfigureSettingsHandler(Protocol[StateT]): + """Protocol for a handler invoked on composeExtension/setting activities.""" + def __call__( - self, - context: TeamsTurnContext, - state: StateT, - query: MessageExtensionQuery - ) -> Awaitable[MessageExtensionResponse]: + self, + context: TeamsTurnContext, + state: StateT, + query: MessagingExtensionQuery, + ) -> Awaitable[MessagingExtensionResponse]: + """Handle a configure settings invoke. + + :param context: Teams-aware turn context. + :param state: The current turn state. + :param query: The messaging extension query with the settings data. + :return: A messaging extension response. + """ ... + class CardButtonClickedHandler(Protocol[StateT]): + """Protocol for a handler invoked on composeExtension/onCardButtonClicked activities.""" + def __call__( - self, - context: TeamsTurnContext, - state: StateT, - card: Any - ) -> Awaitable[None]: - ... \ No newline at end of file + self, + context: TeamsTurnContext, + state: StateT, + card: Any, + ) -> Awaitable[None]: + """Handle a card button clicked invoke. + + :param context: Teams-aware turn context. + :param state: The current turn state. + :param card: The raw card data from the invoke payload. + """ + ... diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/route_handlers.py b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/route_handlers.py index b034619d7..b896cf9ef 100644 --- a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/route_handlers.py +++ b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/route_handlers.py @@ -1,6 +1,8 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. +"""Protocol definitions for Teams-aware route and handoff handlers.""" + from __future__ import annotations from typing import ( @@ -8,7 +10,7 @@ Protocol, ) -from microsoft_agents.hosting.core import TurnContext +from microsoft_agents.hosting.core import AgentApplication, TurnContext from microsoft_agents.hosting.core.app._type_defs import ( RouteHandler, HandoffHandler, @@ -17,22 +19,68 @@ from .teams_turn_context import TeamsTurnContext from .type_defs import StateT + class TeamsRouteHandler(Protocol[StateT]): - def __call__(self, context: TeamsTurnContext, state: StateT) -> Awaitable[None]: ... + """Protocol for a Teams route handler that receives a :class:`TeamsTurnContext`.""" + + def __call__(self, context: TeamsTurnContext, state: StateT) -> Awaitable[None]: + """Handle a turn with Teams context. + + :param context: Teams-aware turn context. + :param state: The current turn state. + """ + ... @staticmethod - def wrap(handler: TeamsRouteHandler[StateT]) -> RouteHandler[StateT]: + def wrap( + handler: TeamsRouteHandler[StateT], app: AgentApplication + ) -> RouteHandler[StateT]: + """Adapt a :class:`TeamsRouteHandler` into a plain :class:`RouteHandler`. + + Wraps *handler* so that the core routing engine (which passes a plain + :class:`TurnContext`) receives a compatible callable. + + :param handler: The Teams-specific handler to wrap. + :param app: The agent application handling the turn. + :return: A :class:`RouteHandler` that upgrades the context before delegating. + """ + async def __func(context: TurnContext, state: StateT) -> None: - teams_context = TeamsTurnContext(context) + teams_context = TeamsTurnContext(context, app) await handler(teams_context, state) + return __func - + + class TeamsHandoffHandler(Protocol[StateT]): - def __call__(self, context: TeamsTurnContext, state: StateT, handoff_data: str) -> Awaitable[None]: ... + """Protocol for a Teams handoff handler that receives handoff continuation data.""" + + def __call__( + self, context: TeamsTurnContext, state: StateT, handoff_data: str + ) -> Awaitable[None]: + """Handle a handoff activity with Teams context. + + :param context: Teams-aware turn context. + :param state: The current turn state. + :param handoff_data: Opaque continuation data from the handoff initiator. + """ + ... @staticmethod - def wrap(handler: TeamsHandoffHandler[StateT]) -> HandoffHandler[StateT]: - async def __func(context: TurnContext, state: StateT, handoff_data: str) -> None: - teams_context = TeamsTurnContext(context) + def wrap( + handler: TeamsHandoffHandler[StateT], app: AgentApplication + ) -> HandoffHandler[StateT]: + """Adapt a :class:`TeamsHandoffHandler` into a plain :class:`HandoffHandler`. + + :param handler: The Teams-specific handoff handler to wrap. + :param app: The agent application handling the turn. + :return: A :class:`HandoffHandler` that upgrades the context before delegating. + """ + + async def __func( + context: TurnContext, state: StateT, handoff_data: str + ) -> None: + teams_context = TeamsTurnContext(context, app) await handler(teams_context, state, handoff_data) - return __func \ No newline at end of file + + return __func diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/task_module/__init__.py b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/task_module/__init__.py index 102cc9157..19da286c2 100644 --- a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/task_module/__init__.py +++ b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/task_module/__init__.py @@ -3,4 +3,4 @@ from .task_module import TaskModule -__all__ = ["TaskModule"] \ No newline at end of file +__all__ = ["TaskModule"] diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/task_module/route_handlers.py b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/task_module/route_handlers.py index 2f5fdf258..42e6e7bda 100644 --- a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/task_module/route_handlers.py +++ b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/task_module/route_handlers.py @@ -1,30 +1,49 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. +"""Protocol definitions for Teams Task Module route handlers.""" + from typing import Awaitable, Protocol -from microsoft_teams.api.models import ( - TaskModuleRequest, - TaskModuleResponse -) +from microsoft_teams.api.models import TaskModuleRequest, TaskModuleResponse from microsoft_agents.hosting.teams.teams_turn_context import TeamsTurnContext from microsoft_agents.hosting.teams.type_defs import StateT + class FetchHandler(Protocol[StateT]): + """Protocol for a handler invoked on task/fetch activities.""" + def __call__( - self, - context: TeamsTurnContext, - state: StateT, - request: TaskModuleRequest, - ) -> Awaitable[TaskModuleResponse]: + self, + context: TeamsTurnContext, + state: StateT, + request: TaskModuleRequest, + ) -> Awaitable[TaskModuleResponse]: + """Handle a task module fetch invoke. + + :param context: Teams-aware turn context. + :param state: The current turn state. + :param request: The parsed task module request payload. + :return: A task module response describing the UI to display. + """ ... + class SubmitHandler(Protocol[StateT]): + """Protocol for a handler invoked on task/submit activities.""" + def __call__( - self, - context: TeamsTurnContext, - state: StateT, - request: TaskModuleRequest, - ) -> Awaitable[TaskModuleResponse]: - ... \ No newline at end of file + self, + context: TeamsTurnContext, + state: StateT, + request: TaskModuleRequest, + ) -> Awaitable[TaskModuleResponse]: + """Handle a task module submit invoke. + + :param context: Teams-aware turn context. + :param state: The current turn state. + :param request: The parsed task module request payload containing user input. + :return: A task module response (e.g. a message or a follow-up task module). + """ + ... diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/task_module/task_module.py b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/task_module/task_module.py index 4c9be1f7e..8c6b6be78 100644 --- a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/task_module/task_module.py +++ b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/task_module/task_module.py @@ -1,12 +1,9 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. +"""Route registration helpers for Teams Task Module (task/fetch, task/submit) invokes.""" -from typing import ( - Any, - Generic, - Optional -) +from typing import Any, Generic, Optional from microsoft_teams.api.models import ( TaskModuleRequest, @@ -14,11 +11,7 @@ from microsoft_agents.activity import ActivityTypes -from microsoft_agents.hosting.core import ( - AgentApplication, - RouteRank, - TurnContext -) +from microsoft_agents.hosting.core import AgentApplication, RouteRank, TurnContext from microsoft_agents.hosting.teams.teams_turn_context import TeamsTurnContext from microsoft_agents.hosting.teams.type_defs import ( @@ -31,10 +24,8 @@ _send_invoke_response, ) -from .route_handlers import ( - FetchHandler, - SubmitHandler -) +from .route_handlers import FetchHandler, SubmitHandler + class TaskModule(Generic[StateT]): """ @@ -43,10 +34,19 @@ class TaskModule(Generic[StateT]): """ def __init__(self, app: AgentApplication[StateT]) -> None: + """Initialise with the owning :class:`AgentApplication`. + + :param app: The application to register routes on. + """ self._app = app @staticmethod def _get_verb(value: Optional[Any]) -> Optional[str]: + """Extract the verb from a task module request payload. + + :param value: The raw activity value (expected to be a dict with a ``data`` sub-dict). + :return: The verb string if present, otherwise None. + """ if not isinstance(value, dict): return None data = value.get("data") @@ -77,7 +77,7 @@ def __selector(context: TurnContext) -> bool: def __call(func: FetchHandler[StateT]) -> FetchHandler[StateT]: async def __handler(context: TurnContext, state: StateT) -> None: - teams_context = TeamsTurnContext(context) + teams_context = TeamsTurnContext(context, self._app) request = TaskModuleRequest.model_validate(context.activity.value or {}) response = await func(teams_context, state, request) if response is not None: @@ -117,7 +117,7 @@ def __selector(context: TurnContext) -> bool: def __call(func: SubmitHandler[StateT]) -> SubmitHandler[StateT]: async def __handler(context: TurnContext, state: StateT) -> None: - teams_context = TeamsTurnContext(context) + teams_context = TeamsTurnContext(context, self._app) request = TaskModuleRequest.model_validate(context.activity.value or {}) response = await func(teams_context, state, request) if response is not None: @@ -132,4 +132,4 @@ async def __handler(context: TurnContext, state: StateT) -> None: ) return func - return __call \ No newline at end of file + return __call diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/team/__init__.py b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/team/__init__.py index 0451c92bb..f6fcbf364 100644 --- a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/team/__init__.py +++ b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/team/__init__.py @@ -3,4 +3,4 @@ from .team import Team -__all__ = ["Team"] \ No newline at end of file +__all__ = ["Team"] diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/team/route_handlers.py b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/team/route_handlers.py index a7e9a1678..7848e92de 100644 --- a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/team/route_handlers.py +++ b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/team/route_handlers.py @@ -1,17 +1,29 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. +"""Protocol definition for Teams team update route handlers.""" + from typing import Awaitable, Protocol -from microsoft_teams.api.models import Team +from microsoft_teams.api.models.channel_data import ChannelData from microsoft_agents.hosting.teams.teams_turn_context import TeamsTurnContext from microsoft_agents.hosting.teams.type_defs import StateT + class TeamUpdateHandler(Protocol[StateT]): + """Protocol for a handler invoked on Teams team conversation update events.""" + def __call__( - self, - context: TeamsTurnContext, - state: StateT, - data: Team - ) -> Awaitable[None]: ... \ No newline at end of file + self, + context: TeamsTurnContext, + state: StateT, + data: ChannelData, + ) -> Awaitable[None]: + """Handle a team update event. + + :param context: Teams-aware turn context. + :param state: The current turn state. + :param data: Parsed team data from the incoming activity's channel_data. + """ + ... diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/team/team.py b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/team/team.py index 0f93f03f6..95b526ecc 100644 --- a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/team/team.py +++ b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/team/team.py @@ -1,13 +1,9 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. -from typing import ( - Callable, - Generic, - Optional -) +"""Route registration helpers for Teams team conversation update events.""" -from microsoft_teams.api.models import Team +from typing import Generic, Optional from microsoft_agents.activity import ActivityTypes from microsoft_agents.hosting.core import ( @@ -16,29 +12,30 @@ TurnContext, ) -from microsoft_agents.hosting.teams.route_handlers import TeamsRouteHandler from microsoft_agents.hosting.teams.teams_turn_context import TeamsTurnContext from microsoft_agents.hosting.teams.type_defs import ( _RouteDecorator, StateT, ) -from microsoft_agetns.hosting.teams._utils import ( +from microsoft_agents.hosting.teams._utils import ( + _get_channel_data, _get_channel_event_type, ) from .route_handlers import TeamUpdateHandler -def _get_team_data(context: TurnContext) -> Team: - data = context.activity.channel_data - if data is None: - raise ValueError("Channel data is required") - if isinstance(data, dict): - return Team(**data) - return Team.model_validate(data) class Team(Generic[StateT]): + """Route registration for Teams team conversation update events. + + Access via :attr:`TeamsAgentExtension.teams` (property currently missing — see known issues). + """ - def __init__(self, app: AgentApplication[StateT]): + def __init__(self, app: AgentApplication[StateT]) -> None: + """Initialise with the owning :class:`AgentApplication`. + + :param app: The application to register routes on. + """ self._app = app def _create_decorator( @@ -48,6 +45,14 @@ def _create_decorator( auth_handlers: Optional[list[str]] = None, rank: RouteRank = RouteRank.DEFAULT, ) -> _RouteDecorator[TeamUpdateHandler[StateT]]: + """Build a route decorator for a specific Teams team event type. + + :param event_type: The ``eventType`` value to match (e.g. ``"teamArchived"``). + :param auth_handlers: Optional auth handler names to run before the route. + :param rank: Route priority rank. + :return: A decorator that registers the handler. + """ + def __selector(context: TurnContext) -> bool: return ( context.activity.type == ActivityTypes.conversation_update @@ -58,17 +63,16 @@ def __selector(context: TurnContext) -> bool: def __call(func: TeamUpdateHandler[StateT]) -> TeamUpdateHandler[StateT]: async def __handler(context: TurnContext, state: StateT) -> None: - teams_context = TeamsTurnContext(context) - team_data = _get_team_data(context) + teams_context = TeamsTurnContext(context, self._app) + team_data = _get_channel_data(context) await func(teams_context, state, team_data) self._app.add_route( __selector, __handler, rank=rank, auth_handlers=auth_handlers ) return func - - return __call + return __call def archived( self, @@ -127,7 +131,6 @@ def restored( def unarchived( self, - handler: Optional[Callable] = None, *, auth_handlers: Optional[list[str]] = None, rank: RouteRank = RouteRank.DEFAULT, @@ -135,4 +138,4 @@ def unarchived( """Register a handler for Teams teamUnarchived conversation update events.""" return self._create_decorator( "teamUnarchived", auth_handlers=auth_handlers, rank=rank - ) \ No newline at end of file + ) 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 index 4be373524..b1b6d7d0f 100644 --- 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 @@ -1,911 +1,911 @@ -""" -Copyright (c) Microsoft Corporation. All rights reserved. -Licensed under the MIT License. -""" - -from http import HTTPStatus -from typing import Any - -from microsoft_agents.hosting.core import ActivityHandler, TurnContext -from microsoft_agents.hosting.teams.errors import teams_errors -from microsoft_agents.activity import ( - InvokeResponse, - ChannelAccount, -) - -from microsoft_agents.activity.teams import ( - AppBasedLinkQuery, - TeamInfo, - ChannelInfo, - ConfigResponse, - FileConsentCardResponse, - MeetingEndEventDetails, - MeetingParticipantsEventDetails, - MeetingStartEventDetails, - MessagingExtensionAction, - MessagingExtensionActionResponse, - MessagingExtensionQuery, - MessagingExtensionResponse, - O365ConnectorCardActionQuery, - ReadReceiptInfo, - SigninStateVerificationQuery, - TabRequest, - TabResponse, - TabSubmit, - TaskModuleRequest, - TaskModuleResponse, - TeamsChannelAccount, - TeamsChannelData, -) - -from .teams_info import TeamsInfo - - -class TeamsActivityHandler(ActivityHandler): - """ - The TeamsActivityHandler is derived from the ActivityHandler class and adds support for - Microsoft Teams-specific functionality. - """ - - async def on_invoke_activity(self, turn_context: TurnContext) -> InvokeResponse: - """ - Handles invoke activities. - - :param turn_context: The context object for the turn. - :return: An InvokeResponse. - """ - - try: - if ( - not turn_context.activity.name - and turn_context.activity.channel_id == "msteams" - ): - return await self.on_teams_card_action_invoke(turn_context) - else: - name = turn_context.activity.name - value = turn_context.activity.value - - if name == "config/fetch": - return self._create_invoke_response( - await self.on_teams_config_fetch(turn_context, value) - ) - elif name == "config/submit": - return self._create_invoke_response( - await self.on_teams_config_submit(turn_context, value) - ) - elif name == "fileConsent/invoke": - return self._create_invoke_response( - await self.on_teams_file_consent(turn_context, value) - ) - elif name == "actionableMessage/executeAction": - await self.on_execute_action(turn_context, value) - return self._create_invoke_response() - elif name == "composeExtension/queryLink": - return self._create_invoke_response( - await self.on_teams_app_based_link_query(turn_context, value) - ) - elif name == "composeExtension/anonymousQueryLink": - return self._create_invoke_response( - await self.on_teams_anonymous_app_based_link_query( - turn_context, value - ) - ) - elif name == "composeExtension/query": - query = MessagingExtensionQuery.model_validate(value) - return self._create_invoke_response( - await self.on_teams_messaging_extension_query( - turn_context, query - ) - ) - elif name == "composeExtension/selectItem": - return self._create_invoke_response( - await self.on_teams_messaging_extension_select_item( - turn_context, value - ) - ) - elif name == "composeExtension/submitAction": - return self._create_invoke_response( - await self.on_teams_messaging_extension_submit_action_dispatch( - turn_context, value - ) - ) - elif name == "composeExtension/fetchTask": - return self._create_invoke_response( - await self.on_teams_messaging_extension_fetch_task( - turn_context, value - ) - ) - elif name == "composeExtension/querySettingUrl": - return self._create_invoke_response( - await self.on_teams_messaging_extension_config_query_setting_url( - turn_context, value - ) - ) - elif name == "composeExtension/setting": - await self.on_teams_messaging_extension_config_setting( - turn_context, value - ) - return self._create_invoke_response() - elif name == "composeExtension/onCardButtonClicked": - await self.on_teams_messaging_extension_card_button_clicked( - turn_context, value - ) - return self._create_invoke_response() - elif name == "task/fetch": - task_module_request = TaskModuleRequest.model_validate(value) - return self._create_invoke_response( - await self.on_teams_task_module_fetch( - turn_context, task_module_request - ) - ) - elif name == "task/submit": - task_module_request = TaskModuleRequest.model_validate(value) - return self._create_invoke_response( - await self.on_teams_task_module_submit( - turn_context, task_module_request - ) - ) - elif name == "tab/fetch": - return self._create_invoke_response( - await self.on_teams_tab_fetch(turn_context, value) - ) - elif name == "tab/submit": - return self._create_invoke_response( - await self.on_teams_tab_submit(turn_context, value) - ) - else: - return await super().on_invoke_activity(turn_context) - except Exception as err: - if str(err) == str(teams_errors.TeamsNotImplemented): - return InvokeResponse(status=int(HTTPStatus.NOT_IMPLEMENTED)) - elif str(err) == str(teams_errors.TeamsBadRequest): - return InvokeResponse(status=int(HTTPStatus.BAD_REQUEST)) - raise - - async def on_teams_card_action_invoke( - self, turn_context: TurnContext - ) -> InvokeResponse: - """ - Handles card action invoke. - - :param turn_context: The context object for the turn. - :return: An InvokeResponse. - """ - raise NotImplementedError(str(teams_errors.TeamsNotImplemented)) - - async def on_teams_config_fetch( - self, turn_context: TurnContext, config_data: Any - ) -> ConfigResponse: - """ - Handles config fetch. - - :param turn_context: The context object for the turn. - :param config_data: The config data. - :return: A ConfigResponse. - """ - raise NotImplementedError(str(teams_errors.TeamsNotImplemented)) - - async def on_teams_config_submit( - self, turn_context: TurnContext, config_data: Any - ) -> ConfigResponse: - """ - Handles config submit. - - :param turn_context: The context object for the turn. - :param config_data: The config data. - :return: A ConfigResponse. - """ - raise NotImplementedError(str(teams_errors.TeamsNotImplemented)) - - async def on_teams_file_consent( - self, - turn_context: TurnContext, - file_consent_card_response: FileConsentCardResponse, - ) -> None: - """ - Handles file consent. - - :param turn_context: The context object for the turn. - :param file_consent_card_response: The file consent card response. - :return: None - """ - if file_consent_card_response.action == "accept": - return await self.on_teams_file_consent_accept( - turn_context, file_consent_card_response - ) - elif file_consent_card_response.action == "decline": - return await self.on_teams_file_consent_decline( - turn_context, file_consent_card_response - ) - else: - raise ValueError(str(teams_errors.TeamsBadRequest)) - - async def on_teams_file_consent_accept( - self, - turn_context: TurnContext, - file_consent_card_response: FileConsentCardResponse, - ) -> None: - """ - Handles file consent accept. - - :param turn_context: The context object for the turn. - :param file_consent_card_response: The file consent card response. - :return: None - """ - raise NotImplementedError(str(teams_errors.TeamsNotImplemented)) - - async def on_teams_file_consent_decline( - self, - turn_context: TurnContext, - file_consent_card_response: FileConsentCardResponse, - ) -> None: - """ - Handles file consent decline. - - :param turn_context: The context object for the turn. - :param file_consent_card_response: The file consent card response. - :return: None - """ - raise NotImplementedError(str(teams_errors.TeamsNotImplemented)) - - async def on_execute_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_config_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_config_setting( - self, turn_context: TurnContext, settings: Any - ) -> None: - """ - Handles messaging extension configuration setting. - - :param turn_context: The context object for the turn. - :param settings: The settings. - :return: None - """ - raise NotImplementedError(str(teams_errors.TeamsNotImplemented)) - - async def on_teams_messaging_extension_card_button_clicked( - self, turn_context: TurnContext, card_data: Any - ) -> None: - """ - Handles messaging extension card button clicked. - - :param turn_context: The context object for the turn. - :param card_data: The card data. - :return: None - """ - raise NotImplementedError(str(teams_errors.TeamsNotImplemented)) - - async def on_teams_task_module_fetch( - self, turn_context: TurnContext, task_module_request: TaskModuleRequest - ) -> TaskModuleResponse: - """ - Handles task module fetch. - - :param turn_context: The context object for the turn. - :param task_module_request: The task module request. - :return: A TaskModuleResponse. - """ - raise NotImplementedError(str(teams_errors.TeamsNotImplemented)) - - async def on_teams_task_module_submit( - self, turn_context: TurnContext, task_module_request: TaskModuleRequest - ) -> TaskModuleResponse: - """ - Handles task module submit. - - :param turn_context: The context object for the turn. - :param task_module_request: The task module request. - :return: A TaskModuleResponse. - """ - raise NotImplementedError(str(teams_errors.TeamsNotImplemented)) - - async def on_teams_tab_fetch( - self, turn_context: TurnContext, tab_request: TabRequest - ) -> TabResponse: - """ - Handles tab fetch. - - :param turn_context: The context object for the turn. - :param tab_request: The tab request. - :return: A TabResponse. - """ - raise NotImplementedError(str(teams_errors.TeamsNotImplemented)) - - async def on_teams_tab_submit( - self, turn_context: TurnContext, tab_submit: TabSubmit - ) -> TabResponse: - """ - Handles tab submit. - - :param turn_context: The context object for the turn. - :param tab_submit: The tab submit. - :return: A TabResponse. - """ - raise NotImplementedError(str(teams_errors.TeamsNotImplemented)) - - async def on_conversation_update_activity(self, turn_context: TurnContext): - """ - Dispatches conversation update activity. - - :param turn_context: The context object for the turn. - :return: None - """ - if turn_context.activity.channel_id == "msteams": - channel_data = ( - TeamsChannelData.model_validate(turn_context.activity.channel_data) - if turn_context.activity.channel_data - else None - ) - - if ( - turn_context.activity.members_added - and len(turn_context.activity.members_added) > 0 - ): - return await self.on_teams_members_added_dispatch( - turn_context.activity.members_added, - channel_data.team if channel_data else None, - turn_context, - ) - - if ( - turn_context.activity.members_removed - and len(turn_context.activity.members_removed) > 0 - ): - return await self.on_teams_members_removed(turn_context) - - if not channel_data or not channel_data.event_type: - return await super().on_conversation_update_activity(turn_context) - - event_type = channel_data.event_type - - if event_type == "channelCreated": - return await self.on_teams_channel_created(turn_context) - elif event_type == "channelDeleted": - return await self.on_teams_channel_deleted(turn_context) - elif event_type == "channelRenamed": - return await self.on_teams_channel_renamed(turn_context) - elif event_type == "teamArchived": - return await self.on_teams_team_archived(turn_context) - elif event_type == "teamDeleted": - return await self.on_teams_team_deleted(turn_context) - elif event_type == "teamHardDeleted": - return await self.on_teams_team_hard_deleted(turn_context) - elif event_type == "channelRestored": - return await self.on_teams_channel_restored(turn_context) - elif event_type == "teamRenamed": - return await self.on_teams_team_renamed(turn_context) - elif event_type == "teamRestored": - return await self.on_teams_team_restored(turn_context) - elif event_type == "teamUnarchived": - return await self.on_teams_team_unarchived(turn_context) - - return await super().on_conversation_update_activity(turn_context) - - async def on_message_update_activity(self, turn_context: TurnContext): - """ - Dispatches message update activity. - - :param turn_context: The context object for the turn. - :return: None - """ - if turn_context.activity.channel_id == "msteams": - channel_data = channel_data = ( - TeamsChannelData.model_validate(turn_context.activity.channel_data) - if turn_context.activity.channel_data - else None - ) - - event_type = channel_data.event_type if channel_data else None - - if event_type == "undeleteMessage": - return await self.on_teams_message_undelete(turn_context) - elif event_type == "editMessage": - return await self.on_teams_message_edit(turn_context) - - return await super().on_message_update_activity(turn_context) - - async def on_message_delete_activity(self, turn_context: TurnContext) -> None: - """ - Dispatches message delete activity. - - :param turn_context: The context object for the turn. - :return: None - """ - if turn_context.activity.channel_id == "msteams": - channel_data = channel_data = ( - TeamsChannelData.model_validate(turn_context.activity.channel_data) - if turn_context.activity.channel_data - else None - ) - - event_type = channel_data.event_type if channel_data else None - - if event_type == "softDeleteMessage": - return await self.on_teams_message_soft_delete(turn_context) - - return await super().on_message_delete_activity(turn_context) - - async def on_teams_message_undelete(self, turn_context: TurnContext) -> None: - """ - Handles Teams message undelete. - - :param turn_context: The context object for the turn. - :return: None - """ - return - - async def on_teams_message_edit(self, turn_context: TurnContext) -> None: - """ - Handles Teams message edit. - - :param turn_context: The context object for the turn. - :return: None - """ - return - - async def on_teams_message_soft_delete(self, turn_context: TurnContext) -> None: - """ - Handles Teams message soft delete. - - :param turn_context: The context object for the turn. - :return: None - """ - return - - async def on_teams_members_added_dispatch( - self, - members_added: list[ChannelAccount], - team_info: TeamInfo, - turn_context: TurnContext, - ) -> None: - """ - Dispatches processing of Teams members added to the conversation. - Processes the members_added collection to get full member information when possible. - - :param members_added: The list of members being added to the conversation. - :param team_info: The team info object. - :param turn_context: The context object for the turn. - :return: None - """ - teams_members_added = [] - - for member in members_added: - # If the member has properties or is the agent/bot being added to the conversation - if len(member.properties) or ( - turn_context.activity.recipient - and turn_context.activity.recipient.id == member.id - ): - - # Convert the ChannelAccount to TeamsChannelAccount - # TODO: Converter between these two classes - teams_member = TeamsChannelAccount.model_validate( - member.model_dump(by_alias=True, exclude_unset=True) - ) - teams_members_added.append(teams_member) - else: - # Try to get the full member details from Teams - try: - teams_member = await TeamsInfo.get_member(turn_context, member.id) - teams_members_added.append(teams_member) - except Exception as err: - # Handle case where conversation is not found - if "ConversationNotFound" in str(err): - teams_channel_account = TeamsChannelAccount( - id=member.id, - name=member.name, - aad_object_id=getattr(member, "aad_object_id", None), - role=getattr(member, "role", None), - ) - teams_members_added.append(teams_channel_account) - else: - # Propagate any other errors - raise - - await self.on_teams_members_added(teams_members_added, team_info, turn_context) - - async def on_teams_members_added( - self, - teams_members_added: list[TeamsChannelAccount], - team_info: TeamInfo, - turn_context: TurnContext, - ) -> None: - """ - Handles Teams members added. - - :param turn_context: The context object for the turn. - :return: None - """ - await self.on_members_added_activity(teams_members_added, turn_context) - - async def on_teams_members_removed_dispatch( - self, - members_removed: list[ChannelAccount], - team_info: TeamInfo, - turn_context: TurnContext, - ) -> None: - """ - Dispatches processing of Teams members removed from the conversation. - """ - teams_members_removed = [] - for member in members_removed: - teams_members_removed.append( - TeamsChannelAccount.model_validate( - member.model_dump(by_alias=True, exclude_unset=True) - ) - ) - return await self.on_teams_members_removed( - teams_members_removed, team_info, turn_context - ) - - async def on_teams_members_removed( - self, - teams_members_removed: list[TeamsChannelAccount], - team_info: TeamInfo, - turn_context: TurnContext, - ) -> None: - """ - Handles Teams members removed. - - :param turn_context: The context object for the turn. - :return: None - """ - await self.on_members_removed_activity(teams_members_removed, turn_context) - - async def on_teams_channel_created( - self, channel_info: ChannelInfo, team_info: TeamInfo, turn_context: TurnContext - ) -> None: - """ - Handles Teams channel created. - - :param turn_context: The context object for the turn. - :return: None - """ - return - - async def on_teams_channel_deleted( - self, channel_info: ChannelInfo, team_info: TeamInfo, turn_context: TurnContext - ) -> None: - """ - Handles Teams channel deleted. - - :param turn_context: The context object for the turn. - :return: None - """ - return - - async def on_teams_channel_renamed( - self, channel_info: ChannelInfo, team_info: TeamInfo, turn_context: TurnContext - ) -> None: - """ - Handles Teams channel renamed. - - :param turn_context: The context object for the turn. - :return: None - """ - return - - async def on_teams_team_archived( - self, team_info: TeamInfo, turn_context: TurnContext - ) -> None: - """ - Handles Teams team archived. - - :param turn_context: The context object for the turn. - :return: None - """ - return - - async def on_teams_team_deleted( - self, team_info: TeamInfo, turn_context: TurnContext - ) -> None: - """ - Handles Teams team deleted. - - :param turn_context: The context object for the turn. - :return: None - """ - return - - async def on_teams_team_hard_deleted( - self, team_info: TeamInfo, turn_context: TurnContext - ) -> None: - """ - Handles Teams team hard deleted. - - :param turn_context: The context object for the turn. - :return: None - """ - return - - async def on_teams_channel_restored( - self, channel_info: ChannelInfo, team_info: TeamInfo, turn_context: TurnContext - ) -> None: - """ - Handles Teams channel restored. - - :param turn_context: The context object for the turn. - :return: None - """ - return - - async def on_teams_team_renamed( - self, team_info: TeamInfo, turn_context: TurnContext - ) -> None: - """ - Handles Teams team renamed. - - :param turn_context: The context object for the turn. - :return: None - """ - return - - async def on_teams_team_restored( - self, team_info: TeamInfo, turn_context: TurnContext - ) -> None: - """ - Handles Teams team restored. - - :param turn_context: The context object for the turn. - :return: None - """ - return - - async def on_teams_team_unarchived( - self, team_info: TeamInfo, turn_context: TurnContext - ) -> None: - """ - Handles Teams team unarchived. - - :param turn_context: The context object for the turn. - :return: None - """ - return - - async def on_event_activity(self, turn_context: TurnContext) -> None: - """ - Dispatches event activity. - - :param turn_context: The context object for the turn. - :return: None - """ - if turn_context.activity.channel_id == "msteams": - if turn_context.activity.name == "application/vnd.microsoft.readReceipt": - return await self.on_teams_read_receipt(turn_context) - elif turn_context.activity.name == "application/vnd.microsoft.meetingStart": - return await self.on_teams_meeting_start(turn_context) - elif turn_context.activity.name == "application/vnd.microsoft.meetingEnd": - return await self.on_teams_meeting_end(turn_context) - elif ( - turn_context.activity.name - == "application/vnd.microsoft.meetingParticipantJoin" - ): - return await self.on_teams_meeting_participants_join(turn_context) - elif ( - turn_context.activity.name - == "application/vnd.microsoft.meetingParticipantLeave" - ): - return await self.on_teams_meeting_participants_leave(turn_context) - - return await super().on_event_activity(turn_context) - - async def on_teams_meeting_start( - self, meeting: MeetingStartEventDetails, turn_context: TurnContext - ) -> None: - """ - Handles Teams meeting start. - - :param turn_context: The context object for the turn. - :return: None - """ - return - - async def on_teams_meeting_end( - self, meeting: MeetingEndEventDetails, turn_context: TurnContext - ) -> None: - """ - Handles Teams meeting end. - - :param turn_context: The context object for the turn. - :return: None - """ - return - - async def on_teams_read_receipt( - self, read_receipt: ReadReceiptInfo, turn_context: TurnContext - ) -> None: - """ - Handles Teams read receipt. - - :param turn_context: The context object for the turn. - :return: None - """ - return - - async def on_teams_meeting_participants_join( - self, meeting: MeetingParticipantsEventDetails, turn_context: TurnContext - ) -> None: - """ - Handles Teams meeting participants join. - - :param turn_context: The context object for the turn. - :return: None - """ - return - - async def on_teams_meeting_participants_leave( - self, meeting: MeetingParticipantsEventDetails, turn_context: TurnContext - ) -> None: - """ - Handles Teams meeting participants leave. - - :param turn_context: The context object for the turn. - :return: None - """ - return +# """ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +# """ + +# from http import HTTPStatus +# from typing import Any + +# from microsoft_agents.hosting.core import ActivityHandler, TurnContext +# from microsoft_agents.hosting.teams.errors import teams_errors +# from microsoft_agents.activity import ( +# InvokeResponse, +# ChannelAccount, +# ) + +# from microsoft_agents.activity.teams import ( +# AppBasedLinkQuery, +# TeamInfo, +# ChannelInfo, +# ConfigResponse, +# FileConsentCardResponse, +# MeetingEndEventDetails, +# MeetingParticipantsEventDetails, +# MeetingStartEventDetails, +# MessagingExtensionAction, +# MessagingExtensionActionResponse, +# MessagingExtensionQuery, +# MessagingExtensionResponse, +# O365ConnectorCardActionQuery, +# ReadReceiptInfo, +# SigninStateVerificationQuery, +# TabRequest, +# TabResponse, +# TabSubmit, +# TaskModuleRequest, +# TaskModuleResponse, +# TeamsChannelAccount, +# TeamsChannelData, +# ) + +# from .teams_info import TeamsInfo + + +# class TeamsActivityHandler(ActivityHandler): +# """ +# The TeamsActivityHandler is derived from the ActivityHandler class and adds support for +# Microsoft Teams-specific functionality. +# """ + +# async def on_invoke_activity(self, turn_context: TurnContext) -> InvokeResponse: +# """ +# Handles invoke activities. + +# :param turn_context: The context object for the turn. +# :return: An InvokeResponse. +# """ + +# try: +# if ( +# not turn_context.activity.name +# and turn_context.activity.channel_id == "msteams" +# ): +# return await self.on_teams_card_action_invoke(turn_context) +# else: +# name = turn_context.activity.name +# value = turn_context.activity.value + +# if name == "config/fetch": +# return self._create_invoke_response( +# await self.on_teams_config_fetch(turn_context, value) +# ) +# elif name == "config/submit": +# return self._create_invoke_response( +# await self.on_teams_config_submit(turn_context, value) +# ) +# elif name == "fileConsent/invoke": +# return self._create_invoke_response( +# await self.on_teams_file_consent(turn_context, value) +# ) +# elif name == "actionableMessage/executeAction": +# await self.on_execute_action(turn_context, value) +# return self._create_invoke_response() +# elif name == "composeExtension/queryLink": +# return self._create_invoke_response( +# await self.on_teams_app_based_link_query(turn_context, value) +# ) +# elif name == "composeExtension/anonymousQueryLink": +# return self._create_invoke_response( +# await self.on_teams_anonymous_app_based_link_query( +# turn_context, value +# ) +# ) +# elif name == "composeExtension/query": +# query = MessagingExtensionQuery.model_validate(value) +# return self._create_invoke_response( +# await self.on_teams_messaging_extension_query( +# turn_context, query +# ) +# ) +# elif name == "composeExtension/selectItem": +# return self._create_invoke_response( +# await self.on_teams_messaging_extension_select_item( +# turn_context, value +# ) +# ) +# elif name == "composeExtension/submitAction": +# return self._create_invoke_response( +# await self.on_teams_messaging_extension_submit_action_dispatch( +# turn_context, value +# ) +# ) +# elif name == "composeExtension/fetchTask": +# return self._create_invoke_response( +# await self.on_teams_messaging_extension_fetch_task( +# turn_context, value +# ) +# ) +# elif name == "composeExtension/querySettingUrl": +# return self._create_invoke_response( +# await self.on_teams_messaging_extension_config_query_setting_url( +# turn_context, value +# ) +# ) +# elif name == "composeExtension/setting": +# await self.on_teams_messaging_extension_config_setting( +# turn_context, value +# ) +# return self._create_invoke_response() +# elif name == "composeExtension/onCardButtonClicked": +# await self.on_teams_messaging_extension_card_button_clicked( +# turn_context, value +# ) +# return self._create_invoke_response() +# elif name == "task/fetch": +# task_module_request = TaskModuleRequest.model_validate(value) +# return self._create_invoke_response( +# await self.on_teams_task_module_fetch( +# turn_context, task_module_request +# ) +# ) +# elif name == "task/submit": +# task_module_request = TaskModuleRequest.model_validate(value) +# return self._create_invoke_response( +# await self.on_teams_task_module_submit( +# turn_context, task_module_request +# ) +# ) +# elif name == "tab/fetch": +# return self._create_invoke_response( +# await self.on_teams_tab_fetch(turn_context, value) +# ) +# elif name == "tab/submit": +# return self._create_invoke_response( +# await self.on_teams_tab_submit(turn_context, value) +# ) +# else: +# return await super().on_invoke_activity(turn_context) +# except Exception as err: +# if str(err) == str(teams_errors.TeamsNotImplemented): +# return InvokeResponse(status=int(HTTPStatus.NOT_IMPLEMENTED)) +# elif str(err) == str(teams_errors.TeamsBadRequest): +# return InvokeResponse(status=int(HTTPStatus.BAD_REQUEST)) +# raise + +# async def on_teams_card_action_invoke( +# self, turn_context: TurnContext +# ) -> InvokeResponse: +# """ +# Handles card action invoke. + +# :param turn_context: The context object for the turn. +# :return: An InvokeResponse. +# """ +# raise NotImplementedError(str(teams_errors.TeamsNotImplemented)) + +# async def on_teams_config_fetch( +# self, turn_context: TurnContext, config_data: Any +# ) -> ConfigResponse: +# """ +# Handles config fetch. + +# :param turn_context: The context object for the turn. +# :param config_data: The config data. +# :return: A ConfigResponse. +# """ +# raise NotImplementedError(str(teams_errors.TeamsNotImplemented)) + +# async def on_teams_config_submit( +# self, turn_context: TurnContext, config_data: Any +# ) -> ConfigResponse: +# """ +# Handles config submit. + +# :param turn_context: The context object for the turn. +# :param config_data: The config data. +# :return: A ConfigResponse. +# """ +# raise NotImplementedError(str(teams_errors.TeamsNotImplemented)) + +# async def on_teams_file_consent( +# self, +# turn_context: TurnContext, +# file_consent_card_response: FileConsentCardResponse, +# ) -> None: +# """ +# Handles file consent. + +# :param turn_context: The context object for the turn. +# :param file_consent_card_response: The file consent card response. +# :return: None +# """ +# if file_consent_card_response.action == "accept": +# return await self.on_teams_file_consent_accept( +# turn_context, file_consent_card_response +# ) +# elif file_consent_card_response.action == "decline": +# return await self.on_teams_file_consent_decline( +# turn_context, file_consent_card_response +# ) +# else: +# raise ValueError(str(teams_errors.TeamsBadRequest)) + +# async def on_teams_file_consent_accept( +# self, +# turn_context: TurnContext, +# file_consent_card_response: FileConsentCardResponse, +# ) -> None: +# """ +# Handles file consent accept. + +# :param turn_context: The context object for the turn. +# :param file_consent_card_response: The file consent card response. +# :return: None +# """ +# raise NotImplementedError(str(teams_errors.TeamsNotImplemented)) + +# async def on_teams_file_consent_decline( +# self, +# turn_context: TurnContext, +# file_consent_card_response: FileConsentCardResponse, +# ) -> None: +# """ +# Handles file consent decline. + +# :param turn_context: The context object for the turn. +# :param file_consent_card_response: The file consent card response. +# :return: None +# """ +# raise NotImplementedError(str(teams_errors.TeamsNotImplemented)) + +# async def on_execute_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_config_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_config_setting( +# self, turn_context: TurnContext, settings: Any +# ) -> None: +# """ +# Handles messaging extension configuration setting. + +# :param turn_context: The context object for the turn. +# :param settings: The settings. +# :return: None +# """ +# raise NotImplementedError(str(teams_errors.TeamsNotImplemented)) + +# async def on_teams_messaging_extension_card_button_clicked( +# self, turn_context: TurnContext, card_data: Any +# ) -> None: +# """ +# Handles messaging extension card button clicked. + +# :param turn_context: The context object for the turn. +# :param card_data: The card data. +# :return: None +# """ +# raise NotImplementedError(str(teams_errors.TeamsNotImplemented)) + +# async def on_teams_task_module_fetch( +# self, turn_context: TurnContext, task_module_request: TaskModuleRequest +# ) -> TaskModuleResponse: +# """ +# Handles task module fetch. + +# :param turn_context: The context object for the turn. +# :param task_module_request: The task module request. +# :return: A TaskModuleResponse. +# """ +# raise NotImplementedError(str(teams_errors.TeamsNotImplemented)) + +# async def on_teams_task_module_submit( +# self, turn_context: TurnContext, task_module_request: TaskModuleRequest +# ) -> TaskModuleResponse: +# """ +# Handles task module submit. + +# :param turn_context: The context object for the turn. +# :param task_module_request: The task module request. +# :return: A TaskModuleResponse. +# """ +# raise NotImplementedError(str(teams_errors.TeamsNotImplemented)) + +# async def on_teams_tab_fetch( +# self, turn_context: TurnContext, tab_request: TabRequest +# ) -> TabResponse: +# """ +# Handles tab fetch. + +# :param turn_context: The context object for the turn. +# :param tab_request: The tab request. +# :return: A TabResponse. +# """ +# raise NotImplementedError(str(teams_errors.TeamsNotImplemented)) + +# async def on_teams_tab_submit( +# self, turn_context: TurnContext, tab_submit: TabSubmit +# ) -> TabResponse: +# """ +# Handles tab submit. + +# :param turn_context: The context object for the turn. +# :param tab_submit: The tab submit. +# :return: A TabResponse. +# """ +# raise NotImplementedError(str(teams_errors.TeamsNotImplemented)) + +# async def on_conversation_update_activity(self, turn_context: TurnContext): +# """ +# Dispatches conversation update activity. + +# :param turn_context: The context object for the turn. +# :return: None +# """ +# if turn_context.activity.channel_id == "msteams": +# channel_data = ( +# TeamsChannelData.model_validate(turn_context.activity.channel_data) +# if turn_context.activity.channel_data +# else None +# ) + +# if ( +# turn_context.activity.members_added +# and len(turn_context.activity.members_added) > 0 +# ): +# return await self.on_teams_members_added_dispatch( +# turn_context.activity.members_added, +# channel_data.team if channel_data else None, +# turn_context, +# ) + +# if ( +# turn_context.activity.members_removed +# and len(turn_context.activity.members_removed) > 0 +# ): +# return await self.on_teams_members_removed(turn_context) + +# if not channel_data or not channel_data.event_type: +# return await super().on_conversation_update_activity(turn_context) + +# event_type = channel_data.event_type + +# if event_type == "channelCreated": +# return await self.on_teams_channel_created(turn_context) +# elif event_type == "channelDeleted": +# return await self.on_teams_channel_deleted(turn_context) +# elif event_type == "channelRenamed": +# return await self.on_teams_channel_renamed(turn_context) +# elif event_type == "teamArchived": +# return await self.on_teams_team_archived(turn_context) +# elif event_type == "teamDeleted": +# return await self.on_teams_team_deleted(turn_context) +# elif event_type == "teamHardDeleted": +# return await self.on_teams_team_hard_deleted(turn_context) +# elif event_type == "channelRestored": +# return await self.on_teams_channel_restored(turn_context) +# elif event_type == "teamRenamed": +# return await self.on_teams_team_renamed(turn_context) +# elif event_type == "teamRestored": +# return await self.on_teams_team_restored(turn_context) +# elif event_type == "teamUnarchived": +# return await self.on_teams_team_unarchived(turn_context) + +# return await super().on_conversation_update_activity(turn_context) + +# async def on_message_update_activity(self, turn_context: TurnContext): +# """ +# Dispatches message update activity. + +# :param turn_context: The context object for the turn. +# :return: None +# """ +# if turn_context.activity.channel_id == "msteams": +# channel_data = ( +# TeamsChannelData.model_validate(turn_context.activity.channel_data) +# if turn_context.activity.channel_data +# else None +# ) + +# event_type = channel_data.event_type if channel_data else None + +# if event_type == "undeleteMessage": +# return await self.on_teams_message_undelete(turn_context) +# elif event_type == "editMessage": +# return await self.on_teams_message_edit(turn_context) + +# return await super().on_message_update_activity(turn_context) + +# async def on_message_delete_activity(self, turn_context: TurnContext) -> None: +# """ +# Dispatches message delete activity. + +# :param turn_context: The context object for the turn. +# :return: None +# """ +# if turn_context.activity.channel_id == "msteams": +# channel_data = channel_data = ( +# TeamsChannelData.model_validate(turn_context.activity.channel_data) +# if turn_context.activity.channel_data +# else None +# ) + +# event_type = channel_data.event_type if channel_data else None + +# if event_type == "softDeleteMessage": +# return await self.on_teams_message_soft_delete(turn_context) + +# return await super().on_message_delete_activity(turn_context) + +# async def on_teams_message_undelete(self, turn_context: TurnContext) -> None: +# """ +# Handles Teams message undelete. + +# :param turn_context: The context object for the turn. +# :return: None +# """ +# return + +# async def on_teams_message_edit(self, turn_context: TurnContext) -> None: +# """ +# Handles Teams message edit. + +# :param turn_context: The context object for the turn. +# :return: None +# """ +# return + +# async def on_teams_message_soft_delete(self, turn_context: TurnContext) -> None: +# """ +# Handles Teams message soft delete. + +# :param turn_context: The context object for the turn. +# :return: None +# """ +# return + +# async def on_teams_members_added_dispatch( +# self, +# members_added: list[ChannelAccount], +# team_info: TeamInfo, +# turn_context: TurnContext, +# ) -> None: +# """ +# Dispatches processing of Teams members added to the conversation. +# Processes the members_added collection to get full member information when possible. + +# :param members_added: The list of members being added to the conversation. +# :param team_info: The team info object. +# :param turn_context: The context object for the turn. +# :return: None +# """ +# teams_members_added = [] + +# for member in members_added: +# # If the member has properties or is the agent/bot being added to the conversation +# if len(member.properties) or ( +# turn_context.activity.recipient +# and turn_context.activity.recipient.id == member.id +# ): + +# # Convert the ChannelAccount to TeamsChannelAccount +# # TODO: Converter between these two classes +# teams_member = TeamsChannelAccount.model_validate( +# member.model_dump(by_alias=True, exclude_unset=True) +# ) +# teams_members_added.append(teams_member) +# else: +# # Try to get the full member details from Teams +# try: +# teams_member = await TeamsInfo.get_member(turn_context, member.id) +# teams_members_added.append(teams_member) +# except Exception as err: +# # Handle case where conversation is not found +# if "ConversationNotFound" in str(err): +# teams_channel_account = TeamsChannelAccount( +# id=member.id, +# name=member.name, +# aad_object_id=getattr(member, "aad_object_id", None), +# role=getattr(member, "role", None), +# ) +# teams_members_added.append(teams_channel_account) +# else: +# # Propagate any other errors +# raise + +# await self.on_teams_members_added(teams_members_added, team_info, turn_context) + +# async def on_teams_members_added( +# self, +# teams_members_added: list[TeamsChannelAccount], +# team_info: TeamInfo, +# turn_context: TurnContext, +# ) -> None: +# """ +# Handles Teams members added. + +# :param turn_context: The context object for the turn. +# :return: None +# """ +# await self.on_members_added_activity(teams_members_added, turn_context) + +# async def on_teams_members_removed_dispatch( +# self, +# members_removed: list[ChannelAccount], +# team_info: TeamInfo, +# turn_context: TurnContext, +# ) -> None: +# """ +# Dispatches processing of Teams members removed from the conversation. +# """ +# teams_members_removed = [] +# for member in members_removed: +# teams_members_removed.append( +# TeamsChannelAccount.model_validate( +# member.model_dump(by_alias=True, exclude_unset=True) +# ) +# ) +# return await self.on_teams_members_removed( +# teams_members_removed, team_info, turn_context +# ) + +# async def on_teams_members_removed( +# self, +# teams_members_removed: list[TeamsChannelAccount], +# team_info: TeamInfo, +# turn_context: TurnContext, +# ) -> None: +# """ +# Handles Teams members removed. + +# :param turn_context: The context object for the turn. +# :return: None +# """ +# await self.on_members_removed_activity(teams_members_removed, turn_context) + +# async def on_teams_channel_created( +# self, channel_info: ChannelInfo, team_info: TeamInfo, turn_context: TurnContext +# ) -> None: +# """ +# Handles Teams channel created. + +# :param turn_context: The context object for the turn. +# :return: None +# """ +# return + +# async def on_teams_channel_deleted( +# self, channel_info: ChannelInfo, team_info: TeamInfo, turn_context: TurnContext +# ) -> None: +# """ +# Handles Teams channel deleted. + +# :param turn_context: The context object for the turn. +# :return: None +# """ +# return + +# async def on_teams_channel_renamed( +# self, channel_info: ChannelInfo, team_info: TeamInfo, turn_context: TurnContext +# ) -> None: +# """ +# Handles Teams channel renamed. + +# :param turn_context: The context object for the turn. +# :return: None +# """ +# return + +# async def on_teams_team_archived( +# self, team_info: TeamInfo, turn_context: TurnContext +# ) -> None: +# """ +# Handles Teams team archived. + +# :param turn_context: The context object for the turn. +# :return: None +# """ +# return + +# async def on_teams_team_deleted( +# self, team_info: TeamInfo, turn_context: TurnContext +# ) -> None: +# """ +# Handles Teams team deleted. + +# :param turn_context: The context object for the turn. +# :return: None +# """ +# return + +# async def on_teams_team_hard_deleted( +# self, team_info: TeamInfo, turn_context: TurnContext +# ) -> None: +# """ +# Handles Teams team hard deleted. + +# :param turn_context: The context object for the turn. +# :return: None +# """ +# return + +# async def on_teams_channel_restored( +# self, channel_info: ChannelInfo, team_info: TeamInfo, turn_context: TurnContext +# ) -> None: +# """ +# Handles Teams channel restored. + +# :param turn_context: The context object for the turn. +# :return: None +# """ +# return + +# async def on_teams_team_renamed( +# self, team_info: TeamInfo, turn_context: TurnContext +# ) -> None: +# """ +# Handles Teams team renamed. + +# :param turn_context: The context object for the turn. +# :return: None +# """ +# return + +# async def on_teams_team_restored( +# self, team_info: TeamInfo, turn_context: TurnContext +# ) -> None: +# """ +# Handles Teams team restored. + +# :param turn_context: The context object for the turn. +# :return: None +# """ +# return + +# async def on_teams_team_unarchived( +# self, team_info: TeamInfo, turn_context: TurnContext +# ) -> None: +# """ +# Handles Teams team unarchived. + +# :param turn_context: The context object for the turn. +# :return: None +# """ +# return + +# async def on_event_activity(self, turn_context: TurnContext) -> None: +# """ +# Dispatches event activity. + +# :param turn_context: The context object for the turn. +# :return: None +# """ +# if turn_context.activity.channel_id == "msteams": +# if turn_context.activity.name == "application/vnd.microsoft.readReceipt": +# return await self.on_teams_read_receipt(turn_context) +# elif turn_context.activity.name == "application/vnd.microsoft.meetingStart": +# return await self.on_teams_meeting_start(turn_context) +# elif turn_context.activity.name == "application/vnd.microsoft.meetingEnd": +# return await self.on_teams_meeting_end(turn_context) +# elif ( +# turn_context.activity.name +# == "application/vnd.microsoft.meetingParticipantJoin" +# ): +# return await self.on_teams_meeting_participants_join(turn_context) +# elif ( +# turn_context.activity.name +# == "application/vnd.microsoft.meetingParticipantLeave" +# ): +# return await self.on_teams_meeting_participants_leave(turn_context) + +# return await super().on_event_activity(turn_context) + +# async def on_teams_meeting_start( +# self, meeting: MeetingStartEventDetails, turn_context: TurnContext +# ) -> None: +# """ +# Handles Teams meeting start. + +# :param turn_context: The context object for the turn. +# :return: None +# """ +# return + +# async def on_teams_meeting_end( +# self, meeting: MeetingEndEventDetails, turn_context: TurnContext +# ) -> None: +# """ +# Handles Teams meeting end. + +# :param turn_context: The context object for the turn. +# :return: None +# """ +# return + +# async def on_teams_read_receipt( +# self, read_receipt: ReadReceiptInfo, turn_context: TurnContext +# ) -> None: +# """ +# Handles Teams read receipt. + +# :param turn_context: The context object for the turn. +# :return: None +# """ +# return + +# async def on_teams_meeting_participants_join( +# self, meeting: MeetingParticipantsEventDetails, turn_context: TurnContext +# ) -> None: +# """ +# Handles Teams meeting participants join. + +# :param turn_context: The context object for the turn. +# :return: None +# """ +# return + +# async def on_teams_meeting_participants_leave( +# self, meeting: MeetingParticipantsEventDetails, turn_context: TurnContext +# ) -> None: +# """ +# Handles Teams meeting participants leave. + +# :param turn_context: The context object for the turn. +# :return: None +# """ +# return diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/teams_agent_extension.py b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/teams_agent_extension.py index a4337baae..61d98ecee 100644 --- 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 @@ -1,12 +1,14 @@ """ Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT License. + +Teams-specific extension for :class:`AgentApplication` that exposes route +registration helpers for every Teams invoke / event surface. """ from __future__ import annotations from typing import ( - Awaitable, Callable, Generic, Optional, @@ -21,13 +23,10 @@ MessageUpdateTypes, ) from microsoft_agents.hosting.core import AgentApplication -from microsoft_agents.hosting.core.app._type_defs import ( - RouteHandler, - HandoffHandler -) +from microsoft_agents.hosting.core.app._type_defs import RouteHandler, HandoffHandler from .channel import Channel -from .configuration import Configuration +from .config import Config from .file_consent import FileConsent from .meeting import Meeting from .message import Message @@ -38,8 +37,18 @@ from .type_defs import StateT, _RouteDecorator + class _AppRouteDecorator(Protocol[StateT]): - def __call__(self, func: TeamsRouteHandler[StateT], /) -> RouteHandler[StateT]: ... + """Protocol for a decorator returned by :class:`TeamsAgentExtension` route methods.""" + + def __call__(self, func: TeamsRouteHandler[StateT], /) -> RouteHandler[StateT]: + """Register *func* as a Teams route handler. + + :param func: Teams-aware handler to register. + :return: The wrapped core route handler. + """ + ... + class TeamsAgentExtension(Generic[StateT]): """ @@ -50,42 +59,45 @@ class TeamsAgentExtension(Generic[StateT]): app = AgentApplication(options) teams = TeamsAgentExtension(app) - @teams.task_module.on_fetch("myVerb") + @teams.task_modules.fetch("myVerb") async def handle_fetch(context, state, request: TaskModuleRequest): return TaskModuleResponse(...) - @teams.message_extension.on_query("searchCmd") + @teams.message_extensions.query("searchCmd") async def handle_query(context, state, query: MessagingExtensionQuery): return MessagingExtensionResponse(...) - @teams.meeting.on_start + @teams.meetings.start() async def handle_meeting_start(context, state, meeting: MeetingDetails): ... """ def __init__(self, app: AgentApplication[StateT]) -> None: - self._app = app + """Attach Teams route namespaces to *app*. + :param app: The :class:`AgentApplication` to extend with Teams-specific routes. + """ + self._app = app self._channel: Channel[StateT] = Channel(app) - self._configuration: Configuration[StateT] = Configuration(app) + self._configuration: Config[StateT] = Config(app) self._file_consent: FileConsent[StateT] = FileConsent(app) self._meeting: Meeting[StateT] = Meeting(app) self._message: Message[StateT] = Message(app) self._message_extension: MessageExtension[StateT] = MessageExtension(app) self._task_module: TaskModule[StateT] = TaskModule(app) self._team: Team[StateT] = Team(app) - + @property def channels(self) -> Channel[StateT]: """Route registration for Channel events.""" return self._channel @property - def config(self) -> Configuration[StateT]: + def config(self) -> Config[StateT]: """Route registration for Configuration events.""" return self._configuration - + @property def file_consent(self) -> FileConsent[StateT]: """Route registration for File Consent events.""" @@ -110,16 +122,30 @@ def message_extensions(self) -> MessageExtension[StateT]: def task_modules(self) -> TaskModule[StateT]: """Route registration for Task Module (task/fetch, task/submit) invokes.""" return self._task_module - + + @property + def teams(self) -> Team[StateT]: + """Route registration for Team lifecycle events.""" + return self._team + # AgentApplication route hooks def _wrap_decorator( - self, - decorator: _RouteDecorator[RouteHandler[StateT]] + self, decorator: _RouteDecorator[RouteHandler[StateT]] ) -> Callable[[TeamsRouteHandler[StateT]], RouteHandler[StateT]]: - """Wrap a core route decorator to create a Teams-specific route decorator.""" + """Wrap a core route decorator so it accepts a :class:`TeamsRouteHandler`. + + The returned decorator converts the Teams handler via + :meth:`TeamsRouteHandler.wrap` before passing it to *decorator*, keeping the + Teams context upgrade transparent to callers. + + :param decorator: A core route decorator from :class:`AgentApplication`. + :return: A decorator that accepts and registers a :class:`TeamsRouteHandler`. + """ + def __call(func: TeamsRouteHandler[StateT]) -> RouteHandler[StateT]: - return decorator(TeamsRouteHandler.wrap(func)) + return decorator(TeamsRouteHandler.wrap(func, self._app)) + return __call def activity( @@ -129,10 +155,16 @@ def activity( auth_handlers: Optional[list[str]] = None, **kwargs, ) -> _AppRouteDecorator[StateT]: + """Register a handler for one or more activity types. + + :param activity_type: Activity type(s) to match (e.g. ``ActivityTypes.message``). + :param auth_handlers: Optional list of auth handler names to run before the route. + :return: A decorator that registers the handler and returns it. + """ return self._wrap_decorator( self._app.activity(activity_type, auth_handlers=auth_handlers, **kwargs) ) - + def message( self, select: str | Pattern[str] | list[str | Pattern[str]], @@ -140,10 +172,17 @@ def message( auth_handlers: Optional[list[str]] = None, **kwargs, ) -> _AppRouteDecorator[StateT]: + """Register a handler for message activities matching *select*. + + :param select: A literal string, regex pattern, or list of either to match against + the message text. + :param auth_handlers: Optional list of auth handler names to run before the route. + :return: A decorator that registers the handler and returns it. + """ return self._wrap_decorator( self._app.message(select, auth_handlers=auth_handlers, **kwargs) ) - + def conversation_update( self, type: ConversationUpdateTypes, @@ -151,10 +190,16 @@ def conversation_update( auth_handlers: Optional[list[str]] = None, **kwargs, ) -> _AppRouteDecorator[StateT]: + """Register a handler for a specific conversation update event type. + + :param type: The :class:`ConversationUpdateTypes` value to match. + :param auth_handlers: Optional list of auth handler names to run before the route. + :return: A decorator that registers the handler and returns it. + """ return self._wrap_decorator( self._app.conversation_update(type, auth_handlers=auth_handlers, **kwargs) ) - + def message_reaction( self, type: MessageReactionTypes, @@ -162,10 +207,16 @@ def message_reaction( auth_handlers: Optional[list[str]] = None, **kwargs, ) -> _AppRouteDecorator[StateT]: + """Register a handler for a specific message reaction type. + + :param type: The :class:`MessageReactionTypes` value to match (e.g. ``reactionsAdded``). + :param auth_handlers: Optional list of auth handler names to run before the route. + :return: A decorator that registers the handler and returns it. + """ return self._wrap_decorator( self._app.message_reaction(type, auth_handlers=auth_handlers, **kwargs) ) - + def message_update( self, type: MessageUpdateTypes, @@ -173,18 +224,31 @@ def message_update( auth_handlers: Optional[list[str]] = None, **kwargs, ) -> _AppRouteDecorator[StateT]: + """Register a handler for a specific message update event type. + + :param type: The :class:`MessageUpdateTypes` value to match (e.g. ``editMessage``). + :param auth_handlers: Optional list of auth handler names to run before the route. + :return: A decorator that registers the handler and returns it. + """ return self._wrap_decorator( self._app.message_update(type, auth_handlers=auth_handlers, **kwargs) ) - + def handoff( self, *, auth_handlers: Optional[list[str]] = None, **kwargs, ) -> Callable[[TeamsHandoffHandler[StateT]], HandoffHandler[StateT]]: + """Register a Teams handoff handler. + + :param auth_handlers: Optional list of auth handler names to run before the route. + :return: A decorator that registers the handler and returns it. + """ + def __call(func: TeamsHandoffHandler[StateT]) -> HandoffHandler[StateT]: return self._app.handoff(auth_handlers=auth_handlers, **kwargs)( - TeamsHandoffHandler.wrap(func) + TeamsHandoffHandler.wrap(func, self._app) ) - return __call \ No newline at end of file + + return __call diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/teams_turn_context.py b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/teams_turn_context.py index 8460aaa56..2a247c37a 100644 --- a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/teams_turn_context.py +++ b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/teams_turn_context.py @@ -1,12 +1,24 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. -from microsoft_agents.hosting.core import TurnContext +"""Teams-specific turn context wrapper.""" + +from microsoft_agents.hosting.core import AgentApplication, TurnContext + class TeamsTurnContext(TurnContext): - """A context object for handling Teams-specific turn functionality.""" - - def __init__(self, context: TurnContext): - super().__init__(context) + """A context object for handling Teams-specific turn functionality. - self._context = context \ No newline at end of file + Wraps a plain :class:`TurnContext` so that Teams-aware route handlers + receive a typed context without changing the core routing engine. + """ + + def __init__(self, context: TurnContext, app: AgentApplication) -> None: + """Initialise the Teams turn context from a plain turn context. + + :param context: The base turn context provided by the core runtime. + :param app: The agent application that is handling the turn. + """ + super().__init__(context) + self._context = context + self._app = app diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/type_defs.py b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/type_defs.py index e807b0e19..21031f516 100644 --- a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/type_defs.py +++ b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/type_defs.py @@ -1,6 +1,8 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. +"""Shared type aliases and protocols used across the Teams hosting sub-package.""" + from typing import ( Callable, TypeVar, @@ -19,5 +21,14 @@ CommandSelector = str | Pattern[str] | None + class _RouteDecorator(Protocol[RouteHandlerT]): - def __call__(self, func: RouteHandlerT, /) -> RouteHandlerT: ... \ No newline at end of file + """Protocol for a decorator that registers *func* as a route and returns it unchanged.""" + + def __call__(self, func: RouteHandlerT, /) -> RouteHandlerT: + """Register *func* as a route handler and return it. + + :param func: The handler to register. + :return: The same handler, unmodified, so it can be used as a plain callable. + """ + ... diff --git a/tests/hosting_core/app/proactive/test_conversation_builder.py b/tests/hosting_core/app/proactive/test_conversation_builder.py index eab7ded16..931e44ea5 100644 --- a/tests/hosting_core/app/proactive/test_conversation_builder.py +++ b/tests/hosting_core/app/proactive/test_conversation_builder.py @@ -11,7 +11,6 @@ ) from microsoft_agents.hosting.core.authorization import ClaimsIdentity - # --------------------------------------------------------------------------- # Helper # --------------------------------------------------------------------------- diff --git a/tests/hosting_core/app/proactive/test_conversation_reference_builder.py b/tests/hosting_core/app/proactive/test_conversation_reference_builder.py index 4ab646e29..98d2bc4fb 100644 --- a/tests/hosting_core/app/proactive/test_conversation_reference_builder.py +++ b/tests/hosting_core/app/proactive/test_conversation_reference_builder.py @@ -10,7 +10,6 @@ _service_url_for_channel, ) - # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- diff --git a/tests/hosting_core/app/proactive/test_proactive.py b/tests/hosting_core/app/proactive/test_proactive.py index f3afa664e..d7c73f518 100644 --- a/tests/hosting_core/app/proactive/test_proactive.py +++ b/tests/hosting_core/app/proactive/test_proactive.py @@ -26,7 +26,6 @@ from microsoft_agents.hosting.core.authorization import ClaimsIdentity from microsoft_agents.hosting.core.channel_adapter import ChannelAdapter - # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- diff --git a/tests/hosting_teams/helpers.py b/tests/hosting_teams/helpers.py new file mode 100644 index 000000000..c4c13b7e4 --- /dev/null +++ b/tests/hosting_teams/helpers.py @@ -0,0 +1,57 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""Shared test helpers for microsoft-agents-hosting-teams tests.""" + +import sys +from unittest.mock import AsyncMock, MagicMock + +from microsoft_agents.activity import Activity, ActivityTypes +from microsoft_agents.hosting.core import TurnContext +from microsoft_agents.hosting.core.app import AgentApplication, RouteRank + +is_supported_version = sys.version_info >= (3, 12) + + +def _make_app() -> AgentApplication: + app = MagicMock(spec=AgentApplication) + app._routes = [] + + def _add_route( + selector, handler, is_invoke=False, rank=RouteRank.DEFAULT, auth_handlers=None + ): + app._routes.append( + dict( + selector=selector, + handler=handler, + is_invoke=is_invoke, + rank=rank, + auth_handlers=auth_handlers, + ) + ) + + app.add_route.side_effect = _add_route + return app + + +def _make_context( + activity_type: str, + name: str = None, + value=None, + channel_id: str = "msteams", + channel_data: dict = None, + members_added=None, + members_removed=None, +) -> TurnContext: + context = MagicMock(spec=TurnContext) + activity = MagicMock(spec=Activity) + activity.type = activity_type + activity.name = name + activity.value = value + activity.channel_id = channel_id + activity.channel_data = channel_data + activity.members_added = members_added + activity.members_removed = members_removed + context.activity = activity + context.send_activity = AsyncMock() + return context diff --git a/tests/hosting_teams/test_channels.py b/tests/hosting_teams/test_channels.py new file mode 100644 index 000000000..1e1e65573 --- /dev/null +++ b/tests/hosting_teams/test_channels.py @@ -0,0 +1,265 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""Tests for TeamsAgentExtension.channels (channel and membership conversation update events).""" + +import pytest +from unittest.mock import AsyncMock, MagicMock + +from microsoft_agents.activity import ActivityTypes + +from .helpers import _make_app, _make_context, is_supported_version + +pytestmark = pytest.mark.skipif( + not is_supported_version, + reason="microsoft-agents-hosting-teams tests require Python 3.12+", +) + +if is_supported_version: + from microsoft_agents.hosting.teams import TeamsAgentExtension + + +class TestChannelLifecycle: + + def setup_method(self): + self.app = _make_app() + self.ext = TeamsAgentExtension(self.app) + + def test_created_selector(self): + @self.ext.channels.created() + async def handler(ctx, state, data): ... + + selector = self.app._routes[0]["selector"] + assert ( + selector( + _make_context( + ActivityTypes.conversation_update, + channel_data={"eventType": "channelCreated"}, + ) + ) + is True + ) + assert ( + selector( + _make_context( + ActivityTypes.conversation_update, + channel_data={"eventType": "channelDeleted"}, + ) + ) + is False + ) + + def test_deleted_selector(self): + @self.ext.channels.deleted() + async def handler(ctx, state, data): ... + + selector = self.app._routes[0]["selector"] + assert ( + selector( + _make_context( + ActivityTypes.conversation_update, + channel_data={"eventType": "channelDeleted"}, + ) + ) + is True + ) + assert ( + selector( + _make_context( + ActivityTypes.conversation_update, + channel_data={"eventType": "channelCreated"}, + ) + ) + is False + ) + + def test_renamed_selector(self): + @self.ext.channels.renamed() + async def handler(ctx, state, data): ... + + selector = self.app._routes[0]["selector"] + assert ( + selector( + _make_context( + ActivityTypes.conversation_update, + channel_data={"eventType": "channelRenamed"}, + ) + ) + is True + ) + + def test_rest_selector_matches_channel_restored(self): + @self.ext.channels.rest() + async def handler(ctx, state, data): ... + + selector = self.app._routes[0]["selector"] + assert ( + selector( + _make_context( + ActivityTypes.conversation_update, + channel_data={"eventType": "channelRestored"}, + ) + ) + is True + ) + assert ( + selector( + _make_context( + ActivityTypes.conversation_update, + channel_data={"eventType": "channelRenamed"}, + ) + ) + is False + ) + + def test_channel_event_requires_msteams_channel(self): + @self.ext.channels.created() + async def handler(ctx, state, data): ... + + selector = self.app._routes[0]["selector"] + assert ( + selector( + _make_context( + ActivityTypes.conversation_update, + channel_id="webchat", + channel_data={"eventType": "channelCreated"}, + ) + ) + is False + ) + + def test_channel_event_is_not_invoke(self): + @self.ext.channels.created() + async def handler(ctx, state, data): ... + + assert self.app._routes[0]["is_invoke"] is False + + +class TestChannelMembers: + + def setup_method(self): + self.app = _make_app() + self.ext = TeamsAgentExtension(self.app) + + def test_members_added_selector(self): + @self.ext.channels.members_added() + async def handler(ctx, state, data): ... + + selector = self.app._routes[0]["selector"] + member = MagicMock() + assert ( + selector( + _make_context(ActivityTypes.conversation_update, members_added=[member]) + ) + is True + ) + assert ( + selector(_make_context(ActivityTypes.conversation_update, members_added=[])) + is False + ) + + def test_members_added_requires_msteams_channel(self): + @self.ext.channels.members_added() + async def handler(ctx, state, data): ... + + selector = self.app._routes[0]["selector"] + member = MagicMock() + assert ( + selector( + _make_context( + ActivityTypes.conversation_update, + channel_id="webchat", + members_added=[member], + ) + ) + is False + ) + + def test_members_removed_selector(self): + @self.ext.channels.members_removed() + async def handler(ctx, state, data): ... + + selector = self.app._routes[0]["selector"] + member = MagicMock() + assert ( + selector( + _make_context( + ActivityTypes.conversation_update, members_removed=[member] + ) + ) + is True + ) + assert ( + selector( + _make_context(ActivityTypes.conversation_update, members_removed=[]) + ) + is False + ) + + def test_members_removed_requires_msteams_channel(self): + @self.ext.channels.members_removed() + async def handler(ctx, state, data): ... + + selector = self.app._routes[0]["selector"] + member = MagicMock() + assert ( + selector( + _make_context( + ActivityTypes.conversation_update, + channel_id="webchat", + members_removed=[member], + ) + ) + is False + ) + + @pytest.mark.asyncio + async def test_members_added_handler_passes_channel_data(self): + from microsoft_teams.api.models.channel_data import ChannelData + + user_handler = AsyncMock() + + @self.ext.channels.members_added() + async def handler(ctx, state, data): + await user_handler(ctx, state, data) + + route_handler = self.app._routes[0]["handler"] + member = MagicMock() + ctx = _make_context( + ActivityTypes.conversation_update, + members_added=[member], + channel_data={"eventType": "membersAdded"}, + ) + await route_handler(ctx, MagicMock()) + assert isinstance(user_handler.call_args[0][2], ChannelData) + + @pytest.mark.asyncio + async def test_members_removed_handler_passes_channel_data(self): + from microsoft_teams.api.models.channel_data import ChannelData + + user_handler = AsyncMock() + + @self.ext.channels.members_removed() + async def handler(ctx, state, data): + await user_handler(ctx, state, data) + + route_handler = self.app._routes[0]["handler"] + member = MagicMock() + ctx = _make_context( + ActivityTypes.conversation_update, + members_removed=[member], + channel_data={"eventType": "membersRemoved"}, + ) + await route_handler(ctx, MagicMock()) + assert isinstance(user_handler.call_args[0][2], ChannelData) + + @pytest.mark.asyncio + async def test_members_added_raises_when_channel_data_missing(self): + @self.ext.channels.members_added() + async def handler(ctx, state, data): ... + + route_handler = self.app._routes[0]["handler"] + member = MagicMock() + ctx = _make_context(ActivityTypes.conversation_update, members_added=[member]) + with pytest.raises(ValueError, match="channel_data"): + await route_handler(ctx, MagicMock()) diff --git a/tests/hosting_teams/test_config.py b/tests/hosting_teams/test_config.py new file mode 100644 index 000000000..29e9202ac --- /dev/null +++ b/tests/hosting_teams/test_config.py @@ -0,0 +1,108 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""Tests for TeamsAgentExtension.config (config/fetch and config/submit invokes).""" + +import pytest +from unittest.mock import AsyncMock, MagicMock, patch + +from microsoft_agents.activity import ActivityTypes + +from .helpers import _make_app, _make_context, is_supported_version + +pytestmark = pytest.mark.skipif( + not is_supported_version, + reason="microsoft-agents-hosting-teams tests require Python 3.12+", +) + +if is_supported_version: + from microsoft_agents.hosting.teams import TeamsAgentExtension + +_PATCH = "microsoft_agents.hosting.teams.config.config._send_invoke_response" + + +class TestConfig: + + def setup_method(self): + self.app = _make_app() + self.ext = TeamsAgentExtension(self.app) + + def test_fetch_selector(self): + @self.ext.config.fetch() + async def handler(ctx, state, data): ... + + selector = self.app._routes[0]["selector"] + assert ( + selector(_make_context(ActivityTypes.invoke, name="config/fetch")) is True + ) + assert ( + selector(_make_context(ActivityTypes.invoke, name="config/submit")) is False + ) + + def test_fetch_is_invoke(self): + @self.ext.config.fetch() + async def handler(ctx, state, data): ... + + assert self.app._routes[0]["is_invoke"] is True + + def test_submit_selector(self): + @self.ext.config.submit() + async def handler(ctx, state, data): ... + + selector = self.app._routes[0]["selector"] + assert ( + selector(_make_context(ActivityTypes.invoke, name="config/submit")) is True + ) + assert ( + selector(_make_context(ActivityTypes.invoke, name="config/fetch")) is False + ) + + def test_submit_is_invoke(self): + @self.ext.config.submit() + async def handler(ctx, state, data): ... + + assert self.app._routes[0]["is_invoke"] is True + + @pytest.mark.asyncio + async def test_fetch_handler_sends_response_when_returned(self): + response = {"type": "auth", "suggestedActions": {}} + user_handler = AsyncMock(return_value=response) + + @self.ext.config.fetch() + async def handler(ctx, state, data): + return await user_handler(ctx, state, data) + + route_handler = self.app._routes[0]["handler"] + ctx = _make_context( + ActivityTypes.invoke, name="config/fetch", value={"setting": "value"} + ) + with patch(_PATCH, new_callable=AsyncMock) as mock_send: + await route_handler(ctx, MagicMock()) + assert user_handler.called + mock_send.assert_awaited_once_with(ctx, response) + + @pytest.mark.asyncio + async def test_fetch_handler_skips_send_when_none_returned(self): + @self.ext.config.fetch() + async def handler(ctx, state, data): ... + + route_handler = self.app._routes[0]["handler"] + ctx = _make_context(ActivityTypes.invoke, name="config/fetch", value={}) + with patch(_PATCH, new_callable=AsyncMock) as mock_send: + await route_handler(ctx, MagicMock()) + mock_send.assert_not_awaited() + + @pytest.mark.asyncio + async def test_fetch_handler_receives_raw_activity_value(self): + user_handler = AsyncMock(return_value=None) + + @self.ext.config.fetch() + async def handler(ctx, state, data): + return await user_handler(ctx, state, data) + + route_handler = self.app._routes[0]["handler"] + payload = {"key": "val"} + ctx = _make_context(ActivityTypes.invoke, name="config/fetch", value=payload) + with patch(_PATCH, new_callable=AsyncMock): + await route_handler(ctx, MagicMock()) + assert user_handler.call_args[0][2] == payload diff --git a/tests/hosting_teams/test_file_consent.py b/tests/hosting_teams/test_file_consent.py new file mode 100644 index 000000000..d6e4eb096 --- /dev/null +++ b/tests/hosting_teams/test_file_consent.py @@ -0,0 +1,135 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""Tests for TeamsAgentExtension.file_consent (fileConsent/invoke activities).""" + +import pytest +from unittest.mock import AsyncMock, MagicMock, patch + +from microsoft_agents.activity import ActivityTypes + +from .helpers import _make_app, _make_context, is_supported_version + +pytestmark = pytest.mark.skipif( + not is_supported_version, + reason="microsoft-agents-hosting-teams tests require Python 3.12+", +) + +if is_supported_version: + from microsoft_teams.api.models import FileConsentCardResponse + from microsoft_agents.hosting.teams import TeamsAgentExtension + +_PATCH = ( + "microsoft_agents.hosting.teams.file_consent.file_consent._send_invoke_response" +) + + +class TestFileConsent: + + def setup_method(self): + self.app = _make_app() + self.ext = TeamsAgentExtension(self.app) + + def test_accept_selector(self): + @self.ext.file_consent.accept() + async def handler(ctx, state, data): ... + + selector = self.app._routes[0]["selector"] + assert ( + selector( + _make_context( + ActivityTypes.invoke, + name="fileConsent/invoke", + value={"action": "accept"}, + ) + ) + is True + ) + assert ( + selector( + _make_context( + ActivityTypes.invoke, + name="fileConsent/invoke", + value={"action": "decline"}, + ) + ) + is False + ) + + def test_decline_selector(self): + @self.ext.file_consent.decline() + async def handler(ctx, state, data): ... + + selector = self.app._routes[0]["selector"] + assert ( + selector( + _make_context( + ActivityTypes.invoke, + name="fileConsent/invoke", + value={"action": "decline"}, + ) + ) + is True + ) + assert ( + selector( + _make_context( + ActivityTypes.invoke, + name="fileConsent/invoke", + value={"action": "accept"}, + ) + ) + is False + ) + + def test_accept_is_invoke(self): + @self.ext.file_consent.accept() + async def handler(ctx, state, data): ... + + assert self.app._routes[0]["is_invoke"] is True + + def test_decline_is_invoke(self): + @self.ext.file_consent.decline() + async def handler(ctx, state, data): ... + + assert self.app._routes[0]["is_invoke"] is True + + @pytest.mark.asyncio + async def test_accept_handler_parses_response_and_sends_empty_invoke_response(self): + user_handler = AsyncMock() + + @self.ext.file_consent.accept() + async def handler(ctx, state, data: FileConsentCardResponse): + await user_handler(ctx, state, data) + + route_handler = self.app._routes[0]["handler"] + ctx = _make_context( + ActivityTypes.invoke, + name="fileConsent/invoke", + value={"action": "accept", "context": None, "uploadInfo": None}, + ) + with patch(_PATCH, new_callable=AsyncMock) as mock_send: + await route_handler(ctx, MagicMock()) + assert isinstance(user_handler.call_args[0][2], FileConsentCardResponse) + mock_send.assert_awaited_once_with(ctx) + + @pytest.mark.asyncio + async def test_decline_handler_parses_response_and_sends_empty_invoke_response( + self, + ): + user_handler = AsyncMock() + + @self.ext.file_consent.decline() + async def handler(ctx, state, data: FileConsentCardResponse): + await user_handler(ctx, state, data) + + route_handler = self.app._routes[0]["handler"] + ctx = _make_context( + ActivityTypes.invoke, + name="fileConsent/invoke", + value={"action": "decline", "context": None, "uploadInfo": None}, + ) + with patch(_PATCH, new_callable=AsyncMock) as mock_send: + await route_handler(ctx, MagicMock()) + assert isinstance(user_handler.call_args[0][2], FileConsentCardResponse) + mock_send.assert_awaited_once_with(ctx) diff --git a/tests/hosting_teams/test_meetings.py b/tests/hosting_teams/test_meetings.py new file mode 100644 index 000000000..a74f619cc --- /dev/null +++ b/tests/hosting_teams/test_meetings.py @@ -0,0 +1,225 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""Tests for TeamsAgentExtension.meetings (meeting lifecycle events).""" + +import pytest +from unittest.mock import AsyncMock, MagicMock + +from microsoft_agents.activity import ActivityTypes + +from .helpers import _make_app, _make_context, is_supported_version + +pytestmark = pytest.mark.skipif( + not is_supported_version, + reason="microsoft-agents-hosting-teams tests require Python 3.12+", +) + +if is_supported_version: + from microsoft_teams.api.models import ( + MeetingDetails, + MeetingParticipantsEventDetails, + ) + from microsoft_agents.hosting.teams import TeamsAgentExtension + + +def _meeting_details_value() -> dict: + return { + "id": "meeting-id", + "type": "scheduled", + "joinUrl": "https://example.com/meet", + "title": "Test Meeting", + "msGraphResourceId": "graph-id", + } + + +class TestMeetingStartEnd: + + def setup_method(self): + self.app = _make_app() + self.ext = TeamsAgentExtension(self.app) + + def test_start_selector(self): + @self.ext.meetings.start() + async def handler(ctx, state, meeting): ... + + selector = self.app._routes[0]["selector"] + assert ( + selector( + _make_context( + ActivityTypes.event, name="application/vnd.microsoft.meetingStart" + ) + ) + is True + ) + assert ( + selector( + _make_context( + ActivityTypes.event, name="application/vnd.microsoft.meetingEnd" + ) + ) + is False + ) + + def test_end_selector(self): + @self.ext.meetings.end() + async def handler(ctx, state, meeting): ... + + selector = self.app._routes[0]["selector"] + assert ( + selector( + _make_context( + ActivityTypes.event, name="application/vnd.microsoft.meetingEnd" + ) + ) + is True + ) + assert ( + selector( + _make_context( + ActivityTypes.event, name="application/vnd.microsoft.meetingStart" + ) + ) + is False + ) + + def test_start_is_not_invoke(self): + @self.ext.meetings.start() + async def handler(ctx, state, meeting): ... + + assert self.app._routes[0]["is_invoke"] is False + + def test_end_is_not_invoke(self): + @self.ext.meetings.end() + async def handler(ctx, state, meeting): ... + + assert self.app._routes[0]["is_invoke"] is False + + @pytest.mark.asyncio + async def test_start_handler_parses_meeting_details(self): + user_handler = AsyncMock() + + @self.ext.meetings.start() + async def handler(ctx, state, meeting: MeetingDetails): + await user_handler(ctx, state, meeting) + + route_handler = self.app._routes[0]["handler"] + ctx = _make_context( + ActivityTypes.event, + name="application/vnd.microsoft.meetingStart", + value=_meeting_details_value(), + ) + await route_handler(ctx, MagicMock()) + assert isinstance(user_handler.call_args[0][2], MeetingDetails) + + @pytest.mark.asyncio + async def test_end_handler_parses_meeting_details(self): + user_handler = AsyncMock() + + @self.ext.meetings.end() + async def handler(ctx, state, meeting: MeetingDetails): + await user_handler(ctx, state, meeting) + + route_handler = self.app._routes[0]["handler"] + ctx = _make_context( + ActivityTypes.event, + name="application/vnd.microsoft.meetingEnd", + value=_meeting_details_value(), + ) + await route_handler(ctx, MagicMock()) + assert isinstance(user_handler.call_args[0][2], MeetingDetails) + + +class TestMeetingParticipants: + + def setup_method(self): + self.app = _make_app() + self.ext = TeamsAgentExtension(self.app) + + def test_participants_join_selector(self): + @self.ext.meetings.participants_join() + async def handler(ctx, state, details): ... + + selector = self.app._routes[0]["selector"] + assert ( + selector( + _make_context( + ActivityTypes.event, + name="application/vnd.microsoft.meetingParticipantJoin", + ) + ) + is True + ) + assert ( + selector( + _make_context( + ActivityTypes.event, + name="application/vnd.microsoft.meetingParticipantLeave", + ) + ) + is False + ) + + def test_participants_leave_selector(self): + @self.ext.meetings.participants_leave() + async def handler(ctx, state, details): ... + + selector = self.app._routes[0]["selector"] + assert ( + selector( + _make_context( + ActivityTypes.event, + name="application/vnd.microsoft.meetingParticipantLeave", + ) + ) + is True + ) + assert ( + selector( + _make_context( + ActivityTypes.event, + name="application/vnd.microsoft.meetingParticipantJoin", + ) + ) + is False + ) + + def test_participants_join_is_not_invoke(self): + @self.ext.meetings.participants_join() + async def handler(ctx, state, details): ... + + assert self.app._routes[0]["is_invoke"] is False + + @pytest.mark.asyncio + async def test_participants_join_handler_parses_details(self): + user_handler = AsyncMock() + + @self.ext.meetings.participants_join() + async def handler(ctx, state, details: MeetingParticipantsEventDetails): + await user_handler(ctx, state, details) + + route_handler = self.app._routes[0]["handler"] + ctx = _make_context( + ActivityTypes.event, + name="application/vnd.microsoft.meetingParticipantJoin", + value={}, + ) + await route_handler(ctx, MagicMock()) + assert isinstance(user_handler.call_args[0][2], MeetingParticipantsEventDetails) + + @pytest.mark.asyncio + async def test_participants_leave_handler_parses_details(self): + user_handler = AsyncMock() + + @self.ext.meetings.participants_leave() + async def handler(ctx, state, details: MeetingParticipantsEventDetails): + await user_handler(ctx, state, details) + + route_handler = self.app._routes[0]["handler"] + ctx = _make_context( + ActivityTypes.event, + name="application/vnd.microsoft.meetingParticipantLeave", + value={}, + ) + await route_handler(ctx, MagicMock()) + assert isinstance(user_handler.call_args[0][2], MeetingParticipantsEventDetails) diff --git a/tests/hosting_teams/test_message_extensions.py b/tests/hosting_teams/test_message_extensions.py new file mode 100644 index 000000000..a7c2a0dc6 --- /dev/null +++ b/tests/hosting_teams/test_message_extensions.py @@ -0,0 +1,538 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""Tests for TeamsAgentExtension.message_extensions (composeExtension invokes).""" + +import pytest +from unittest.mock import AsyncMock, MagicMock, patch + +from microsoft_agents.activity import ActivityTypes + +from .helpers import _make_app, _make_context, is_supported_version + +pytestmark = pytest.mark.skipif( + not is_supported_version, + reason="microsoft-agents-hosting-teams tests require Python 3.12+", +) + +if is_supported_version: + from microsoft_teams.api.models import ( + MessagingExtensionQuery, + MessagingExtensionResponse, + ) + from microsoft_agents.hosting.teams import TeamsAgentExtension + +_PATCH = "microsoft_agents.hosting.teams.message_extension.message_extension._send_invoke_response" + + +class TestMessageExtensionQuery: + + def setup_method(self): + self.app = _make_app() + self.ext = TeamsAgentExtension(self.app) + + def test_query_matches_by_command_id(self): + @self.ext.message_extensions.query("searchCmd") + async def handler(ctx, state, query): ... + + selector = self.app._routes[0]["selector"] + assert ( + selector( + _make_context( + ActivityTypes.invoke, + name="composeExtension/query", + value={"commandId": "searchCmd"}, + ) + ) + is True + ) + assert ( + selector( + _make_context( + ActivityTypes.invoke, + name="composeExtension/query", + value={"commandId": "other"}, + ) + ) + is False + ) + + def test_query_no_command_id_matches_all(self): + @self.ext.message_extensions.query() + async def handler(ctx, state, query): ... + + selector = self.app._routes[0]["selector"] + assert ( + selector( + _make_context( + ActivityTypes.invoke, + name="composeExtension/query", + value={"commandId": "anything"}, + ) + ) + is True + ) + assert ( + selector( + _make_context( + ActivityTypes.invoke, name="composeExtension/query", value={} + ) + ) + is True + ) + + def test_query_wrong_invoke_name(self): + @self.ext.message_extensions.query() + async def handler(ctx, state, query): ... + + selector = self.app._routes[0]["selector"] + assert ( + selector( + _make_context( + ActivityTypes.invoke, name="composeExtension/submitAction", value={} + ) + ) + is False + ) + + def test_query_is_invoke(self): + @self.ext.message_extensions.query() + async def handler(ctx, state, query): ... + + assert self.app._routes[0]["is_invoke"] is True + + @pytest.mark.asyncio + async def test_query_handler_parses_query_and_sends_response(self): + response = MessagingExtensionResponse() + user_handler = AsyncMock(return_value=response) + + @self.ext.message_extensions.query() + async def handler(ctx, state, query: MessagingExtensionQuery): + return await user_handler(ctx, state, query) + + route_handler = self.app._routes[0]["handler"] + ctx = _make_context( + ActivityTypes.invoke, + name="composeExtension/query", + value={"commandId": "search"}, + ) + with patch(_PATCH, new_callable=AsyncMock) as mock_send: + await route_handler(ctx, MagicMock()) + assert isinstance(user_handler.call_args[0][2], MessagingExtensionQuery) + mock_send.assert_awaited_once_with(ctx, response) + + +class TestMessageExtensionSubmitAction: + + def setup_method(self): + self.app = _make_app() + self.ext = TeamsAgentExtension(self.app) + + def test_submit_action_excludes_preview(self): + @self.ext.message_extensions.submit_action() + async def handler(ctx, state, action): ... + + selector = self.app._routes[0]["selector"] + assert ( + selector( + _make_context( + ActivityTypes.invoke, + name="composeExtension/submitAction", + value={"commandId": "cmd"}, + ) + ) + is True + ) + assert ( + selector( + _make_context( + ActivityTypes.invoke, + name="composeExtension/submitAction", + value={"commandId": "cmd", "botMessagePreviewAction": "edit"}, + ) + ) + is False + ) + + def test_message_preview_edit_selector(self): + @self.ext.message_extensions.message_preview_edit() + async def handler(ctx, state, action): ... + + selector = self.app._routes[0]["selector"] + assert ( + selector( + _make_context( + ActivityTypes.invoke, + name="composeExtension/submitAction", + value={"commandId": "cmd", "botMessagePreviewAction": "edit"}, + ) + ) + is True + ) + assert ( + selector( + _make_context( + ActivityTypes.invoke, + name="composeExtension/submitAction", + value={"commandId": "cmd", "botMessagePreviewAction": "send"}, + ) + ) + is False + ) + + def test_message_preview_send_selector(self): + @self.ext.message_extensions.message_preview_send() + async def handler(ctx, state, action): ... + + selector = self.app._routes[0]["selector"] + assert ( + selector( + _make_context( + ActivityTypes.invoke, + name="composeExtension/submitAction", + value={"commandId": "cmd", "botMessagePreviewAction": "send"}, + ) + ) + is True + ) + assert ( + selector( + _make_context( + ActivityTypes.invoke, + name="composeExtension/submitAction", + value={"commandId": "cmd", "botMessagePreviewAction": "edit"}, + ) + ) + is False + ) + + def test_submit_action_is_invoke(self): + @self.ext.message_extensions.submit_action() + async def handler(ctx, state, action): ... + + assert self.app._routes[0]["is_invoke"] is True + + +class TestMessagePreviewEdit: + + def setup_method(self): + self.app = _make_app() + self.ext = TeamsAgentExtension(self.app) + + @pytest.mark.asyncio + async def test_handler_receives_deserialized_activity(self): + from microsoft_agents.activity import Activity + + user_handler = AsyncMock() + + @self.ext.message_extensions.message_preview_edit() + async def handler(ctx, state, preview): + await user_handler(ctx, state, preview) + + route_handler = self.app._routes[0]["handler"] + ctx = _make_context( + ActivityTypes.invoke, + name="composeExtension/submitAction", + value={ + "commandId": "cmd", + "botMessagePreviewAction": "edit", + "botActivityPreview": [{"type": "message", "text": "preview text"}], + }, + ) + with patch(_PATCH, new_callable=AsyncMock): + await route_handler(ctx, MagicMock()) + preview_arg = user_handler.call_args[0][2] + assert isinstance(preview_arg, Activity) + assert preview_arg.text == "preview text" + + @pytest.mark.asyncio + async def test_raises_when_value_is_not_dict(self): + @self.ext.message_extensions.message_preview_edit() + async def handler(ctx, state, preview): ... + + route_handler = self.app._routes[0]["handler"] + ctx = _make_context( + ActivityTypes.invoke, + name="composeExtension/submitAction", + value=None, + ) + with pytest.raises(ValueError, match="botActivityPreview"): + await route_handler(ctx, MagicMock()) + + @pytest.mark.asyncio + async def test_raises_when_bot_activity_preview_is_empty(self): + @self.ext.message_extensions.message_preview_edit() + async def handler(ctx, state, preview): ... + + route_handler = self.app._routes[0]["handler"] + ctx = _make_context( + ActivityTypes.invoke, + name="composeExtension/submitAction", + value={ + "commandId": "cmd", + "botMessagePreviewAction": "edit", + "botActivityPreview": [], + }, + ) + with pytest.raises(ValueError, match="botActivityPreview"): + await route_handler(ctx, MagicMock()) + + +class TestMessagePreviewSend: + + def setup_method(self): + self.app = _make_app() + self.ext = TeamsAgentExtension(self.app) + + @pytest.mark.asyncio + async def test_handler_receives_deserialized_activity(self): + from microsoft_agents.activity import Activity + + user_handler = AsyncMock(return_value=None) + + @self.ext.message_extensions.message_preview_send() + async def handler(ctx, state, preview): + return await user_handler(ctx, state, preview) + + route_handler = self.app._routes[0]["handler"] + ctx = _make_context( + ActivityTypes.invoke, + name="composeExtension/submitAction", + value={ + "commandId": "cmd", + "botMessagePreviewAction": "send", + "botActivityPreview": [{"type": "message", "text": "send text"}], + }, + ) + with patch(_PATCH, new_callable=AsyncMock): + await route_handler(ctx, MagicMock()) + preview_arg = user_handler.call_args[0][2] + assert isinstance(preview_arg, Activity) + assert preview_arg.text == "send text" + + @pytest.mark.asyncio + async def test_raises_when_value_is_not_dict(self): + @self.ext.message_extensions.message_preview_send() + async def handler(ctx, state, preview): ... + + route_handler = self.app._routes[0]["handler"] + ctx = _make_context( + ActivityTypes.invoke, + name="composeExtension/submitAction", + value=None, + ) + with pytest.raises(ValueError, match="botActivityPreview"): + await route_handler(ctx, MagicMock()) + + @pytest.mark.asyncio + async def test_raises_when_bot_activity_preview_missing(self): + @self.ext.message_extensions.message_preview_send() + async def handler(ctx, state, preview): ... + + route_handler = self.app._routes[0]["handler"] + ctx = _make_context( + ActivityTypes.invoke, + name="composeExtension/submitAction", + value={"commandId": "cmd", "botMessagePreviewAction": "send"}, + ) + with pytest.raises(ValueError, match="botActivityPreview"): + await route_handler(ctx, MagicMock()) + + +class TestMessageExtensionFetchTask: + + def setup_method(self): + self.app = _make_app() + self.ext = TeamsAgentExtension(self.app) + + def test_fetch_task_matches_command_id(self): + @self.ext.message_extensions.fetch_task("myCmd") + async def handler(ctx, state, action): ... + + selector = self.app._routes[0]["selector"] + assert ( + selector( + _make_context( + ActivityTypes.invoke, + name="composeExtension/fetchTask", + value={"commandId": "myCmd"}, + ) + ) + is True + ) + assert ( + selector( + _make_context( + ActivityTypes.invoke, + name="composeExtension/fetchTask", + value={"commandId": "other"}, + ) + ) + is False + ) + + def test_fetch_task_no_command_id_matches_all(self): + @self.ext.message_extensions.fetch_task() + async def handler(ctx, state, action): ... + + selector = self.app._routes[0]["selector"] + assert ( + selector( + _make_context( + ActivityTypes.invoke, + name="composeExtension/fetchTask", + value={"commandId": "anything"}, + ) + ) + is True + ) + + def test_fetch_task_is_invoke(self): + @self.ext.message_extensions.fetch_task() + async def handler(ctx, state, action): ... + + assert self.app._routes[0]["is_invoke"] is True + + +class TestMessageExtensionLinkAndItem: + + def setup_method(self): + self.app = _make_app() + self.ext = TeamsAgentExtension(self.app) + + def test_query_link_selector(self): + @self.ext.message_extensions.query_link() + async def handler(ctx, state, query): ... + + selector = self.app._routes[0]["selector"] + assert ( + selector( + _make_context(ActivityTypes.invoke, name="composeExtension/queryLink") + ) + is True + ) + assert ( + selector(_make_context(ActivityTypes.invoke, name="composeExtension/query")) + is False + ) + + def test_anonymous_query_link_selector(self): + @self.ext.message_extensions.anonymous_query_link() + async def handler(ctx, state, query): ... + + selector = self.app._routes[0]["selector"] + assert ( + selector( + _make_context( + ActivityTypes.invoke, name="composeExtension/anonymousQueryLink" + ) + ) + is True + ) + assert ( + selector( + _make_context(ActivityTypes.invoke, name="composeExtension/queryLink") + ) + is False + ) + + def test_select_item_selector(self): + @self.ext.message_extensions.select_item() + async def handler(ctx, state, item): ... + + selector = self.app._routes[0]["selector"] + assert ( + selector( + _make_context(ActivityTypes.invoke, name="composeExtension/selectItem") + ) + is True + ) + assert ( + selector(_make_context(ActivityTypes.invoke, name="composeExtension/query")) + is False + ) + + def test_query_link_is_invoke(self): + @self.ext.message_extensions.query_link() + async def handler(ctx, state, query): ... + + assert self.app._routes[0]["is_invoke"] is True + + +class TestMessageExtensionSetting: + + def setup_method(self): + self.app = _make_app() + self.ext = TeamsAgentExtension(self.app) + + def test_setting_selector(self): + @self.ext.message_extensions.setting() + async def handler(ctx, state, settings): ... + + selector = self.app._routes[0]["selector"] + assert ( + selector( + _make_context(ActivityTypes.invoke, name="composeExtension/setting") + ) + is True + ) + assert ( + selector(_make_context(ActivityTypes.invoke, name="composeExtension/query")) + is False + ) + + def test_setting_is_invoke(self): + @self.ext.message_extensions.setting() + async def handler(ctx, state, settings): ... + + assert self.app._routes[0]["is_invoke"] is True + + def test_setting_direct_decorator_style(self): + @self.ext.message_extensions.setting + async def handler(ctx, state, settings): ... + + assert len(self.app._routes) == 1 + + @pytest.mark.asyncio + async def test_setting_always_sends_empty_response(self): + @self.ext.message_extensions.setting() + async def handler(ctx, state, settings): ... + + route_handler = self.app._routes[0]["handler"] + ctx = _make_context( + ActivityTypes.invoke, name="composeExtension/setting", value={} + ) + with patch(_PATCH, new_callable=AsyncMock) as mock_send: + await route_handler(ctx, MagicMock()) + mock_send.assert_awaited_once_with(ctx) + + +class TestMessageExtensionCardButtonClicked: + + def setup_method(self): + self.app = _make_app() + self.ext = TeamsAgentExtension(self.app) + + def test_card_button_clicked_selector(self): + @self.ext.message_extensions.card_button_clicked() + async def handler(ctx, state, data): ... + + selector = self.app._routes[0]["selector"] + assert ( + selector( + _make_context( + ActivityTypes.invoke, name="composeExtension/onCardButtonClicked" + ) + ) + is True + ) + assert ( + selector(_make_context(ActivityTypes.invoke, name="composeExtension/query")) + is False + ) + + def test_card_button_clicked_is_invoke(self): + @self.ext.message_extensions.card_button_clicked() + async def handler(ctx, state, data): ... + + assert self.app._routes[0]["is_invoke"] is True diff --git a/tests/hosting_teams/test_messages.py b/tests/hosting_teams/test_messages.py new file mode 100644 index 000000000..824473bf2 --- /dev/null +++ b/tests/hosting_teams/test_messages.py @@ -0,0 +1,281 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""Tests for TeamsAgentExtension.messages (message update and actionable message events).""" + +import pytest +from unittest.mock import AsyncMock, MagicMock, patch + +from microsoft_agents.activity import ActivityTypes + +from .helpers import _make_app, _make_context, is_supported_version + +pytestmark = pytest.mark.skipif( + not is_supported_version, + reason="microsoft-agents-hosting-teams tests require Python 3.12+", +) + +if is_supported_version: + from microsoft_teams.api.models import O365ConnectorCardActionQuery + from microsoft_agents.hosting.teams import TeamsAgentExtension + +_PATCH = "microsoft_agents.hosting.teams.message.message._send_invoke_response" + + +class TestMessageEdit: + + def setup_method(self): + self.app = _make_app() + self.ext = TeamsAgentExtension(self.app) + + def test_edit_selector(self): + @self.ext.messages.edit() + async def handler(ctx, state): ... + + selector = self.app._routes[0]["selector"] + assert ( + selector( + _make_context( + ActivityTypes.message_update, + channel_data={"eventType": "editMessage"}, + ) + ) + is True + ) + + def test_edit_wrong_event_type(self): + @self.ext.messages.edit() + async def handler(ctx, state): ... + + selector = self.app._routes[0]["selector"] + assert ( + selector( + _make_context( + ActivityTypes.message_update, + channel_data={"eventType": "undeleteMessage"}, + ) + ) + is False + ) + + def test_edit_wrong_channel(self): + @self.ext.messages.edit() + async def handler(ctx, state): ... + + selector = self.app._routes[0]["selector"] + assert ( + selector( + _make_context( + ActivityTypes.message_update, + channel_id="webchat", + channel_data={"eventType": "editMessage"}, + ) + ) + is False + ) + + def test_edit_is_not_invoke(self): + @self.ext.messages.edit() + async def handler(ctx, state): ... + + assert self.app._routes[0]["is_invoke"] is False + + +class TestMessageUndelete: + + def setup_method(self): + self.app = _make_app() + self.ext = TeamsAgentExtension(self.app) + + def test_undelete_selector(self): + @self.ext.messages.undelete() + async def handler(ctx, state): ... + + selector = self.app._routes[0]["selector"] + assert ( + selector( + _make_context( + ActivityTypes.message_update, + channel_data={"eventType": "undeleteMessage"}, + ) + ) + is True + ) + assert ( + selector( + _make_context( + ActivityTypes.message_update, + channel_data={"eventType": "editMessage"}, + ) + ) + is False + ) + + +class TestMessageSoftDelete: + + def setup_method(self): + self.app = _make_app() + self.ext = TeamsAgentExtension(self.app) + + def test_soft_delete_selector(self): + @self.ext.messages.soft_delete() + async def handler(ctx, state): ... + + selector = self.app._routes[0]["selector"] + assert ( + selector( + _make_context( + ActivityTypes.message_delete, + channel_data={"eventType": "softDeleteMessage"}, + ) + ) + is True + ) + assert ( + selector( + _make_context( + ActivityTypes.message_update, + channel_data={"eventType": "softDeleteMessage"}, + ) + ) + is False + ) + assert ( + selector( + _make_context( + ActivityTypes.message_delete, + channel_data={"eventType": "editMessage"}, + ) + ) + is False + ) + + def test_soft_delete_wrong_channel(self): + @self.ext.messages.soft_delete() + async def handler(ctx, state): ... + + selector = self.app._routes[0]["selector"] + assert ( + selector( + _make_context( + ActivityTypes.message_delete, + channel_id="webchat", + channel_data={"eventType": "softDeleteMessage"}, + ) + ) + is False + ) + + +class TestMessageReadReceipt: + + def setup_method(self): + self.app = _make_app() + self.ext = TeamsAgentExtension(self.app) + + def test_read_receipt_selector(self): + @self.ext.messages.read_receipt() + async def handler(ctx, state, receipt): ... + + selector = self.app._routes[0]["selector"] + assert ( + selector( + _make_context( + ActivityTypes.event, name="application/vnd.microsoft.readReceipt" + ) + ) + is True + ) + assert ( + selector( + _make_context( + ActivityTypes.event, name="application/vnd.microsoft.meetingStart" + ) + ) + is False + ) + + def test_read_receipt_is_not_invoke(self): + @self.ext.messages.read_receipt() + async def handler(ctx, state, receipt): ... + + assert self.app._routes[0]["is_invoke"] is False + + @pytest.mark.asyncio + async def test_read_receipt_handler_receives_dict(self): + user_handler = AsyncMock() + + @self.ext.messages.read_receipt() + async def handler(ctx, state, receipt): + await user_handler(ctx, state, receipt) + + route_handler = self.app._routes[0]["handler"] + ctx = _make_context( + ActivityTypes.event, + name="application/vnd.microsoft.readReceipt", + value={"lastReadMessageId": "msg123"}, + ) + await route_handler(ctx, MagicMock()) + assert user_handler.call_args[0][2] == {"lastReadMessageId": "msg123"} + + @pytest.mark.asyncio + async def test_read_receipt_raises_on_non_dict_value(self): + @self.ext.messages.read_receipt() + async def handler(ctx, state, receipt): ... + + route_handler = self.app._routes[0]["handler"] + ctx = _make_context( + ActivityTypes.event, + name="application/vnd.microsoft.readReceipt", + value='{"lastReadMessageId": "msg123"}', + ) + with pytest.raises(TypeError, match="read_receipt"): + await route_handler(ctx, MagicMock()) + + +class TestMessageExecuteAction: + + def setup_method(self): + self.app = _make_app() + self.ext = TeamsAgentExtension(self.app) + + def test_execute_action_selector(self): + @self.ext.messages.execute_action() + async def handler(ctx, state, query): ... + + selector = self.app._routes[0]["selector"] + assert ( + selector( + _make_context( + ActivityTypes.invoke, name="actionableMessage/executeAction" + ) + ) + is True + ) + assert selector(_make_context(ActivityTypes.invoke, name="task/fetch")) is False + + def test_execute_action_is_invoke(self): + @self.ext.messages.execute_action() + async def handler(ctx, state, query): ... + + assert self.app._routes[0]["is_invoke"] is True + + @pytest.mark.asyncio + async def test_execute_action_parses_query_and_sends_empty_response(self): + user_handler = AsyncMock() + + @self.ext.messages.execute_action() + async def handler(ctx, state, query: O365ConnectorCardActionQuery): + await user_handler(ctx, state, query) + + route_handler = self.app._routes[0]["handler"] + ctx = _make_context( + ActivityTypes.invoke, name="actionableMessage/executeAction", value={} + ) + with patch(_PATCH, new_callable=AsyncMock) as mock_send: + await route_handler(ctx, MagicMock()) + assert isinstance( + user_handler.call_args[0][2], O365ConnectorCardActionQuery + ) + mock_send.assert_awaited_once_with(ctx) diff --git a/tests/hosting_teams/test_task_modules.py b/tests/hosting_teams/test_task_modules.py new file mode 100644 index 000000000..ce1a4b1cd --- /dev/null +++ b/tests/hosting_teams/test_task_modules.py @@ -0,0 +1,245 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""Tests for TeamsAgentExtension.task_modules (task/fetch and task/submit invokes).""" + +import re +import pytest +from unittest.mock import AsyncMock, MagicMock, patch + +from microsoft_agents.activity import ActivityTypes + +from .helpers import _make_app, _make_context, is_supported_version + +pytestmark = pytest.mark.skipif( + not is_supported_version, + reason="microsoft-agents-hosting-teams tests require Python 3.12+", +) + +if is_supported_version: + from microsoft_teams.api.models import TaskModuleRequest, TaskModuleResponse + from microsoft_agents.hosting.teams import TeamsAgentExtension + +_PATCH = "microsoft_agents.hosting.teams.task_module.task_module._send_invoke_response" + + +class TestTaskModuleFetch: + + def setup_method(self): + self.app = _make_app() + self.ext = TeamsAgentExtension(self.app) + + # ── Selector tests ────────────────────────────────────────────────────── + + def test_fetch_no_verb_matches_any(self): + @self.ext.task_modules.fetch() + async def handler(ctx, state, req): ... + + selector = self.app._routes[0]["selector"] + ctx = _make_context(ActivityTypes.invoke, name="task/fetch", value={}) + assert selector(ctx) is True + + def test_fetch_verb_matches_exact(self): + @self.ext.task_modules.fetch("myVerb") + async def handler(ctx, state, req): ... + + selector = self.app._routes[0]["selector"] + assert ( + selector( + _make_context( + ActivityTypes.invoke, + name="task/fetch", + value={"data": {"verb": "myVerb"}}, + ) + ) + is True + ) + assert ( + selector( + _make_context( + ActivityTypes.invoke, + name="task/fetch", + value={"data": {"verb": "otherVerb"}}, + ) + ) + is False + ) + + def test_fetch_verb_matches_regex(self): + @self.ext.task_modules.fetch(re.compile(r"my.*")) + async def handler(ctx, state, req): ... + + selector = self.app._routes[0]["selector"] + assert ( + selector( + _make_context( + ActivityTypes.invoke, + name="task/fetch", + value={"data": {"verb": "mySpecialVerb"}}, + ) + ) + is True + ) + assert ( + selector( + _make_context( + ActivityTypes.invoke, + name="task/fetch", + value={"data": {"verb": "otherVerb"}}, + ) + ) + is False + ) + + def test_fetch_wrong_invoke_name(self): + @self.ext.task_modules.fetch() + async def handler(ctx, state, req): ... + + selector = self.app._routes[0]["selector"] + assert ( + selector(_make_context(ActivityTypes.invoke, name="task/submit", value={})) + is False + ) + + def test_fetch_wrong_activity_type(self): + @self.ext.task_modules.fetch() + async def handler(ctx, state, req): ... + + selector = self.app._routes[0]["selector"] + assert ( + selector(_make_context(ActivityTypes.message, name="task/fetch")) is False + ) + + def test_fetch_is_invoke(self): + @self.ext.task_modules.fetch() + async def handler(ctx, state, req): ... + + assert self.app._routes[0]["is_invoke"] is True + + # ── Handler tests ─────────────────────────────────────────────────────── + + @pytest.mark.asyncio + async def test_fetch_handler_passes_request_and_sends_response(self): + response = TaskModuleResponse() + user_handler = AsyncMock(return_value=response) + + @self.ext.task_modules.fetch() + async def handler(ctx, state, req: TaskModuleRequest): + return await user_handler(ctx, state, req) + + route_handler = self.app._routes[0]["handler"] + ctx = _make_context( + ActivityTypes.invoke, + name="task/fetch", + value={"data": {"verb": "myVerb"}, "context": None}, + ) + with patch(_PATCH, new_callable=AsyncMock) as mock_send: + await route_handler(ctx, MagicMock()) + assert user_handler.called + assert isinstance(user_handler.call_args[0][2], TaskModuleRequest) + mock_send.assert_awaited_once_with(ctx, response) + + @pytest.mark.asyncio + async def test_fetch_handler_skips_send_when_none_returned(self): + @self.ext.task_modules.fetch() + async def handler(ctx, state, req): ... + + route_handler = self.app._routes[0]["handler"] + ctx = _make_context( + ActivityTypes.invoke, + name="task/fetch", + value={"data": None, "context": None}, + ) + with patch(_PATCH, new_callable=AsyncMock) as mock_send: + await route_handler(ctx, MagicMock()) + mock_send.assert_not_awaited() + + +class TestTaskModuleSubmit: + + def setup_method(self): + self.app = _make_app() + self.ext = TeamsAgentExtension(self.app) + + def test_submit_no_verb_matches_any(self): + @self.ext.task_modules.submit() + async def handler(ctx, state, req): ... + + selector = self.app._routes[0]["selector"] + assert ( + selector(_make_context(ActivityTypes.invoke, name="task/submit", value={})) + is True + ) + + def test_submit_verb_matches_exact(self): + @self.ext.task_modules.submit("submitVerb") + async def handler(ctx, state, req): ... + + selector = self.app._routes[0]["selector"] + assert ( + selector( + _make_context( + ActivityTypes.invoke, + name="task/submit", + value={"data": {"verb": "submitVerb"}}, + ) + ) + is True + ) + assert ( + selector( + _make_context( + ActivityTypes.invoke, + name="task/submit", + value={"data": {"verb": "other"}}, + ) + ) + is False + ) + + def test_submit_wrong_invoke_name(self): + @self.ext.task_modules.submit() + async def handler(ctx, state, req): ... + + selector = self.app._routes[0]["selector"] + assert ( + selector(_make_context(ActivityTypes.invoke, name="task/fetch", value={})) + is False + ) + + def test_submit_is_invoke(self): + @self.ext.task_modules.submit() + async def handler(ctx, state, req): ... + + assert self.app._routes[0]["is_invoke"] is True + + @pytest.mark.asyncio + async def test_submit_handler_passes_request_and_sends_response(self): + response = TaskModuleResponse() + user_handler = AsyncMock(return_value=response) + + @self.ext.task_modules.submit() + async def handler(ctx, state, req: TaskModuleRequest): + return await user_handler(ctx, state, req) + + route_handler = self.app._routes[0]["handler"] + ctx = _make_context( + ActivityTypes.invoke, + name="task/submit", + value={"data": {"verb": "v"}, "context": None}, + ) + with patch(_PATCH, new_callable=AsyncMock) as mock_send: + await route_handler(ctx, MagicMock()) + assert isinstance(user_handler.call_args[0][2], TaskModuleRequest) + mock_send.assert_awaited_once_with(ctx, response) + + @pytest.mark.asyncio + async def test_submit_handler_skips_send_when_none_returned(self): + @self.ext.task_modules.submit() + async def handler(ctx, state, req): ... + + route_handler = self.app._routes[0]["handler"] + ctx = _make_context(ActivityTypes.invoke, name="task/submit", value={}) + with patch(_PATCH, new_callable=AsyncMock) as mock_send: + await route_handler(ctx, MagicMock()) + mock_send.assert_not_awaited() diff --git a/tests/hosting_teams/test_team_lifecycle.py b/tests/hosting_teams/test_team_lifecycle.py new file mode 100644 index 000000000..fc68c8546 --- /dev/null +++ b/tests/hosting_teams/test_team_lifecycle.py @@ -0,0 +1,172 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""Tests for TeamsAgentExtension.teams (team lifecycle conversation update events).""" + +import pytest + +from microsoft_agents.activity import ActivityTypes + +from .helpers import _make_app, _make_context, is_supported_version + +pytestmark = pytest.mark.skipif( + not is_supported_version, + reason="microsoft-agents-hosting-teams tests require Python 3.12+", +) + +if is_supported_version: + from microsoft_agents.hosting.teams import TeamsAgentExtension + + +class TestTeamLifecycle: + + def setup_method(self): + self.app = _make_app() + self.ext = TeamsAgentExtension(self.app) + + def _selector_for(self, event_type: str): + """Register a no-op handler and return the registered selector.""" + self.app._routes.clear() + + async def handler(ctx, state, data): ... + + return handler + + def test_archived_selector(self): + @self.ext.teams.archived() + async def handler(ctx, state, data): ... + + selector = self.app._routes[0]["selector"] + assert ( + selector( + _make_context( + ActivityTypes.conversation_update, + channel_data={"eventType": "teamArchived"}, + ) + ) + is True + ) + assert ( + selector( + _make_context( + ActivityTypes.conversation_update, + channel_data={"eventType": "teamDeleted"}, + ) + ) + is False + ) + + def test_deleted_selector(self): + @self.ext.teams.deleted() + async def handler(ctx, state, data): ... + + selector = self.app._routes[0]["selector"] + assert ( + selector( + _make_context( + ActivityTypes.conversation_update, + channel_data={"eventType": "teamDeleted"}, + ) + ) + is True + ) + + def test_hard_deleted_selector(self): + @self.ext.teams.hard_deleted() + async def handler(ctx, state, data): ... + + selector = self.app._routes[0]["selector"] + assert ( + selector( + _make_context( + ActivityTypes.conversation_update, + channel_data={"eventType": "teamHardDeleted"}, + ) + ) + is True + ) + assert ( + selector( + _make_context( + ActivityTypes.conversation_update, + channel_data={"eventType": "teamDeleted"}, + ) + ) + is False + ) + + def test_renamed_selector(self): + @self.ext.teams.renamed() + async def handler(ctx, state, data): ... + + selector = self.app._routes[0]["selector"] + assert ( + selector( + _make_context( + ActivityTypes.conversation_update, + channel_data={"eventType": "teamRenamed"}, + ) + ) + is True + ) + + def test_restored_selector(self): + @self.ext.teams.restored() + async def handler(ctx, state, data): ... + + selector = self.app._routes[0]["selector"] + assert ( + selector( + _make_context( + ActivityTypes.conversation_update, + channel_data={"eventType": "teamRestored"}, + ) + ) + is True + ) + assert ( + selector( + _make_context( + ActivityTypes.conversation_update, + channel_data={"eventType": "teamArchived"}, + ) + ) + is False + ) + + def test_unarchived_selector(self): + @self.ext.teams.unarchived() + async def handler(ctx, state, data): ... + + selector = self.app._routes[0]["selector"] + assert ( + selector( + _make_context( + ActivityTypes.conversation_update, + channel_data={"eventType": "teamUnarchived"}, + ) + ) + is True + ) + + def test_team_event_requires_msteams_channel(self): + @self.ext.teams.archived() + async def handler(ctx, state, data): ... + + selector = self.app._routes[0]["selector"] + assert ( + selector( + _make_context( + ActivityTypes.conversation_update, + channel_id="webchat", + channel_data={"eventType": "teamArchived"}, + ) + ) + is False + ) + + def test_team_event_is_not_invoke(self): + @self.ext.teams.archived() + async def handler(ctx, state, data): ... + + assert self.app._routes[0]["is_invoke"] is False diff --git a/tests/hosting_teams/test_teams_agent_extension.py b/tests/hosting_teams/test_teams_agent_extension.py index ac7f1529a..55fac396b 100644 --- a/tests/hosting_teams/test_teams_agent_extension.py +++ b/tests/hosting_teams/test_teams_agent_extension.py @@ -1,996 +1,61 @@ -""" -Copyright (c) Microsoft Corporation. All rights reserved. -Licensed under the MIT License. -""" +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""Tests for TeamsAgentExtension property accessors and top-level wrapping.""" -import re -import sys import pytest -from unittest.mock import AsyncMock, MagicMock, patch -is_supported_version = sys.version_info >= (3, 12) +from .helpers import _make_app, is_supported_version pytestmark = pytest.mark.skipif( not is_supported_version, reason="microsoft-agents-hosting-teams tests require Python 3.12+", ) -from microsoft_agents.activity import Activity, ActivityTypes -from microsoft_agents.hosting.core import TurnContext -from microsoft_agents.hosting.core.app import AgentApplication, RouteRank - if is_supported_version: - from microsoft_agents.activity.teams import ( - MeetingParticipantsEventDetails, - ReadReceiptInfo, - ) - from microsoft_teams.api.models import ( - MeetingDetails, - MessagingExtensionQuery, - MessagingExtensionResponse, - O365ConnectorCardActionQuery, - TaskModuleRequest, - TaskModuleResponse, - ) from microsoft_agents.hosting.teams import TeamsAgentExtension + from microsoft_agents.hosting.teams.channel import Channel + from microsoft_agents.hosting.teams.config import Config + from microsoft_agents.hosting.teams.file_consent import FileConsent + from microsoft_agents.hosting.teams.meeting import Meeting + from microsoft_agents.hosting.teams.message import Message + from microsoft_agents.hosting.teams.message_extension import MessageExtension + from microsoft_agents.hosting.teams.task_module import TaskModule + from microsoft_agents.hosting.teams.team import Team -def _make_app() -> AgentApplication: - app = MagicMock(spec=AgentApplication) - app._routes = [] - - def _add_route(selector, handler, is_invoke=False, rank=RouteRank.DEFAULT, auth_handlers=None): - app._routes.append( - dict( - selector=selector, - handler=handler, - is_invoke=is_invoke, - rank=rank, - auth_handlers=auth_handlers, - ) - ) - - app.add_route.side_effect = _add_route - return app - - -def _meeting_details_value() -> dict: - return { - "id": "meeting-id", - "type": "scheduled", - "joinUrl": "https://example.com/meet", - "title": "Test Meeting", - "msGraphResourceId": "graph-id", - } - - -def _make_context( - activity_type: str, - name: str = None, - value: dict = None, - channel_id: str = "msteams", - channel_data: dict = None, - members_added=None, - members_removed=None, -) -> TurnContext: - context = MagicMock(spec=TurnContext) - activity = MagicMock(spec=Activity) - activity.type = activity_type - activity.name = name - activity.value = value - activity.channel_id = channel_id - activity.channel_data = channel_data - activity.members_added = members_added - activity.members_removed = members_removed - context.activity = activity - context.send_activity = AsyncMock() - return context - - -class TestTaskModule: - - def setup_method(self): - self.app = _make_app() - self.teams = TeamsAgentExtension(self.app) - - # ── Selector tests ────────────────────────────────────────────────────── - - @pytestmark - def test_on_fetch_no_verb_matches_any(self): - @self.teams.task_module.on_fetch() - async def handler(ctx, state, req): ... - - selector = self.app._routes[0]["selector"] - ctx = _make_context(ActivityTypes.invoke, name="task/fetch", value={}) - assert selector(ctx) is True - - @pytestmark - def test_on_fetch_verb_matches_exact(self): - @self.teams.task_module.on_fetch("myVerb") - async def handler(ctx, state, req): ... - - selector = self.app._routes[0]["selector"] - ctx_match = _make_context( - ActivityTypes.invoke, name="task/fetch", - value={"data": {"verb": "myVerb"}} - ) - ctx_no_match = _make_context( - ActivityTypes.invoke, name="task/fetch", - value={"data": {"verb": "otherVerb"}} - ) - assert selector(ctx_match) is True - assert selector(ctx_no_match) is False - - @pytestmark - def test_on_fetch_verb_matches_regex(self): - @self.teams.task_module.on_fetch(re.compile(r"my.*")) - async def handler(ctx, state, req): ... - - selector = self.app._routes[0]["selector"] - ctx_match = _make_context( - ActivityTypes.invoke, name="task/fetch", - value={"data": {"verb": "mySpecialVerb"}} - ) - ctx_no_match = _make_context( - ActivityTypes.invoke, name="task/fetch", - value={"data": {"verb": "otherVerb"}} - ) - assert selector(ctx_match) is True - assert selector(ctx_no_match) is False - - @pytestmark - def test_on_fetch_wrong_invoke_name(self): - @self.teams.task_module.on_fetch() - async def handler(ctx, state, req): ... - - selector = self.app._routes[0]["selector"] - ctx = _make_context(ActivityTypes.invoke, name="task/submit", value={}) - assert selector(ctx) is False - - @pytestmark - def test_on_fetch_wrong_activity_type(self): - @self.teams.task_module.on_fetch() - async def handler(ctx, state, req): ... - - selector = self.app._routes[0]["selector"] - ctx = _make_context(ActivityTypes.message, name="task/fetch") - assert selector(ctx) is False - - @pytestmark - def test_on_submit_selector(self): - @self.teams.task_module.on_submit("submitVerb") - async def handler(ctx, state, req): ... - - selector = self.app._routes[0]["selector"] - ctx_match = _make_context( - ActivityTypes.invoke, name="task/submit", - value={"data": {"verb": "submitVerb"}} - ) - ctx_no_match = _make_context( - ActivityTypes.invoke, name="task/submit", - value={"data": {"verb": "other"}} - ) - assert selector(ctx_match) is True - assert selector(ctx_no_match) is False - - @pytestmark - def test_on_fetch_is_invoke(self): - @self.teams.task_module.on_fetch() - async def handler(ctx, state, req): ... - - assert self.app._routes[0]["is_invoke"] is True - - @pytestmark - def test_on_submit_is_invoke(self): - @self.teams.task_module.on_submit() - async def handler(ctx, state, req): ... - - assert self.app._routes[0]["is_invoke"] is True - - # ── Handler tests ─────────────────────────────────────────────────────── - - @pytestmark - @pytest.mark.asyncio - async def test_on_fetch_handler_passes_request_and_sends_response(self): - response = TaskModuleResponse() - user_handler = AsyncMock(return_value=response) - - @self.teams.task_module.on_fetch() - async def handler(ctx, state, req: TaskModuleRequest): - return await user_handler(ctx, state, req) - - route_handler = self.app._routes[0]["handler"] - state = MagicMock() - ctx = _make_context( - ActivityTypes.invoke, name="task/fetch", - value={"data": {"verb": "myVerb"}, "context": None} - ) - with patch( - "microsoft_agents.hosting.teams.teams_agent_extension._send_invoke_response", - new_callable=AsyncMock, - ) as mock_send: - await route_handler(ctx, state) - assert user_handler.called - args = user_handler.call_args[0] - assert isinstance(args[2], TaskModuleRequest) - mock_send.assert_awaited_once_with(ctx, response) - - @pytestmark - @pytest.mark.asyncio - async def test_on_fetch_handler_skips_send_when_none_returned(self): - @self.teams.task_module.on_fetch() - async def handler(ctx, state, req): ... - - route_handler = self.app._routes[0]["handler"] - ctx = _make_context( - ActivityTypes.invoke, name="task/fetch", - value={"data": None, "context": None} - ) - with patch( - "microsoft_agents.hosting.teams.teams_agent_extension._send_invoke_response", - new_callable=AsyncMock, - ) as mock_send: - await route_handler(ctx, MagicMock()) - mock_send.assert_not_awaited() - - -class TestMessageExtension: - - def setup_method(self): - self.app = _make_app() - self.teams = TeamsAgentExtension(self.app) - - @pytestmark - def test_on_query_matches_by_command_id(self): - @self.teams.message_extension.on_query("searchCmd") - async def handler(ctx, state, query): ... - - selector = self.app._routes[0]["selector"] - ctx_match = _make_context( - ActivityTypes.invoke, - name="composeExtension/query", - value={"commandId": "searchCmd", "parameters": [{"name": "searchQuery", "value": "pizza"}]}, - ) - ctx_no_match = _make_context( - ActivityTypes.invoke, - name="composeExtension/query", - value={"commandId": "other", "parameters": [{"name": "searchQuery", "value": "searchCmd"}]}, - ) - assert selector(ctx_match) is True - assert selector(ctx_no_match) is False - - @pytestmark - def test_on_query_no_selector_matches_all(self): - @self.teams.message_extension.on_query() - async def handler(ctx, state, query): ... - - selector = self.app._routes[0]["selector"] - ctx = _make_context( - ActivityTypes.invoke, - name="composeExtension/query", - value={"parameters": [{"name": "searchQuery", "value": "anything"}]}, - ) - ctx_no_params = _make_context( - ActivityTypes.invoke, - name="composeExtension/query", - value={}, - ) - assert selector(ctx) is True - assert selector(ctx_no_params) is True - - @pytestmark - def test_on_query_is_invoke(self): - @self.teams.message_extension.on_query() - async def handler(ctx, state, query): ... - - assert self.app._routes[0]["is_invoke"] is True - - @pytestmark - def test_on_submit_action_excludes_preview(self): - @self.teams.message_extension.on_submit_action() - async def handler(ctx, state, action): ... - - selector = self.app._routes[0]["selector"] - ctx_normal = _make_context( - ActivityTypes.invoke, - name="composeExtension/submitAction", - value={"commandId": "cmd"}, - ) - ctx_preview = _make_context( - ActivityTypes.invoke, - name="composeExtension/submitAction", - value={"commandId": "cmd", "botMessagePreviewAction": "edit"}, - ) - assert selector(ctx_normal) is True - assert selector(ctx_preview) is False - - @pytestmark - def test_on_agent_message_preview_edit_selector(self): - @self.teams.message_extension.on_agent_message_preview_edit() - async def handler(ctx, state, action): ... - - selector = self.app._routes[0]["selector"] - ctx_edit = _make_context( - ActivityTypes.invoke, - name="composeExtension/submitAction", - value={"commandId": "cmd", "botMessagePreviewAction": "edit"}, - ) - ctx_send = _make_context( - ActivityTypes.invoke, - name="composeExtension/submitAction", - value={"commandId": "cmd", "botMessagePreviewAction": "send"}, - ) - assert selector(ctx_edit) is True - assert selector(ctx_send) is False - - @pytestmark - def test_on_agent_message_preview_send_selector(self): - @self.teams.message_extension.on_agent_message_preview_send() - async def handler(ctx, state, action): ... - - selector = self.app._routes[0]["selector"] - ctx_send = _make_context( - ActivityTypes.invoke, - name="composeExtension/submitAction", - value={"commandId": "cmd", "botMessagePreviewAction": "send"}, - ) - ctx_edit = _make_context( - ActivityTypes.invoke, - name="composeExtension/submitAction", - value={"commandId": "cmd", "botMessagePreviewAction": "edit"}, - ) - assert selector(ctx_send) is True - assert selector(ctx_edit) is False - - @pytestmark - def test_on_fetch_task_matches_command_id(self): - @self.teams.message_extension.on_fetch_task("myCmd") - async def handler(ctx, state, action): ... - - selector = self.app._routes[0]["selector"] - ctx_match = _make_context( - ActivityTypes.invoke, - name="composeExtension/fetchTask", - value={"commandId": "myCmd"}, - ) - ctx_no_match = _make_context( - ActivityTypes.invoke, - name="composeExtension/fetchTask", - value={"commandId": "other"}, - ) - assert selector(ctx_match) is True - assert selector(ctx_no_match) is False - - @pytestmark - def test_on_query_link_selector(self): - @self.teams.message_extension.on_query_link - async def handler(ctx, state, query): ... - - selector = self.app._routes[0]["selector"] - ctx = _make_context(ActivityTypes.invoke, name="composeExtension/queryLink") - ctx_other = _make_context(ActivityTypes.invoke, name="composeExtension/query") - assert selector(ctx) is True - assert selector(ctx_other) is False - - @pytestmark - def test_on_anonymous_query_link_selector(self): - @self.teams.message_extension.on_anonymous_query_link - async def handler(ctx, state, query): ... - - selector = self.app._routes[0]["selector"] - ctx = _make_context(ActivityTypes.invoke, name="composeExtension/anonymousQueryLink") - assert selector(ctx) is True - - @pytestmark - def test_on_select_item_selector(self): - @self.teams.message_extension.on_select_item - async def handler(ctx, state, item): ... - - selector = self.app._routes[0]["selector"] - ctx = _make_context(ActivityTypes.invoke, name="composeExtension/selectItem") - ctx_other = _make_context(ActivityTypes.invoke, name="composeExtension/query") - assert selector(ctx) is True - assert selector(ctx_other) is False - - @pytestmark - def test_on_configure_settings_sends_empty_response(self): - """on_configure_settings always sends a 200 regardless of handler return value.""" - - @self.teams.message_extension.on_configure_settings - async def handler(ctx, state, settings): ... - - assert self.app._routes[0]["is_invoke"] is True - - @pytestmark - def test_on_card_button_clicked_selector(self): - @self.teams.message_extension.on_card_button_clicked - async def handler(ctx, state, data): ... - - selector = self.app._routes[0]["selector"] - ctx = _make_context(ActivityTypes.invoke, name="composeExtension/onCardButtonClicked") - assert selector(ctx) is True - - @pytestmark - @pytest.mark.asyncio - async def test_on_query_handler_parses_query_and_sends_response(self): - response = MessagingExtensionResponse() - user_handler = AsyncMock(return_value=response) - - @self.teams.message_extension.on_query() - async def handler(ctx, state, query: MessagingExtensionQuery): - return await user_handler(ctx, state, query) - - route_handler = self.app._routes[0]["handler"] - ctx = _make_context( - ActivityTypes.invoke, - name="composeExtension/query", - value={"commandId": "search"}, - ) - with patch( - "microsoft_agents.hosting.teams.teams_agent_extension._send_invoke_response", - new_callable=AsyncMock, - ) as mock_send: - await route_handler(ctx, MagicMock()) - args = user_handler.call_args[0] - assert isinstance(args[2], MessagingExtensionQuery) - mock_send.assert_awaited_once_with(ctx, response) - - @pytestmark - @pytest.mark.asyncio - async def test_on_configure_settings_always_sends_response(self): - @self.teams.message_extension.on_configure_settings - async def handler(ctx, state, settings): ... - - route_handler = self.app._routes[0]["handler"] - ctx = _make_context( - ActivityTypes.invoke, name="composeExtension/setting", value={} - ) - with patch( - "microsoft_agents.hosting.teams.teams_agent_extension._send_invoke_response", - new_callable=AsyncMock, - ) as mock_send: - await route_handler(ctx, MagicMock()) - mock_send.assert_awaited_once_with(ctx) - - -class TestMeeting: - - def setup_method(self): - self.app = _make_app() - self.teams = TeamsAgentExtension(self.app) - - @pytestmark - def test_on_start_selector(self): - @self.teams.meeting.on_start - async def handler(ctx, state, meeting): ... - - selector = self.app._routes[0]["selector"] - ctx = _make_context(ActivityTypes.event, name="application/vnd.microsoft.meetingStart") - ctx_other = _make_context(ActivityTypes.event, name="application/vnd.microsoft.meetingEnd") - assert selector(ctx) is True - assert selector(ctx_other) is False - - @pytestmark - def test_on_end_selector(self): - @self.teams.meeting.on_end - async def handler(ctx, state, meeting): ... - - selector = self.app._routes[0]["selector"] - ctx = _make_context(ActivityTypes.event, name="application/vnd.microsoft.meetingEnd") - assert selector(ctx) is True - - @pytestmark - def test_on_participants_join_selector(self): - @self.teams.meeting.on_participants_join - async def handler(ctx, state, details): ... - - selector = self.app._routes[0]["selector"] - ctx = _make_context( - ActivityTypes.event, - name="application/vnd.microsoft.meetingParticipantJoin", - ) - assert selector(ctx) is True - - @pytestmark - def test_on_participants_leave_selector(self): - @self.teams.meeting.on_participants_leave - async def handler(ctx, state, details): ... - - selector = self.app._routes[0]["selector"] - ctx = _make_context( - ActivityTypes.event, - name="application/vnd.microsoft.meetingParticipantLeave", - ) - assert selector(ctx) is True - - @pytestmark - def test_on_start_is_not_invoke(self): - @self.teams.meeting.on_start - async def handler(ctx, state, meeting): ... - - assert self.app._routes[0]["is_invoke"] is False - - @pytestmark - @pytest.mark.asyncio - async def test_on_start_handler_parses_meeting_details(self): - user_handler = AsyncMock() - - @self.teams.meeting.on_start - async def handler(ctx, state, meeting: MeetingDetails): - await user_handler(ctx, state, meeting) - - route_handler = self.app._routes[0]["handler"] - ctx = _make_context( - ActivityTypes.event, - name="application/vnd.microsoft.meetingStart", - value=_meeting_details_value(), - ) - await route_handler(ctx, MagicMock()) - args = user_handler.call_args[0] - assert isinstance(args[2], MeetingDetails) - - @pytestmark - @pytest.mark.asyncio - async def test_on_end_handler_parses_meeting_details(self): - user_handler = AsyncMock() - - @self.teams.meeting.on_end - async def handler(ctx, state, meeting: MeetingDetails): - await user_handler(ctx, state, meeting) - - route_handler = self.app._routes[0]["handler"] - ctx = _make_context( - ActivityTypes.event, - name="application/vnd.microsoft.meetingEnd", - value=_meeting_details_value(), - ) - await route_handler(ctx, MagicMock()) - args = user_handler.call_args[0] - assert isinstance(args[2], MeetingDetails) - - @pytestmark - @pytest.mark.asyncio - async def test_on_participants_join_handler_parses_details(self): - user_handler = AsyncMock() - - @self.teams.meeting.on_participants_join - async def handler(ctx, state, details: MeetingParticipantsEventDetails): - await user_handler(ctx, state, details) - - route_handler = self.app._routes[0]["handler"] - ctx = _make_context( - ActivityTypes.event, - name="application/vnd.microsoft.meetingParticipantJoin", - value={}, - ) - await route_handler(ctx, MagicMock()) - args = user_handler.call_args[0] - assert isinstance(args[2], MeetingParticipantsEventDetails) - - -class TestTeamsAgentExtensionTopLevel: +class TestTeamsAgentExtensionProperties: def setup_method(self): self.app = _make_app() - self.teams = TeamsAgentExtension(self.app) - - # ── Message edit / undelete / soft delete ─────────────────────────────── - - @pytestmark - def test_on_message_edit_selector(self): - @self.teams.on_message_edit - async def handler(ctx, state): ... - - selector = self.app._routes[0]["selector"] - ctx = _make_context( - ActivityTypes.message_update, - channel_data={"eventType": "editMessage"}, - ) - assert selector(ctx) is True - - @pytestmark - def test_on_message_edit_wrong_event_type(self): - @self.teams.on_message_edit - async def handler(ctx, state): ... - - selector = self.app._routes[0]["selector"] - ctx = _make_context( - ActivityTypes.message_update, - channel_data={"eventType": "undeleteMessage"}, - ) - assert selector(ctx) is False - - @pytestmark - def test_on_message_edit_wrong_channel(self): - @self.teams.on_message_edit - async def handler(ctx, state): ... - - selector = self.app._routes[0]["selector"] - ctx = _make_context( - ActivityTypes.message_update, - channel_id="webchat", - channel_data={"eventType": "editMessage"}, - ) - assert selector(ctx) is False - - @pytestmark - def test_on_message_undelete_selector(self): - @self.teams.on_message_undelete - async def handler(ctx, state): ... - - selector = self.app._routes[0]["selector"] - ctx = _make_context( - ActivityTypes.message_update, - channel_data={"eventType": "undeleteMessage"}, - ) - assert selector(ctx) is True - - @pytestmark - def test_on_message_soft_delete_selector(self): - @self.teams.on_message_soft_delete - async def handler(ctx, state): ... - - selector = self.app._routes[0]["selector"] - ctx = _make_context( - ActivityTypes.message_delete, - channel_data={"eventType": "softDeleteMessage"}, - ) - assert selector(ctx) is True - - # ── Read receipt ─────────────────────────────────────────────────────── - - @pytestmark - def test_on_read_receipt_selector(self): - @self.teams.on_read_receipt - async def handler(ctx, state, receipt): ... - - selector = self.app._routes[0]["selector"] - ctx = _make_context(ActivityTypes.event, name="application/vnd.microsoft.readReceipt") - assert selector(ctx) is True - - @pytestmark - @pytest.mark.asyncio - async def test_on_read_receipt_handler_parses_receipt(self): - user_handler = AsyncMock() - - @self.teams.on_read_receipt - async def handler(ctx, state, receipt: ReadReceiptInfo): - await user_handler(ctx, state, receipt) - - route_handler = self.app._routes[0]["handler"] - ctx = _make_context( - ActivityTypes.event, - name="application/vnd.microsoft.readReceipt", - value={}, - ) - await route_handler(ctx, MagicMock()) - args = user_handler.call_args[0] - assert isinstance(args[2], ReadReceiptInfo) - - # ── Config ───────────────────────────────────────────────────────────── - - @pytestmark - def test_on_config_fetch_selector(self): - @self.teams.on_config_fetch - async def handler(ctx, state, data): ... - - selector = self.app._routes[0]["selector"] - ctx = _make_context(ActivityTypes.invoke, name="config/fetch") - ctx_other = _make_context(ActivityTypes.invoke, name="config/submit") - assert selector(ctx) is True - assert selector(ctx_other) is False - - @pytestmark - def test_on_config_fetch_is_invoke(self): - @self.teams.on_config_fetch - async def handler(ctx, state, data): ... - - assert self.app._routes[0]["is_invoke"] is True - - @pytestmark - def test_on_config_submit_selector(self): - @self.teams.on_config_submit - async def handler(ctx, state, data): ... - - selector = self.app._routes[0]["selector"] - ctx = _make_context(ActivityTypes.invoke, name="config/submit") - assert selector(ctx) is True - - # ── File consent ─────────────────────────────────────────────────────── - - @pytestmark - def test_on_file_consent_accept_selector(self): - @self.teams.on_file_consent_accept - async def handler(ctx, state, data): ... - - selector = self.app._routes[0]["selector"] - ctx_accept = _make_context( - ActivityTypes.invoke, - name="fileConsent/invoke", - value={"action": "accept"}, - ) - ctx_decline = _make_context( - ActivityTypes.invoke, - name="fileConsent/invoke", - value={"action": "decline"}, - ) - assert selector(ctx_accept) is True - assert selector(ctx_decline) is False - - @pytestmark - def test_on_file_consent_decline_selector(self): - @self.teams.on_file_consent_decline - async def handler(ctx, state, data): ... - - selector = self.app._routes[0]["selector"] - ctx_decline = _make_context( - ActivityTypes.invoke, - name="fileConsent/invoke", - value={"action": "decline"}, - ) - ctx_accept = _make_context( - ActivityTypes.invoke, - name="fileConsent/invoke", - value={"action": "accept"}, - ) - assert selector(ctx_decline) is True - assert selector(ctx_accept) is False - - @pytestmark - @pytest.mark.asyncio - async def test_on_file_consent_accept_sends_empty_response(self): - @self.teams.on_file_consent_accept - async def handler(ctx, state, data): ... - - route_handler = self.app._routes[0]["handler"] - ctx = _make_context( - ActivityTypes.invoke, - name="fileConsent/invoke", - value={"action": "accept", "context": None, "uploadInfo": None}, - ) - with patch( - "microsoft_agents.hosting.teams.teams_agent_extension._send_invoke_response", - new_callable=AsyncMock, - ) as mock_send: - await route_handler(ctx, MagicMock()) - mock_send.assert_awaited_once_with(ctx) - - # ── O365 ─────────────────────────────────────────────────────────────── - - @pytestmark - def test_on_o365_connector_card_action_selector(self): - @self.teams.on_o365_connector_card_action - async def handler(ctx, state, query): ... - - selector = self.app._routes[0]["selector"] - ctx = _make_context(ActivityTypes.invoke, name="actionableMessage/executeAction") - assert selector(ctx) is True - - @pytestmark - @pytest.mark.asyncio - async def test_on_o365_connector_card_action_parses_query(self): - user_handler = AsyncMock() - - @self.teams.on_o365_connector_card_action - async def handler(ctx, state, query: O365ConnectorCardActionQuery): - await user_handler(ctx, state, query) - - route_handler = self.app._routes[0]["handler"] - ctx = _make_context( - ActivityTypes.invoke, - name="actionableMessage/executeAction", - value={}, - ) - with patch( - "microsoft_agents.hosting.teams.teams_agent_extension._send_invoke_response", - new_callable=AsyncMock, - ): - await route_handler(ctx, MagicMock()) - args = user_handler.call_args[0] - assert isinstance(args[2], O365ConnectorCardActionQuery) - - # ── Conversation update events ───────────────────────────────────────── - - @pytestmark - def test_on_members_added_selector(self): - @self.teams.on_members_added - async def handler(ctx, state): ... - - selector = self.app._routes[0]["selector"] - member = MagicMock() - ctx = _make_context( - ActivityTypes.conversation_update, - members_added=[member], - ) - ctx_empty = _make_context( - ActivityTypes.conversation_update, - members_added=[], - ) - assert selector(ctx) is True - assert selector(ctx_empty) is False - - @pytestmark - def test_on_members_added_requires_teams_channel(self): - @self.teams.on_members_added - async def handler(ctx, state): ... - - selector = self.app._routes[0]["selector"] - member = MagicMock() - ctx = _make_context( - ActivityTypes.conversation_update, - channel_id="webchat", - members_added=[member], - ) - assert selector(ctx) is False - - @pytestmark - def test_on_members_removed_selector(self): - @self.teams.on_members_removed - async def handler(ctx, state): ... - - selector = self.app._routes[0]["selector"] - member = MagicMock() - ctx = _make_context( - ActivityTypes.conversation_update, - members_removed=[member], - ) - assert selector(ctx) is True - - @pytestmark - def test_on_channel_created_selector(self): - @self.teams.on_channel_created - async def handler(ctx, state): ... - - selector = self.app._routes[0]["selector"] - ctx = _make_context( - ActivityTypes.conversation_update, - channel_data={"eventType": "channelCreated"}, - ) - ctx_other = _make_context( - ActivityTypes.conversation_update, - channel_data={"eventType": "channelDeleted"}, - ) - assert selector(ctx) is True - assert selector(ctx_other) is False - - @pytestmark - def test_on_channel_deleted_selector(self): - @self.teams.on_channel_deleted - async def handler(ctx, state): ... - - selector = self.app._routes[0]["selector"] - ctx = _make_context( - ActivityTypes.conversation_update, - channel_data={"eventType": "channelDeleted"}, - ) - assert selector(ctx) is True - - @pytestmark - def test_on_channel_renamed_selector(self): - @self.teams.on_channel_renamed - async def handler(ctx, state): ... - - selector = self.app._routes[0]["selector"] - ctx = _make_context( - ActivityTypes.conversation_update, - channel_data={"eventType": "channelRenamed"}, - ) - assert selector(ctx) is True - - @pytestmark - def test_on_channel_restored_selector(self): - @self.teams.on_channel_restored - async def handler(ctx, state): ... - - selector = self.app._routes[0]["selector"] - ctx = _make_context( - ActivityTypes.conversation_update, - channel_data={"eventType": "channelRestored"}, - ) - assert selector(ctx) is True - - @pytestmark - def test_on_team_archived_selector(self): - @self.teams.on_team_archived - async def handler(ctx, state): ... - - selector = self.app._routes[0]["selector"] - ctx = _make_context( - ActivityTypes.conversation_update, - channel_data={"eventType": "teamArchived"}, - ) - assert selector(ctx) is True - - @pytestmark - def test_on_team_deleted_selector(self): - @self.teams.on_team_deleted - async def handler(ctx, state): ... - - selector = self.app._routes[0]["selector"] - ctx = _make_context( - ActivityTypes.conversation_update, - channel_data={"eventType": "teamDeleted"}, - ) - assert selector(ctx) is True - - @pytestmark - def test_on_team_hard_deleted_selector(self): - @self.teams.on_team_hard_deleted - async def handler(ctx, state): ... - - selector = self.app._routes[0]["selector"] - ctx = _make_context( - ActivityTypes.conversation_update, - channel_data={"eventType": "teamHardDeleted"}, - ) - assert selector(ctx) is True - - @pytestmark - def test_on_team_renamed_selector(self): - @self.teams.on_team_renamed - async def handler(ctx, state): ... - - selector = self.app._routes[0]["selector"] - ctx = _make_context( - ActivityTypes.conversation_update, - channel_data={"eventType": "teamRenamed"}, - ) - assert selector(ctx) is True - - @pytestmark - def test_on_team_restored_selector(self): - @self.teams.on_team_restored - async def handler(ctx, state): ... - - selector = self.app._routes[0]["selector"] - ctx = _make_context( - ActivityTypes.conversation_update, - channel_data={"eventType": "teamRestored"}, - ) - assert selector(ctx) is True - - @pytestmark - def test_on_team_unarchived_selector(self): - @self.teams.on_team_unarchived - async def handler(ctx, state): ... - - selector = self.app._routes[0]["selector"] - ctx = _make_context( - ActivityTypes.conversation_update, - channel_data={"eventType": "teamUnarchived"}, - ) - assert selector(ctx) is True - - # ── Sub-object accessors ──────────────────────────────────────────────── + self.ext = TeamsAgentExtension(self.app) - @pytestmark - def test_properties_return_sub_objects(self): - from microsoft_agents.hosting.teams.teams_agent_extension import ( - MessageExtension, - TaskModule, - Meeting, - ) + def test_channels_property_returns_channel(self): + assert isinstance(self.ext.channels, Channel) - assert isinstance(self.teams.message_extension, MessageExtension) - assert isinstance(self.teams.task_module, TaskModule) - assert isinstance(self.teams.meeting, Meeting) + def test_config_property_returns_config(self): + assert isinstance(self.ext.config, Config) - # ── Decorator style without args ──────────────────────────────────────── + def test_file_consent_property_returns_file_consent(self): + assert isinstance(self.ext.file_consent, FileConsent) - @pytestmark - def test_on_message_edit_direct_decorator(self): - """Verify on_message_edit works as @teams.on_message_edit (no call parens).""" + def test_meetings_property_returns_meeting(self): + assert isinstance(self.ext.meetings, Meeting) - @self.teams.on_message_edit - async def handler(ctx, state): ... + def test_messages_property_returns_message(self): + assert isinstance(self.ext.messages, Message) - assert len(self.app._routes) == 1 + def test_message_extensions_property_returns_message_extension(self): + assert isinstance(self.ext.message_extensions, MessageExtension) - @pytestmark - def test_on_message_edit_factory_decorator(self): - """Verify on_message_edit works as @teams.on_message_edit() (with call parens).""" + def test_task_modules_property_returns_task_module(self): + assert isinstance(self.ext.task_modules, TaskModule) - @self.teams.on_message_edit() - async def handler(ctx, state): ... + def test_teams_property_returns_team(self): + assert isinstance(self.ext.teams, Team) - assert len(self.app._routes) == 1 + def test_properties_return_same_instance_each_call(self): + assert self.ext.channels is self.ext.channels + assert self.ext.meetings is self.ext.meetings + assert self.ext.message_extensions is self.ext.message_extensions + assert self.ext.task_modules is self.ext.task_modules diff --git a/tests/hosting_teams/test_utils.py b/tests/hosting_teams/test_utils.py new file mode 100644 index 000000000..2075132b5 --- /dev/null +++ b/tests/hosting_teams/test_utils.py @@ -0,0 +1,150 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""Tests for Teams hosting internal utilities (_utils.py).""" + +import re +import pytest +from unittest.mock import AsyncMock, MagicMock + +from .helpers import _make_context, is_supported_version + +pytestmark = pytest.mark.skipif( + not is_supported_version, + reason="microsoft-agents-hosting-teams tests require Python 3.12+", +) + +if is_supported_version: + from microsoft_agents.activity import Activity, ActivityTypes + from microsoft_teams.api.models.channel_data import ChannelData + from microsoft_agents.hosting.teams._utils import ( + _get_channel_data, + _get_channel_event_type, + _match_selector, + _send_invoke_response, + ) + + +class TestMatchSelector: + + def test_none_selector_matches_any_value(self): + assert _match_selector(None, "anything") is True + + def test_none_selector_matches_none_value(self): + assert _match_selector(None, None) is True + + def test_string_exact_match(self): + assert _match_selector("cmd", "cmd") is True + + def test_string_no_match(self): + assert _match_selector("cmd", "other") is False + + def test_string_does_not_partial_match(self): + assert _match_selector("cmd", "cmdExtra") is False + + def test_none_value_does_not_match_non_none_selector(self): + assert _match_selector("cmd", None) is False + + def test_regex_full_match(self): + assert _match_selector(re.compile(r"cmd\d+"), "cmd42") is True + + def test_regex_no_match(self): + assert _match_selector(re.compile(r"cmd\d+"), "other") is False + + def test_regex_requires_full_match(self): + assert _match_selector(re.compile(r"cmd"), "cmdExtra") is False + + +class TestGetChannelEventType: + + def test_returns_none_when_channel_data_absent(self): + ctx = _make_context(ActivityTypes.conversation_update) + assert _get_channel_event_type(ctx) is None + + def test_reads_camel_case_eventType_from_dict(self): + ctx = _make_context( + ActivityTypes.conversation_update, + channel_data={"eventType": "channelCreated"}, + ) + assert _get_channel_event_type(ctx) == "channelCreated" + + def test_reads_snake_case_event_type_from_dict(self): + ctx = _make_context( + ActivityTypes.conversation_update, + channel_data={"event_type": "channelDeleted"}, + ) + assert _get_channel_event_type(ctx) == "channelDeleted" + + def test_reads_event_type_from_object(self): + data = MagicMock() + data.event_type = "teamArchived" + ctx = _make_context(ActivityTypes.conversation_update) + ctx.activity.channel_data = data + assert _get_channel_event_type(ctx) == "teamArchived" + + def test_returns_none_when_object_has_no_event_type(self): + data = MagicMock(spec=[]) + ctx = _make_context(ActivityTypes.conversation_update) + ctx.activity.channel_data = data + assert _get_channel_event_type(ctx) is None + + +class TestGetChannelData: + + def test_raises_when_channel_data_is_none(self): + ctx = _make_context(ActivityTypes.conversation_update) + with pytest.raises(ValueError, match="channel_data"): + _get_channel_data(ctx) + + def test_returns_existing_channel_data_unchanged(self): + channel_data = ChannelData() + ctx = _make_context(ActivityTypes.conversation_update) + ctx.activity.channel_data = channel_data + assert _get_channel_data(ctx) is channel_data + + def test_model_validates_from_dict(self): + ctx = _make_context( + ActivityTypes.conversation_update, + channel_data={"eventType": "channelCreated"}, + ) + result = _get_channel_data(ctx) + assert isinstance(result, ChannelData) + + def test_model_validates_camel_case_dict_keys(self): + ctx = _make_context( + ActivityTypes.conversation_update, + channel_data={"eventType": "teamArchived"}, + ) + result = _get_channel_data(ctx) + assert isinstance(result, ChannelData) + + +class TestSendInvokeResponse: + + @pytest.mark.asyncio + async def test_sends_invoke_response_without_body(self): + ctx = _make_context(ActivityTypes.invoke) + await _send_invoke_response(ctx) + ctx.send_activity.assert_awaited_once() + sent = ctx.send_activity.call_args[0][0] + assert sent.type == ActivityTypes.invoke_response + assert sent.value.status == 200 + assert sent.value.body is None + + @pytest.mark.asyncio + async def test_serializes_pydantic_body(self): + ctx = _make_context(ActivityTypes.invoke) + body = Activity(type="message", text="hello") + await _send_invoke_response(ctx, body) + sent = ctx.send_activity.call_args[0][0] + assert sent.value.body == body.model_dump( + mode="json", by_alias=True, exclude_none=True + ) + + @pytest.mark.asyncio + async def test_passes_plain_dict_body_through(self): + ctx = _make_context(ActivityTypes.invoke) + body = {"key": "value"} + await _send_invoke_response(ctx, body) + sent = ctx.send_activity.call_args[0][0] + assert sent.value.body == {"key": "value"} From 22a280ed1cd76c5858f4cdf94a9800b29c9cc1b4 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Thu, 18 Jun 2026 14:28:21 -0700 Subject: [PATCH 11/46] Fixing teams models imports --- .../hosting/core/turn_context.py | 6 ++++-- .../hosting/teams/meeting/meeting.py | 19 +++++++------------ .../hosting/teams/meeting/route_handlers.py | 8 +++----- .../hosting/teams/message/message.py | 2 +- .../message_extension/message_extension.py | 4 ++-- .../hosting/teams/task_module/task_module.py | 6 ++---- tests/hosting_teams/test_meetings.py | 6 ++---- 7 files changed, 21 insertions(+), 30 deletions(-) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/turn_context.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/turn_context.py index af71ffac2..b80811dc6 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/turn_context.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/turn_context.py @@ -27,6 +27,8 @@ class TurnContext(TurnContextProtocol): # Same constant as in the BF Adapter, duplicating here to avoid circular dependency _INVOKE_RESPONSE_KEY = "TurnContext.InvokeResponse" + _activity: Activity + def __init__( self, adapter_or_context, @@ -43,7 +45,7 @@ def __init__( self._identity = adapter_or_context.identity else: self.adapter = adapter_or_context - self._activity = request + self._activity = request # exception thrown if None further down self.responses: list[Activity] = [] self._services: dict = {} self._on_send_activities: Callable[ @@ -60,7 +62,7 @@ def __init__( if self.adapter is None: raise TypeError("TurnContext must be instantiated with an adapter.") - if self.activity is None: + if self._activity is None: raise TypeError( "TurnContext must be instantiated with a request parameter of type Activity." ) diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/meeting/meeting.py b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/meeting/meeting.py index 3838f8adf..0cbd07ab5 100644 --- a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/meeting/meeting.py +++ b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/meeting/meeting.py @@ -3,17 +3,12 @@ """Route registration helpers for Teams meeting lifecycle event activities.""" -from typing import Callable, Generic, Optional +from typing import Generic, Optional -from microsoft_teams.api.models import ( - MeetingDetails, - MeetingParticipant, -) +from microsoft_teams.api.models.meetings import MeetingDetails -from microsoft_agents.activity import ( - Activity, - ActivityTypes, -) +from microsoft_agents.activity import ActivityTypes +from microsoft_agents.activity.teams import MeetingParticipantsEventDetails from microsoft_agents.hosting.core import ( AgentApplication, RouteRank, @@ -36,7 +31,7 @@ class Meeting(Generic[StateT]): """ Route registration for Teams Meeting event activities. - Access via TeamsAgentExtension.meeting. + Access via TeamsAgentExtension.meetings. """ def __init__(self, app: AgentApplication[StateT]) -> None: @@ -122,7 +117,7 @@ def __call( ) -> MeetingParticipantsEventHandler[StateT]: async def __handler(context: TurnContext, state: StateT) -> None: teams_context = TeamsTurnContext(context, self._app) - details = MeetingParticipant.model_validate( + details = MeetingParticipantsEventDetails.model_validate( context.activity.value or {} ) await func(teams_context, state, details) @@ -157,7 +152,7 @@ def __call( ) -> MeetingParticipantsEventHandler[StateT]: async def __handler(context: TurnContext, state: StateT) -> None: teams_context = TeamsTurnContext(context, self._app) - details = MeetingParticipant.model_validate( + details = MeetingParticipantsEventDetails.model_validate( context.activity.value or {} ) await func(teams_context, state, details) diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/meeting/route_handlers.py b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/meeting/route_handlers.py index 00f8fad81..a80711f3a 100644 --- a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/meeting/route_handlers.py +++ b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/meeting/route_handlers.py @@ -5,10 +5,8 @@ from typing import Awaitable, Protocol -from microsoft_teams.api.models import ( - MeetingDetails, - MeetingParticipant, -) +from microsoft_teams.api.models.meetings import MeetingDetails +from microsoft_agents.activity.teams import MeetingParticipantsEventDetails from microsoft_agents.hosting.teams.teams_turn_context import TeamsTurnContext from microsoft_agents.hosting.teams.type_defs import StateT @@ -57,7 +55,7 @@ def __call__( self, context: TeamsTurnContext, state: StateT, - meeting: MeetingParticipant, + meeting: MeetingParticipantsEventDetails, ) -> Awaitable[None]: """Handle a meeting participant join or leave event. diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/message/message.py b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/message/message.py index aaba1caa8..3da99eb31 100644 --- a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/message/message.py +++ b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/message/message.py @@ -3,7 +3,7 @@ """Route registration helpers for Teams message update and actionable message activities.""" -from typing import Callable, Generic, Optional +from typing import Generic, Optional from microsoft_teams.api.models.o365 import O365ConnectorCardActionQuery diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/message_extension/message_extension.py b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/message_extension/message_extension.py index cb5ab29bf..87ef08df6 100644 --- a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/message_extension/message_extension.py +++ b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/message_extension/message_extension.py @@ -5,10 +5,10 @@ from typing import Generic, Optional, Callable -from microsoft_teams.api.models import ( +from microsoft_teams.api.models import AppBasedLinkQuery +from microsoft_teams.api.models.messaging_extension import ( MessagingExtensionQuery, MessagingExtensionAction, - AppBasedLinkQuery, ) from microsoft_agents.activity import Activity, ActivityTypes diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/task_module/task_module.py b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/task_module/task_module.py index 8c6b6be78..1c937a7ec 100644 --- a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/task_module/task_module.py +++ b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/task_module/task_module.py @@ -5,9 +5,7 @@ from typing import Any, Generic, Optional -from microsoft_teams.api.models import ( - TaskModuleRequest, -) +from microsoft_teams.api.models.task_module import TaskModuleRequest from microsoft_agents.activity import ActivityTypes @@ -30,7 +28,7 @@ class TaskModule(Generic[StateT]): """ Route registration for Teams Task Module (task/fetch, task/submit) invoke activities. - Access via TeamsAgentExtension.task_module. + Access via TeamsAgentExtension.task_modules. """ def __init__(self, app: AgentApplication[StateT]) -> None: diff --git a/tests/hosting_teams/test_meetings.py b/tests/hosting_teams/test_meetings.py index a74f619cc..d7e52a34e 100644 --- a/tests/hosting_teams/test_meetings.py +++ b/tests/hosting_teams/test_meetings.py @@ -16,10 +16,8 @@ ) if is_supported_version: - from microsoft_teams.api.models import ( - MeetingDetails, - MeetingParticipantsEventDetails, - ) + from microsoft_teams.api.models.meetings import MeetingDetails + from microsoft_agents.activity.teams import MeetingParticipantsEventDetails from microsoft_agents.hosting.teams import TeamsAgentExtension From 7a37f958058fc758e50c19110c1509fa342f9617 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Thu, 18 Jun 2026 14:51:38 -0700 Subject: [PATCH 12/46] Adding direct decorator support for route hooks without positional arguments --- .../hosting/teams/channel/channel.py | 105 +++++++++++++---- .../teams/file_consent/file_consent.py | 48 +++++--- .../hosting/teams/meeting/meeting.py | 58 +++++++++- .../hosting/teams/message/message.py | 83 ++++++++++--- .../message_extension/message_extension.py | 103 ++++++++++++++--- .../hosting/teams/team/team.py | 109 ++++++++++++++---- .../hosting/teams/type_defs.py | 2 +- tests/hosting_teams/helpers.py | 33 +++++- tests/hosting_teams/test_channels.py | 43 +++++++ tests/hosting_teams/test_file_consent.py | 12 ++ tests/hosting_teams/test_meetings.py | 31 +++++ .../hosting_teams/test_message_extensions.py | 49 +++++++- tests/hosting_teams/test_messages.py | 37 ++++++ tests/hosting_teams/test_team_lifecycle.py | 43 +++++++ 14 files changed, 655 insertions(+), 101 deletions(-) diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/channel/channel.py b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/channel/channel.py index 2317af89b..d179178de 100644 --- a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/channel/channel.py +++ b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/channel/channel.py @@ -3,7 +3,7 @@ """Route registration helpers for Teams channel conversation update events.""" -from typing import Generic, Optional +from typing import Generic, Optional, overload from microsoft_agents.activity import ActivityTypes from microsoft_agents.hosting.core import ( @@ -45,13 +45,7 @@ def _create_decorator( auth_handlers: Optional[list[str]] = None, rank: RouteRank = RouteRank.DEFAULT, ) -> _RouteDecorator[ChannelUpdateHandler[StateT]]: - """Build a route decorator that matches a specific Teams channel event type. - - :param event_type: The ``eventType`` value in channel_data to match (e.g. ``"channelCreated"``). - :param auth_handlers: Optional auth handler names to run before the route. - :param rank: Route priority rank. - :return: A decorator that registers the handler. - """ + """Build a route decorator that matches a specific Teams channel event type.""" def __selector(context: TurnContext) -> bool: return ( @@ -61,7 +55,6 @@ def __selector(context: TurnContext) -> bool: ) def __call(func: ChannelUpdateHandler[StateT]) -> ChannelUpdateHandler[StateT]: - async def __handler(context: TurnContext, state: StateT) -> None: teams_context = TeamsTurnContext(context, self._app) channel_data = _get_channel_data(context) @@ -74,56 +67,113 @@ async def __handler(context: TurnContext, state: StateT) -> None: return __call + @overload + def created( + self, handler: ChannelUpdateHandler[StateT] + ) -> ChannelUpdateHandler[StateT]: ... + @overload + def created( + self, *, auth_handlers: Optional[list[str]] = ..., rank: RouteRank = ... + ) -> _RouteDecorator[ChannelUpdateHandler[StateT]]: ... def created( self, + handler: Optional[ChannelUpdateHandler[StateT]] = None, *, auth_handlers: Optional[list[str]] = None, rank: RouteRank = RouteRank.DEFAULT, - ) -> _RouteDecorator[ChannelUpdateHandler[StateT]]: + ) -> ChannelUpdateHandler[StateT] | _RouteDecorator[ChannelUpdateHandler[StateT]]: """Register a handler for Teams channelCreated conversation update events.""" - return self._create_decorator( + decorator = self._create_decorator( "channelCreated", auth_handlers=auth_handlers, rank=rank ) + if handler is not None: + return decorator(handler) + return decorator + @overload + def deleted( + self, handler: ChannelUpdateHandler[StateT] + ) -> ChannelUpdateHandler[StateT]: ... + @overload + def deleted( + self, *, auth_handlers: Optional[list[str]] = ..., rank: RouteRank = ... + ) -> _RouteDecorator[ChannelUpdateHandler[StateT]]: ... def deleted( self, + handler: Optional[ChannelUpdateHandler[StateT]] = None, *, auth_handlers: Optional[list[str]] = None, rank: RouteRank = RouteRank.DEFAULT, - ) -> _RouteDecorator[ChannelUpdateHandler[StateT]]: + ) -> ChannelUpdateHandler[StateT] | _RouteDecorator[ChannelUpdateHandler[StateT]]: """Register a handler for Teams channelDeleted conversation update events.""" - return self._create_decorator( + decorator = self._create_decorator( "channelDeleted", auth_handlers=auth_handlers, rank=rank ) + if handler is not None: + return decorator(handler) + return decorator + @overload + def renamed( + self, handler: ChannelUpdateHandler[StateT] + ) -> ChannelUpdateHandler[StateT]: ... + @overload + def renamed( + self, *, auth_handlers: Optional[list[str]] = ..., rank: RouteRank = ... + ) -> _RouteDecorator[ChannelUpdateHandler[StateT]]: ... def renamed( self, + handler: Optional[ChannelUpdateHandler[StateT]] = None, *, auth_handlers: Optional[list[str]] = None, rank: RouteRank = RouteRank.DEFAULT, - ) -> _RouteDecorator[ChannelUpdateHandler[StateT]]: + ) -> ChannelUpdateHandler[StateT] | _RouteDecorator[ChannelUpdateHandler[StateT]]: """Register a handler for Teams channelRenamed conversation update events.""" - return self._create_decorator( + decorator = self._create_decorator( "channelRenamed", auth_handlers=auth_handlers, rank=rank ) + if handler is not None: + return decorator(handler) + return decorator + @overload + def rest( + self, handler: ChannelUpdateHandler[StateT] + ) -> ChannelUpdateHandler[StateT]: ... + @overload + def rest( + self, *, auth_handlers: Optional[list[str]] = ..., rank: RouteRank = ... + ) -> _RouteDecorator[ChannelUpdateHandler[StateT]]: ... def rest( self, + handler: Optional[ChannelUpdateHandler[StateT]] = None, *, auth_handlers: Optional[list[str]] = None, rank: RouteRank = RouteRank.DEFAULT, - ) -> _RouteDecorator[ChannelUpdateHandler[StateT]]: + ) -> ChannelUpdateHandler[StateT] | _RouteDecorator[ChannelUpdateHandler[StateT]]: """Register a handler for Teams channelRestored conversation update events.""" - return self._create_decorator( + decorator = self._create_decorator( "channelRestored", auth_handlers=auth_handlers, rank=rank ) + if handler is not None: + return decorator(handler) + return decorator + @overload + def members_added( + self, handler: ChannelUpdateHandler[StateT] + ) -> ChannelUpdateHandler[StateT]: ... + @overload + def members_added( + self, *, auth_handlers: Optional[list[str]] = ..., rank: RouteRank = ... + ) -> _RouteDecorator[ChannelUpdateHandler[StateT]]: ... def members_added( self, + handler: Optional[ChannelUpdateHandler[StateT]] = None, *, auth_handlers: Optional[list[str]] = None, rank: RouteRank = RouteRank.DEFAULT, - ) -> _RouteDecorator[ChannelUpdateHandler[StateT]]: + ) -> ChannelUpdateHandler[StateT] | _RouteDecorator[ChannelUpdateHandler[StateT]]: """Register a handler for Teams membersAdded conversation update events.""" def __selector(context: TurnContext) -> bool: @@ -135,7 +185,6 @@ def __selector(context: TurnContext) -> bool: ) def __call(func: ChannelUpdateHandler[StateT]) -> ChannelUpdateHandler[StateT]: - async def __func(context: TurnContext, state: StateT) -> None: teams_context = TeamsTurnContext(context, self._app) channel_data = _get_channel_data(context) @@ -144,17 +193,27 @@ async def __func(context: TurnContext, state: StateT) -> None: self._app.add_route( __selector, __func, rank=rank, auth_handlers=auth_handlers ) - return func + if handler is not None: + return __call(handler) return __call + @overload + def members_removed( + self, handler: ChannelUpdateHandler[StateT] + ) -> ChannelUpdateHandler[StateT]: ... + @overload + def members_removed( + self, *, auth_handlers: Optional[list[str]] = ..., rank: RouteRank = ... + ) -> _RouteDecorator[ChannelUpdateHandler[StateT]]: ... def members_removed( self, + handler: Optional[ChannelUpdateHandler[StateT]] = None, *, auth_handlers: Optional[list[str]] = None, rank: RouteRank = RouteRank.DEFAULT, - ) -> _RouteDecorator[ChannelUpdateHandler[StateT]]: + ) -> ChannelUpdateHandler[StateT] | _RouteDecorator[ChannelUpdateHandler[StateT]]: """Register a handler for Teams membersRemoved conversation update events.""" def __selector(context: TurnContext) -> bool: @@ -166,7 +225,6 @@ def __selector(context: TurnContext) -> bool: ) def __call(func: ChannelUpdateHandler[StateT]) -> ChannelUpdateHandler[StateT]: - async def __func(context: TurnContext, state: StateT) -> None: teams_context = TeamsTurnContext(context, self._app) channel_data = _get_channel_data(context) @@ -175,7 +233,8 @@ async def __func(context: TurnContext, state: StateT) -> None: self._app.add_route( __selector, __func, rank=rank, auth_handlers=auth_handlers ) - return func + if handler is not None: + return __call(handler) return __call diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/file_consent/file_consent.py b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/file_consent/file_consent.py index 9d7511f2e..57396a26b 100644 --- a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/file_consent/file_consent.py +++ b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/file_consent/file_consent.py @@ -3,7 +3,7 @@ """Route registration helpers for Teams fileConsent/invoke activities.""" -from typing import Generic, Optional +from typing import Generic, Optional, overload from microsoft_teams.api.models import FileConsentCardResponse @@ -44,13 +44,7 @@ def _create_decorator( auth_handlers: Optional[list[str]] = None, rank: RouteRank = RouteRank.DEFAULT, ) -> _RouteDecorator[FileConsentHandler[StateT]]: - """Build a route decorator for a fileConsent/invoke action. - - :param action: The consent action to match — ``"accept"`` or ``"decline"``. - :param auth_handlers: Optional auth handler names to run before the route. - :param rank: Route priority rank. - :return: A decorator that registers the handler. - """ + """Build a route decorator for a fileConsent/invoke action.""" def __selector(context: TurnContext) -> bool: return ( @@ -80,20 +74,48 @@ async def __handler(context: TurnContext, state: StateT) -> None: return __call + @overload + def accept( + self, handler: FileConsentHandler[StateT] + ) -> FileConsentHandler[StateT]: ... + @overload + def accept( + self, *, auth_handlers: Optional[list[str]] = ..., rank: RouteRank = ... + ) -> _RouteDecorator[FileConsentHandler[StateT]]: ... def accept( self, + handler: Optional[FileConsentHandler[StateT]] = None, *, auth_handlers: Optional[list[str]] = None, rank: RouteRank = RouteRank.DEFAULT, - ) -> _RouteDecorator[FileConsentHandler[StateT]]: + ) -> FileConsentHandler[StateT] | _RouteDecorator[FileConsentHandler[StateT]]: """Register a handler for fileConsent/invoke with action == 'accept'.""" - return self._create_decorator("accept", auth_handlers=auth_handlers, rank=rank) - + decorator = self._create_decorator( + "accept", auth_handlers=auth_handlers, rank=rank + ) + if handler is not None: + return decorator(handler) + return decorator + + @overload + def decline( + self, handler: FileConsentHandler[StateT] + ) -> FileConsentHandler[StateT]: ... + @overload + def decline( + self, *, auth_handlers: Optional[list[str]] = ..., rank: RouteRank = ... + ) -> _RouteDecorator[FileConsentHandler[StateT]]: ... def decline( self, + handler: Optional[FileConsentHandler[StateT]] = None, *, auth_handlers: Optional[list[str]] = None, rank: RouteRank = RouteRank.DEFAULT, - ) -> _RouteDecorator[FileConsentHandler[StateT]]: + ) -> FileConsentHandler[StateT] | _RouteDecorator[FileConsentHandler[StateT]]: """Register a handler for fileConsent/invoke with action == 'decline'.""" - return self._create_decorator("decline", auth_handlers=auth_handlers, rank=rank) + decorator = self._create_decorator( + "decline", auth_handlers=auth_handlers, rank=rank + ) + if handler is not None: + return decorator(handler) + return decorator diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/meeting/meeting.py b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/meeting/meeting.py index 0cbd07ab5..76d8db3b3 100644 --- a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/meeting/meeting.py +++ b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/meeting/meeting.py @@ -3,7 +3,7 @@ """Route registration helpers for Teams meeting lifecycle event activities.""" -from typing import Generic, Optional +from typing import Generic, Optional, overload from microsoft_teams.api.models.meetings import MeetingDetails @@ -37,12 +37,21 @@ class Meeting(Generic[StateT]): def __init__(self, app: AgentApplication[StateT]) -> None: self._app = app + @overload + def start( + self, handler: MeetingStartHandler[StateT] + ) -> MeetingStartHandler[StateT]: ... + @overload + def start( + self, *, auth_handlers: Optional[list[str]] = ..., rank: RouteRank = ... + ) -> _RouteDecorator[MeetingStartHandler[StateT]]: ... def start( self, + handler: Optional[MeetingStartHandler[StateT]] = None, *, auth_handlers: Optional[list[str]] = None, rank: RouteRank = RouteRank.DEFAULT, - ) -> _RouteDecorator[MeetingStartHandler[StateT]]: + ) -> MeetingStartHandler[StateT] | _RouteDecorator[MeetingStartHandler[StateT]]: """Register a handler for meeting start events.""" def __selector(context: TurnContext) -> bool: @@ -65,14 +74,23 @@ async def __handler(context: TurnContext, state: StateT) -> None: ) return func + if handler is not None: + return __call(handler) return __call + @overload + def end(self, handler: MeetingEndHandler[StateT]) -> MeetingEndHandler[StateT]: ... + @overload + def end( + self, *, auth_handlers: Optional[list[str]] = ..., rank: RouteRank = ... + ) -> _RouteDecorator[MeetingEndHandler[StateT]]: ... def end( self, + handler: Optional[MeetingEndHandler[StateT]] = None, *, auth_handlers: Optional[list[str]] = None, rank: RouteRank = RouteRank.DEFAULT, - ) -> _RouteDecorator[MeetingEndHandler[StateT]]: + ) -> MeetingEndHandler[StateT] | _RouteDecorator[MeetingEndHandler[StateT]]: """Register a handler for meeting end events.""" def __selector(context: TurnContext) -> bool: @@ -95,14 +113,28 @@ async def __handler(context: TurnContext, state: StateT) -> None: ) return func + if handler is not None: + return __call(handler) return __call + @overload + def participants_join( + self, handler: MeetingParticipantsEventHandler[StateT] + ) -> MeetingParticipantsEventHandler[StateT]: ... + @overload + def participants_join( + self, *, auth_handlers: Optional[list[str]] = ..., rank: RouteRank = ... + ) -> _RouteDecorator[MeetingParticipantsEventHandler[StateT]]: ... def participants_join( self, + handler: Optional[MeetingParticipantsEventHandler[StateT]] = None, *, auth_handlers: Optional[list[str]] = None, rank: RouteRank = RouteRank.DEFAULT, - ) -> _RouteDecorator[MeetingParticipantsEventHandler[StateT]]: + ) -> ( + MeetingParticipantsEventHandler[StateT] + | _RouteDecorator[MeetingParticipantsEventHandler[StateT]] + ): """Register a handler for meeting participant join events.""" def __selector(context: TurnContext) -> bool: @@ -130,14 +162,28 @@ async def __handler(context: TurnContext, state: StateT) -> None: ) return func + if handler is not None: + return __call(handler) return __call + @overload + def participants_leave( + self, handler: MeetingParticipantsEventHandler[StateT] + ) -> MeetingParticipantsEventHandler[StateT]: ... + @overload + def participants_leave( + self, *, auth_handlers: Optional[list[str]] = ..., rank: RouteRank = ... + ) -> _RouteDecorator[MeetingParticipantsEventHandler[StateT]]: ... def participants_leave( self, + handler: Optional[MeetingParticipantsEventHandler[StateT]] = None, *, auth_handlers: Optional[list[str]] = None, rank: RouteRank = RouteRank.DEFAULT, - ) -> _RouteDecorator[MeetingParticipantsEventHandler[StateT]]: + ) -> ( + MeetingParticipantsEventHandler[StateT] + | _RouteDecorator[MeetingParticipantsEventHandler[StateT]] + ): """Register a handler for meeting participant leave events.""" def __selector(context: TurnContext) -> bool: @@ -165,4 +211,6 @@ async def __handler(context: TurnContext, state: StateT) -> None: ) return func + if handler is not None: + return __call(handler) return __call diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/message/message.py b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/message/message.py index 3da99eb31..aeee27056 100644 --- a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/message/message.py +++ b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/message/message.py @@ -3,7 +3,7 @@ """Route registration helpers for Teams message update and actionable message activities.""" -from typing import Generic, Optional +from typing import Generic, Optional, overload from microsoft_teams.api.models.o365 import O365ConnectorCardActionQuery @@ -49,14 +49,7 @@ def _create_basic_decorator( auth_handlers: Optional[list[str]] = None, rank: RouteRank = RouteRank.DEFAULT, ) -> _RouteDecorator[TeamsRouteHandler[StateT]]: - """Build a route decorator for a Teams messageUpdate channel event type. - - :param event_type: The channel event type to match (e.g. ``"editMessage"``). - :param message_type: The activity type to match. - :param auth_handlers: Optional auth handler names to run before the route. - :param rank: Route priority rank. - :return: A decorator that registers the handler. - """ + """Build a route decorator for a Teams messageUpdate channel event type.""" def __selector(context: TurnContext) -> bool: return ( @@ -76,54 +69,97 @@ def __call(func: TeamsRouteHandler[StateT]) -> TeamsRouteHandler[StateT]: return __call + @overload + def edit(self, handler: TeamsRouteHandler[StateT]) -> TeamsRouteHandler[StateT]: ... + @overload + def edit( + self, *, auth_handlers: Optional[list[str]] = ..., rank: RouteRank = ... + ) -> _RouteDecorator[TeamsRouteHandler[StateT]]: ... def edit( self, + handler: Optional[TeamsRouteHandler[StateT]] = None, *, auth_handlers: Optional[list[str]] = None, rank: RouteRank = RouteRank.DEFAULT, - ) -> _RouteDecorator[TeamsRouteHandler[StateT]]: + ) -> TeamsRouteHandler[StateT] | _RouteDecorator[TeamsRouteHandler[StateT]]: """Register a handler for Teams editMessage events.""" - return self._create_basic_decorator( + decorator = self._create_basic_decorator( "editMessage", ActivityTypes.message_update, auth_handlers=auth_handlers, rank=rank, ) + if handler is not None: + return decorator(handler) + return decorator + @overload + def undelete( + self, handler: TeamsRouteHandler[StateT] + ) -> TeamsRouteHandler[StateT]: ... + @overload + def undelete( + self, *, auth_handlers: Optional[list[str]] = ..., rank: RouteRank = ... + ) -> _RouteDecorator[TeamsRouteHandler[StateT]]: ... def undelete( self, + handler: Optional[TeamsRouteHandler[StateT]] = None, *, auth_handlers: Optional[list[str]] = None, rank: RouteRank = RouteRank.DEFAULT, - ) -> _RouteDecorator[TeamsRouteHandler[StateT]]: + ) -> TeamsRouteHandler[StateT] | _RouteDecorator[TeamsRouteHandler[StateT]]: """Register a handler for Teams undeleteMessage events.""" - return self._create_basic_decorator( + decorator = self._create_basic_decorator( "undeleteMessage", ActivityTypes.message_update, auth_handlers=auth_handlers, rank=rank, ) + if handler is not None: + return decorator(handler) + return decorator + @overload + def soft_delete( + self, handler: TeamsRouteHandler[StateT] + ) -> TeamsRouteHandler[StateT]: ... + @overload + def soft_delete( + self, *, auth_handlers: Optional[list[str]] = ..., rank: RouteRank = ... + ) -> _RouteDecorator[TeamsRouteHandler[StateT]]: ... def soft_delete( self, + handler: Optional[TeamsRouteHandler[StateT]] = None, *, auth_handlers: Optional[list[str]] = None, rank: RouteRank = RouteRank.DEFAULT, - ) -> _RouteDecorator[TeamsRouteHandler[StateT]]: + ) -> TeamsRouteHandler[StateT] | _RouteDecorator[TeamsRouteHandler[StateT]]: """Register a handler for Teams softDeleteMessage events.""" - return self._create_basic_decorator( + decorator = self._create_basic_decorator( "softDeleteMessage", ActivityTypes.message_delete, auth_handlers=auth_handlers, rank=rank, ) + if handler is not None: + return decorator(handler) + return decorator + @overload + def read_receipt( + self, handler: ReadReceiptHandler[StateT] + ) -> ReadReceiptHandler[StateT]: ... + @overload + def read_receipt( + self, *, auth_handlers: Optional[list[str]] = ..., rank: RouteRank = ... + ) -> _RouteDecorator[ReadReceiptHandler[StateT]]: ... def read_receipt( self, + handler: Optional[ReadReceiptHandler[StateT]] = None, *, auth_handlers: Optional[list[str]] = None, rank: RouteRank = RouteRank.DEFAULT, - ) -> _RouteDecorator[ReadReceiptHandler[StateT]]: + ) -> ReadReceiptHandler[StateT] | _RouteDecorator[ReadReceiptHandler[StateT]]: """Register a handler for Teams readReceipt events.""" def __selector(context: TurnContext) -> bool: @@ -147,14 +183,25 @@ async def __handler(context: TurnContext, state: StateT) -> None: ) return func + if handler is not None: + return __call(handler) return __call + @overload + def execute_action( + self, handler: ExecuteActionHandler[StateT] + ) -> ExecuteActionHandler[StateT]: ... + @overload + def execute_action( + self, *, auth_handlers: Optional[list[str]] = ..., rank: RouteRank = ... + ) -> _RouteDecorator[ExecuteActionHandler[StateT]]: ... def execute_action( self, + handler: Optional[ExecuteActionHandler[StateT]] = None, *, auth_handlers: Optional[list[str]] = None, rank: RouteRank = RouteRank.DEFAULT, - ) -> _RouteDecorator[ExecuteActionHandler[StateT]]: + ) -> ExecuteActionHandler[StateT] | _RouteDecorator[ExecuteActionHandler[StateT]]: """Register a handler for actionableMessage/executeAction invokes.""" def __selector(context: TurnContext) -> bool: @@ -181,4 +228,6 @@ async def __handler(context: TurnContext, state: StateT) -> None: ) return func + if handler is not None: + return __call(handler) return __call diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/message_extension/message_extension.py b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/message_extension/message_extension.py index 87ef08df6..b3c07cb3a 100644 --- a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/message_extension/message_extension.py +++ b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/message_extension/message_extension.py @@ -3,7 +3,7 @@ """Route registration helpers for Teams Message Extension (composeExtension) invokes.""" -from typing import Generic, Optional, Callable +from typing import Generic, Optional, Callable, overload from microsoft_teams.api.models import AppBasedLinkQuery from microsoft_teams.api.models.messaging_extension import ( @@ -116,12 +116,21 @@ async def __handler(context: TurnContext, state: StateT) -> None: return __call + @overload + def select_item( + self, handler: SelectItemHandler[StateT] + ) -> SelectItemHandler[StateT]: ... + @overload + def select_item( + self, *, auth_handlers: Optional[list[str]] = ..., rank: RouteRank = ... + ) -> _RouteDecorator[SelectItemHandler[StateT]]: ... def select_item( self, + handler: Optional[SelectItemHandler[StateT]] = None, *, auth_handlers: Optional[list[str]] = None, rank: RouteRank = RouteRank.DEFAULT, - ) -> _RouteDecorator[SelectItemHandler[StateT]]: + ) -> SelectItemHandler[StateT] | _RouteDecorator[SelectItemHandler[StateT]]: """Register a handler for composeExtension/selectItem invokes.""" def __selector(context: TurnContext) -> bool: @@ -146,6 +155,8 @@ async def __handler(context: TurnContext, state: StateT) -> None: ) return func + if handler is not None: + return __call(handler) return __call def submit_action( @@ -214,7 +225,9 @@ def __selector(context: TurnContext) -> bool: or context.activity.name != "composeExtension/submitAction" ): return False - value = context.activity.value or {} + value = context.activity.value + if not isinstance(value, dict): + return False if value.get("botMessagePreviewAction") != "edit": return False return _match_selector(command_id, value.get("commandId")) @@ -255,7 +268,9 @@ def __selector(context: TurnContext) -> bool: or context.activity.name != "composeExtension/submitAction" ): return False - value = context.activity.value or {} + value = context.activity.value + if not isinstance(value, dict): + return False if value.get("botMessagePreviewAction") != "send": return False return _match_selector(command_id, value.get("commandId")) @@ -291,13 +306,12 @@ def fetch_task( """Register a handler for composeExtension/fetchTask invokes.""" def __selector(context: TurnContext) -> bool: + value = context.activity.value return ( context.activity.type == ActivityTypes.invoke and context.activity.name == "composeExtension/fetchTask" - and _match_selector( - command_id, - (context.activity.value or {}).get("commandId"), - ) + and isinstance(value, dict) + and _match_selector(command_id, value.get("commandId")) ) def __call(func: FetchTaskHandler[StateT]) -> FetchTaskHandler[StateT]: @@ -321,12 +335,21 @@ async def __handler(context: TurnContext, state: StateT) -> None: return __call + @overload + def query_link( + self, handler: QueryLinkHandler[StateT] + ) -> QueryLinkHandler[StateT]: ... + @overload + def query_link( + self, *, auth_handlers: Optional[list[str]] = ..., rank: RouteRank = ... + ) -> _RouteDecorator[QueryLinkHandler[StateT]]: ... def query_link( self, + handler: Optional[QueryLinkHandler[StateT]] = None, *, auth_handlers: Optional[list[str]] = None, rank: RouteRank = RouteRank.DEFAULT, - ) -> _RouteDecorator[QueryLinkHandler[StateT]]: + ) -> QueryLinkHandler[StateT] | _RouteDecorator[QueryLinkHandler[StateT]]: """Register a handler for composeExtension/queryLink invokes.""" def __selector(context: TurnContext) -> bool: @@ -352,14 +375,25 @@ async def __handler(context: TurnContext, state: StateT) -> None: ) return func + if handler is not None: + return __call(handler) return __call + @overload + def anonymous_query_link( + self, handler: QueryLinkHandler[StateT] + ) -> QueryLinkHandler[StateT]: ... + @overload + def anonymous_query_link( + self, *, auth_handlers: Optional[list[str]] = ..., rank: RouteRank = ... + ) -> _RouteDecorator[QueryLinkHandler[StateT]]: ... def anonymous_query_link( self, + handler: Optional[QueryLinkHandler[StateT]] = None, *, auth_handlers: Optional[list[str]] = None, rank: RouteRank = RouteRank.DEFAULT, - ) -> _RouteDecorator[QueryLinkHandler[StateT]]: + ) -> QueryLinkHandler[StateT] | _RouteDecorator[QueryLinkHandler[StateT]]: """Register a handler for composeExtension/anonymousQueryLink invokes.""" def __selector(context: TurnContext) -> bool: @@ -385,14 +419,27 @@ async def __handler(context: TurnContext, state: StateT) -> None: ) return func + if handler is not None: + return __call(handler) return __call + @overload + def query_setting_url( + self, handler: QueryUrlSettingHandler[StateT] + ) -> QueryUrlSettingHandler[StateT]: ... + @overload + def query_setting_url( + self, *, auth_handlers: Optional[list[str]] = ..., rank: RouteRank = ... + ) -> _RouteDecorator[QueryUrlSettingHandler[StateT]]: ... def query_setting_url( self, + handler: Optional[QueryUrlSettingHandler[StateT]] = None, *, auth_handlers: Optional[list[str]] = None, rank: RouteRank = RouteRank.DEFAULT, - ) -> _RouteDecorator[QueryUrlSettingHandler[StateT]]: + ) -> ( + QueryUrlSettingHandler[StateT] | _RouteDecorator[QueryUrlSettingHandler[StateT]] + ): """Register a handler for composeExtension/querySettingUrl invokes.""" def __selector(context: TurnContext) -> bool: @@ -422,14 +469,28 @@ async def __handler(context: TurnContext, state: StateT) -> None: ) return func + if handler is not None: + return __call(handler) return __call + @overload + def setting( + self, handler: ConfigureSettingsHandler[StateT] + ) -> ConfigureSettingsHandler[StateT]: ... + @overload + def setting( + self, *, auth_handlers: Optional[list[str]] = ..., rank: RouteRank = ... + ) -> _RouteDecorator[ConfigureSettingsHandler[StateT]]: ... def setting( self, + handler: Optional[Callable] = None, *, auth_handlers: Optional[list[str]] = None, rank: RouteRank = RouteRank.DEFAULT, - ) -> _RouteDecorator[ConfigureSettingsHandler[StateT]]: + ) -> ( + ConfigureSettingsHandler[StateT] + | _RouteDecorator[ConfigureSettingsHandler[StateT]] + ): """Register a handler for composeExtension/setting invokes.""" def __selector(context: TurnContext) -> bool: @@ -453,14 +514,28 @@ async def __handler(context: TurnContext, state: StateT) -> None: ) return func + if handler is not None: + return __call(handler) return __call + @overload + def card_button_clicked( + self, handler: CardButtonClickedHandler[StateT] + ) -> CardButtonClickedHandler[StateT]: ... + @overload + def card_button_clicked( + self, *, auth_handlers: Optional[list[str]] = ..., rank: RouteRank = ... + ) -> _RouteDecorator[CardButtonClickedHandler[StateT]]: ... def card_button_clicked( self, + handler: Optional[CardButtonClickedHandler[StateT]] = None, *, auth_handlers: Optional[list[str]] = None, rank: RouteRank = RouteRank.DEFAULT, - ) -> _RouteDecorator[CardButtonClickedHandler[StateT]]: + ) -> ( + CardButtonClickedHandler[StateT] + | _RouteDecorator[CardButtonClickedHandler[StateT]] + ): """Register a handler for composeExtension/onCardButtonClicked invokes.""" def __selector(context: TurnContext) -> bool: @@ -486,4 +561,6 @@ async def __handler(context: TurnContext, state: StateT) -> None: ) return func + if handler is not None: + return __call(handler) return __call diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/team/team.py b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/team/team.py index 95b526ecc..3422c3eb9 100644 --- a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/team/team.py +++ b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/team/team.py @@ -3,7 +3,7 @@ """Route registration helpers for Teams team conversation update events.""" -from typing import Generic, Optional +from typing import Generic, Optional, overload from microsoft_agents.activity import ActivityTypes from microsoft_agents.hosting.core import ( @@ -28,7 +28,7 @@ class Team(Generic[StateT]): """Route registration for Teams team conversation update events. - Access via :attr:`TeamsAgentExtension.teams` (property currently missing — see known issues). + Access via :attr:`TeamsAgentExtension.teams`. """ def __init__(self, app: AgentApplication[StateT]) -> None: @@ -45,13 +45,7 @@ def _create_decorator( auth_handlers: Optional[list[str]] = None, rank: RouteRank = RouteRank.DEFAULT, ) -> _RouteDecorator[TeamUpdateHandler[StateT]]: - """Build a route decorator for a specific Teams team event type. - - :param event_type: The ``eventType`` value to match (e.g. ``"teamArchived"``). - :param auth_handlers: Optional auth handler names to run before the route. - :param rank: Route priority rank. - :return: A decorator that registers the handler. - """ + """Build a route decorator for a specific Teams team event type.""" def __selector(context: TurnContext) -> bool: return ( @@ -61,7 +55,6 @@ def __selector(context: TurnContext) -> bool: ) def __call(func: TeamUpdateHandler[StateT]) -> TeamUpdateHandler[StateT]: - async def __handler(context: TurnContext, state: StateT) -> None: teams_context = TeamsTurnContext(context, self._app) team_data = _get_channel_data(context) @@ -74,68 +67,140 @@ async def __handler(context: TurnContext, state: StateT) -> None: return __call + @overload + def archived( + self, handler: TeamUpdateHandler[StateT] + ) -> TeamUpdateHandler[StateT]: ... + @overload + def archived( + self, *, auth_handlers: Optional[list[str]] = ..., rank: RouteRank = ... + ) -> _RouteDecorator[TeamUpdateHandler[StateT]]: ... def archived( self, + handler: Optional[TeamUpdateHandler[StateT]] = None, *, auth_handlers: Optional[list[str]] = None, rank: RouteRank = RouteRank.DEFAULT, - ) -> _RouteDecorator[TeamUpdateHandler[StateT]]: + ) -> TeamUpdateHandler[StateT] | _RouteDecorator[TeamUpdateHandler[StateT]]: """Register a handler for Teams teamArchived conversation update events.""" - return self._create_decorator( + decorator = self._create_decorator( "teamArchived", auth_handlers=auth_handlers, rank=rank ) + if handler is not None: + return decorator(handler) + return decorator + @overload + def deleted( + self, handler: TeamUpdateHandler[StateT] + ) -> TeamUpdateHandler[StateT]: ... + @overload + def deleted( + self, *, auth_handlers: Optional[list[str]] = ..., rank: RouteRank = ... + ) -> _RouteDecorator[TeamUpdateHandler[StateT]]: ... def deleted( self, + handler: Optional[TeamUpdateHandler[StateT]] = None, *, auth_handlers: Optional[list[str]] = None, rank: RouteRank = RouteRank.DEFAULT, - ) -> _RouteDecorator[TeamUpdateHandler[StateT]]: + ) -> TeamUpdateHandler[StateT] | _RouteDecorator[TeamUpdateHandler[StateT]]: """Register a handler for Teams teamDeleted conversation update events.""" - return self._create_decorator( + decorator = self._create_decorator( "teamDeleted", auth_handlers=auth_handlers, rank=rank ) + if handler is not None: + return decorator(handler) + return decorator + @overload + def hard_deleted( + self, handler: TeamUpdateHandler[StateT] + ) -> TeamUpdateHandler[StateT]: ... + @overload + def hard_deleted( + self, *, auth_handlers: Optional[list[str]] = ..., rank: RouteRank = ... + ) -> _RouteDecorator[TeamUpdateHandler[StateT]]: ... def hard_deleted( self, + handler: Optional[TeamUpdateHandler[StateT]] = None, *, auth_handlers: Optional[list[str]] = None, rank: RouteRank = RouteRank.DEFAULT, - ) -> _RouteDecorator[TeamUpdateHandler[StateT]]: + ) -> TeamUpdateHandler[StateT] | _RouteDecorator[TeamUpdateHandler[StateT]]: """Register a handler for Teams teamHardDeleted conversation update events.""" - return self._create_decorator( + decorator = self._create_decorator( "teamHardDeleted", auth_handlers=auth_handlers, rank=rank ) + if handler is not None: + return decorator(handler) + return decorator + @overload + def renamed( + self, handler: TeamUpdateHandler[StateT] + ) -> TeamUpdateHandler[StateT]: ... + @overload + def renamed( + self, *, auth_handlers: Optional[list[str]] = ..., rank: RouteRank = ... + ) -> _RouteDecorator[TeamUpdateHandler[StateT]]: ... def renamed( self, + handler: Optional[TeamUpdateHandler[StateT]] = None, *, auth_handlers: Optional[list[str]] = None, rank: RouteRank = RouteRank.DEFAULT, - ) -> _RouteDecorator[TeamUpdateHandler[StateT]]: + ) -> TeamUpdateHandler[StateT] | _RouteDecorator[TeamUpdateHandler[StateT]]: """Register a handler for Teams teamRenamed conversation update events.""" - return self._create_decorator( + decorator = self._create_decorator( "teamRenamed", auth_handlers=auth_handlers, rank=rank ) + if handler is not None: + return decorator(handler) + return decorator + @overload + def restored( + self, handler: TeamUpdateHandler[StateT] + ) -> TeamUpdateHandler[StateT]: ... + @overload + def restored( + self, *, auth_handlers: Optional[list[str]] = ..., rank: RouteRank = ... + ) -> _RouteDecorator[TeamUpdateHandler[StateT]]: ... def restored( self, + handler: Optional[TeamUpdateHandler[StateT]] = None, *, auth_handlers: Optional[list[str]] = None, rank: RouteRank = RouteRank.DEFAULT, - ) -> _RouteDecorator[TeamUpdateHandler[StateT]]: + ) -> TeamUpdateHandler[StateT] | _RouteDecorator[TeamUpdateHandler[StateT]]: """Register a handler for Teams teamRestored conversation update events.""" - return self._create_decorator( + decorator = self._create_decorator( "teamRestored", auth_handlers=auth_handlers, rank=rank ) + if handler is not None: + return decorator(handler) + return decorator + @overload + def unarchived( + self, handler: TeamUpdateHandler[StateT] + ) -> TeamUpdateHandler[StateT]: ... + @overload + def unarchived( + self, *, auth_handlers: Optional[list[str]] = ..., rank: RouteRank = ... + ) -> _RouteDecorator[TeamUpdateHandler[StateT]]: ... def unarchived( self, + handler: Optional[TeamUpdateHandler[StateT]] = None, *, auth_handlers: Optional[list[str]] = None, rank: RouteRank = RouteRank.DEFAULT, - ) -> _RouteDecorator[TeamUpdateHandler[StateT]]: + ) -> TeamUpdateHandler[StateT] | _RouteDecorator[TeamUpdateHandler[StateT]]: """Register a handler for Teams teamUnarchived conversation update events.""" - return self._create_decorator( + decorator = self._create_decorator( "teamUnarchived", auth_handlers=auth_handlers, rank=rank ) + if handler is not None: + return decorator(handler) + return decorator diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/type_defs.py b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/type_defs.py index 21031f516..363ec45d7 100644 --- a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/type_defs.py +++ b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/type_defs.py @@ -25,7 +25,7 @@ class _RouteDecorator(Protocol[RouteHandlerT]): """Protocol for a decorator that registers *func* as a route and returns it unchanged.""" - def __call__(self, func: RouteHandlerT, /) -> RouteHandlerT: + def __call__(self, func: RouteHandlerT) -> RouteHandlerT: """Register *func* as a route handler and return it. :param func: The handler to register. diff --git a/tests/hosting_teams/helpers.py b/tests/hosting_teams/helpers.py index c4c13b7e4..dbc40052b 100644 --- a/tests/hosting_teams/helpers.py +++ b/tests/hosting_teams/helpers.py @@ -4,6 +4,7 @@ """Shared test helpers for microsoft-agents-hosting-teams tests.""" import sys +from typing import Any from unittest.mock import AsyncMock, MagicMock from microsoft_agents.activity import Activity, ActivityTypes @@ -12,8 +13,11 @@ is_supported_version = sys.version_info >= (3, 12) +if is_supported_version: + from microsoft_agents.hosting.teams.teams_turn_context import TeamsTurnContext -def _make_app() -> AgentApplication: + +def _make_app() -> Any: app = MagicMock(spec=AgentApplication) app._routes = [] @@ -54,4 +58,31 @@ def _make_context( activity.members_removed = members_removed context.activity = activity context.send_activity = AsyncMock() + + mock_adapter = MagicMock() + context.adapter = mock_adapter + context._responded = False + context._services = {} + context._on_send_activities = [] + context._on_update_activity = [] + context._on_delete_activity = [] + context.identity = MagicMock() + + def _copy_to(target): + target.adapter = mock_adapter + target._activity = activity + target._responded = False + target._services = {} + target._on_send_activities = [] + target._on_update_activity = [] + target._on_delete_activity = [] + + context.copy_to.side_effect = _copy_to return context + + +def _make_teams_context() -> "TeamsTurnContext": + """Return a MagicMock shaped like a TeamsTurnContext for use in unit tests.""" + ctx = MagicMock(spec=TeamsTurnContext) + ctx.send_activity = AsyncMock() + return ctx diff --git a/tests/hosting_teams/test_channels.py b/tests/hosting_teams/test_channels.py index 1e1e65573..ee633ecda 100644 --- a/tests/hosting_teams/test_channels.py +++ b/tests/hosting_teams/test_channels.py @@ -263,3 +263,46 @@ async def handler(ctx, state, data): ... ctx = _make_context(ActivityTypes.conversation_update, members_added=[member]) with pytest.raises(ValueError, match="channel_data"): await route_handler(ctx, MagicMock()) + + +class TestChannelDirectDecoratorStyle: + + def setup_method(self): + self.app = _make_app() + self.ext = TeamsAgentExtension(self.app) + + def test_created_direct(self): + @self.ext.channels.created # type: ignore[arg-type] + async def handler(ctx, state, data): ... + + assert self.app._routes[0]["selector"] is not None + + def test_deleted_direct(self): + @self.ext.channels.deleted # type: ignore[arg-type] + async def handler(ctx, state, data): ... + + assert self.app._routes[0]["selector"] is not None + + def test_renamed_direct(self): + @self.ext.channels.renamed # type: ignore[arg-type] + async def handler(ctx, state, data): ... + + assert self.app._routes[0]["selector"] is not None + + def test_rest_direct(self): + @self.ext.channels.rest # type: ignore[arg-type] + async def handler(ctx, state, data): ... + + assert self.app._routes[0]["selector"] is not None + + def test_members_added_direct(self): + @self.ext.channels.members_added # type: ignore[arg-type] + async def handler(ctx, state, data): ... + + assert self.app._routes[0]["selector"] is not None + + def test_members_removed_direct(self): + @self.ext.channels.members_removed # type: ignore[arg-type] + async def handler(ctx, state, data): ... + + assert self.app._routes[0]["selector"] is not None diff --git a/tests/hosting_teams/test_file_consent.py b/tests/hosting_teams/test_file_consent.py index d6e4eb096..635283bf6 100644 --- a/tests/hosting_teams/test_file_consent.py +++ b/tests/hosting_teams/test_file_consent.py @@ -133,3 +133,15 @@ async def handler(ctx, state, data: FileConsentCardResponse): await route_handler(ctx, MagicMock()) assert isinstance(user_handler.call_args[0][2], FileConsentCardResponse) mock_send.assert_awaited_once_with(ctx) + + def test_accept_direct_decorator_style(self): + @self.ext.file_consent.accept # type: ignore[arg-type] + async def handler(ctx, state, data): ... + + assert self.app._routes[0]["selector"] is not None + + def test_decline_direct_decorator_style(self): + @self.ext.file_consent.decline # type: ignore[arg-type] + async def handler(ctx, state, data): ... + + assert self.app._routes[0]["selector"] is not None diff --git a/tests/hosting_teams/test_meetings.py b/tests/hosting_teams/test_meetings.py index d7e52a34e..951337c87 100644 --- a/tests/hosting_teams/test_meetings.py +++ b/tests/hosting_teams/test_meetings.py @@ -221,3 +221,34 @@ async def handler(ctx, state, details: MeetingParticipantsEventDetails): ) await route_handler(ctx, MagicMock()) assert isinstance(user_handler.call_args[0][2], MeetingParticipantsEventDetails) + + +class TestMeetingDirectDecoratorStyle: + + def setup_method(self): + self.app = _make_app() + self.ext = TeamsAgentExtension(self.app) + + def test_start_direct(self): + @self.ext.meetings.start # type: ignore[arg-type] + async def handler(ctx, state, meeting): ... + + assert self.app._routes[0]["selector"] is not None + + def test_end_direct(self): + @self.ext.meetings.end # type: ignore[arg-type] + async def handler(ctx, state, meeting): ... + + assert self.app._routes[0]["selector"] is not None + + def test_participants_join_direct(self): + @self.ext.meetings.participants_join # type: ignore[arg-type] + async def handler(ctx, state, details): ... + + assert self.app._routes[0]["selector"] is not None + + def test_participants_leave_direct(self): + @self.ext.meetings.participants_leave # type: ignore[arg-type] + async def handler(ctx, state, details): ... + + assert self.app._routes[0]["selector"] is not None diff --git a/tests/hosting_teams/test_message_extensions.py b/tests/hosting_teams/test_message_extensions.py index a7c2a0dc6..d2a689770 100644 --- a/tests/hosting_teams/test_message_extensions.py +++ b/tests/hosting_teams/test_message_extensions.py @@ -487,12 +487,6 @@ async def handler(ctx, state, settings): ... assert self.app._routes[0]["is_invoke"] is True - def test_setting_direct_decorator_style(self): - @self.ext.message_extensions.setting - async def handler(ctx, state, settings): ... - - assert len(self.app._routes) == 1 - @pytest.mark.asyncio async def test_setting_always_sends_empty_response(self): @self.ext.message_extensions.setting() @@ -536,3 +530,46 @@ def test_card_button_clicked_is_invoke(self): async def handler(ctx, state, data): ... assert self.app._routes[0]["is_invoke"] is True + + +class TestMessageExtensionDirectDecoratorStyle: + + def setup_method(self): + self.app = _make_app() + self.ext = TeamsAgentExtension(self.app) + + def test_select_item_direct(self): + @self.ext.message_extensions.select_item # type: ignore[arg-type] + async def handler(ctx, state, value): ... + + assert self.app._routes[0]["selector"] is not None + + def test_query_link_direct(self): + @self.ext.message_extensions.query_link # type: ignore[arg-type] + async def handler(ctx, state, query): ... + + assert self.app._routes[0]["selector"] is not None + + def test_anonymous_query_link_direct(self): + @self.ext.message_extensions.anonymous_query_link # type: ignore[arg-type] + async def handler(ctx, state, query): ... + + assert self.app._routes[0]["selector"] is not None + + def test_query_setting_url_direct(self): + @self.ext.message_extensions.query_setting_url # type: ignore[arg-type] + async def handler(ctx, state, query): ... + + assert self.app._routes[0]["selector"] is not None + + def test_setting_direct(self): + @self.ext.message_extensions.setting # type: ignore[arg-type] + async def handler(ctx, state, value): ... + + assert self.app._routes[0]["selector"] is not None + + def test_card_button_clicked_direct(self): + @self.ext.message_extensions.card_button_clicked # type: ignore[arg-type] + async def handler(ctx, state, value): ... + + assert self.app._routes[0]["selector"] is not None diff --git a/tests/hosting_teams/test_messages.py b/tests/hosting_teams/test_messages.py index 824473bf2..58dc6e651 100644 --- a/tests/hosting_teams/test_messages.py +++ b/tests/hosting_teams/test_messages.py @@ -279,3 +279,40 @@ async def handler(ctx, state, query: O365ConnectorCardActionQuery): user_handler.call_args[0][2], O365ConnectorCardActionQuery ) mock_send.assert_awaited_once_with(ctx) + + +class TestMessageDirectDecoratorStyle: + + def setup_method(self): + self.app = _make_app() + self.ext = TeamsAgentExtension(self.app) + + def test_edit_direct(self): + @self.ext.messages.edit # type: ignore[arg-type] + async def handler(ctx, state): ... + + assert self.app._routes[0]["selector"] is not None + + def test_undelete_direct(self): + @self.ext.messages.undelete # type: ignore[arg-type] + async def handler(ctx, state): ... + + assert self.app._routes[0]["selector"] is not None + + def test_soft_delete_direct(self): + @self.ext.messages.soft_delete # type: ignore[arg-type] + async def handler(ctx, state): ... + + assert self.app._routes[0]["selector"] is not None + + def test_read_receipt_direct(self): + @self.ext.messages.read_receipt # type: ignore[arg-type] + async def handler(ctx, state, value): ... + + assert self.app._routes[0]["selector"] is not None + + def test_execute_action_direct(self): + @self.ext.messages.execute_action # type: ignore[arg-type] + async def handler(ctx, state, query): ... + + assert self.app._routes[0]["selector"] is not None diff --git a/tests/hosting_teams/test_team_lifecycle.py b/tests/hosting_teams/test_team_lifecycle.py index fc68c8546..0a11fb9a0 100644 --- a/tests/hosting_teams/test_team_lifecycle.py +++ b/tests/hosting_teams/test_team_lifecycle.py @@ -170,3 +170,46 @@ def test_team_event_is_not_invoke(self): async def handler(ctx, state, data): ... assert self.app._routes[0]["is_invoke"] is False + + +class TestTeamDirectDecoratorStyle: + + def setup_method(self): + self.app = _make_app() + self.ext = TeamsAgentExtension(self.app) + + def test_archived_direct(self): + @self.ext.teams.archived # type: ignore[arg-type] + async def handler(ctx, state, data): ... + + assert self.app._routes[0]["selector"] is not None + + def test_deleted_direct(self): + @self.ext.teams.deleted # type: ignore[arg-type] + async def handler(ctx, state, data): ... + + assert self.app._routes[0]["selector"] is not None + + def test_hard_deleted_direct(self): + @self.ext.teams.hard_deleted # type: ignore[arg-type] + async def handler(ctx, state, data): ... + + assert self.app._routes[0]["selector"] is not None + + def test_renamed_direct(self): + @self.ext.teams.renamed # type: ignore[arg-type] + async def handler(ctx, state, data): ... + + assert self.app._routes[0]["selector"] is not None + + def test_restored_direct(self): + @self.ext.teams.restored # type: ignore[arg-type] + async def handler(ctx, state, data): ... + + assert self.app._routes[0]["selector"] is not None + + def test_unarchived_direct(self): + @self.ext.teams.unarchived # type: ignore[arg-type] + async def handler(ctx, state, data): ... + + assert self.app._routes[0]["selector"] is not None From 5758dac19caa79cbbc87dddfce08614d4f04d709 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Mon, 22 Jun 2026 09:22:38 -0700 Subject: [PATCH 13/46] Adding missing route hooks --- .../hosting/teams/channel/channel.py | 88 +++++++- .../hosting/teams/message/message.py | 6 +- .../hosting/teams/team/team.py | 37 +++- tests/hosting_teams/test_channels.py | 204 +++++++++++++++++- tests/hosting_teams/test_messages.py | 6 +- tests/hosting_teams/test_team_lifecycle.py | 128 +++++++++++ 6 files changed, 451 insertions(+), 18 deletions(-) diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/channel/channel.py b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/channel/channel.py index d179178de..ea603eb35 100644 --- a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/channel/channel.py +++ b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/channel/channel.py @@ -3,6 +3,7 @@ """Route registration helpers for Teams channel conversation update events.""" +import re from typing import Generic, Optional, overload from microsoft_agents.activity import ActivityTypes @@ -40,7 +41,7 @@ def __init__(self, app: AgentApplication[StateT]) -> None: def _create_decorator( self, - event_type: str, + event_type: str | re.Pattern, *, auth_handlers: Optional[list[str]] = None, rank: RouteRank = RouteRank.DEFAULT, @@ -48,10 +49,18 @@ def _create_decorator( """Build a route decorator that matches a specific Teams channel event type.""" def __selector(context: TurnContext) -> bool: + event_match = False + channel_event_type = _get_channel_event_type(context) + if channel_event_type: + if isinstance(event_type, re.Pattern): + event_match = re.fullmatch(event_type, channel_event_type) is not None + else: + event_match = event_type == channel_event_type + return ( context.activity.type == ActivityTypes.conversation_update and context.activity.channel_id == "msteams" - and _get_channel_event_type(context) == event_type + and event_match ) def __call(func: ChannelUpdateHandler[StateT]) -> ChannelUpdateHandler[StateT]: @@ -66,6 +75,29 @@ async def __handler(context: TurnContext, state: StateT) -> None: return func return __call + + @overload + def event( + self, handler: ChannelUpdateHandler[StateT] + ) -> ChannelUpdateHandler[StateT]: ... + @overload + def event( + self, *, auth_handlers: Optional[list[str]] = ..., rank: RouteRank = ... + ) -> _RouteDecorator[ChannelUpdateHandler[StateT]]: ... + def event( + self, + handler: Optional[ChannelUpdateHandler[StateT]] = None, + *, + auth_handlers: Optional[list[str]] = None, + rank: RouteRank = RouteRank.DEFAULT, + ) -> ChannelUpdateHandler[StateT] | _RouteDecorator[ChannelUpdateHandler[StateT]]: + """Register a handler for Teams team event conversation update events.""" + decorator = self._create_decorator( + r"channel.*", auth_handlers=auth_handlers, rank=rank + ) + if handler is not None: + return decorator(handler) + return decorator @overload def created( @@ -135,16 +167,62 @@ def renamed( if handler is not None: return decorator(handler) return decorator + + @overload + def shared( + self, handler: ChannelUpdateHandler[StateT] + ) -> ChannelUpdateHandler[StateT]: ... + @overload + def shared( + self, *, auth_handlers: Optional[list[str]] = ..., rank: RouteRank = ... + ) -> _RouteDecorator[ChannelUpdateHandler[StateT]]: ... + def shared( + self, + handler: Optional[ChannelUpdateHandler[StateT]] = None, + *, + auth_handlers: Optional[list[str]] = None, + rank: RouteRank = RouteRank.DEFAULT, + ) -> ChannelUpdateHandler[StateT] | _RouteDecorator[ChannelUpdateHandler[StateT]]: + """Register a handler for Teams channelShared conversation update events.""" + decorator = self._create_decorator( + "channelShared", auth_handlers=auth_handlers, rank=rank + ) + if handler is not None: + return decorator(handler) + return decorator + + @overload + def unshared( + self, handler: ChannelUpdateHandler[StateT] + ) -> ChannelUpdateHandler[StateT]: ... + @overload + def unshared( + self, *, auth_handlers: Optional[list[str]] = ..., rank: RouteRank = ... + ) -> _RouteDecorator[ChannelUpdateHandler[StateT]]: ... + def unshared( + self, + handler: Optional[ChannelUpdateHandler[StateT]] = None, + *, + auth_handlers: Optional[list[str]] = None, + rank: RouteRank = RouteRank.DEFAULT, + ) -> ChannelUpdateHandler[StateT] | _RouteDecorator[ChannelUpdateHandler[StateT]]: + """Register a handler for Teams channelUnshared conversation update events.""" + decorator = self._create_decorator( + "channelUnshared", auth_handlers=auth_handlers, rank=rank + ) + if handler is not None: + return decorator(handler) + return decorator @overload - def rest( + def restored( self, handler: ChannelUpdateHandler[StateT] ) -> ChannelUpdateHandler[StateT]: ... @overload - def rest( + def restored( self, *, auth_handlers: Optional[list[str]] = ..., rank: RouteRank = ... ) -> _RouteDecorator[ChannelUpdateHandler[StateT]]: ... - def rest( + def restored( self, handler: Optional[ChannelUpdateHandler[StateT]] = None, *, diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/message/message.py b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/message/message.py index aeee27056..2f7cf9121 100644 --- a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/message/message.py +++ b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/message/message.py @@ -120,14 +120,14 @@ def undelete( return decorator @overload - def soft_delete( + def delete( self, handler: TeamsRouteHandler[StateT] ) -> TeamsRouteHandler[StateT]: ... @overload - def soft_delete( + def delete( self, *, auth_handlers: Optional[list[str]] = ..., rank: RouteRank = ... ) -> _RouteDecorator[TeamsRouteHandler[StateT]]: ... - def soft_delete( + def delete( self, handler: Optional[TeamsRouteHandler[StateT]] = None, *, diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/team/team.py b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/team/team.py index 3422c3eb9..b3a29f44d 100644 --- a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/team/team.py +++ b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/team/team.py @@ -3,6 +3,7 @@ """Route registration helpers for Teams team conversation update events.""" +import re from typing import Generic, Optional, overload from microsoft_agents.activity import ActivityTypes @@ -40,7 +41,7 @@ def __init__(self, app: AgentApplication[StateT]) -> None: def _create_decorator( self, - event_type: str, + event_type: str | re.Pattern, *, auth_handlers: Optional[list[str]] = None, rank: RouteRank = RouteRank.DEFAULT, @@ -48,10 +49,19 @@ def _create_decorator( """Build a route decorator for a specific Teams team event type.""" def __selector(context: TurnContext) -> bool: + + event_match = False + channel_event_type = _get_channel_event_type(context) + if channel_event_type: + if isinstance(event_type, re.Pattern): + event_match = re.fullmatch(event_type, channel_event_type) is not None + else: + event_match = event_type == channel_event_type + return ( context.activity.type == ActivityTypes.conversation_update and context.activity.channel_id == "msteams" - and _get_channel_event_type(context) == event_type + and event_match ) def __call(func: TeamUpdateHandler[StateT]) -> TeamUpdateHandler[StateT]: @@ -66,6 +76,29 @@ async def __handler(context: TurnContext, state: StateT) -> None: return func return __call + + @overload + def event( + self, handler: TeamUpdateHandler[StateT] + ) -> TeamUpdateHandler[StateT]: ... + @overload + def event( + self, *, auth_handlers: Optional[list[str]] = ..., rank: RouteRank = ... + ) -> _RouteDecorator[TeamUpdateHandler[StateT]]: ... + def event( + self, + handler: Optional[TeamUpdateHandler[StateT]] = None, + *, + auth_handlers: Optional[list[str]] = None, + rank: RouteRank = RouteRank.DEFAULT, + ) -> TeamUpdateHandler[StateT] | _RouteDecorator[TeamUpdateHandler[StateT]]: + """Register a handler for Teams team event conversation update events.""" + decorator = self._create_decorator( + r"team.*", auth_handlers=auth_handlers, rank=rank + ) + if handler is not None: + return decorator(handler) + return decorator @overload def archived( diff --git a/tests/hosting_teams/test_channels.py b/tests/hosting_teams/test_channels.py index ee633ecda..9755ef1a3 100644 --- a/tests/hosting_teams/test_channels.py +++ b/tests/hosting_teams/test_channels.py @@ -88,9 +88,9 @@ async def handler(ctx, state, data): ... is True ) - def test_rest_selector_matches_channel_restored(self): - @self.ext.channels.rest() - async def handler(ctx, state, data): ... + def test_restored_selector(self): + @self.ext.channels.restored() + async def handler(context, state, data): ... selector = self.app._routes[0]["selector"] assert ( @@ -112,6 +112,54 @@ async def handler(ctx, state, data): ... is False ) + def test_shared_selector(self): + @self.ext.channels.shared() + async def handler(context, state, data): ... + + selector = self.app._routes[0]["selector"] + assert ( + selector( + _make_context( + ActivityTypes.conversation_update, + channel_data={"eventType": "channelShared"}, + ) + ) + is True + ) + assert ( + selector( + _make_context( + ActivityTypes.conversation_update, + channel_data={"eventType": "channelUnshared"}, + ) + ) + is False + ) + + def test_unshared_selector(self): + @self.ext.channels.unshared() + async def handler(context, state, data): ... + + selector = self.app._routes[0]["selector"] + assert ( + selector( + _make_context( + ActivityTypes.conversation_update, + channel_data={"eventType": "channelUnshared"}, + ) + ) + is True + ) + assert ( + selector( + _make_context( + ActivityTypes.conversation_update, + channel_data={"eventType": "channelShared"}, + ) + ) + is False + ) + def test_channel_event_requires_msteams_channel(self): @self.ext.channels.created() async def handler(ctx, state, data): ... @@ -265,6 +313,134 @@ async def handler(ctx, state, data): ... await route_handler(ctx, MagicMock()) +class TestChannelEvent: + + def setup_method(self): + self.app = _make_app() + self.ext = TeamsAgentExtension(self.app) + + def _selector(self): + @self.ext.channels.event() + async def handler(context, state, data): ... + + return self.app._routes[0]["selector"] + + def test_event_matches_channel_created(self): + selector = self._selector() + assert ( + selector( + _make_context( + ActivityTypes.conversation_update, + channel_data={"eventType": "channelCreated"}, + ) + ) + is True + ) + + def test_event_matches_channel_deleted(self): + selector = self._selector() + assert ( + selector( + _make_context( + ActivityTypes.conversation_update, + channel_data={"eventType": "channelDeleted"}, + ) + ) + is True + ) + + def test_event_matches_channel_renamed(self): + selector = self._selector() + assert ( + selector( + _make_context( + ActivityTypes.conversation_update, + channel_data={"eventType": "channelRenamed"}, + ) + ) + is True + ) + + def test_event_matches_channel_restored(self): + selector = self._selector() + assert ( + selector( + _make_context( + ActivityTypes.conversation_update, + channel_data={"eventType": "channelRestored"}, + ) + ) + is True + ) + + def test_event_matches_channel_shared(self): + selector = self._selector() + assert ( + selector( + _make_context( + ActivityTypes.conversation_update, + channel_data={"eventType": "channelShared"}, + ) + ) + is True + ) + + def test_event_matches_channel_unshared(self): + selector = self._selector() + assert ( + selector( + _make_context( + ActivityTypes.conversation_update, + channel_data={"eventType": "channelUnshared"}, + ) + ) + is True + ) + + def test_event_no_match_non_channel_event(self): + selector = self._selector() + assert ( + selector( + _make_context( + ActivityTypes.conversation_update, + channel_data={"eventType": "teamArchived"}, + ) + ) + is False + ) + + def test_event_requires_msteams_channel(self): + selector = self._selector() + assert ( + selector( + _make_context( + ActivityTypes.conversation_update, + channel_id="webchat", + channel_data={"eventType": "channelCreated"}, + ) + ) + is False + ) + + def test_event_requires_conversation_update(self): + selector = self._selector() + assert ( + selector( + _make_context( + ActivityTypes.message, + channel_data={"eventType": "channelCreated"}, + ) + ) + is False + ) + + def test_event_is_not_invoke(self): + @self.ext.channels.event() + async def handler(context, state, data): ... + + assert self.app._routes[0]["is_invoke"] is False + + class TestChannelDirectDecoratorStyle: def setup_method(self): @@ -289,8 +465,26 @@ async def handler(ctx, state, data): ... assert self.app._routes[0]["selector"] is not None - def test_rest_direct(self): - @self.ext.channels.rest # type: ignore[arg-type] + def test_restored_direct(self): + @self.ext.channels.restored # type: ignore[arg-type] + async def handler(ctx, state, data): ... + + assert self.app._routes[0]["selector"] is not None + + def test_shared_direct(self): + @self.ext.channels.shared # type: ignore[arg-type] + async def handler(ctx, state, data): ... + + assert self.app._routes[0]["selector"] is not None + + def test_unshared_direct(self): + @self.ext.channels.unshared # type: ignore[arg-type] + async def handler(ctx, state, data): ... + + assert self.app._routes[0]["selector"] is not None + + def test_event_direct(self): + @self.ext.channels.event # type: ignore[arg-type] async def handler(ctx, state, data): ... assert self.app._routes[0]["selector"] is not None diff --git a/tests/hosting_teams/test_messages.py b/tests/hosting_teams/test_messages.py index 58dc6e651..ddfc63819 100644 --- a/tests/hosting_teams/test_messages.py +++ b/tests/hosting_teams/test_messages.py @@ -119,7 +119,7 @@ def setup_method(self): self.ext = TeamsAgentExtension(self.app) def test_soft_delete_selector(self): - @self.ext.messages.soft_delete() + @self.ext.messages.delete() async def handler(ctx, state): ... selector = self.app._routes[0]["selector"] @@ -152,7 +152,7 @@ async def handler(ctx, state): ... ) def test_soft_delete_wrong_channel(self): - @self.ext.messages.soft_delete() + @self.ext.messages.delete() async def handler(ctx, state): ... selector = self.app._routes[0]["selector"] @@ -300,7 +300,7 @@ async def handler(ctx, state): ... assert self.app._routes[0]["selector"] is not None def test_soft_delete_direct(self): - @self.ext.messages.soft_delete # type: ignore[arg-type] + @self.ext.messages.delete # type: ignore[arg-type] async def handler(ctx, state): ... assert self.app._routes[0]["selector"] is not None diff --git a/tests/hosting_teams/test_team_lifecycle.py b/tests/hosting_teams/test_team_lifecycle.py index 0a11fb9a0..115d37eeb 100644 --- a/tests/hosting_teams/test_team_lifecycle.py +++ b/tests/hosting_teams/test_team_lifecycle.py @@ -172,6 +172,134 @@ async def handler(ctx, state, data): ... assert self.app._routes[0]["is_invoke"] is False +class TestTeamEvent: + + def setup_method(self): + self.app = _make_app() + self.ext = TeamsAgentExtension(self.app) + + def _selector(self): + @self.ext.teams.event() + async def handler(context, state, data): ... + + return self.app._routes[0]["selector"] + + def test_event_matches_team_archived(self): + selector = self._selector() + assert ( + selector( + _make_context( + ActivityTypes.conversation_update, + channel_data={"eventType": "teamArchived"}, + ) + ) + is True + ) + + def test_event_matches_team_renamed(self): + selector = self._selector() + assert ( + selector( + _make_context( + ActivityTypes.conversation_update, + channel_data={"eventType": "teamRenamed"}, + ) + ) + is True + ) + + def test_event_matches_team_deleted(self): + selector = self._selector() + assert ( + selector( + _make_context( + ActivityTypes.conversation_update, + channel_data={"eventType": "teamDeleted"}, + ) + ) + is True + ) + + def test_event_matches_team_hard_deleted(self): + selector = self._selector() + assert ( + selector( + _make_context( + ActivityTypes.conversation_update, + channel_data={"eventType": "teamHardDeleted"}, + ) + ) + is True + ) + + def test_event_matches_team_restored(self): + selector = self._selector() + assert ( + selector( + _make_context( + ActivityTypes.conversation_update, + channel_data={"eventType": "teamRestored"}, + ) + ) + is True + ) + + def test_event_matches_team_unarchived(self): + selector = self._selector() + assert ( + selector( + _make_context( + ActivityTypes.conversation_update, + channel_data={"eventType": "teamUnarchived"}, + ) + ) + is True + ) + + def test_event_no_match_non_team_event(self): + selector = self._selector() + assert ( + selector( + _make_context( + ActivityTypes.conversation_update, + channel_data={"eventType": "membersAdded"}, + ) + ) + is False + ) + + def test_event_requires_msteams_channel(self): + selector = self._selector() + assert ( + selector( + _make_context( + ActivityTypes.conversation_update, + channel_id="webchat", + channel_data={"eventType": "teamArchived"}, + ) + ) + is False + ) + + def test_event_requires_conversation_update(self): + selector = self._selector() + assert ( + selector( + _make_context( + ActivityTypes.message, + channel_data={"eventType": "teamArchived"}, + ) + ) + is False + ) + + def test_event_is_not_invoke(self): + @self.ext.teams.event() + async def handler(ctx, state, data): ... + + assert self.app._routes[0]["is_invoke"] is False + + class TestTeamDirectDecoratorStyle: def setup_method(self): From a11e18a1f7d7a3721f4d9a4168663f44ded9890e Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Mon, 22 Jun 2026 09:38:57 -0700 Subject: [PATCH 14/46] Adding _StateContra for proper route handler definitions --- .../hosting/teams/channel/channel.py | 10 +++-- .../hosting/teams/channel/route_handlers.py | 6 +-- .../hosting/teams/config/route_handlers.py | 6 +-- .../teams/file_consent/route_handlers.py | 6 +-- .../hosting/teams/meeting/route_handlers.py | 14 +++---- .../hosting/teams/message/route_handlers.py | 10 ++--- .../teams/message_extension/route_handlers.py | 42 +++++++++---------- .../teams/task_module/route_handlers.py | 10 ++--- .../hosting/teams/team/route_handlers.py | 6 +-- .../hosting/teams/team/team.py | 6 ++- .../hosting/teams/teams_agent_extension.py | 4 +- .../hosting/teams/type_defs.py | 1 + 12 files changed, 63 insertions(+), 58 deletions(-) diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/channel/channel.py b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/channel/channel.py index ea603eb35..90f2ee6b4 100644 --- a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/channel/channel.py +++ b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/channel/channel.py @@ -53,7 +53,9 @@ def __selector(context: TurnContext) -> bool: channel_event_type = _get_channel_event_type(context) if channel_event_type: if isinstance(event_type, re.Pattern): - event_match = re.fullmatch(event_type, channel_event_type) is not None + event_match = ( + re.fullmatch(event_type, channel_event_type) is not None + ) else: event_match = event_type == channel_event_type @@ -75,7 +77,7 @@ async def __handler(context: TurnContext, state: StateT) -> None: return func return __call - + @overload def event( self, handler: ChannelUpdateHandler[StateT] @@ -167,7 +169,7 @@ def renamed( if handler is not None: return decorator(handler) return decorator - + @overload def shared( self, handler: ChannelUpdateHandler[StateT] @@ -190,7 +192,7 @@ def shared( if handler is not None: return decorator(handler) return decorator - + @overload def unshared( self, handler: ChannelUpdateHandler[StateT] diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/channel/route_handlers.py b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/channel/route_handlers.py index 1d2418f0d..f59f84191 100644 --- a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/channel/route_handlers.py +++ b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/channel/route_handlers.py @@ -8,10 +8,10 @@ from microsoft_teams.api.models.channel_data import ChannelData from microsoft_agents.hosting.teams.teams_turn_context import TeamsTurnContext -from microsoft_agents.hosting.teams.type_defs import StateT +from microsoft_agents.hosting.teams.type_defs import _StateContra -class ChannelUpdateHandler(Protocol[StateT]): +class ChannelUpdateHandler(Protocol[_StateContra]): """Protocol for a handler invoked on Teams channel update events. Handlers receive the Teams turn context, the current turn state, and the @@ -21,7 +21,7 @@ class ChannelUpdateHandler(Protocol[StateT]): def __call__( self, context: TeamsTurnContext, - state: StateT, + state: _StateContra, data: ChannelData, ) -> Awaitable[None]: """Handle a channel update event. diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/config/route_handlers.py b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/config/route_handlers.py index e4a2a0276..b156b0950 100644 --- a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/config/route_handlers.py +++ b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/config/route_handlers.py @@ -8,10 +8,10 @@ from microsoft_teams.api.models.config import ConfigResponse from microsoft_agents.hosting.teams.teams_turn_context import TeamsTurnContext -from microsoft_agents.hosting.teams.type_defs import StateT +from microsoft_agents.hosting.teams.type_defs import _StateContra -class ConfigHandler(Protocol[StateT]): +class ConfigHandler(Protocol[_StateContra]): """Protocol for a handler invoked on Teams config/fetch or config/submit activities. The handler returns a :class:`ConfigResponse` which is automatically sent back @@ -21,7 +21,7 @@ class ConfigHandler(Protocol[StateT]): def __call__( self, context: TeamsTurnContext, - state: StateT, + state: _StateContra, config_data: Any, ) -> Awaitable[ConfigResponse]: """Handle a configuration invoke. diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/file_consent/route_handlers.py b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/file_consent/route_handlers.py index 41034773e..ccd6f2325 100644 --- a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/file_consent/route_handlers.py +++ b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/file_consent/route_handlers.py @@ -8,10 +8,10 @@ from microsoft_teams.api.models import FileConsentCardResponse from microsoft_agents.hosting.teams.teams_turn_context import TeamsTurnContext -from microsoft_agents.hosting.teams.type_defs import StateT +from microsoft_agents.hosting.teams.type_defs import _StateContra -class FileConsentHandler(Protocol[StateT]): +class FileConsentHandler(Protocol[_StateContra]): """Protocol for a handler invoked on fileConsent/invoke activities. Called for both ``accept`` and ``decline`` actions; the routing layer sends @@ -21,7 +21,7 @@ class FileConsentHandler(Protocol[StateT]): def __call__( self, context: TeamsTurnContext, - state: StateT, + state: _StateContra, file_consent: FileConsentCardResponse, ) -> Awaitable[None]: """Handle a file consent invoke. diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/meeting/route_handlers.py b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/meeting/route_handlers.py index a80711f3a..d6b554b1e 100644 --- a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/meeting/route_handlers.py +++ b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/meeting/route_handlers.py @@ -9,16 +9,16 @@ from microsoft_agents.activity.teams import MeetingParticipantsEventDetails from microsoft_agents.hosting.teams.teams_turn_context import TeamsTurnContext -from microsoft_agents.hosting.teams.type_defs import StateT +from microsoft_agents.hosting.teams.type_defs import _StateContra -class MeetingStartHandler(Protocol[StateT]): +class MeetingStartHandler(Protocol[_StateContra]): """Protocol for a handler invoked when a Teams meeting starts.""" def __call__( self, context: TeamsTurnContext, - state: StateT, + state: _StateContra, meeting: MeetingDetails, ) -> Awaitable[None]: """Handle a meeting start event. @@ -30,13 +30,13 @@ def __call__( ... -class MeetingEndHandler(Protocol[StateT]): +class MeetingEndHandler(Protocol[_StateContra]): """Protocol for a handler invoked when a Teams meeting ends.""" def __call__( self, context: TeamsTurnContext, - state: StateT, + state: _StateContra, meeting: MeetingDetails, ) -> Awaitable[None]: """Handle a meeting end event. @@ -48,13 +48,13 @@ def __call__( ... -class MeetingParticipantsEventHandler(Protocol[StateT]): +class MeetingParticipantsEventHandler(Protocol[_StateContra]): """Protocol for a handler invoked when participants join or leave a Teams meeting.""" def __call__( self, context: TeamsTurnContext, - state: StateT, + state: _StateContra, meeting: MeetingParticipantsEventDetails, ) -> Awaitable[None]: """Handle a meeting participant join or leave event. diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/message/route_handlers.py b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/message/route_handlers.py index 43318643d..1df7e3255 100644 --- a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/message/route_handlers.py +++ b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/message/route_handlers.py @@ -8,16 +8,16 @@ from microsoft_teams.api.models.o365 import O365ConnectorCardActionQuery from microsoft_agents.hosting.teams.teams_turn_context import TeamsTurnContext -from microsoft_agents.hosting.teams.type_defs import StateT +from microsoft_agents.hosting.teams.type_defs import _StateContra -class ExecuteActionHandler(Protocol[StateT]): +class ExecuteActionHandler(Protocol[_StateContra]): """Protocol for a handler invoked on actionableMessage/executeAction activities.""" def __call__( self, context: TeamsTurnContext, - state: StateT, + state: _StateContra, query: O365ConnectorCardActionQuery, ) -> Awaitable[None]: """Handle an O365 connector card action execution. @@ -29,13 +29,13 @@ def __call__( ... -class ReadReceiptHandler(Protocol[StateT]): +class ReadReceiptHandler(Protocol[_StateContra]): """Protocol for a handler invoked on Teams read receipt events.""" def __call__( self, context: TeamsTurnContext, - state: StateT, + state: _StateContra, data: dict, ) -> Awaitable[None]: """Handle a read receipt event. diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/message_extension/route_handlers.py b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/message_extension/route_handlers.py index 9d053ac9f..56c1ff0eb 100644 --- a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/message_extension/route_handlers.py +++ b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/message_extension/route_handlers.py @@ -20,16 +20,16 @@ from microsoft_agents.activity import Activity from microsoft_agents.hosting.teams.teams_turn_context import TeamsTurnContext -from microsoft_agents.hosting.teams.type_defs import StateT +from microsoft_agents.hosting.teams.type_defs import _StateContra -class FetchTaskHandler(Protocol[StateT]): +class FetchTaskHandler(Protocol[_StateContra]): """Protocol for a handler invoked on composeExtension/fetchTask activities.""" def __call__( self, context: TeamsTurnContext, - state: StateT, + state: _StateContra, action: MessagingExtensionAction, ) -> Awaitable[MessagingExtensionActionResponse]: """Handle a fetch task invoke. @@ -42,13 +42,13 @@ def __call__( ... -class SubmitActionHandler(Protocol[StateT]): +class SubmitActionHandler(Protocol[_StateContra]): """Protocol for a handler invoked on composeExtension/submitAction activities.""" def __call__( self, context: TeamsTurnContext, - state: StateT, + state: _StateContra, action: MessagingExtensionAction, ) -> Awaitable[MessagingExtensionResponse]: """Handle a submit action invoke. @@ -61,13 +61,13 @@ def __call__( ... -class MessagePreviewEditHandler(Protocol[StateT]): +class MessagePreviewEditHandler(Protocol[_StateContra]): """Protocol for a handler invoked on composeExtension/submitAction with botMessagePreviewAction == 'edit'.""" def __call__( self, context: TeamsTurnContext, - state: StateT, + state: _StateContra, activity_preview: Activity, ) -> Awaitable[MessagingExtensionResponse]: """Handle a message preview edit request. @@ -80,13 +80,13 @@ def __call__( ... -class MessagePreviewSendHandler(Protocol[StateT]): +class MessagePreviewSendHandler(Protocol[_StateContra]): """Protocol for a handler invoked on composeExtension/submitAction with botMessagePreviewAction == 'send'.""" def __call__( self, context: TeamsTurnContext, - state: StateT, + state: _StateContra, activity_preview: Activity, ) -> Awaitable[MessagingExtensionResponse | None]: """Handle a message preview send request. @@ -98,13 +98,13 @@ def __call__( ... -class QueryHandler(Protocol[StateT]): +class QueryHandler(Protocol[_StateContra]): """Protocol for a handler invoked on composeExtension/query activities.""" def __call__( self, context: TeamsTurnContext, - state: StateT, + state: _StateContra, query: MessagingExtensionQuery, ) -> Awaitable[MessagingExtensionResponse]: """Handle a message extension query invoke. @@ -117,13 +117,13 @@ def __call__( ... -class SelectItemHandler(Protocol[StateT]): +class SelectItemHandler(Protocol[_StateContra]): """Protocol for a handler invoked on composeExtension/selectItem activities.""" def __call__( self, context: TeamsTurnContext, - state: StateT, + state: _StateContra, item: Any, ) -> Awaitable[MessagingExtensionResponse]: """Handle a select item invoke. @@ -136,13 +136,13 @@ def __call__( ... -class QueryLinkHandler(Protocol[StateT]): +class QueryLinkHandler(Protocol[_StateContra]): """Protocol for a handler invoked on composeExtension/queryLink or anonymousQueryLink activities.""" def __call__( self, context: TeamsTurnContext, - state: StateT, + state: _StateContra, query: AppBasedLinkQuery, ) -> Awaitable[MessagingExtensionResponse]: """Handle a link unfurling query. @@ -155,13 +155,13 @@ def __call__( ... -class QueryUrlSettingHandler(Protocol[StateT]): +class QueryUrlSettingHandler(Protocol[_StateContra]): """Protocol for a handler invoked on composeExtension/querySettingUrl activities.""" def __call__( self, context: TeamsTurnContext, - state: StateT, + state: _StateContra, query: MessagingExtensionQuery, ) -> Awaitable[MessagingExtensionResponse]: """Handle a query setting URL invoke. @@ -174,13 +174,13 @@ def __call__( ... -class ConfigureSettingsHandler(Protocol[StateT]): +class ConfigureSettingsHandler(Protocol[_StateContra]): """Protocol for a handler invoked on composeExtension/setting activities.""" def __call__( self, context: TeamsTurnContext, - state: StateT, + state: _StateContra, query: MessagingExtensionQuery, ) -> Awaitable[MessagingExtensionResponse]: """Handle a configure settings invoke. @@ -193,13 +193,13 @@ def __call__( ... -class CardButtonClickedHandler(Protocol[StateT]): +class CardButtonClickedHandler(Protocol[_StateContra]): """Protocol for a handler invoked on composeExtension/onCardButtonClicked activities.""" def __call__( self, context: TeamsTurnContext, - state: StateT, + state: _StateContra, card: Any, ) -> Awaitable[None]: """Handle a card button clicked invoke. diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/task_module/route_handlers.py b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/task_module/route_handlers.py index 42e6e7bda..dd2871dcb 100644 --- a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/task_module/route_handlers.py +++ b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/task_module/route_handlers.py @@ -8,16 +8,16 @@ from microsoft_teams.api.models import TaskModuleRequest, TaskModuleResponse from microsoft_agents.hosting.teams.teams_turn_context import TeamsTurnContext -from microsoft_agents.hosting.teams.type_defs import StateT +from microsoft_agents.hosting.teams.type_defs import _StateContra -class FetchHandler(Protocol[StateT]): +class FetchHandler(Protocol[_StateContra]): """Protocol for a handler invoked on task/fetch activities.""" def __call__( self, context: TeamsTurnContext, - state: StateT, + state: _StateContra, request: TaskModuleRequest, ) -> Awaitable[TaskModuleResponse]: """Handle a task module fetch invoke. @@ -30,13 +30,13 @@ def __call__( ... -class SubmitHandler(Protocol[StateT]): +class SubmitHandler(Protocol[_StateContra]): """Protocol for a handler invoked on task/submit activities.""" def __call__( self, context: TeamsTurnContext, - state: StateT, + state: _StateContra, request: TaskModuleRequest, ) -> Awaitable[TaskModuleResponse]: """Handle a task module submit invoke. diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/team/route_handlers.py b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/team/route_handlers.py index 7848e92de..175080deb 100644 --- a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/team/route_handlers.py +++ b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/team/route_handlers.py @@ -8,16 +8,16 @@ from microsoft_teams.api.models.channel_data import ChannelData from microsoft_agents.hosting.teams.teams_turn_context import TeamsTurnContext -from microsoft_agents.hosting.teams.type_defs import StateT +from microsoft_agents.hosting.teams.type_defs import _StateContra -class TeamUpdateHandler(Protocol[StateT]): +class TeamUpdateHandler(Protocol[_StateContra]): """Protocol for a handler invoked on Teams team conversation update events.""" def __call__( self, context: TeamsTurnContext, - state: StateT, + state: _StateContra, data: ChannelData, ) -> Awaitable[None]: """Handle a team update event. diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/team/team.py b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/team/team.py index b3a29f44d..e2ca9b228 100644 --- a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/team/team.py +++ b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/team/team.py @@ -54,7 +54,9 @@ def __selector(context: TurnContext) -> bool: channel_event_type = _get_channel_event_type(context) if channel_event_type: if isinstance(event_type, re.Pattern): - event_match = re.fullmatch(event_type, channel_event_type) is not None + event_match = ( + re.fullmatch(event_type, channel_event_type) is not None + ) else: event_match = event_type == channel_event_type @@ -76,7 +78,7 @@ async def __handler(context: TurnContext, state: StateT) -> None: return func return __call - + @overload def event( self, handler: TeamUpdateHandler[StateT] 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 index 61d98ecee..67bcbac52 100644 --- 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 @@ -35,7 +35,7 @@ from .task_module import TaskModule from .team import Team -from .type_defs import StateT, _RouteDecorator +from .type_defs import StateT class _AppRouteDecorator(Protocol[StateT]): @@ -131,7 +131,7 @@ def teams(self) -> Team[StateT]: # AgentApplication route hooks def _wrap_decorator( - self, decorator: _RouteDecorator[RouteHandler[StateT]] + self, decorator: Callable[[RouteHandler[StateT]], RouteHandler[StateT]] ) -> Callable[[TeamsRouteHandler[StateT]], RouteHandler[StateT]]: """Wrap a core route decorator so it accepts a :class:`TeamsRouteHandler`. diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/type_defs.py b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/type_defs.py index 363ec45d7..c89df7489 100644 --- a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/type_defs.py +++ b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/type_defs.py @@ -17,6 +17,7 @@ TeamsRouteSelector = Callable[[TeamsTurnContext], bool] StateT = TypeVar("StateT", bound=TurnState) +_StateContra = TypeVar("_StateContra", bound=TurnState, contravariant=True) RouteHandlerT = TypeVar("RouteHandlerT", bound=Callable) CommandSelector = str | Pattern[str] | None From 9ff85599a377d97c3b72bced7fbc6b2e611c8fd6 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Mon, 22 Jun 2026 11:32:19 -0700 Subject: [PATCH 15/46] Revisions to TeamsInfo file --- .../hosting/teams/errors/error_resources.py | 5 + .../hosting/teams/teams_info.py | 192 +++--- tests/hosting_teams/test_teams_info.py | 626 ++++++++++++++++++ 3 files changed, 709 insertions(+), 114 deletions(-) create mode 100644 tests/hosting_teams/test_teams_info.py 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 index f324224f2..167226c8a 100644 --- 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 @@ -67,6 +67,11 @@ class TeamsErrorResources: -62009, ) + TeamsTenantIdRequired = ErrorMessage( + "tenant_id is required.", + -62010, + ) + def __init__(self): """Initialize TeamsErrorResources.""" pass 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 index b90eb6f84..51d9de3bf 100644 --- 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 @@ -3,9 +3,16 @@ """Teams information utilities for Microsoft Agents.""" -from typing import Optional, Any +from typing import Optional -from microsoft_agents.activity import Activity, Channels, ConversationParameters +from microsoft_teams.api.models import ChannelData + +from microsoft_agents.activity import ( + Activity, + Channels, + ConversationParameters, + ConversationReference, +) from microsoft_agents.activity.teams import ( TeamsChannelAccount, @@ -26,20 +33,49 @@ from microsoft_agents.hosting.core import ( ChannelServiceAdapter, TurnContext, - error_resources, ) from microsoft_agents.hosting.teams.errors import teams_errors +from ._utils import _get_channel_data + +_DEFAULT_PAGE_SIZE = 16 + class TeamsInfo: """Teams information utilities for interacting with Teams-specific data.""" + @staticmethod + def _get_meeting_id( + channel_data: ChannelData, meeting_id: str | None = None + ) -> str: + if not meeting_id: + meeting_id = getattr(channel_data.meeting, "id", None) + if not meeting_id: + raise ValueError(str(teams_errors.TeamsMeetingIdRequired)) + return meeting_id + + @staticmethod + def _get_tenant_id(channel_data: ChannelData, tenant_id: str | None = None) -> str: + if not tenant_id: + tenant_id = getattr(channel_data.tenant, "id", None) + if not tenant_id: + raise ValueError(str(teams_errors.TeamsTenantIdRequired)) + return tenant_id + + @staticmethod + def _get_team_id(channel_data: ChannelData, team_id: str | None = None) -> str: + if not team_id: + team_id = getattr(channel_data.team, "id", None) + if not team_id: + raise ValueError(str(teams_errors.TeamsTeamIdRequired)) + return team_id + @staticmethod async def get_meeting_participant( context: TurnContext, - meeting_id: Optional[str] = None, - participant_id: Optional[str] = None, - tenant_id: Optional[str] = None, + meeting_id: str | None = None, + participant_id: str | None = None, + tenant_id: str | None = None, ) -> TeamsMeetingParticipant: """ Gets the meeting participant information. @@ -56,26 +92,21 @@ async def get_meeting_participant( 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 + channel_data: ChannelData = _get_channel_data(context) - 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)) + meeting_id = TeamsInfo._get_meeting_id(channel_data, meeting_id) - if participant_id is None: - participant_id = getattr(activity.from_property, "aad_object_id", None) + if not tenant_id: + tenant_id = getattr(channel_data.tenant, "id", None) + if not tenant_id: + raise ValueError(str(teams_errors.TeamsTenantIdRequired)) 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) + participant_id = getattr( + context.activity.from_property, "aad_object_id", None + ) + if not participant_id: + raise ValueError(str(teams_errors.TeamsParticipantIdRequired)) rest_client = TeamsInfo._get_rest_client(context) result = await rest_client.fetch_meeting_participant( @@ -85,7 +116,7 @@ async def get_meeting_participant( @staticmethod async def get_meeting_info( - context: TurnContext, meeting_id: Optional[str] = None + context: TurnContext, meeting_id: str | None = None ) -> MeetingInfo: """ Gets the meeting information. @@ -100,12 +131,8 @@ async def get_meeting_info( 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)) + channel_data: ChannelData = _get_channel_data(context) + meeting_id = TeamsInfo._get_meeting_id(channel_data, meeting_id) rest_client = TeamsInfo._get_rest_client(context) result = await rest_client.fetch_meeting_info(meeting_id) @@ -113,7 +140,7 @@ async def get_meeting_info( @staticmethod async def get_team_details( - context: TurnContext, team_id: Optional[str] = None + context: TurnContext, team_id: str | None = None ) -> TeamDetails: """ Gets the team details. @@ -128,12 +155,8 @@ async def get_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)) + channel_data: ChannelData = _get_channel_data(context) + team_id = TeamsInfo._get_team_id(channel_data, team_id) rest_client = TeamsInfo._get_rest_client(context) result = await rest_client.fetch_team_details(team_id) @@ -144,8 +167,8 @@ 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]: + app_id: str | None = None, + ) -> tuple[ConversationReference | None, str | None]: """ Sends a message to a Teams channel. @@ -161,14 +184,6 @@ async def send_message_to_teams_channel( 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, @@ -178,7 +193,7 @@ async def send_message_to_teams_channel( }, }, activity=activity, - agent=context.activity.recipient, + bot=context.activity.recipient, ) conversation_reference = None @@ -224,7 +239,7 @@ async def _conversation_callback( @staticmethod async def get_team_channels( - context: TurnContext, team_id: Optional[str] = None + context: TurnContext, team_id: str | None = None ) -> list[ChannelInfo]: """ Gets the channels of a team. @@ -239,12 +254,8 @@ async def get_team_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)) + channel_data = _get_channel_data(context) + team_id = TeamsInfo._get_team_id(channel_data, team_id) rest_client = TeamsInfo._get_rest_client(context) return await rest_client.fetch_channel_list(team_id) @@ -252,8 +263,8 @@ async def get_team_channels( @staticmethod async def get_paged_members( context: TurnContext, - page_size: Optional[int] = None, - continuation_token: Optional[str] = None, + page_size: int = _DEFAULT_PAGE_SIZE, + continuation_token: str = "", ) -> TeamsPagedMembersResult: """ Gets the paged members of a team or conversation. @@ -269,8 +280,8 @@ async def get_paged_members( 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) + channel_data = _get_channel_data(context) + team_id = getattr(channel_data.team, "id") if team_id: return await TeamsInfo.get_paged_team_members( @@ -305,8 +316,8 @@ async def get_member(context: TurnContext, user_id: str) -> TeamsChannelAccount: 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) + channel_data = _get_channel_data(context) + team_id = getattr(channel_data.team, "id") if team_id: return await TeamsInfo.get_team_member(context, team_id, user_id) @@ -326,9 +337,9 @@ async def get_member(context: TurnContext, user_id: str) -> TeamsChannelAccount: @staticmethod async def get_paged_team_members( context: TurnContext, - team_id: Optional[str] = None, - page_size: Optional[int] = None, - continuation_token: Optional[str] = None, + team_id: str | None = None, + page_size: int = _DEFAULT_PAGE_SIZE, + continuation_token: str = "", ) -> TeamsPagedMembersResult: """ Gets the paged members of a team. @@ -345,12 +356,8 @@ async def get_paged_team_members( 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)) + channel_data = _get_channel_data(context) + team_id = TeamsInfo._get_team_id(channel_data) rest_client = TeamsInfo._get_rest_client(context) paged_results = await rest_client.get_conversation_paged_member( @@ -408,14 +415,8 @@ async def send_meeting_notification( 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)) + channel_data = _get_channel_data(context) + meeting_id = TeamsInfo._get_meeting_id(channel_data) rest_client = TeamsInfo._get_rest_client(context) return await rest_client.send_meeting_notification(meeting_id, notification) @@ -442,12 +443,6 @@ async def send_message_to_list_of_users( 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.") @@ -474,13 +469,6 @@ async def send_message_to_all_users_in_tenant( 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 @@ -505,15 +493,6 @@ async def send_message_to_all_users_in_team( 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 @@ -541,12 +520,6 @@ async def send_message_to_list_of_channels( 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.") @@ -572,9 +545,6 @@ async def get_operation_state( 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) @@ -595,9 +565,6 @@ async def get_failed_entries( 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) @@ -618,9 +585,6 @@ async def cancel_operation( 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) diff --git a/tests/hosting_teams/test_teams_info.py b/tests/hosting_teams/test_teams_info.py new file mode 100644 index 000000000..e671849a2 --- /dev/null +++ b/tests/hosting_teams/test_teams_info.py @@ -0,0 +1,626 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""Unit tests for TeamsInfo.""" + +import sys +import pytest +from unittest.mock import AsyncMock, MagicMock + +from .helpers import is_supported_version + +pytestmark = pytest.mark.skipif( + not is_supported_version, + reason="microsoft-agents-hosting-teams tests require Python 3.12+", +) + +if is_supported_version: + from microsoft_teams.api.models.channel_data import ChannelData + from microsoft_agents.activity import ( + Activity, + ActivityTypes, + ChannelAccount, + ConversationAccount, + ) + from microsoft_agents.hosting.core import TurnContext, ChannelServiceAdapter + from microsoft_agents.hosting.core.connector.teams import TeamsConnectorClient + from microsoft_agents.activity.teams import ( + TeamsChannelAccount, + TeamsMeetingParticipant, + MeetingInfo, + TeamDetails, + TeamsPagedMembersResult, + MeetingNotification, + MeetingNotificationResponse, + TeamsMember, + BatchOperationStateResponse, + BatchFailedEntriesResponse, + CancelOperationResponse, + TeamsBatchOperationResponse, + ChannelInfo, + ) + from microsoft_agents.hosting.teams.teams_info import TeamsInfo + + +# --- helpers --- + + +def _make_channel_data(team_id=None, tenant_id=None, meeting_id=None) -> "ChannelData": + data = {} + if team_id: + data["team"] = {"id": team_id} + if tenant_id: + data["tenant"] = {"id": tenant_id} + if meeting_id: + data["meeting"] = {"id": meeting_id} + return ChannelData.model_validate(data) + + +def _make_context(channel_data=None, mock_client=None) -> "TurnContext": + context = MagicMock(spec=TurnContext) + activity = MagicMock(spec=Activity) + activity.channel_data = channel_data + from_prop = MagicMock() + from_prop.aad_object_id = "user-aad-id" + activity.from_property = from_prop + conversation = MagicMock() + conversation.id = "conv-id" + activity.conversation = conversation + activity.recipient = MagicMock(spec=ChannelAccount) + activity.service_url = "https://example.com" + activity.id = "activity-id" + activity.get_conversation_reference = MagicMock( + return_value=MagicMock(conversation=MagicMock()) + ) + context.activity = activity + context.turn_state = {"ConnectorClient": mock_client} + context.adapter = MagicMock() + return context + + +# --- _get_meeting_id --- + + +class TestGetMeetingIdHelper: + def test_returns_explicit_meeting_id(self): + channel_data = _make_channel_data() + assert TeamsInfo._get_meeting_id(channel_data, "explicit-id") == "explicit-id" + + def test_falls_back_to_channel_data_meeting(self): + channel_data = _make_channel_data(meeting_id="from-data") + assert TeamsInfo._get_meeting_id(channel_data) == "from-data" + + def test_raises_if_both_missing(self): + channel_data = _make_channel_data() + with pytest.raises(ValueError, match="meeting_id"): + TeamsInfo._get_meeting_id(channel_data) + + +# --- _get_tenant_id --- + + +class TestGetTenantIdHelper: + def test_returns_explicit_tenant_id(self): + channel_data = _make_channel_data() + assert ( + TeamsInfo._get_tenant_id(channel_data, "explicit-tenant") + == "explicit-tenant" + ) + + def test_falls_back_to_channel_data_tenant(self): + channel_data = _make_channel_data(tenant_id="tenant-from-data") + assert TeamsInfo._get_tenant_id(channel_data) == "tenant-from-data" + + def test_raises_if_both_missing(self): + channel_data = _make_channel_data() + with pytest.raises(ValueError, match="tenant_id"): + TeamsInfo._get_tenant_id(channel_data) + + +# --- _get_team_id --- + + +class TestGetTeamIdHelper: + def test_returns_explicit_team_id(self): + channel_data = _make_channel_data() + assert TeamsInfo._get_team_id(channel_data, "explicit-team") == "explicit-team" + + def test_falls_back_to_channel_data_team(self): + channel_data = _make_channel_data(team_id="team-from-data") + assert TeamsInfo._get_team_id(channel_data) == "team-from-data" + + def test_raises_if_both_missing(self): + channel_data = _make_channel_data() + with pytest.raises(ValueError, match="team_id"): + TeamsInfo._get_team_id(channel_data) + + +# --- _get_rest_client --- + + +class TestGetRestClient: + def test_returns_client_from_turn_state(self): + mock_client = MagicMock(spec=TeamsConnectorClient) + context = _make_context(mock_client=mock_client) + assert TeamsInfo._get_rest_client(context) is mock_client + + def test_raises_if_client_missing(self): + context = _make_context(mock_client=None) + with pytest.raises(ValueError, match="TeamsConnectorClient"): + TeamsInfo._get_rest_client(context) + + +# --- get_meeting_participant --- + + +class TestGetMeetingParticipant: + @pytest.mark.asyncio + async def test_calls_client_with_explicit_params(self): + mock_client = AsyncMock(spec=TeamsConnectorClient) + expected = MagicMock(spec=TeamsMeetingParticipant) + mock_client.fetch_meeting_participant.return_value = expected + + channel_data = _make_channel_data(tenant_id="t1", meeting_id="m1") + context = _make_context(channel_data, mock_client) + + result = await TeamsInfo.get_meeting_participant( + context, meeting_id="m1", participant_id="p1", tenant_id="t1" + ) + + mock_client.fetch_meeting_participant.assert_called_once_with("m1", "p1", "t1") + assert result is expected + + @pytest.mark.asyncio + async def test_extracts_params_from_activity(self): + mock_client = AsyncMock(spec=TeamsConnectorClient) + mock_client.fetch_meeting_participant.return_value = MagicMock() + + channel_data = _make_channel_data(tenant_id="t1", meeting_id="m1") + context = _make_context(channel_data, mock_client) + context.activity.from_property.aad_object_id = "aad-user" + + await TeamsInfo.get_meeting_participant(context) + + mock_client.fetch_meeting_participant.assert_called_once_with( + "m1", "aad-user", "t1" + ) + + @pytest.mark.asyncio + async def test_raises_if_participant_id_missing(self): + mock_client = AsyncMock(spec=TeamsConnectorClient) + channel_data = _make_channel_data(tenant_id="t1", meeting_id="m1") + context = _make_context(channel_data, mock_client) + context.activity.from_property.aad_object_id = None + + with pytest.raises(ValueError, match="participant_id"): + await TeamsInfo.get_meeting_participant(context) + + +# --- get_meeting_info --- + + +class TestGetMeetingInfo: + @pytest.mark.asyncio + async def test_calls_client_with_explicit_meeting_id(self): + mock_client = AsyncMock(spec=TeamsConnectorClient) + expected = MagicMock(spec=MeetingInfo) + mock_client.fetch_meeting_info.return_value = expected + + channel_data = _make_channel_data(meeting_id="m1") + context = _make_context(channel_data, mock_client) + + result = await TeamsInfo.get_meeting_info(context, "m1") + + mock_client.fetch_meeting_info.assert_called_once_with("m1") + assert result is expected + + @pytest.mark.asyncio + async def test_falls_back_to_channel_data_meeting_id(self): + mock_client = AsyncMock(spec=TeamsConnectorClient) + mock_client.fetch_meeting_info.return_value = MagicMock() + + channel_data = _make_channel_data(meeting_id="from-data") + context = _make_context(channel_data, mock_client) + + await TeamsInfo.get_meeting_info(context) + + mock_client.fetch_meeting_info.assert_called_once_with("from-data") + + +# --- get_team_details --- + + +class TestGetTeamDetails: + @pytest.mark.asyncio + async def test_calls_client_with_explicit_team_id(self): + mock_client = AsyncMock(spec=TeamsConnectorClient) + expected = MagicMock(spec=TeamDetails) + mock_client.fetch_team_details.return_value = expected + + channel_data = _make_channel_data(team_id="team1") + context = _make_context(channel_data, mock_client) + + result = await TeamsInfo.get_team_details(context, "team1") + + mock_client.fetch_team_details.assert_called_once_with("team1") + assert result is expected + + @pytest.mark.asyncio + async def test_falls_back_to_channel_data_team_id(self): + mock_client = AsyncMock(spec=TeamsConnectorClient) + mock_client.fetch_team_details.return_value = MagicMock() + + channel_data = _make_channel_data(team_id="team-from-data") + context = _make_context(channel_data, mock_client) + + await TeamsInfo.get_team_details(context) + + mock_client.fetch_team_details.assert_called_once_with("team-from-data") + + +# --- send_message_to_teams_channel --- + + +class TestSendMessageToTeamsChannel: + @pytest.mark.asyncio + async def test_uses_connector_client_when_no_app_id(self): + mock_client = AsyncMock(spec=TeamsConnectorClient) + resource_response = MagicMock() + resource_response.id = "new-conv-id" + resource_response.activity_id = "new-act-id" + mock_client.conversations = AsyncMock() + mock_client.conversations.create_conversation = AsyncMock( + return_value=resource_response + ) + + channel_data = _make_channel_data() + context = _make_context(channel_data, mock_client) + activity = Activity(type=ActivityTypes.message, text="hello") + + conv_ref, act_id = await TeamsInfo.send_message_to_teams_channel( + context, activity, "channel-id" + ) + + mock_client.conversations.create_conversation.assert_called_once() + assert act_id == "new-act-id" + + @pytest.mark.asyncio + async def test_uses_adapter_when_app_id_and_channel_service_adapter(self): + mock_client = AsyncMock(spec=TeamsConnectorClient) + channel_data = _make_channel_data() + context = _make_context(channel_data, mock_client) + + mock_adapter = MagicMock() + mock_adapter.__class__ = ChannelServiceAdapter + mock_adapter.create_conversation = AsyncMock() + context.adapter = mock_adapter + + activity = Activity(type=ActivityTypes.message, text="hello") + + await TeamsInfo.send_message_to_teams_channel( + context, activity, "channel-id", app_id="app-123" + ) + + mock_adapter.create_conversation.assert_called_once() + mock_client.conversations.create_conversation.assert_not_called() + + +# --- get_team_channels --- + + +class TestGetTeamChannels: + @pytest.mark.asyncio + async def test_calls_client_with_team_id(self): + mock_client = AsyncMock(spec=TeamsConnectorClient) + expected = [MagicMock(spec=ChannelInfo)] + mock_client.fetch_channel_list.return_value = expected + + channel_data = _make_channel_data(team_id="team1") + context = _make_context(channel_data, mock_client) + + result = await TeamsInfo.get_team_channels(context, "team1") + + mock_client.fetch_channel_list.assert_called_once_with("team1") + assert result is expected + + @pytest.mark.asyncio + async def test_falls_back_to_channel_data_team_id(self): + mock_client = AsyncMock(spec=TeamsConnectorClient) + mock_client.fetch_channel_list.return_value = [] + + channel_data = _make_channel_data(team_id="team-from-data") + context = _make_context(channel_data, mock_client) + + await TeamsInfo.get_team_channels(context) + + mock_client.fetch_channel_list.assert_called_once_with("team-from-data") + + +# --- get_paged_members --- + + +class TestGetPagedMembers: + @pytest.mark.asyncio + async def test_routes_to_team_paged_members_when_team_set(self): + mock_client = AsyncMock(spec=TeamsConnectorClient) + page_result = MagicMock(spec=TeamsPagedMembersResult) + page_result.members = [] + page_result.continuation_token = None + mock_client.get_conversation_paged_member.return_value = page_result + + channel_data = _make_channel_data(team_id="team1") + context = _make_context(channel_data, mock_client) + + result = await TeamsInfo.get_paged_members(context) + + mock_client.get_conversation_paged_member.assert_called_once_with( + "team1", 16, "" + ) + assert result is page_result + + +# --- get_paged_team_members --- + + +class TestGetPagedTeamMembers: + @pytest.mark.asyncio + async def test_returns_single_page_result(self): + mock_client = AsyncMock(spec=TeamsConnectorClient) + page_result = MagicMock(spec=TeamsPagedMembersResult) + page_result.members = [MagicMock(), MagicMock()] + page_result.continuation_token = None + mock_client.get_conversation_paged_member.return_value = page_result + + channel_data = _make_channel_data(team_id="team1") + context = _make_context(channel_data, mock_client) + + result = await TeamsInfo.get_paged_team_members(context) + + mock_client.get_conversation_paged_member.assert_called_once() + assert result is page_result + + @pytest.mark.asyncio + async def test_aggregates_multiple_pages(self): + mock_client = AsyncMock(spec=TeamsConnectorClient) + page1 = MagicMock(spec=TeamsPagedMembersResult) + page1.members = [MagicMock()] + page1.continuation_token = "token1" + + page2 = MagicMock(spec=TeamsPagedMembersResult) + page2.members = [MagicMock()] + page2.continuation_token = None + + mock_client.get_conversation_paged_member.side_effect = [page1, page2] + + channel_data = _make_channel_data(team_id="team1") + context = _make_context(channel_data, mock_client) + + result = await TeamsInfo.get_paged_team_members(context) + + assert mock_client.get_conversation_paged_member.call_count == 2 + assert len(result.members) == 2 + + +# --- get_team_member --- + + +class TestGetTeamMember: + @pytest.mark.asyncio + async def test_calls_client_with_team_and_user_id(self): + mock_client = AsyncMock(spec=TeamsConnectorClient) + expected = MagicMock(spec=TeamsChannelAccount) + mock_client.get_conversation_member.return_value = expected + + context = _make_context(mock_client=mock_client) + + result = await TeamsInfo.get_team_member(context, "team1", "user1") + + mock_client.get_conversation_member.assert_called_once_with("team1", "user1") + assert result is expected + + +# --- get_member --- + + +class TestGetMember: + @pytest.mark.asyncio + async def test_routes_to_get_team_member_when_team_id_set(self): + mock_client = AsyncMock(spec=TeamsConnectorClient) + expected = MagicMock(spec=TeamsChannelAccount) + mock_client.get_conversation_member.return_value = expected + + channel_data = _make_channel_data(team_id="team1") + context = _make_context(channel_data, mock_client) + + result = await TeamsInfo.get_member(context, "user1") + + mock_client.get_conversation_member.assert_called_once_with("team1", "user1") + assert result is expected + + +# --- send_meeting_notification --- + + +class TestSendMeetingNotification: + @pytest.mark.asyncio + async def test_calls_client_with_meeting_id(self): + mock_client = AsyncMock(spec=TeamsConnectorClient) + expected = MagicMock(spec=MeetingNotificationResponse) + mock_client.send_meeting_notification.return_value = expected + + channel_data = _make_channel_data(meeting_id="m1") + context = _make_context(channel_data, mock_client) + notification = MagicMock(spec=MeetingNotification) + + result = await TeamsInfo.send_meeting_notification(context, notification) + + mock_client.send_meeting_notification.assert_called_once_with( + "m1", notification + ) + assert result is expected + + +# --- send_message_to_list_of_users --- + + +class TestSendMessageToListOfUsers: + @pytest.mark.asyncio + async def test_raises_if_members_empty(self): + context = _make_context() + activity = MagicMock(spec=Activity) + with pytest.raises(ValueError, match="members"): + await TeamsInfo.send_message_to_list_of_users( + context, activity, "tenant1", [] + ) + + @pytest.mark.asyncio + async def test_calls_client_with_members(self): + mock_client = AsyncMock(spec=TeamsConnectorClient) + expected = MagicMock(spec=TeamsBatchOperationResponse) + mock_client.send_message_to_list_of_users.return_value = expected + + context = _make_context(mock_client=mock_client) + activity = MagicMock(spec=Activity) + members = [MagicMock(spec=TeamsMember)] + + result = await TeamsInfo.send_message_to_list_of_users( + context, activity, "tenant1", members + ) + + mock_client.send_message_to_list_of_users.assert_called_once_with( + activity, "tenant1", members + ) + assert result is expected + + +# --- send_message_to_all_users_in_tenant --- + + +class TestSendMessageToAllUsersInTenant: + @pytest.mark.asyncio + async def test_calls_client(self): + mock_client = AsyncMock(spec=TeamsConnectorClient) + expected = MagicMock(spec=TeamsBatchOperationResponse) + mock_client.send_message_to_all_users_in_tenant.return_value = expected + + context = _make_context(mock_client=mock_client) + activity = MagicMock(spec=Activity) + + result = await TeamsInfo.send_message_to_all_users_in_tenant( + context, activity, "tenant1" + ) + + mock_client.send_message_to_all_users_in_tenant.assert_called_once_with( + activity, "tenant1" + ) + assert result is expected + + +# --- send_message_to_all_users_in_team --- + + +class TestSendMessageToAllUsersInTeam: + @pytest.mark.asyncio + async def test_calls_client(self): + mock_client = AsyncMock(spec=TeamsConnectorClient) + expected = MagicMock(spec=TeamsBatchOperationResponse) + mock_client.send_message_to_all_users_in_team.return_value = expected + + context = _make_context(mock_client=mock_client) + activity = MagicMock(spec=Activity) + + result = await TeamsInfo.send_message_to_all_users_in_team( + context, activity, "tenant1", "team1" + ) + + mock_client.send_message_to_all_users_in_team.assert_called_once_with( + activity, "tenant1", "team1" + ) + assert result is expected + + +# --- send_message_to_list_of_channels --- + + +class TestSendMessageToListOfChannels: + @pytest.mark.asyncio + async def test_raises_if_members_empty(self): + context = _make_context() + activity = MagicMock(spec=Activity) + with pytest.raises(ValueError, match="members"): + await TeamsInfo.send_message_to_list_of_channels( + context, activity, "tenant1", [] + ) + + @pytest.mark.asyncio + async def test_calls_client_with_members(self): + mock_client = AsyncMock(spec=TeamsConnectorClient) + expected = MagicMock(spec=TeamsBatchOperationResponse) + mock_client.send_message_to_list_of_channels.return_value = expected + + context = _make_context(mock_client=mock_client) + activity = MagicMock(spec=Activity) + members = [MagicMock(spec=TeamsMember)] + + result = await TeamsInfo.send_message_to_list_of_channels( + context, activity, "tenant1", members + ) + + mock_client.send_message_to_list_of_channels.assert_called_once_with( + activity, "tenant1", members + ) + assert result is expected + + +# --- get_operation_state --- + + +class TestGetOperationState: + @pytest.mark.asyncio + async def test_calls_client_with_operation_id(self): + mock_client = AsyncMock(spec=TeamsConnectorClient) + expected = MagicMock(spec=BatchOperationStateResponse) + mock_client.get_operation_state.return_value = expected + + context = _make_context(mock_client=mock_client) + + result = await TeamsInfo.get_operation_state(context, "op1") + + mock_client.get_operation_state.assert_called_once_with("op1") + assert result is expected + + +# --- get_failed_entries --- + + +class TestGetFailedEntries: + @pytest.mark.asyncio + async def test_calls_client_with_operation_id(self): + mock_client = AsyncMock(spec=TeamsConnectorClient) + expected = MagicMock(spec=BatchFailedEntriesResponse) + mock_client.get_failed_entries.return_value = expected + + context = _make_context(mock_client=mock_client) + + result = await TeamsInfo.get_failed_entries(context, "op1") + + mock_client.get_failed_entries.assert_called_once_with("op1") + assert result is expected + + +# --- cancel_operation --- + + +class TestCancelOperation: + @pytest.mark.asyncio + async def test_calls_client_with_operation_id(self): + mock_client = AsyncMock(spec=TeamsConnectorClient) + expected = MagicMock(spec=CancelOperationResponse) + mock_client.cancel_operation.return_value = expected + + context = _make_context(mock_client=mock_client) + + result = await TeamsInfo.cancel_operation(context, "op1") + + mock_client.cancel_operation.assert_called_once_with("op1") + assert result is expected From c166ca9da73ec13dc1838b1b9cd4874bc36a1d03 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Mon, 22 Jun 2026 11:46:40 -0700 Subject: [PATCH 16/46] Adding targeted activity functionality to TeamsTurnContext --- .../microsoft_agents/activity/__init__.py | 4 +++ .../activity/entity/__init__.py | 3 +++ .../activity/entity/activity_treatment.py | 27 +++++++++++++++++++ .../entity/activity_treatment_types.py | 7 +++++ .../activity/entity/entity_types.py | 1 + .../hosting/core/turn_context.py | 4 +-- .../hosting/teams/teams_turn_context.py | 27 +++++++++++++++++++ 7 files changed, 71 insertions(+), 2 deletions(-) create mode 100644 libraries/microsoft-agents-activity/microsoft_agents/activity/entity/activity_treatment.py create mode 100644 libraries/microsoft-agents-activity/microsoft_agents/activity/entity/activity_treatment_types.py diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/__init__.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/__init__.py index e4edc5258..8a4b57e4e 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/__init__.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/__init__.py @@ -45,6 +45,8 @@ ProductInfo, Thing, StreamInfo, + ActivityTreatment, + ActivityTreatmentTypes, ) from .error import Error from .error_response import ErrorResponse @@ -199,5 +201,7 @@ "load_configuration_from_env", "ChannelAdapterProtocol", "TurnContextProtocol", + "ActivityTreatment", + "ActivityTreatmentTypes", "TokenOrSignInResourceResponse", ] diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/__init__.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/__init__.py index 32345420a..80259ccfa 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/__init__.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/__init__.py @@ -1,6 +1,7 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. +from .activity_treatment import ActivityTreatment, ActivityTreatmentTypes from .mention import Mention from .entity import Entity from .entity_types import EntityTypes @@ -35,4 +36,6 @@ "Place", "ProductInfo", "Thing", + "ActivityTreatment", + "ActivityTreatmentTypes", ] diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/activity_treatment.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/activity_treatment.py new file mode 100644 index 000000000..4ffe930ad --- /dev/null +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/activity_treatment.py @@ -0,0 +1,27 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from enum import Enum +from typing import Literal + +from .entity import Entity +from .entity_types import EntityTypes + + +class ActivityTreatmentTypes(str, Enum): + """Well-known enumeration of activity treatment types.""" + + TARGETED = "targeted" + + +class ActivityTreatment(Entity): + """Activity treatment information (entity type: "activity_treatment"). + + :param treatment: The type of treatment + :type treatment: ~microsoft_agents.activity.ActivityTreatmentTypes + :param type: Type of this entity (RFC 3987 IRI) + :type type: str + """ + + type: Literal[EntityTypes.ACTIVITY_TREATMENT] = EntityTypes.ACTIVITY_TREATMENT + treatment: ActivityTreatmentTypes diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/activity_treatment_types.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/activity_treatment_types.py new file mode 100644 index 000000000..bd1042eca --- /dev/null +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/activity_treatment_types.py @@ -0,0 +1,7 @@ +from enum import Enum + + +class ActivityTreatmentTypes(str, Enum): + """Well-known enumeration of activity treatment types.""" + + TARGETED = "targeted" diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/entity_types.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/entity_types.py index 0cb3da0fe..66b3faf57 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/entity_types.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/entity_types.py @@ -7,6 +7,7 @@ class EntityTypes(str, Enum): """Well-known enumeration of entity types.""" + ACTIVITY_TREATMENT = "activityTreatment" GEO_COORDINATES = "GeoCoordinates" MENTION = "mention" PLACE = "Place" diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/turn_context.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/turn_context.py index b80811dc6..04617e55b 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/turn_context.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/turn_context.py @@ -189,7 +189,7 @@ async def send_activity( activity_or_text: Activity | str, speak: str | None = None, input_hint: str | None = None, - ) -> ResourceResponse | None: + ) -> ResourceResponse: """ Sends a single activity or message to the user. :param activity_or_text: @@ -205,7 +205,7 @@ async def send_activity( activity_or_text.speak = speak result = await self.send_activities([activity_or_text]) - return result[0] if result else None + return result[0] if result else ResourceResponse() async def send_activities( self, activities: list[Activity] diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/teams_turn_context.py b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/teams_turn_context.py index 2a247c37a..3a86aeb55 100644 --- a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/teams_turn_context.py +++ b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/teams_turn_context.py @@ -3,6 +3,12 @@ """Teams-specific turn context wrapper.""" +from microsoft_agents.activity import ( + Activity, + ActivityTreatment, + ActivityTreatmentTypes, + ResourceResponse, +) from microsoft_agents.hosting.core import AgentApplication, TurnContext @@ -22,3 +28,24 @@ def __init__(self, context: TurnContext, app: AgentApplication) -> None: super().__init__(context) self._context = context self._app = app + + @staticmethod + def _make_targeted_activity(activity: Activity) -> Activity: + activity = activity.model_copy() + activity.entities = activity.entities or [] + activity.entities.append( + ActivityTreatment(treatment=ActivityTreatmentTypes.TARGETED) + ) + return activity + + async def send_targeted_activity(self, activity: Activity) -> ResourceResponse: + return await self.send_activity( + TeamsTurnContext._make_targeted_activity(activity) + ) + + async def send_targeted_activities( + self, activities: list[Activity] + ) -> list[ResourceResponse]: + return await self.send_activities( + [TeamsTurnContext._make_targeted_activity(act) for act in activities] + ) From dc0e76391c75b253af5267b2654d14dd96118c43 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Mon, 22 Jun 2026 13:55:07 -0700 Subject: [PATCH 17/46] Modification to test sample --- .../entity/activity_treatment_types.py | 7 - .../hosting/teams/_models/__init__.py | 0 .../hosting/teams/channel/route_handlers.py | 1 + .../hosting/teams/config/route_handlers.py | 1 + .../teams/file_consent/route_handlers.py | 1 + .../hosting/teams/meeting/route_handlers.py | 3 + .../hosting/teams/message/message.py | 7 +- .../hosting/teams/message/route_handlers.py | 2 + .../teams/message_extension/route_handlers.py | 10 + .../hosting/teams/route_handlers.py | 62 ++-- .../teams/task_module/route_handlers.py | 2 + .../hosting/teams/team/route_handlers.py | 1 + .../hosting/teams/teams_agent_extension.py | 13 +- .../hosting/teams/teams_info.py | 4 +- .../hosting/teams/teams_turn_context.py | 2 + .../teams/conversation-agent/env.TEMPLATE | 3 + .../teams/conversation-agent/pyproject.toml | 20 ++ .../teams/conversation-agent/src/__init__.py | 0 .../teams/conversation-agent/src/agent.py | 316 ++++++++++++++++++ .../teams/conversation-agent/src/main.py | 11 + .../conversation-agent/src/start_server.py | 32 ++ 21 files changed, 451 insertions(+), 47 deletions(-) delete mode 100644 libraries/microsoft-agents-activity/microsoft_agents/activity/entity/activity_treatment_types.py create mode 100644 libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/_models/__init__.py create mode 100644 test_samples/teams/conversation-agent/env.TEMPLATE create mode 100644 test_samples/teams/conversation-agent/pyproject.toml create mode 100644 test_samples/teams/conversation-agent/src/__init__.py create mode 100644 test_samples/teams/conversation-agent/src/agent.py create mode 100644 test_samples/teams/conversation-agent/src/main.py create mode 100644 test_samples/teams/conversation-agent/src/start_server.py diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/activity_treatment_types.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/activity_treatment_types.py deleted file mode 100644 index bd1042eca..000000000 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/activity_treatment_types.py +++ /dev/null @@ -1,7 +0,0 @@ -from enum import Enum - - -class ActivityTreatmentTypes(str, Enum): - """Well-known enumeration of activity treatment types.""" - - TARGETED = "targeted" diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/_models/__init__.py b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/_models/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/channel/route_handlers.py b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/channel/route_handlers.py index f59f84191..403414664 100644 --- a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/channel/route_handlers.py +++ b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/channel/route_handlers.py @@ -23,6 +23,7 @@ def __call__( context: TeamsTurnContext, state: _StateContra, data: ChannelData, + /, ) -> Awaitable[None]: """Handle a channel update event. diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/config/route_handlers.py b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/config/route_handlers.py index b156b0950..ecb2dd0b2 100644 --- a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/config/route_handlers.py +++ b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/config/route_handlers.py @@ -23,6 +23,7 @@ def __call__( context: TeamsTurnContext, state: _StateContra, config_data: Any, + /, ) -> Awaitable[ConfigResponse]: """Handle a configuration invoke. diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/file_consent/route_handlers.py b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/file_consent/route_handlers.py index ccd6f2325..9a0726046 100644 --- a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/file_consent/route_handlers.py +++ b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/file_consent/route_handlers.py @@ -23,6 +23,7 @@ def __call__( context: TeamsTurnContext, state: _StateContra, file_consent: FileConsentCardResponse, + /, ) -> Awaitable[None]: """Handle a file consent invoke. diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/meeting/route_handlers.py b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/meeting/route_handlers.py index d6b554b1e..22cdfe5de 100644 --- a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/meeting/route_handlers.py +++ b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/meeting/route_handlers.py @@ -20,6 +20,7 @@ def __call__( context: TeamsTurnContext, state: _StateContra, meeting: MeetingDetails, + /, ) -> Awaitable[None]: """Handle a meeting start event. @@ -38,6 +39,7 @@ def __call__( context: TeamsTurnContext, state: _StateContra, meeting: MeetingDetails, + /, ) -> Awaitable[None]: """Handle a meeting end event. @@ -56,6 +58,7 @@ def __call__( context: TeamsTurnContext, state: _StateContra, meeting: MeetingParticipantsEventDetails, + /, ) -> Awaitable[None]: """Handle a meeting participant join or leave event. diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/message/message.py b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/message/message.py index 2f7cf9121..c234f5eb4 100644 --- a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/message/message.py +++ b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/message/message.py @@ -14,7 +14,10 @@ TurnContext, ) -from microsoft_agents.hosting.teams.route_handlers import TeamsRouteHandler +from microsoft_agents.hosting.teams.route_handlers import ( + TeamsRouteHandler, + wrap_teams_route_handler, +) from microsoft_agents.hosting.teams.teams_turn_context import TeamsTurnContext from microsoft_agents.hosting.teams.type_defs import ( _RouteDecorator, @@ -61,7 +64,7 @@ def __selector(context: TurnContext) -> bool: def __call(func: TeamsRouteHandler[StateT]) -> TeamsRouteHandler[StateT]: self._app.add_route( __selector, - TeamsRouteHandler.wrap(func, self._app), + wrap_teams_route_handler(func, self._app), rank=rank, auth_handlers=auth_handlers, ) diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/message/route_handlers.py b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/message/route_handlers.py index 1df7e3255..7731ff0ed 100644 --- a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/message/route_handlers.py +++ b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/message/route_handlers.py @@ -19,6 +19,7 @@ def __call__( context: TeamsTurnContext, state: _StateContra, query: O365ConnectorCardActionQuery, + /, ) -> Awaitable[None]: """Handle an O365 connector card action execution. @@ -37,6 +38,7 @@ def __call__( context: TeamsTurnContext, state: _StateContra, data: dict, + /, ) -> Awaitable[None]: """Handle a read receipt event. diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/message_extension/route_handlers.py b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/message_extension/route_handlers.py index 56c1ff0eb..77fcc3d69 100644 --- a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/message_extension/route_handlers.py +++ b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/message_extension/route_handlers.py @@ -31,6 +31,7 @@ def __call__( context: TeamsTurnContext, state: _StateContra, action: MessagingExtensionAction, + /, ) -> Awaitable[MessagingExtensionActionResponse]: """Handle a fetch task invoke. @@ -50,6 +51,7 @@ def __call__( context: TeamsTurnContext, state: _StateContra, action: MessagingExtensionAction, + /, ) -> Awaitable[MessagingExtensionResponse]: """Handle a submit action invoke. @@ -69,6 +71,7 @@ def __call__( context: TeamsTurnContext, state: _StateContra, activity_preview: Activity, + /, ) -> Awaitable[MessagingExtensionResponse]: """Handle a message preview edit request. @@ -88,6 +91,7 @@ def __call__( context: TeamsTurnContext, state: _StateContra, activity_preview: Activity, + /, ) -> Awaitable[MessagingExtensionResponse | None]: """Handle a message preview send request. @@ -106,6 +110,7 @@ def __call__( context: TeamsTurnContext, state: _StateContra, query: MessagingExtensionQuery, + /, ) -> Awaitable[MessagingExtensionResponse]: """Handle a message extension query invoke. @@ -125,6 +130,7 @@ def __call__( context: TeamsTurnContext, state: _StateContra, item: Any, + /, ) -> Awaitable[MessagingExtensionResponse]: """Handle a select item invoke. @@ -144,6 +150,7 @@ def __call__( context: TeamsTurnContext, state: _StateContra, query: AppBasedLinkQuery, + /, ) -> Awaitable[MessagingExtensionResponse]: """Handle a link unfurling query. @@ -163,6 +170,7 @@ def __call__( context: TeamsTurnContext, state: _StateContra, query: MessagingExtensionQuery, + /, ) -> Awaitable[MessagingExtensionResponse]: """Handle a query setting URL invoke. @@ -182,6 +190,7 @@ def __call__( context: TeamsTurnContext, state: _StateContra, query: MessagingExtensionQuery, + /, ) -> Awaitable[MessagingExtensionResponse]: """Handle a configure settings invoke. @@ -201,6 +210,7 @@ def __call__( context: TeamsTurnContext, state: _StateContra, card: Any, + /, ) -> Awaitable[None]: """Handle a card button clicked invoke. diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/route_handlers.py b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/route_handlers.py index b896cf9ef..fbbc2667e 100644 --- a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/route_handlers.py +++ b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/route_handlers.py @@ -23,7 +23,7 @@ class TeamsRouteHandler(Protocol[StateT]): """Protocol for a Teams route handler that receives a :class:`TeamsTurnContext`.""" - def __call__(self, context: TeamsTurnContext, state: StateT) -> Awaitable[None]: + def __call__(self, context: TeamsTurnContext, state: StateT, /) -> Awaitable[None]: """Handle a turn with Teams context. :param context: Teams-aware turn context. @@ -31,32 +31,32 @@ def __call__(self, context: TeamsTurnContext, state: StateT) -> Awaitable[None]: """ ... - @staticmethod - def wrap( - handler: TeamsRouteHandler[StateT], app: AgentApplication - ) -> RouteHandler[StateT]: - """Adapt a :class:`TeamsRouteHandler` into a plain :class:`RouteHandler`. - Wraps *handler* so that the core routing engine (which passes a plain - :class:`TurnContext`) receives a compatible callable. +def wrap_teams_route_handler( + handler: TeamsRouteHandler[StateT], app: AgentApplication +) -> RouteHandler[StateT]: + """Adapt a :class:`TeamsRouteHandler` into a plain :class:`RouteHandler`. - :param handler: The Teams-specific handler to wrap. - :param app: The agent application handling the turn. - :return: A :class:`RouteHandler` that upgrades the context before delegating. - """ + Wraps *handler* so that the core routing engine (which passes a plain + :class:`TurnContext`) receives a compatible callable. + + :param handler: The Teams-specific handler to wrap. + :param app: The agent application handling the turn. + :return: A :class:`RouteHandler` that upgrades the context before delegating. + """ - async def __func(context: TurnContext, state: StateT) -> None: - teams_context = TeamsTurnContext(context, app) - await handler(teams_context, state) + async def __func(context: TurnContext, state: StateT) -> None: + teams_context = TeamsTurnContext(context, app) + await handler(teams_context, state) - return __func + return __func class TeamsHandoffHandler(Protocol[StateT]): """Protocol for a Teams handoff handler that receives handoff continuation data.""" def __call__( - self, context: TeamsTurnContext, state: StateT, handoff_data: str + self, context: TeamsTurnContext, state: StateT, handoff_data: str, / ) -> Awaitable[None]: """Handle a handoff activity with Teams context. @@ -66,21 +66,19 @@ def __call__( """ ... - @staticmethod - def wrap( - handler: TeamsHandoffHandler[StateT], app: AgentApplication - ) -> HandoffHandler[StateT]: - """Adapt a :class:`TeamsHandoffHandler` into a plain :class:`HandoffHandler`. - :param handler: The Teams-specific handoff handler to wrap. - :param app: The agent application handling the turn. - :return: A :class:`HandoffHandler` that upgrades the context before delegating. - """ +def wrap_teams_handoff_handler( + handler: TeamsHandoffHandler[StateT], app: AgentApplication +) -> HandoffHandler[StateT]: + """Adapt a :class:`TeamsHandoffHandler` into a plain :class:`HandoffHandler`. + + :param handler: The Teams-specific handoff handler to wrap. + :param app: The agent application handling the turn. + :return: A :class:`HandoffHandler` that upgrades the context before delegating. + """ - async def __func( - context: TurnContext, state: StateT, handoff_data: str - ) -> None: - teams_context = TeamsTurnContext(context, app) - await handler(teams_context, state, handoff_data) + async def __func(context: TurnContext, state: StateT, handoff_data: str) -> None: + teams_context = TeamsTurnContext(context, app) + await handler(teams_context, state, handoff_data) - return __func + return __func diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/task_module/route_handlers.py b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/task_module/route_handlers.py index dd2871dcb..057395188 100644 --- a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/task_module/route_handlers.py +++ b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/task_module/route_handlers.py @@ -19,6 +19,7 @@ def __call__( context: TeamsTurnContext, state: _StateContra, request: TaskModuleRequest, + /, ) -> Awaitable[TaskModuleResponse]: """Handle a task module fetch invoke. @@ -38,6 +39,7 @@ def __call__( context: TeamsTurnContext, state: _StateContra, request: TaskModuleRequest, + /, ) -> Awaitable[TaskModuleResponse]: """Handle a task module submit invoke. diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/team/route_handlers.py b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/team/route_handlers.py index 175080deb..0c9ee95b4 100644 --- a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/team/route_handlers.py +++ b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/team/route_handlers.py @@ -19,6 +19,7 @@ def __call__( context: TeamsTurnContext, state: _StateContra, data: ChannelData, + /, ) -> Awaitable[None]: """Handle a team update event. 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 index 67bcbac52..180ac3f61 100644 --- 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 @@ -31,7 +31,12 @@ from .meeting import Meeting from .message import Message from .message_extension import MessageExtension -from .route_handlers import TeamsRouteHandler, TeamsHandoffHandler +from .route_handlers import ( + TeamsRouteHandler, + TeamsHandoffHandler, + wrap_teams_route_handler, + wrap_teams_handoff_handler, +) from .task_module import TaskModule from .team import Team @@ -136,7 +141,7 @@ def _wrap_decorator( """Wrap a core route decorator so it accepts a :class:`TeamsRouteHandler`. The returned decorator converts the Teams handler via - :meth:`TeamsRouteHandler.wrap` before passing it to *decorator*, keeping the + :func:`wrap_teams_route_handler` before passing it to *decorator*, keeping the Teams context upgrade transparent to callers. :param decorator: A core route decorator from :class:`AgentApplication`. @@ -144,7 +149,7 @@ def _wrap_decorator( """ def __call(func: TeamsRouteHandler[StateT]) -> RouteHandler[StateT]: - return decorator(TeamsRouteHandler.wrap(func, self._app)) + return decorator(wrap_teams_route_handler(func, self._app)) return __call @@ -248,7 +253,7 @@ def handoff( def __call(func: TeamsHandoffHandler[StateT]) -> HandoffHandler[StateT]: return self._app.handoff(auth_handlers=auth_handlers, **kwargs)( - TeamsHandoffHandler.wrap(func, self._app) + wrap_teams_handoff_handler(func, self._app) ) return __call 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 index 51d9de3bf..eca64349f 100644 --- 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 @@ -281,7 +281,7 @@ async def get_paged_members( ValueError: If required parameters are missing. """ channel_data = _get_channel_data(context) - team_id = getattr(channel_data.team, "id") + team_id = getattr(channel_data.team, "id", None) if team_id: return await TeamsInfo.get_paged_team_members( @@ -317,7 +317,7 @@ async def get_member(context: TurnContext, user_id: str) -> TeamsChannelAccount: ValueError: If required parameters are missing. """ channel_data = _get_channel_data(context) - team_id = getattr(channel_data.team, "id") + team_id = getattr(channel_data.team, "id", None) if team_id: return await TeamsInfo.get_team_member(context, team_id, user_id) diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/teams_turn_context.py b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/teams_turn_context.py index 3a86aeb55..7faf39eb7 100644 --- a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/teams_turn_context.py +++ b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/teams_turn_context.py @@ -29,6 +29,8 @@ def __init__(self, context: TurnContext, app: AgentApplication) -> None: self._context = context self._app = app + self._turn_state.update(context.turn_state) + @staticmethod def _make_targeted_activity(activity: Activity) -> Activity: activity = activity.model_copy() diff --git a/test_samples/teams/conversation-agent/env.TEMPLATE b/test_samples/teams/conversation-agent/env.TEMPLATE new file mode 100644 index 000000000..187ec681c --- /dev/null +++ b/test_samples/teams/conversation-agent/env.TEMPLATE @@ -0,0 +1,3 @@ +CONNECTIONS__SERVICE_CONNECTION__SETTINGS__CLIENTID= +CONNECTIONS__SERVICE_CONNECTION__SETTINGS__CLIENTSECRET= +CONNECTIONS__SERVICE_CONNECTION__SETTINGS__TENANTID= \ No newline at end of file diff --git a/test_samples/teams/conversation-agent/pyproject.toml b/test_samples/teams/conversation-agent/pyproject.toml new file mode 100644 index 000000000..686d7aa3e --- /dev/null +++ b/test_samples/teams/conversation-agent/pyproject.toml @@ -0,0 +1,20 @@ +[build-system] +requires = ["setuptools"] +build-backend = "setuptools.build_meta" + +[project] +name = "conversation-agent" +version = "0.1.0" +description = "Teams Conversation Agent sample — demonstrates channel/team lifecycle, member events, and message commands" +authors = [{name = "Microsoft Corporation"}] +license = "MIT" +requires-python = ">=3.12" +dependencies = [ + "microsoft-agents-activity", + "microsoft-agents-hosting-core", + "microsoft-agents-authentication-msal", + "microsoft-agents-hosting-aiohttp", + "microsoft-agents-hosting-teams", + "python-dotenv", + "aiohttp", +] diff --git a/test_samples/teams/conversation-agent/src/__init__.py b/test_samples/teams/conversation-agent/src/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/test_samples/teams/conversation-agent/src/agent.py b/test_samples/teams/conversation-agent/src/agent.py new file mode 100644 index 000000000..c5b115ecb --- /dev/null +++ b/test_samples/teams/conversation-agent/src/agent.py @@ -0,0 +1,316 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""Teams Conversation Agent — Python port of the .NET ConversationAgent sample. + +Demonstrates Teams-specific events: channel lifecycle, team lifecycle, member +add/remove, and message commands (update card, who am I, mention, proactive +message-all, targeted send, etc.). +""" + +import json +import logging +from os import environ, path + +from dotenv import load_dotenv + +from microsoft_teams.api.models import ChannelData + +from microsoft_agents.activity import ( + ActionTypes, + CardAction, + ChannelAccount, + Channels, + ConversationParameters, + HeroCard, + Mention, + load_configuration_from_env, +) +from microsoft_agents.authentication.msal import MsalConnectionManager +from microsoft_agents.hosting.aiohttp import CloudAdapter +from microsoft_agents.hosting.core import ( + AgentApplication, + Authorization, + CardFactory, + MessageFactory, + MemoryStorage, + TurnContext, + TurnState, +) +from microsoft_agents.hosting.teams import TeamsAgentExtension, TeamsInfo +from microsoft_agents.hosting.teams.teams_turn_context import TeamsTurnContext + +logging.basicConfig(level=logging.INFO) +load_dotenv() + +agents_sdk_config = load_configuration_from_env(environ) + +STORAGE = MemoryStorage() +CONNECTION_MANAGER = MsalConnectionManager(**agents_sdk_config) +ADAPTER = CloudAdapter(connection_manager=CONNECTION_MANAGER) +AUTHORIZATION = Authorization(STORAGE, CONNECTION_MANAGER, **agents_sdk_config) + +AGENT_APP = AgentApplication[TurnState]( + storage=STORAGE, + adapter=ADAPTER, + authorization=AUTHORIZATION, + **agents_sdk_config, +) + +teams = TeamsAgentExtension[TurnState](AGENT_APP) + + +def _make_welcome_card(title: str, count: int = 0) -> HeroCard: + return HeroCard( + title=title, + buttons=[ + CardAction(type=ActionTypes.message_back, title="Message all members", text="messageall"), + CardAction(type=ActionTypes.message_back, title="Who am I?", text="whoami"), + CardAction(type=ActionTypes.message_back, title="Mention Me", text="mentionme"), + CardAction(type=ActionTypes.message_back, title="Delete Card", text="delete"), + CardAction(type=ActionTypes.message_back, title="Send Targeted", text="targeted"), + CardAction( + type=ActionTypes.message_back, + title="Update Card", + text="update", + value=json.dumps({"count": count}), + ), + ], + ) + + +# ── Installation update ────────────────────────────────────────────────────── + +@teams.activity("installationUpdate") +async def on_installation_update(context: TeamsTurnContext, state: TurnState) -> None: + conv = context.activity.conversation + if conv and conv.conversation_type == "channel": + name = conv.name or "this channel" + await context.send_activity( + f"Welcome to Microsoft Teams conversationUpdate events demo. " + f"This agent is configured in {name}" + ) + else: + await context.send_activity( + "Welcome to Microsoft Teams conversationUpdate events demo." + ) + + +# ── Member lifecycle ───────────────────────────────────────────────────────── + +@teams.conversation_update("membersAdded") +async def on_members_added(context: TeamsTurnContext, state: TurnState) -> None: + conv = context.activity.conversation + for member in context.activity.members_added or []: + if member.id != context.activity.recipient.id: + if not conv or conv.conversation_type != "personal": + await context.send_activity( + MessageFactory.text(f"Welcome to the team {member.name}.") + ) + + +@teams.conversation_update("membersRemoved") +async def on_members_removed(context: TeamsTurnContext, state: TurnState) -> None: + channel_data = context.activity.channel_data + team_name = "the team" + if isinstance(channel_data, dict): + team_name = (channel_data.get("team") or {}).get("name", team_name) + + for member in context.activity.members_removed or []: + if member.id == context.activity.recipient.id: + pass # bot removed — clear any cached data here if needed + else: + card = HeroCard(text=f"{member.name} was removed from {team_name}") + await context.send_activity( + MessageFactory.attachment(CardFactory.hero_card(card)) + ) + + +# ── Channel events ─────────────────────────────────────────────────────────── + +@teams.channels.created +async def on_channel_created(context: TeamsTurnContext, state: TurnState, channel_data: ChannelData) -> None: + name = channel_data.channel.name if channel_data.channel else "Unknown" + card = HeroCard(text=f"{name} is the Channel created") + await context.send_activity(MessageFactory.attachment(CardFactory.hero_card(card))) + + +@teams.channels.renamed +async def on_channel_renamed(context: TeamsTurnContext, state: TurnState, channel_data: ChannelData) -> None: + name = channel_data.channel.name if channel_data.channel else "Unknown" + card = HeroCard(text=f"{name} is the new Channel name") + await context.send_activity(MessageFactory.attachment(CardFactory.hero_card(card))) + + +@teams.channels.deleted() +async def on_channel_deleted(context: TeamsTurnContext, state: TurnState, channel_data) -> None: + name = channel_data.channel.name if channel_data.channel else "Unknown" + card = HeroCard(text=f"{name} is the Channel deleted") + await context.send_activity(MessageFactory.attachment(CardFactory.hero_card(card))) + + +# ── Team events ────────────────────────────────────────────────────────────── + +@teams.teams.renamed() +async def on_team_renamed(context: TeamsTurnContext, state: TurnState, channel_data) -> None: + name = channel_data.team.name if channel_data.team else "Unknown" + card = HeroCard(text=f"{name} is the new Team name") + await context.send_activity(MessageFactory.attachment(CardFactory.hero_card(card))) + + +# ── Message commands ───────────────────────────────────────────────────────── + +@teams.message("targeted") +async def on_targeted(context: TeamsTurnContext, state: TurnState) -> None: + """Send a 1:1 proactive message to every member in the conversation.""" + paged = await TeamsInfo.get_paged_members(context) + app_id = context.identity.get_app_id() if context.identity else "" + audience = ( + context.identity.get_token_audience() + if context.identity + else "https://api.botframework.com" + ) + for member in paged.members or []: + params = ConversationParameters( + is_group=False, + members=[ChannelAccount(id=member.id, name=member.name)], + channel_data={"tenant": {"id": context.activity.conversation.tenant_id}}, + agent=context.activity.recipient, + ) + + async def _send(ctx: TurnContext, _, _name=member.name) -> None: + await ctx.send_activity( + f"{_name}, this is a **targeted message** — only you can see this." + ) + + await context.adapter.create_conversation( + app_id or "", + Channels.ms_teams, + context.activity.service_url, + audience or "https://api.botframework.com", + params, + _send, + ) + + +@teams.message("update") +async def on_update_card(context: TeamsTurnContext, state: TurnState) -> None: + """Update the card that triggered this message with an incremented counter.""" + value = context.activity.value + if isinstance(value, str): + try: + value = json.loads(value) + except (json.JSONDecodeError, TypeError): + value = {} + count = (int(value.get("count") or 0) + 1) if isinstance(value, dict) else 1 + + card = _make_welcome_card("I've been updated", count=count) + card.text = f"Update count - {count}" + + activity = MessageFactory.attachment(CardFactory.hero_card(card)) + activity.id = context.activity.reply_to_id + await context.update_activity(activity) + + +@teams.message("whoami") +async def on_who_am_i(context: TeamsTurnContext, state: TurnState) -> None: + """Fetch the caller's Teams member profile.""" + try: + member = await TeamsInfo.get_member( + context, context.activity.from_property.id + ) + await context.send_activity(f"You are: {member.name}.") + except Exception as exc: + if "MemberNotFoundInConversation" in str(exc): + await context.send_activity("Member not found.") + else: + raise + + +@teams.message("delete") +async def on_delete_card(context: TeamsTurnContext, state: TurnState) -> None: + await context.delete_activity(context.activity.reply_to_id) + + +@teams.message("messageall") +async def on_message_all(context: TeamsTurnContext, state: TurnState) -> None: + """Proactively send a 1:1 greeting to every team member.""" + app_id = context.identity.get_app_id() if context.identity else "" + audience = ( + context.identity.get_token_audience() + if context.identity + else "https://api.botframework.com" + ) + continuation_token: str = "" + while True: + paged = await TeamsInfo.get_paged_members( + context, page_size=100, continuation_token=continuation_token + ) + for member in paged.members or []: + params = ConversationParameters( + is_group=False, + members=[ChannelAccount(id=member.id, name=member.name)], + channel_data={"tenant": {"id": context.activity.conversation.tenant_id}}, + bot=context.activity.recipient, + ) + + async def _greet(ctx: TurnContext, _, _name=member.name) -> None: + await ctx.send_activity(f"Hello {_name}. I'm a Teams agent.") + + await context.adapter.create_conversation( + app_id or "", + Channels.ms_teams, + context.activity.service_url, + audience or "https://api.botframework.com", + params, + _greet, + ) + continuation_token = paged.continuation_token + if not continuation_token: + break + + await context.send_activity("All messages have been sent.") + + +@teams.message("mentionme") +async def on_mention_me(context: TeamsTurnContext, state: TurnState) -> None: + """Mention the sender by name in the reply.""" + try: + member = await TeamsInfo.get_member( + context, context.activity.from_property.id + ) + except Exception as exc: + if "MemberNotFoundInConversation" in str(exc): + await context.send_activity("Member not found.") + return + raise + + mention = Mention( + mentioned=context.activity.from_property, + text=f"{member.name}", + ) + reply = MessageFactory.text(f"Hello {mention.text}.") + reply.entities = [mention] + await context.send_activity(reply) + + +@teams.message("atmention") +async def on_at_mention(context: TeamsTurnContext, state: TurnState) -> None: + from_account = context.activity.from_property + mention = Mention( + mentioned=from_account, + text=f"{from_account.name}", + ) + reply = MessageFactory.text(f"Hello {mention.text}.") + reply.entities = [mention] + await context.send_activity(reply) + + +# ── Default message — send the welcome card ────────────────────────────────── + +@teams.activity("message") +async def on_message(context: TeamsTurnContext, state: TurnState) -> None: + card = _make_welcome_card("Welcome!") + await context.send_activity( + MessageFactory.attachment(CardFactory.hero_card(card)) + ) \ No newline at end of file diff --git a/test_samples/teams/conversation-agent/src/main.py b/test_samples/teams/conversation-agent/src/main.py new file mode 100644 index 000000000..d2c005a4c --- /dev/null +++ b/test_samples/teams/conversation-agent/src/main.py @@ -0,0 +1,11 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from .agent import AGENT_APP, CONNECTION_MANAGER +from .start_server import start_server + +if __name__ == "__main__": + start_server( + agent_application=AGENT_APP, + auth_configuration=CONNECTION_MANAGER.get_default_connection_configuration(), + ) diff --git a/test_samples/teams/conversation-agent/src/start_server.py b/test_samples/teams/conversation-agent/src/start_server.py new file mode 100644 index 000000000..97792f47d --- /dev/null +++ b/test_samples/teams/conversation-agent/src/start_server.py @@ -0,0 +1,32 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from os import environ + +from aiohttp.web import Application, Request, Response, run_app + +from microsoft_agents.hosting.aiohttp import ( + CloudAdapter, + jwt_authorization_middleware, + start_agent_process, +) +from microsoft_agents.hosting.core import AgentApplication + + +def start_server( + agent_application: AgentApplication, + auth_configuration, +) -> None: + async def entry_point(req: Request) -> Response: + agent: AgentApplication = req.app["agent_app"] + adapter: CloudAdapter = req.app["adapter"] + return await start_agent_process(req, agent, adapter) + + app = Application(middlewares=[jwt_authorization_middleware]) + app.router.add_post("/api/messages", entry_point) + app.router.add_get("/api/messages", lambda _: Response(status=200)) + app["agent_configuration"] = auth_configuration + app["agent_app"] = agent_application + app["adapter"] = agent_application.adapter + + run_app(app, host="localhost", port=int(environ.get("PORT", 3978))) From d1f9c5dcfb885f812b08f5c2ecf560f1f25ba5ef Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Mon, 22 Jun 2026 14:21:28 -0700 Subject: [PATCH 18/46] Adding back in ActivityHandler extension and fixing TeamsInfo tests --- .../hosting/teams/_teams_api_client.py | 60 + .../hosting/teams/teams_activity_handler.py | 1822 ++++++++--------- .../hosting/teams/teams_info.py | 438 +--- .../hosting/teams/teams_turn_context.py | 33 +- tests/hosting_teams/test_teams_info.py | 520 ++--- 5 files changed, 1189 insertions(+), 1684 deletions(-) create mode 100644 libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/_teams_api_client.py diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/_teams_api_client.py b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/_teams_api_client.py new file mode 100644 index 000000000..65f51a16a --- /dev/null +++ b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/_teams_api_client.py @@ -0,0 +1,60 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from microsoft_teams.common import ClientOptions +from microsoft_teams.api import ApiClient + +from microsoft_agents.hosting.core import ( + Connections, + TurnContext, +) + +_TEAMS_API_CLIENT_KEY = "TeamsApiClient" + + +def get_cached_teams_api_client(context: TurnContext) -> ApiClient: + client = context.turn_state.get(_TEAMS_API_CLIENT_KEY) + if isinstance(client, ApiClient): + return client + raise ValueError("Unable to retrieve Teams API client.") + + +async def get_teams_api_client( + context: TurnContext, + connections: Connections | None = None, +) -> ApiClient: + + state = context.turn_state.get(_TEAMS_API_CLIENT_KEY) + if isinstance(state, ApiClient): + return state + + if not connections: + raise ValueError("Unable to retrieve Teams API client.") + + token: str | None = None + if context.identity: + assert context.identity is not None + provider = connections.get_token_provider( + context.identity, context.activity.service_url + ) + token = await provider.get_access_token( + "https://api.botframework.com", ["https://api.botframework.com/.default"] + ) + + headers = { + "Accept": "application/json", + "Content-Type": "application/json", + } + + options = ClientOptions( + base_url=context.activity.service_url, headers=headers, token=token + ) + + api_client = ApiClient( + context.activity.service_url, + options, + ) + + context.turn_state[_TEAMS_API_CLIENT_KEY] = api_client + + return api_client 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 index b1b6d7d0f..dd636f167 100644 --- 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 @@ -1,911 +1,911 @@ -# """ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. -# """ - -# from http import HTTPStatus -# from typing import Any - -# from microsoft_agents.hosting.core import ActivityHandler, TurnContext -# from microsoft_agents.hosting.teams.errors import teams_errors -# from microsoft_agents.activity import ( -# InvokeResponse, -# ChannelAccount, -# ) - -# from microsoft_agents.activity.teams import ( -# AppBasedLinkQuery, -# TeamInfo, -# ChannelInfo, -# ConfigResponse, -# FileConsentCardResponse, -# MeetingEndEventDetails, -# MeetingParticipantsEventDetails, -# MeetingStartEventDetails, -# MessagingExtensionAction, -# MessagingExtensionActionResponse, -# MessagingExtensionQuery, -# MessagingExtensionResponse, -# O365ConnectorCardActionQuery, -# ReadReceiptInfo, -# SigninStateVerificationQuery, -# TabRequest, -# TabResponse, -# TabSubmit, -# TaskModuleRequest, -# TaskModuleResponse, -# TeamsChannelAccount, -# TeamsChannelData, -# ) - -# from .teams_info import TeamsInfo - - -# class TeamsActivityHandler(ActivityHandler): -# """ -# The TeamsActivityHandler is derived from the ActivityHandler class and adds support for -# Microsoft Teams-specific functionality. -# """ - -# async def on_invoke_activity(self, turn_context: TurnContext) -> InvokeResponse: -# """ -# Handles invoke activities. - -# :param turn_context: The context object for the turn. -# :return: An InvokeResponse. -# """ - -# try: -# if ( -# not turn_context.activity.name -# and turn_context.activity.channel_id == "msteams" -# ): -# return await self.on_teams_card_action_invoke(turn_context) -# else: -# name = turn_context.activity.name -# value = turn_context.activity.value - -# if name == "config/fetch": -# return self._create_invoke_response( -# await self.on_teams_config_fetch(turn_context, value) -# ) -# elif name == "config/submit": -# return self._create_invoke_response( -# await self.on_teams_config_submit(turn_context, value) -# ) -# elif name == "fileConsent/invoke": -# return self._create_invoke_response( -# await self.on_teams_file_consent(turn_context, value) -# ) -# elif name == "actionableMessage/executeAction": -# await self.on_execute_action(turn_context, value) -# return self._create_invoke_response() -# elif name == "composeExtension/queryLink": -# return self._create_invoke_response( -# await self.on_teams_app_based_link_query(turn_context, value) -# ) -# elif name == "composeExtension/anonymousQueryLink": -# return self._create_invoke_response( -# await self.on_teams_anonymous_app_based_link_query( -# turn_context, value -# ) -# ) -# elif name == "composeExtension/query": -# query = MessagingExtensionQuery.model_validate(value) -# return self._create_invoke_response( -# await self.on_teams_messaging_extension_query( -# turn_context, query -# ) -# ) -# elif name == "composeExtension/selectItem": -# return self._create_invoke_response( -# await self.on_teams_messaging_extension_select_item( -# turn_context, value -# ) -# ) -# elif name == "composeExtension/submitAction": -# return self._create_invoke_response( -# await self.on_teams_messaging_extension_submit_action_dispatch( -# turn_context, value -# ) -# ) -# elif name == "composeExtension/fetchTask": -# return self._create_invoke_response( -# await self.on_teams_messaging_extension_fetch_task( -# turn_context, value -# ) -# ) -# elif name == "composeExtension/querySettingUrl": -# return self._create_invoke_response( -# await self.on_teams_messaging_extension_config_query_setting_url( -# turn_context, value -# ) -# ) -# elif name == "composeExtension/setting": -# await self.on_teams_messaging_extension_config_setting( -# turn_context, value -# ) -# return self._create_invoke_response() -# elif name == "composeExtension/onCardButtonClicked": -# await self.on_teams_messaging_extension_card_button_clicked( -# turn_context, value -# ) -# return self._create_invoke_response() -# elif name == "task/fetch": -# task_module_request = TaskModuleRequest.model_validate(value) -# return self._create_invoke_response( -# await self.on_teams_task_module_fetch( -# turn_context, task_module_request -# ) -# ) -# elif name == "task/submit": -# task_module_request = TaskModuleRequest.model_validate(value) -# return self._create_invoke_response( -# await self.on_teams_task_module_submit( -# turn_context, task_module_request -# ) -# ) -# elif name == "tab/fetch": -# return self._create_invoke_response( -# await self.on_teams_tab_fetch(turn_context, value) -# ) -# elif name == "tab/submit": -# return self._create_invoke_response( -# await self.on_teams_tab_submit(turn_context, value) -# ) -# else: -# return await super().on_invoke_activity(turn_context) -# except Exception as err: -# if str(err) == str(teams_errors.TeamsNotImplemented): -# return InvokeResponse(status=int(HTTPStatus.NOT_IMPLEMENTED)) -# elif str(err) == str(teams_errors.TeamsBadRequest): -# return InvokeResponse(status=int(HTTPStatus.BAD_REQUEST)) -# raise - -# async def on_teams_card_action_invoke( -# self, turn_context: TurnContext -# ) -> InvokeResponse: -# """ -# Handles card action invoke. - -# :param turn_context: The context object for the turn. -# :return: An InvokeResponse. -# """ -# raise NotImplementedError(str(teams_errors.TeamsNotImplemented)) - -# async def on_teams_config_fetch( -# self, turn_context: TurnContext, config_data: Any -# ) -> ConfigResponse: -# """ -# Handles config fetch. - -# :param turn_context: The context object for the turn. -# :param config_data: The config data. -# :return: A ConfigResponse. -# """ -# raise NotImplementedError(str(teams_errors.TeamsNotImplemented)) - -# async def on_teams_config_submit( -# self, turn_context: TurnContext, config_data: Any -# ) -> ConfigResponse: -# """ -# Handles config submit. - -# :param turn_context: The context object for the turn. -# :param config_data: The config data. -# :return: A ConfigResponse. -# """ -# raise NotImplementedError(str(teams_errors.TeamsNotImplemented)) - -# async def on_teams_file_consent( -# self, -# turn_context: TurnContext, -# file_consent_card_response: FileConsentCardResponse, -# ) -> None: -# """ -# Handles file consent. - -# :param turn_context: The context object for the turn. -# :param file_consent_card_response: The file consent card response. -# :return: None -# """ -# if file_consent_card_response.action == "accept": -# return await self.on_teams_file_consent_accept( -# turn_context, file_consent_card_response -# ) -# elif file_consent_card_response.action == "decline": -# return await self.on_teams_file_consent_decline( -# turn_context, file_consent_card_response -# ) -# else: -# raise ValueError(str(teams_errors.TeamsBadRequest)) - -# async def on_teams_file_consent_accept( -# self, -# turn_context: TurnContext, -# file_consent_card_response: FileConsentCardResponse, -# ) -> None: -# """ -# Handles file consent accept. - -# :param turn_context: The context object for the turn. -# :param file_consent_card_response: The file consent card response. -# :return: None -# """ -# raise NotImplementedError(str(teams_errors.TeamsNotImplemented)) - -# async def on_teams_file_consent_decline( -# self, -# turn_context: TurnContext, -# file_consent_card_response: FileConsentCardResponse, -# ) -> None: -# """ -# Handles file consent decline. - -# :param turn_context: The context object for the turn. -# :param file_consent_card_response: The file consent card response. -# :return: None -# """ -# raise NotImplementedError(str(teams_errors.TeamsNotImplemented)) - -# async def on_execute_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_config_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_config_setting( -# self, turn_context: TurnContext, settings: Any -# ) -> None: -# """ -# Handles messaging extension configuration setting. - -# :param turn_context: The context object for the turn. -# :param settings: The settings. -# :return: None -# """ -# raise NotImplementedError(str(teams_errors.TeamsNotImplemented)) - -# async def on_teams_messaging_extension_card_button_clicked( -# self, turn_context: TurnContext, card_data: Any -# ) -> None: -# """ -# Handles messaging extension card button clicked. - -# :param turn_context: The context object for the turn. -# :param card_data: The card data. -# :return: None -# """ -# raise NotImplementedError(str(teams_errors.TeamsNotImplemented)) - -# async def on_teams_task_module_fetch( -# self, turn_context: TurnContext, task_module_request: TaskModuleRequest -# ) -> TaskModuleResponse: -# """ -# Handles task module fetch. - -# :param turn_context: The context object for the turn. -# :param task_module_request: The task module request. -# :return: A TaskModuleResponse. -# """ -# raise NotImplementedError(str(teams_errors.TeamsNotImplemented)) - -# async def on_teams_task_module_submit( -# self, turn_context: TurnContext, task_module_request: TaskModuleRequest -# ) -> TaskModuleResponse: -# """ -# Handles task module submit. - -# :param turn_context: The context object for the turn. -# :param task_module_request: The task module request. -# :return: A TaskModuleResponse. -# """ -# raise NotImplementedError(str(teams_errors.TeamsNotImplemented)) - -# async def on_teams_tab_fetch( -# self, turn_context: TurnContext, tab_request: TabRequest -# ) -> TabResponse: -# """ -# Handles tab fetch. - -# :param turn_context: The context object for the turn. -# :param tab_request: The tab request. -# :return: A TabResponse. -# """ -# raise NotImplementedError(str(teams_errors.TeamsNotImplemented)) - -# async def on_teams_tab_submit( -# self, turn_context: TurnContext, tab_submit: TabSubmit -# ) -> TabResponse: -# """ -# Handles tab submit. - -# :param turn_context: The context object for the turn. -# :param tab_submit: The tab submit. -# :return: A TabResponse. -# """ -# raise NotImplementedError(str(teams_errors.TeamsNotImplemented)) - -# async def on_conversation_update_activity(self, turn_context: TurnContext): -# """ -# Dispatches conversation update activity. - -# :param turn_context: The context object for the turn. -# :return: None -# """ -# if turn_context.activity.channel_id == "msteams": -# channel_data = ( -# TeamsChannelData.model_validate(turn_context.activity.channel_data) -# if turn_context.activity.channel_data -# else None -# ) - -# if ( -# turn_context.activity.members_added -# and len(turn_context.activity.members_added) > 0 -# ): -# return await self.on_teams_members_added_dispatch( -# turn_context.activity.members_added, -# channel_data.team if channel_data else None, -# turn_context, -# ) - -# if ( -# turn_context.activity.members_removed -# and len(turn_context.activity.members_removed) > 0 -# ): -# return await self.on_teams_members_removed(turn_context) - -# if not channel_data or not channel_data.event_type: -# return await super().on_conversation_update_activity(turn_context) - -# event_type = channel_data.event_type - -# if event_type == "channelCreated": -# return await self.on_teams_channel_created(turn_context) -# elif event_type == "channelDeleted": -# return await self.on_teams_channel_deleted(turn_context) -# elif event_type == "channelRenamed": -# return await self.on_teams_channel_renamed(turn_context) -# elif event_type == "teamArchived": -# return await self.on_teams_team_archived(turn_context) -# elif event_type == "teamDeleted": -# return await self.on_teams_team_deleted(turn_context) -# elif event_type == "teamHardDeleted": -# return await self.on_teams_team_hard_deleted(turn_context) -# elif event_type == "channelRestored": -# return await self.on_teams_channel_restored(turn_context) -# elif event_type == "teamRenamed": -# return await self.on_teams_team_renamed(turn_context) -# elif event_type == "teamRestored": -# return await self.on_teams_team_restored(turn_context) -# elif event_type == "teamUnarchived": -# return await self.on_teams_team_unarchived(turn_context) - -# return await super().on_conversation_update_activity(turn_context) - -# async def on_message_update_activity(self, turn_context: TurnContext): -# """ -# Dispatches message update activity. - -# :param turn_context: The context object for the turn. -# :return: None -# """ -# if turn_context.activity.channel_id == "msteams": -# channel_data = ( -# TeamsChannelData.model_validate(turn_context.activity.channel_data) -# if turn_context.activity.channel_data -# else None -# ) - -# event_type = channel_data.event_type if channel_data else None - -# if event_type == "undeleteMessage": -# return await self.on_teams_message_undelete(turn_context) -# elif event_type == "editMessage": -# return await self.on_teams_message_edit(turn_context) - -# return await super().on_message_update_activity(turn_context) - -# async def on_message_delete_activity(self, turn_context: TurnContext) -> None: -# """ -# Dispatches message delete activity. - -# :param turn_context: The context object for the turn. -# :return: None -# """ -# if turn_context.activity.channel_id == "msteams": -# channel_data = channel_data = ( -# TeamsChannelData.model_validate(turn_context.activity.channel_data) -# if turn_context.activity.channel_data -# else None -# ) - -# event_type = channel_data.event_type if channel_data else None - -# if event_type == "softDeleteMessage": -# return await self.on_teams_message_soft_delete(turn_context) - -# return await super().on_message_delete_activity(turn_context) - -# async def on_teams_message_undelete(self, turn_context: TurnContext) -> None: -# """ -# Handles Teams message undelete. - -# :param turn_context: The context object for the turn. -# :return: None -# """ -# return - -# async def on_teams_message_edit(self, turn_context: TurnContext) -> None: -# """ -# Handles Teams message edit. - -# :param turn_context: The context object for the turn. -# :return: None -# """ -# return - -# async def on_teams_message_soft_delete(self, turn_context: TurnContext) -> None: -# """ -# Handles Teams message soft delete. - -# :param turn_context: The context object for the turn. -# :return: None -# """ -# return - -# async def on_teams_members_added_dispatch( -# self, -# members_added: list[ChannelAccount], -# team_info: TeamInfo, -# turn_context: TurnContext, -# ) -> None: -# """ -# Dispatches processing of Teams members added to the conversation. -# Processes the members_added collection to get full member information when possible. - -# :param members_added: The list of members being added to the conversation. -# :param team_info: The team info object. -# :param turn_context: The context object for the turn. -# :return: None -# """ -# teams_members_added = [] - -# for member in members_added: -# # If the member has properties or is the agent/bot being added to the conversation -# if len(member.properties) or ( -# turn_context.activity.recipient -# and turn_context.activity.recipient.id == member.id -# ): - -# # Convert the ChannelAccount to TeamsChannelAccount -# # TODO: Converter between these two classes -# teams_member = TeamsChannelAccount.model_validate( -# member.model_dump(by_alias=True, exclude_unset=True) -# ) -# teams_members_added.append(teams_member) -# else: -# # Try to get the full member details from Teams -# try: -# teams_member = await TeamsInfo.get_member(turn_context, member.id) -# teams_members_added.append(teams_member) -# except Exception as err: -# # Handle case where conversation is not found -# if "ConversationNotFound" in str(err): -# teams_channel_account = TeamsChannelAccount( -# id=member.id, -# name=member.name, -# aad_object_id=getattr(member, "aad_object_id", None), -# role=getattr(member, "role", None), -# ) -# teams_members_added.append(teams_channel_account) -# else: -# # Propagate any other errors -# raise - -# await self.on_teams_members_added(teams_members_added, team_info, turn_context) - -# async def on_teams_members_added( -# self, -# teams_members_added: list[TeamsChannelAccount], -# team_info: TeamInfo, -# turn_context: TurnContext, -# ) -> None: -# """ -# Handles Teams members added. - -# :param turn_context: The context object for the turn. -# :return: None -# """ -# await self.on_members_added_activity(teams_members_added, turn_context) - -# async def on_teams_members_removed_dispatch( -# self, -# members_removed: list[ChannelAccount], -# team_info: TeamInfo, -# turn_context: TurnContext, -# ) -> None: -# """ -# Dispatches processing of Teams members removed from the conversation. -# """ -# teams_members_removed = [] -# for member in members_removed: -# teams_members_removed.append( -# TeamsChannelAccount.model_validate( -# member.model_dump(by_alias=True, exclude_unset=True) -# ) -# ) -# return await self.on_teams_members_removed( -# teams_members_removed, team_info, turn_context -# ) - -# async def on_teams_members_removed( -# self, -# teams_members_removed: list[TeamsChannelAccount], -# team_info: TeamInfo, -# turn_context: TurnContext, -# ) -> None: -# """ -# Handles Teams members removed. - -# :param turn_context: The context object for the turn. -# :return: None -# """ -# await self.on_members_removed_activity(teams_members_removed, turn_context) - -# async def on_teams_channel_created( -# self, channel_info: ChannelInfo, team_info: TeamInfo, turn_context: TurnContext -# ) -> None: -# """ -# Handles Teams channel created. - -# :param turn_context: The context object for the turn. -# :return: None -# """ -# return - -# async def on_teams_channel_deleted( -# self, channel_info: ChannelInfo, team_info: TeamInfo, turn_context: TurnContext -# ) -> None: -# """ -# Handles Teams channel deleted. - -# :param turn_context: The context object for the turn. -# :return: None -# """ -# return - -# async def on_teams_channel_renamed( -# self, channel_info: ChannelInfo, team_info: TeamInfo, turn_context: TurnContext -# ) -> None: -# """ -# Handles Teams channel renamed. - -# :param turn_context: The context object for the turn. -# :return: None -# """ -# return - -# async def on_teams_team_archived( -# self, team_info: TeamInfo, turn_context: TurnContext -# ) -> None: -# """ -# Handles Teams team archived. - -# :param turn_context: The context object for the turn. -# :return: None -# """ -# return - -# async def on_teams_team_deleted( -# self, team_info: TeamInfo, turn_context: TurnContext -# ) -> None: -# """ -# Handles Teams team deleted. - -# :param turn_context: The context object for the turn. -# :return: None -# """ -# return - -# async def on_teams_team_hard_deleted( -# self, team_info: TeamInfo, turn_context: TurnContext -# ) -> None: -# """ -# Handles Teams team hard deleted. - -# :param turn_context: The context object for the turn. -# :return: None -# """ -# return - -# async def on_teams_channel_restored( -# self, channel_info: ChannelInfo, team_info: TeamInfo, turn_context: TurnContext -# ) -> None: -# """ -# Handles Teams channel restored. - -# :param turn_context: The context object for the turn. -# :return: None -# """ -# return - -# async def on_teams_team_renamed( -# self, team_info: TeamInfo, turn_context: TurnContext -# ) -> None: -# """ -# Handles Teams team renamed. - -# :param turn_context: The context object for the turn. -# :return: None -# """ -# return - -# async def on_teams_team_restored( -# self, team_info: TeamInfo, turn_context: TurnContext -# ) -> None: -# """ -# Handles Teams team restored. - -# :param turn_context: The context object for the turn. -# :return: None -# """ -# return - -# async def on_teams_team_unarchived( -# self, team_info: TeamInfo, turn_context: TurnContext -# ) -> None: -# """ -# Handles Teams team unarchived. - -# :param turn_context: The context object for the turn. -# :return: None -# """ -# return - -# async def on_event_activity(self, turn_context: TurnContext) -> None: -# """ -# Dispatches event activity. - -# :param turn_context: The context object for the turn. -# :return: None -# """ -# if turn_context.activity.channel_id == "msteams": -# if turn_context.activity.name == "application/vnd.microsoft.readReceipt": -# return await self.on_teams_read_receipt(turn_context) -# elif turn_context.activity.name == "application/vnd.microsoft.meetingStart": -# return await self.on_teams_meeting_start(turn_context) -# elif turn_context.activity.name == "application/vnd.microsoft.meetingEnd": -# return await self.on_teams_meeting_end(turn_context) -# elif ( -# turn_context.activity.name -# == "application/vnd.microsoft.meetingParticipantJoin" -# ): -# return await self.on_teams_meeting_participants_join(turn_context) -# elif ( -# turn_context.activity.name -# == "application/vnd.microsoft.meetingParticipantLeave" -# ): -# return await self.on_teams_meeting_participants_leave(turn_context) - -# return await super().on_event_activity(turn_context) - -# async def on_teams_meeting_start( -# self, meeting: MeetingStartEventDetails, turn_context: TurnContext -# ) -> None: -# """ -# Handles Teams meeting start. - -# :param turn_context: The context object for the turn. -# :return: None -# """ -# return - -# async def on_teams_meeting_end( -# self, meeting: MeetingEndEventDetails, turn_context: TurnContext -# ) -> None: -# """ -# Handles Teams meeting end. - -# :param turn_context: The context object for the turn. -# :return: None -# """ -# return - -# async def on_teams_read_receipt( -# self, read_receipt: ReadReceiptInfo, turn_context: TurnContext -# ) -> None: -# """ -# Handles Teams read receipt. - -# :param turn_context: The context object for the turn. -# :return: None -# """ -# return - -# async def on_teams_meeting_participants_join( -# self, meeting: MeetingParticipantsEventDetails, turn_context: TurnContext -# ) -> None: -# """ -# Handles Teams meeting participants join. - -# :param turn_context: The context object for the turn. -# :return: None -# """ -# return - -# async def on_teams_meeting_participants_leave( -# self, meeting: MeetingParticipantsEventDetails, turn_context: TurnContext -# ) -> None: -# """ -# Handles Teams meeting participants leave. - -# :param turn_context: The context object for the turn. -# :return: None -# """ -# return +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. +""" + +from http import HTTPStatus +from typing import Any + +from microsoft_agents.hosting.core import ActivityHandler, TurnContext +from microsoft_agents.hosting.teams.errors import teams_errors +from microsoft_agents.activity import ( + InvokeResponse, + ChannelAccount, +) + +from microsoft_agents.activity.teams import ( + AppBasedLinkQuery, + TeamInfo, + ChannelInfo, + ConfigResponse, + FileConsentCardResponse, + MeetingEndEventDetails, + MeetingParticipantsEventDetails, + MeetingStartEventDetails, + MessagingExtensionAction, + MessagingExtensionActionResponse, + MessagingExtensionQuery, + MessagingExtensionResponse, + O365ConnectorCardActionQuery, + ReadReceiptInfo, + SigninStateVerificationQuery, + TabRequest, + TabResponse, + TabSubmit, + TaskModuleRequest, + TaskModuleResponse, + TeamsChannelAccount, + TeamsChannelData, +) + +from .teams_info import TeamsInfo + + +class TeamsActivityHandler(ActivityHandler): + """ + The TeamsActivityHandler is derived from the ActivityHandler class and adds support for + Microsoft Teams-specific functionality. + """ + + async def on_invoke_activity(self, turn_context: TurnContext) -> InvokeResponse: + """ + Handles invoke activities. + + :param turn_context: The context object for the turn. + :return: An InvokeResponse. + """ + + try: + if ( + not turn_context.activity.name + and turn_context.activity.channel_id == "msteams" + ): + return await self.on_teams_card_action_invoke(turn_context) + else: + name = turn_context.activity.name + value = turn_context.activity.value + + if name == "config/fetch": + return self._create_invoke_response( + await self.on_teams_config_fetch(turn_context, value) + ) + elif name == "config/submit": + return self._create_invoke_response( + await self.on_teams_config_submit(turn_context, value) + ) + elif name == "fileConsent/invoke": + return self._create_invoke_response( + await self.on_teams_file_consent(turn_context, value) + ) + elif name == "actionableMessage/executeAction": + await self.on_execute_action(turn_context, value) + return self._create_invoke_response() + elif name == "composeExtension/queryLink": + return self._create_invoke_response( + await self.on_teams_app_based_link_query(turn_context, value) + ) + elif name == "composeExtension/anonymousQueryLink": + return self._create_invoke_response( + await self.on_teams_anonymous_app_based_link_query( + turn_context, value + ) + ) + elif name == "composeExtension/query": + query = MessagingExtensionQuery.model_validate(value) + return self._create_invoke_response( + await self.on_teams_messaging_extension_query( + turn_context, query + ) + ) + elif name == "composeExtension/selectItem": + return self._create_invoke_response( + await self.on_teams_messaging_extension_select_item( + turn_context, value + ) + ) + elif name == "composeExtension/submitAction": + return self._create_invoke_response( + await self.on_teams_messaging_extension_submit_action_dispatch( + turn_context, value + ) + ) + elif name == "composeExtension/fetchTask": + return self._create_invoke_response( + await self.on_teams_messaging_extension_fetch_task( + turn_context, value + ) + ) + elif name == "composeExtension/querySettingUrl": + return self._create_invoke_response( + await self.on_teams_messaging_extension_config_query_setting_url( + turn_context, value + ) + ) + elif name == "composeExtension/setting": + await self.on_teams_messaging_extension_config_setting( + turn_context, value + ) + return self._create_invoke_response() + elif name == "composeExtension/onCardButtonClicked": + await self.on_teams_messaging_extension_card_button_clicked( + turn_context, value + ) + return self._create_invoke_response() + elif name == "task/fetch": + task_module_request = TaskModuleRequest.model_validate(value) + return self._create_invoke_response( + await self.on_teams_task_module_fetch( + turn_context, task_module_request + ) + ) + elif name == "task/submit": + task_module_request = TaskModuleRequest.model_validate(value) + return self._create_invoke_response( + await self.on_teams_task_module_submit( + turn_context, task_module_request + ) + ) + elif name == "tab/fetch": + return self._create_invoke_response( + await self.on_teams_tab_fetch(turn_context, value) + ) + elif name == "tab/submit": + return self._create_invoke_response( + await self.on_teams_tab_submit(turn_context, value) + ) + else: + return await super().on_invoke_activity(turn_context) + except Exception as err: + if str(err) == str(teams_errors.TeamsNotImplemented): + return InvokeResponse(status=int(HTTPStatus.NOT_IMPLEMENTED)) + elif str(err) == str(teams_errors.TeamsBadRequest): + return InvokeResponse(status=int(HTTPStatus.BAD_REQUEST)) + raise + + async def on_teams_card_action_invoke( + self, turn_context: TurnContext + ) -> InvokeResponse: + """ + Handles card action invoke. + + :param turn_context: The context object for the turn. + :return: An InvokeResponse. + """ + raise NotImplementedError(str(teams_errors.TeamsNotImplemented)) + + async def on_teams_config_fetch( + self, turn_context: TurnContext, config_data: Any + ) -> ConfigResponse: + """ + Handles config fetch. + + :param turn_context: The context object for the turn. + :param config_data: The config data. + :return: A ConfigResponse. + """ + raise NotImplementedError(str(teams_errors.TeamsNotImplemented)) + + async def on_teams_config_submit( + self, turn_context: TurnContext, config_data: Any + ) -> ConfigResponse: + """ + Handles config submit. + + :param turn_context: The context object for the turn. + :param config_data: The config data. + :return: A ConfigResponse. + """ + raise NotImplementedError(str(teams_errors.TeamsNotImplemented)) + + async def on_teams_file_consent( + self, + turn_context: TurnContext, + file_consent_card_response: FileConsentCardResponse, + ) -> None: + """ + Handles file consent. + + :param turn_context: The context object for the turn. + :param file_consent_card_response: The file consent card response. + :return: None + """ + if file_consent_card_response.action == "accept": + return await self.on_teams_file_consent_accept( + turn_context, file_consent_card_response + ) + elif file_consent_card_response.action == "decline": + return await self.on_teams_file_consent_decline( + turn_context, file_consent_card_response + ) + else: + raise ValueError(str(teams_errors.TeamsBadRequest)) + + async def on_teams_file_consent_accept( + self, + turn_context: TurnContext, + file_consent_card_response: FileConsentCardResponse, + ) -> None: + """ + Handles file consent accept. + + :param turn_context: The context object for the turn. + :param file_consent_card_response: The file consent card response. + :return: None + """ + raise NotImplementedError(str(teams_errors.TeamsNotImplemented)) + + async def on_teams_file_consent_decline( + self, + turn_context: TurnContext, + file_consent_card_response: FileConsentCardResponse, + ) -> None: + """ + Handles file consent decline. + + :param turn_context: The context object for the turn. + :param file_consent_card_response: The file consent card response. + :return: None + """ + raise NotImplementedError(str(teams_errors.TeamsNotImplemented)) + + async def on_execute_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_config_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_config_setting( + self, turn_context: TurnContext, settings: Any + ) -> None: + """ + Handles messaging extension configuration setting. + + :param turn_context: The context object for the turn. + :param settings: The settings. + :return: None + """ + raise NotImplementedError(str(teams_errors.TeamsNotImplemented)) + + async def on_teams_messaging_extension_card_button_clicked( + self, turn_context: TurnContext, card_data: Any + ) -> None: + """ + Handles messaging extension card button clicked. + + :param turn_context: The context object for the turn. + :param card_data: The card data. + :return: None + """ + raise NotImplementedError(str(teams_errors.TeamsNotImplemented)) + + async def on_teams_task_module_fetch( + self, turn_context: TurnContext, task_module_request: TaskModuleRequest + ) -> TaskModuleResponse: + """ + Handles task module fetch. + + :param turn_context: The context object for the turn. + :param task_module_request: The task module request. + :return: A TaskModuleResponse. + """ + raise NotImplementedError(str(teams_errors.TeamsNotImplemented)) + + async def on_teams_task_module_submit( + self, turn_context: TurnContext, task_module_request: TaskModuleRequest + ) -> TaskModuleResponse: + """ + Handles task module submit. + + :param turn_context: The context object for the turn. + :param task_module_request: The task module request. + :return: A TaskModuleResponse. + """ + raise NotImplementedError(str(teams_errors.TeamsNotImplemented)) + + async def on_teams_tab_fetch( + self, turn_context: TurnContext, tab_request: TabRequest + ) -> TabResponse: + """ + Handles tab fetch. + + :param turn_context: The context object for the turn. + :param tab_request: The tab request. + :return: A TabResponse. + """ + raise NotImplementedError(str(teams_errors.TeamsNotImplemented)) + + async def on_teams_tab_submit( + self, turn_context: TurnContext, tab_submit: TabSubmit + ) -> TabResponse: + """ + Handles tab submit. + + :param turn_context: The context object for the turn. + :param tab_submit: The tab submit. + :return: A TabResponse. + """ + raise NotImplementedError(str(teams_errors.TeamsNotImplemented)) + + async def on_conversation_update_activity(self, turn_context: TurnContext): + """ + Dispatches conversation update activity. + + :param turn_context: The context object for the turn. + :return: None + """ + if turn_context.activity.channel_id == "msteams": + channel_data = ( + TeamsChannelData.model_validate(turn_context.activity.channel_data) + if turn_context.activity.channel_data + else None + ) + + if ( + turn_context.activity.members_added + and len(turn_context.activity.members_added) > 0 + ): + return await self.on_teams_members_added_dispatch( + turn_context.activity.members_added, + channel_data.team if channel_data else None, + turn_context, + ) + + if ( + turn_context.activity.members_removed + and len(turn_context.activity.members_removed) > 0 + ): + return await self.on_teams_members_removed(turn_context) + + if not channel_data or not channel_data.event_type: + return await super().on_conversation_update_activity(turn_context) + + event_type = channel_data.event_type + + if event_type == "channelCreated": + return await self.on_teams_channel_created(turn_context) + elif event_type == "channelDeleted": + return await self.on_teams_channel_deleted(turn_context) + elif event_type == "channelRenamed": + return await self.on_teams_channel_renamed(turn_context) + elif event_type == "teamArchived": + return await self.on_teams_team_archived(turn_context) + elif event_type == "teamDeleted": + return await self.on_teams_team_deleted(turn_context) + elif event_type == "teamHardDeleted": + return await self.on_teams_team_hard_deleted(turn_context) + elif event_type == "channelRestored": + return await self.on_teams_channel_restored(turn_context) + elif event_type == "teamRenamed": + return await self.on_teams_team_renamed(turn_context) + elif event_type == "teamRestored": + return await self.on_teams_team_restored(turn_context) + elif event_type == "teamUnarchived": + return await self.on_teams_team_unarchived(turn_context) + + return await super().on_conversation_update_activity(turn_context) + + async def on_message_update_activity(self, turn_context: TurnContext): + """ + Dispatches message update activity. + + :param turn_context: The context object for the turn. + :return: None + """ + if turn_context.activity.channel_id == "msteams": + channel_data = ( + TeamsChannelData.model_validate(turn_context.activity.channel_data) + if turn_context.activity.channel_data + else None + ) + + event_type = channel_data.event_type if channel_data else None + + if event_type == "undeleteMessage": + return await self.on_teams_message_undelete(turn_context) + elif event_type == "editMessage": + return await self.on_teams_message_edit(turn_context) + + return await super().on_message_update_activity(turn_context) + + async def on_message_delete_activity(self, turn_context: TurnContext) -> None: + """ + Dispatches message delete activity. + + :param turn_context: The context object for the turn. + :return: None + """ + if turn_context.activity.channel_id == "msteams": + channel_data = channel_data = ( + TeamsChannelData.model_validate(turn_context.activity.channel_data) + if turn_context.activity.channel_data + else None + ) + + event_type = channel_data.event_type if channel_data else None + + if event_type == "softDeleteMessage": + return await self.on_teams_message_soft_delete(turn_context) + + return await super().on_message_delete_activity(turn_context) + + async def on_teams_message_undelete(self, turn_context: TurnContext) -> None: + """ + Handles Teams message undelete. + + :param turn_context: The context object for the turn. + :return: None + """ + return + + async def on_teams_message_edit(self, turn_context: TurnContext) -> None: + """ + Handles Teams message edit. + + :param turn_context: The context object for the turn. + :return: None + """ + return + + async def on_teams_message_soft_delete(self, turn_context: TurnContext) -> None: + """ + Handles Teams message soft delete. + + :param turn_context: The context object for the turn. + :return: None + """ + return + + async def on_teams_members_added_dispatch( + self, + members_added: list[ChannelAccount], + team_info: TeamInfo, + turn_context: TurnContext, + ) -> None: + """ + Dispatches processing of Teams members added to the conversation. + Processes the members_added collection to get full member information when possible. + + :param members_added: The list of members being added to the conversation. + :param team_info: The team info object. + :param turn_context: The context object for the turn. + :return: None + """ + teams_members_added = [] + + for member in members_added: + # If the member has properties or is the agent/bot being added to the conversation + if len(member.properties) or ( + turn_context.activity.recipient + and turn_context.activity.recipient.id == member.id + ): + + # Convert the ChannelAccount to TeamsChannelAccount + # TODO: Converter between these two classes + teams_member = TeamsChannelAccount.model_validate( + member.model_dump(by_alias=True, exclude_unset=True) + ) + teams_members_added.append(teams_member) + else: + # Try to get the full member details from Teams + try: + teams_member = await TeamsInfo.get_member(turn_context, member.id) + teams_members_added.append(teams_member) + except Exception as err: + # Handle case where conversation is not found + if "ConversationNotFound" in str(err): + teams_channel_account = TeamsChannelAccount( + id=member.id, + name=member.name, + aad_object_id=getattr(member, "aad_object_id", None), + role=getattr(member, "role", None), + ) + teams_members_added.append(teams_channel_account) + else: + # Propagate any other errors + raise + + await self.on_teams_members_added(teams_members_added, team_info, turn_context) + + async def on_teams_members_added( + self, + teams_members_added: list[TeamsChannelAccount], + team_info: TeamInfo, + turn_context: TurnContext, + ) -> None: + """ + Handles Teams members added. + + :param turn_context: The context object for the turn. + :return: None + """ + await self.on_members_added_activity(teams_members_added, turn_context) + + async def on_teams_members_removed_dispatch( + self, + members_removed: list[ChannelAccount], + team_info: TeamInfo, + turn_context: TurnContext, + ) -> None: + """ + Dispatches processing of Teams members removed from the conversation. + """ + teams_members_removed = [] + for member in members_removed: + teams_members_removed.append( + TeamsChannelAccount.model_validate( + member.model_dump(by_alias=True, exclude_unset=True) + ) + ) + return await self.on_teams_members_removed( + teams_members_removed, team_info, turn_context + ) + + async def on_teams_members_removed( + self, + teams_members_removed: list[TeamsChannelAccount], + team_info: TeamInfo, + turn_context: TurnContext, + ) -> None: + """ + Handles Teams members removed. + + :param turn_context: The context object for the turn. + :return: None + """ + await self.on_members_removed_activity(teams_members_removed, turn_context) + + async def on_teams_channel_created( + self, channel_info: ChannelInfo, team_info: TeamInfo, turn_context: TurnContext + ) -> None: + """ + Handles Teams channel created. + + :param turn_context: The context object for the turn. + :return: None + """ + return + + async def on_teams_channel_deleted( + self, channel_info: ChannelInfo, team_info: TeamInfo, turn_context: TurnContext + ) -> None: + """ + Handles Teams channel deleted. + + :param turn_context: The context object for the turn. + :return: None + """ + return + + async def on_teams_channel_renamed( + self, channel_info: ChannelInfo, team_info: TeamInfo, turn_context: TurnContext + ) -> None: + """ + Handles Teams channel renamed. + + :param turn_context: The context object for the turn. + :return: None + """ + return + + async def on_teams_team_archived( + self, team_info: TeamInfo, turn_context: TurnContext + ) -> None: + """ + Handles Teams team archived. + + :param turn_context: The context object for the turn. + :return: None + """ + return + + async def on_teams_team_deleted( + self, team_info: TeamInfo, turn_context: TurnContext + ) -> None: + """ + Handles Teams team deleted. + + :param turn_context: The context object for the turn. + :return: None + """ + return + + async def on_teams_team_hard_deleted( + self, team_info: TeamInfo, turn_context: TurnContext + ) -> None: + """ + Handles Teams team hard deleted. + + :param turn_context: The context object for the turn. + :return: None + """ + return + + async def on_teams_channel_restored( + self, channel_info: ChannelInfo, team_info: TeamInfo, turn_context: TurnContext + ) -> None: + """ + Handles Teams channel restored. + + :param turn_context: The context object for the turn. + :return: None + """ + return + + async def on_teams_team_renamed( + self, team_info: TeamInfo, turn_context: TurnContext + ) -> None: + """ + Handles Teams team renamed. + + :param turn_context: The context object for the turn. + :return: None + """ + return + + async def on_teams_team_restored( + self, team_info: TeamInfo, turn_context: TurnContext + ) -> None: + """ + Handles Teams team restored. + + :param turn_context: The context object for the turn. + :return: None + """ + return + + async def on_teams_team_unarchived( + self, team_info: TeamInfo, turn_context: TurnContext + ) -> None: + """ + Handles Teams team unarchived. + + :param turn_context: The context object for the turn. + :return: None + """ + return + + async def on_event_activity(self, turn_context: TurnContext) -> None: + """ + Dispatches event activity. + + :param turn_context: The context object for the turn. + :return: None + """ + if turn_context.activity.channel_id == "msteams": + if turn_context.activity.name == "application/vnd.microsoft.readReceipt": + return await self.on_teams_read_receipt(turn_context) + elif turn_context.activity.name == "application/vnd.microsoft.meetingStart": + return await self.on_teams_meeting_start(turn_context) + elif turn_context.activity.name == "application/vnd.microsoft.meetingEnd": + return await self.on_teams_meeting_end(turn_context) + elif ( + turn_context.activity.name + == "application/vnd.microsoft.meetingParticipantJoin" + ): + return await self.on_teams_meeting_participants_join(turn_context) + elif ( + turn_context.activity.name + == "application/vnd.microsoft.meetingParticipantLeave" + ): + return await self.on_teams_meeting_participants_leave(turn_context) + + return await super().on_event_activity(turn_context) + + async def on_teams_meeting_start( + self, meeting: MeetingStartEventDetails, turn_context: TurnContext + ) -> None: + """ + Handles Teams meeting start. + + :param turn_context: The context object for the turn. + :return: None + """ + return + + async def on_teams_meeting_end( + self, meeting: MeetingEndEventDetails, turn_context: TurnContext + ) -> None: + """ + Handles Teams meeting end. + + :param turn_context: The context object for the turn. + :return: None + """ + return + + async def on_teams_read_receipt( + self, read_receipt: ReadReceiptInfo, turn_context: TurnContext + ) -> None: + """ + Handles Teams read receipt. + + :param turn_context: The context object for the turn. + :return: None + """ + return + + async def on_teams_meeting_participants_join( + self, meeting: MeetingParticipantsEventDetails, turn_context: TurnContext + ) -> None: + """ + Handles Teams meeting participants join. + + :param turn_context: The context object for the turn. + :return: None + """ + return + + async def on_teams_meeting_participants_leave( + self, meeting: MeetingParticipantsEventDetails, turn_context: TurnContext + ) -> None: + """ + Handles Teams meeting participants leave. + + :param turn_context: The context object for the turn. + :return: None + """ + return diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/teams_info.py b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/teams_info.py index eca64349f..5f193c788 100644 --- 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 @@ -3,43 +3,22 @@ """Teams information utilities for Microsoft Agents.""" -from typing import Optional - -from microsoft_teams.api.models import ChannelData - -from microsoft_agents.activity import ( - Activity, - Channels, - ConversationParameters, - ConversationReference, -) - -from microsoft_agents.activity.teams import ( - TeamsChannelAccount, - TeamsMeetingParticipant, +from microsoft_teams.api.models import ( + ChannelData, + ChannelInfo, MeetingInfo, + MeetingParticipant, + PagedMembersResult, + TeamsChannelAccount, 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, ) + +from microsoft_agents.hosting.core import TurnContext from microsoft_agents.hosting.teams.errors import teams_errors +from ._teams_api_client import get_cached_teams_api_client from ._utils import _get_channel_data -_DEFAULT_PAGE_SIZE = 16 - class TeamsInfo: """Teams information utilities for interacting with Teams-specific data.""" @@ -70,13 +49,22 @@ def _get_team_id(channel_data: ChannelData, team_id: str | None = None) -> str: raise ValueError(str(teams_errors.TeamsTeamIdRequired)) return team_id + @staticmethod + def _get_conversation_id(context: TurnContext) -> str: + conversation_id = ( + context.activity.conversation.id if context.activity.conversation else None + ) + if not conversation_id: + raise ValueError(str(teams_errors.TeamsConversationIdRequired)) + return conversation_id + @staticmethod async def get_meeting_participant( context: TurnContext, meeting_id: str | None = None, participant_id: str | None = None, tenant_id: str | None = None, - ) -> TeamsMeetingParticipant: + ) -> MeetingParticipant: """ Gets the meeting participant information. @@ -108,11 +96,10 @@ async def get_meeting_participant( if not participant_id: raise ValueError(str(teams_errors.TeamsParticipantIdRequired)) - rest_client = TeamsInfo._get_rest_client(context) - result = await rest_client.fetch_meeting_participant( + api_client = get_cached_teams_api_client(context) + return await api_client.meetings.get_participant( meeting_id, participant_id, tenant_id ) - return result @staticmethod async def get_meeting_info( @@ -134,9 +121,8 @@ async def get_meeting_info( channel_data: ChannelData = _get_channel_data(context) meeting_id = TeamsInfo._get_meeting_id(channel_data, meeting_id) - rest_client = TeamsInfo._get_rest_client(context) - result = await rest_client.fetch_meeting_info(meeting_id) - return result + api_client = get_cached_teams_api_client(context) + return await api_client.meetings.get_by_id(meeting_id) @staticmethod async def get_team_details( @@ -158,84 +144,8 @@ async def get_team_details( channel_data: ChannelData = _get_channel_data(context) team_id = TeamsInfo._get_team_id(channel_data, team_id) - 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: str | None = None, - ) -> tuple[ConversationReference | None, str | None]: - """ - 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. - """ - - convo_params = ConversationParameters( - is_group=True, - channel_data={ - "channel": { - "id": teams_channel_id, - }, - }, - activity=activity, - bot=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 + api_client = get_cached_teams_api_client(context) + return await api_client.teams.get_by_id(team_id) @staticmethod async def get_team_channels( @@ -257,15 +167,15 @@ async def get_team_channels( channel_data = _get_channel_data(context) team_id = TeamsInfo._get_team_id(channel_data, team_id) - rest_client = TeamsInfo._get_rest_client(context) - return await rest_client.fetch_channel_list(team_id) + api_client = get_cached_teams_api_client(context) + return await api_client.teams.get_conversations(team_id) @staticmethod async def get_paged_members( context: TurnContext, - page_size: int = _DEFAULT_PAGE_SIZE, + page_size: int | None = None, continuation_token: str = "", - ) -> TeamsPagedMembersResult: + ) -> PagedMembersResult: """ Gets the paged members of a team or conversation. @@ -283,23 +193,12 @@ async def get_paged_members( channel_data = _get_channel_data(context) team_id = getattr(channel_data.team, "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)) + conversation_id = team_id or TeamsInfo._get_conversation_id(context) - rest_client = TeamsInfo._get_rest_client(context) - return await rest_client.get_conversation_paged_member( - conversation_id, page_size, continuation_token - ) + api_client = get_cached_teams_api_client(context) + return await api_client.conversations.members(conversation_id).get_paged( + page_size, continuation_token + ) @staticmethod async def get_member(context: TurnContext, user_id: str) -> TeamsChannelAccount: @@ -322,58 +221,12 @@ async def get_member(context: TurnContext, user_id: str) -> TeamsChannelAccount: 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)) + conversation_id = TeamsInfo._get_conversation_id(context) return await TeamsInfo._get_member_internal( context, conversation_id, user_id ) - @staticmethod - async def get_paged_team_members( - context: TurnContext, - team_id: str | None = None, - page_size: int = _DEFAULT_PAGE_SIZE, - continuation_token: str = "", - ) -> 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. - """ - channel_data = _get_channel_data(context) - team_id = TeamsInfo._get_team_id(channel_data) - - 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 @@ -392,201 +245,8 @@ async def get_team_member( 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. - """ - channel_data = _get_channel_data(context) - meeting_id = TeamsInfo._get_meeting_id(channel_data) - - 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 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. - """ - 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. - """ - 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 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. - """ - 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. - """ - 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. - """ - rest_client = TeamsInfo._get_rest_client(context) - return await rest_client.cancel_operation(operation_id) + api_client = get_cached_teams_api_client(context) + return await api_client.conversations.members(team_id).get(user_id) @staticmethod async def _get_member_internal( @@ -606,25 +266,5 @@ async def _get_member_internal( 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 + api_client = get_cached_teams_api_client(context) + return await api_client.conversations.members(conversation_id).get(user_id) diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/teams_turn_context.py b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/teams_turn_context.py index 7faf39eb7..bb57fb2ca 100644 --- a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/teams_turn_context.py +++ b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/teams_turn_context.py @@ -3,6 +3,10 @@ """Teams-specific turn context wrapper.""" +from __future__ import annotations + +from microsoft_teams.api import ApiClient + from microsoft_agents.activity import ( Activity, ActivityTreatment, @@ -11,6 +15,8 @@ ) from microsoft_agents.hosting.core import AgentApplication, TurnContext +from ._teams_api_client import get_teams_api_client, _TEAMS_API_CLIENT_KEY + class TeamsTurnContext(TurnContext): """A context object for handling Teams-specific turn functionality. @@ -26,11 +32,34 @@ def __init__(self, context: TurnContext, app: AgentApplication) -> None: :param app: The agent application that is handling the turn. """ super().__init__(context) - self._context = context self._app = app - self._turn_state.update(context.turn_state) + @classmethod + async def create( + cls, context: TurnContext, app: AgentApplication + ) -> TeamsTurnContext: + """Create a Teams turn context from a plain turn context. + + :param context: The base turn context provided by the core runtime. + :param app: The agent application that is handling the turn. + :return: A new Teams turn context. + """ + instance = TeamsTurnContext(context, app) + await get_teams_api_client(context, app.auth._connection_manager) + return instance + + @property + def api_client(self) -> ApiClient: + """Get the API client for the Teams turn context.""" + + api_client = self._turn_state.get(_TEAMS_API_CLIENT_KEY) + if not isinstance(api_client, ApiClient): + raise ValueError( + "Teams ApiClient unavailable. Use TeamsTurnContext.create() to create it." + ) + return api_client + @staticmethod def _make_targeted_activity(activity: Activity) -> Activity: activity = activity.model_copy() diff --git a/tests/hosting_teams/test_teams_info.py b/tests/hosting_teams/test_teams_info.py index e671849a2..d4aae98fc 100644 --- a/tests/hosting_teams/test_teams_info.py +++ b/tests/hosting_teams/test_teams_info.py @@ -3,9 +3,8 @@ """Unit tests for TeamsInfo.""" -import sys import pytest -from unittest.mock import AsyncMock, MagicMock +from unittest.mock import AsyncMock, MagicMock, patch from .helpers import is_supported_version @@ -15,30 +14,17 @@ ) if is_supported_version: - from microsoft_teams.api.models.channel_data import ChannelData - from microsoft_agents.activity import ( - Activity, - ActivityTypes, - ChannelAccount, - ConversationAccount, - ) - from microsoft_agents.hosting.core import TurnContext, ChannelServiceAdapter - from microsoft_agents.hosting.core.connector.teams import TeamsConnectorClient - from microsoft_agents.activity.teams import ( - TeamsChannelAccount, - TeamsMeetingParticipant, + from microsoft_teams.api.models import ( + ChannelData, + ChannelInfo, MeetingInfo, + MeetingParticipant, + PagedMembersResult, + TeamsChannelAccount, TeamDetails, - TeamsPagedMembersResult, - MeetingNotification, - MeetingNotificationResponse, - TeamsMember, - BatchOperationStateResponse, - BatchFailedEntriesResponse, - CancelOperationResponse, - TeamsBatchOperationResponse, - ChannelInfo, ) + from microsoft_agents.activity import Activity, ChannelAccount + from microsoft_agents.hosting.core import TurnContext from microsoft_agents.hosting.teams.teams_info import TeamsInfo @@ -56,7 +42,25 @@ def _make_channel_data(team_id=None, tenant_id=None, meeting_id=None) -> "Channe return ChannelData.model_validate(data) -def _make_context(channel_data=None, mock_client=None) -> "TurnContext": +def _make_api_client(): + """Build a mock ApiClient with meetings/teams/conversations sub-objects.""" + members_proxy = MagicMock() + members_proxy.get_paged = AsyncMock() + members_proxy.get = AsyncMock() + + api_client = MagicMock() + api_client.meetings = MagicMock() + api_client.meetings.get_participant = AsyncMock() + api_client.meetings.get_by_id = AsyncMock() + api_client.teams = MagicMock() + api_client.teams.get_by_id = AsyncMock() + api_client.teams.get_conversations = AsyncMock() + api_client.conversations = MagicMock() + api_client.conversations.members = MagicMock(return_value=members_proxy) + return api_client + + +def _make_context(channel_data=None, api_client=None) -> "TurnContext": context = MagicMock(spec=TurnContext) activity = MagicMock(spec=Activity) activity.channel_data = channel_data @@ -69,15 +73,15 @@ def _make_context(channel_data=None, mock_client=None) -> "TurnContext": activity.recipient = MagicMock(spec=ChannelAccount) activity.service_url = "https://example.com" activity.id = "activity-id" - activity.get_conversation_reference = MagicMock( - return_value=MagicMock(conversation=MagicMock()) - ) context.activity = activity - context.turn_state = {"ConnectorClient": mock_client} + context.turn_state = {"TeamsApiClient": api_client} context.adapter = MagicMock() return context +_PATCH_TARGET = "microsoft_agents.hosting.teams.teams_info.get_cached_teams_api_client" + + # --- _get_meeting_id --- @@ -135,65 +139,53 @@ def test_raises_if_both_missing(self): TeamsInfo._get_team_id(channel_data) -# --- _get_rest_client --- - - -class TestGetRestClient: - def test_returns_client_from_turn_state(self): - mock_client = MagicMock(spec=TeamsConnectorClient) - context = _make_context(mock_client=mock_client) - assert TeamsInfo._get_rest_client(context) is mock_client - - def test_raises_if_client_missing(self): - context = _make_context(mock_client=None) - with pytest.raises(ValueError, match="TeamsConnectorClient"): - TeamsInfo._get_rest_client(context) - - # --- get_meeting_participant --- class TestGetMeetingParticipant: @pytest.mark.asyncio async def test_calls_client_with_explicit_params(self): - mock_client = AsyncMock(spec=TeamsConnectorClient) - expected = MagicMock(spec=TeamsMeetingParticipant) - mock_client.fetch_meeting_participant.return_value = expected + api_client = _make_api_client() + expected = MagicMock(spec=MeetingParticipant) + api_client.meetings.get_participant.return_value = expected channel_data = _make_channel_data(tenant_id="t1", meeting_id="m1") - context = _make_context(channel_data, mock_client) + context = _make_context(channel_data, api_client) - result = await TeamsInfo.get_meeting_participant( - context, meeting_id="m1", participant_id="p1", tenant_id="t1" - ) + with patch(_PATCH_TARGET, return_value=api_client): + result = await TeamsInfo.get_meeting_participant( + context, meeting_id="m1", participant_id="p1", tenant_id="t1" + ) - mock_client.fetch_meeting_participant.assert_called_once_with("m1", "p1", "t1") + api_client.meetings.get_participant.assert_called_once_with("m1", "p1", "t1") assert result is expected @pytest.mark.asyncio async def test_extracts_params_from_activity(self): - mock_client = AsyncMock(spec=TeamsConnectorClient) - mock_client.fetch_meeting_participant.return_value = MagicMock() + api_client = _make_api_client() + api_client.meetings.get_participant.return_value = MagicMock() channel_data = _make_channel_data(tenant_id="t1", meeting_id="m1") - context = _make_context(channel_data, mock_client) + context = _make_context(channel_data, api_client) context.activity.from_property.aad_object_id = "aad-user" - await TeamsInfo.get_meeting_participant(context) + with patch(_PATCH_TARGET, return_value=api_client): + await TeamsInfo.get_meeting_participant(context) - mock_client.fetch_meeting_participant.assert_called_once_with( + api_client.meetings.get_participant.assert_called_once_with( "m1", "aad-user", "t1" ) @pytest.mark.asyncio async def test_raises_if_participant_id_missing(self): - mock_client = AsyncMock(spec=TeamsConnectorClient) + api_client = _make_api_client() channel_data = _make_channel_data(tenant_id="t1", meeting_id="m1") - context = _make_context(channel_data, mock_client) + context = _make_context(channel_data, api_client) context.activity.from_property.aad_object_id = None - with pytest.raises(ValueError, match="participant_id"): - await TeamsInfo.get_meeting_participant(context) + with patch(_PATCH_TARGET, return_value=api_client): + with pytest.raises(ValueError, match="participant_id"): + await TeamsInfo.get_meeting_participant(context) # --- get_meeting_info --- @@ -202,29 +194,31 @@ async def test_raises_if_participant_id_missing(self): class TestGetMeetingInfo: @pytest.mark.asyncio async def test_calls_client_with_explicit_meeting_id(self): - mock_client = AsyncMock(spec=TeamsConnectorClient) + api_client = _make_api_client() expected = MagicMock(spec=MeetingInfo) - mock_client.fetch_meeting_info.return_value = expected + api_client.meetings.get_by_id.return_value = expected channel_data = _make_channel_data(meeting_id="m1") - context = _make_context(channel_data, mock_client) + context = _make_context(channel_data, api_client) - result = await TeamsInfo.get_meeting_info(context, "m1") + with patch(_PATCH_TARGET, return_value=api_client): + result = await TeamsInfo.get_meeting_info(context, "m1") - mock_client.fetch_meeting_info.assert_called_once_with("m1") + api_client.meetings.get_by_id.assert_called_once_with("m1") assert result is expected @pytest.mark.asyncio async def test_falls_back_to_channel_data_meeting_id(self): - mock_client = AsyncMock(spec=TeamsConnectorClient) - mock_client.fetch_meeting_info.return_value = MagicMock() + api_client = _make_api_client() + api_client.meetings.get_by_id.return_value = MagicMock() channel_data = _make_channel_data(meeting_id="from-data") - context = _make_context(channel_data, mock_client) + context = _make_context(channel_data, api_client) - await TeamsInfo.get_meeting_info(context) + with patch(_PATCH_TARGET, return_value=api_client): + await TeamsInfo.get_meeting_info(context) - mock_client.fetch_meeting_info.assert_called_once_with("from-data") + api_client.meetings.get_by_id.assert_called_once_with("from-data") # --- get_team_details --- @@ -233,76 +227,31 @@ async def test_falls_back_to_channel_data_meeting_id(self): class TestGetTeamDetails: @pytest.mark.asyncio async def test_calls_client_with_explicit_team_id(self): - mock_client = AsyncMock(spec=TeamsConnectorClient) + api_client = _make_api_client() expected = MagicMock(spec=TeamDetails) - mock_client.fetch_team_details.return_value = expected + api_client.teams.get_by_id.return_value = expected channel_data = _make_channel_data(team_id="team1") - context = _make_context(channel_data, mock_client) + context = _make_context(channel_data, api_client) - result = await TeamsInfo.get_team_details(context, "team1") + with patch(_PATCH_TARGET, return_value=api_client): + result = await TeamsInfo.get_team_details(context, "team1") - mock_client.fetch_team_details.assert_called_once_with("team1") + api_client.teams.get_by_id.assert_called_once_with("team1") assert result is expected @pytest.mark.asyncio async def test_falls_back_to_channel_data_team_id(self): - mock_client = AsyncMock(spec=TeamsConnectorClient) - mock_client.fetch_team_details.return_value = MagicMock() + api_client = _make_api_client() + api_client.teams.get_by_id.return_value = MagicMock() channel_data = _make_channel_data(team_id="team-from-data") - context = _make_context(channel_data, mock_client) - - await TeamsInfo.get_team_details(context) - - mock_client.fetch_team_details.assert_called_once_with("team-from-data") - - -# --- send_message_to_teams_channel --- - - -class TestSendMessageToTeamsChannel: - @pytest.mark.asyncio - async def test_uses_connector_client_when_no_app_id(self): - mock_client = AsyncMock(spec=TeamsConnectorClient) - resource_response = MagicMock() - resource_response.id = "new-conv-id" - resource_response.activity_id = "new-act-id" - mock_client.conversations = AsyncMock() - mock_client.conversations.create_conversation = AsyncMock( - return_value=resource_response - ) - - channel_data = _make_channel_data() - context = _make_context(channel_data, mock_client) - activity = Activity(type=ActivityTypes.message, text="hello") - - conv_ref, act_id = await TeamsInfo.send_message_to_teams_channel( - context, activity, "channel-id" - ) - - mock_client.conversations.create_conversation.assert_called_once() - assert act_id == "new-act-id" + context = _make_context(channel_data, api_client) - @pytest.mark.asyncio - async def test_uses_adapter_when_app_id_and_channel_service_adapter(self): - mock_client = AsyncMock(spec=TeamsConnectorClient) - channel_data = _make_channel_data() - context = _make_context(channel_data, mock_client) - - mock_adapter = MagicMock() - mock_adapter.__class__ = ChannelServiceAdapter - mock_adapter.create_conversation = AsyncMock() - context.adapter = mock_adapter + with patch(_PATCH_TARGET, return_value=api_client): + await TeamsInfo.get_team_details(context) - activity = Activity(type=ActivityTypes.message, text="hello") - - await TeamsInfo.send_message_to_teams_channel( - context, activity, "channel-id", app_id="app-123" - ) - - mock_adapter.create_conversation.assert_called_once() - mock_client.conversations.create_conversation.assert_not_called() + api_client.teams.get_by_id.assert_called_once_with("team-from-data") # --- get_team_channels --- @@ -311,29 +260,31 @@ async def test_uses_adapter_when_app_id_and_channel_service_adapter(self): class TestGetTeamChannels: @pytest.mark.asyncio async def test_calls_client_with_team_id(self): - mock_client = AsyncMock(spec=TeamsConnectorClient) + api_client = _make_api_client() expected = [MagicMock(spec=ChannelInfo)] - mock_client.fetch_channel_list.return_value = expected + api_client.teams.get_conversations.return_value = expected channel_data = _make_channel_data(team_id="team1") - context = _make_context(channel_data, mock_client) + context = _make_context(channel_data, api_client) - result = await TeamsInfo.get_team_channels(context, "team1") + with patch(_PATCH_TARGET, return_value=api_client): + result = await TeamsInfo.get_team_channels(context, "team1") - mock_client.fetch_channel_list.assert_called_once_with("team1") + api_client.teams.get_conversations.assert_called_once_with("team1") assert result is expected @pytest.mark.asyncio async def test_falls_back_to_channel_data_team_id(self): - mock_client = AsyncMock(spec=TeamsConnectorClient) - mock_client.fetch_channel_list.return_value = [] + api_client = _make_api_client() + api_client.teams.get_conversations.return_value = [] channel_data = _make_channel_data(team_id="team-from-data") - context = _make_context(channel_data, mock_client) + context = _make_context(channel_data, api_client) - await TeamsInfo.get_team_channels(context) + with patch(_PATCH_TARGET, return_value=api_client): + await TeamsInfo.get_team_channels(context) - mock_client.fetch_channel_list.assert_called_once_with("team-from-data") + api_client.teams.get_conversations.assert_called_once_with("team-from-data") # --- get_paged_members --- @@ -341,64 +292,53 @@ async def test_falls_back_to_channel_data_team_id(self): class TestGetPagedMembers: @pytest.mark.asyncio - async def test_routes_to_team_paged_members_when_team_set(self): - mock_client = AsyncMock(spec=TeamsConnectorClient) - page_result = MagicMock(spec=TeamsPagedMembersResult) - page_result.members = [] - page_result.continuation_token = None - mock_client.get_conversation_paged_member.return_value = page_result + async def test_uses_team_id_when_present(self): + api_client = _make_api_client() + expected = MagicMock(spec=PagedMembersResult) + members_proxy = api_client.conversations.members.return_value + members_proxy.get_paged.return_value = expected channel_data = _make_channel_data(team_id="team1") - context = _make_context(channel_data, mock_client) - - result = await TeamsInfo.get_paged_members(context) - - mock_client.get_conversation_paged_member.assert_called_once_with( - "team1", 16, "" - ) - assert result is page_result + context = _make_context(channel_data, api_client) + with patch(_PATCH_TARGET, return_value=api_client): + result = await TeamsInfo.get_paged_members(context) -# --- get_paged_team_members --- - + api_client.conversations.members.assert_called_once_with("team1") + members_proxy.get_paged.assert_called_once_with(None, "") + assert result is expected -class TestGetPagedTeamMembers: @pytest.mark.asyncio - async def test_returns_single_page_result(self): - mock_client = AsyncMock(spec=TeamsConnectorClient) - page_result = MagicMock(spec=TeamsPagedMembersResult) - page_result.members = [MagicMock(), MagicMock()] - page_result.continuation_token = None - mock_client.get_conversation_paged_member.return_value = page_result + async def test_falls_back_to_conversation_id_when_no_team(self): + api_client = _make_api_client() + expected = MagicMock(spec=PagedMembersResult) + members_proxy = api_client.conversations.members.return_value + members_proxy.get_paged.return_value = expected - channel_data = _make_channel_data(team_id="team1") - context = _make_context(channel_data, mock_client) + channel_data = _make_channel_data() # no team_id + context = _make_context(channel_data, api_client) - result = await TeamsInfo.get_paged_team_members(context) + with patch(_PATCH_TARGET, return_value=api_client): + result = await TeamsInfo.get_paged_members(context) - mock_client.get_conversation_paged_member.assert_called_once() - assert result is page_result + api_client.conversations.members.assert_called_once_with("conv-id") + assert result is expected @pytest.mark.asyncio - async def test_aggregates_multiple_pages(self): - mock_client = AsyncMock(spec=TeamsConnectorClient) - page1 = MagicMock(spec=TeamsPagedMembersResult) - page1.members = [MagicMock()] - page1.continuation_token = "token1" - - page2 = MagicMock(spec=TeamsPagedMembersResult) - page2.members = [MagicMock()] - page2.continuation_token = None - - mock_client.get_conversation_paged_member.side_effect = [page1, page2] + async def test_passes_page_size_and_continuation_token(self): + api_client = _make_api_client() + members_proxy = api_client.conversations.members.return_value + members_proxy.get_paged.return_value = MagicMock(spec=PagedMembersResult) channel_data = _make_channel_data(team_id="team1") - context = _make_context(channel_data, mock_client) + context = _make_context(channel_data, api_client) - result = await TeamsInfo.get_paged_team_members(context) + with patch(_PATCH_TARGET, return_value=api_client): + await TeamsInfo.get_paged_members( + context, page_size=10, continuation_token="tok" + ) - assert mock_client.get_conversation_paged_member.call_count == 2 - assert len(result.members) == 2 + members_proxy.get_paged.assert_called_once_with(10, "tok") # --- get_team_member --- @@ -407,15 +347,18 @@ async def test_aggregates_multiple_pages(self): class TestGetTeamMember: @pytest.mark.asyncio async def test_calls_client_with_team_and_user_id(self): - mock_client = AsyncMock(spec=TeamsConnectorClient) + api_client = _make_api_client() expected = MagicMock(spec=TeamsChannelAccount) - mock_client.get_conversation_member.return_value = expected + members_proxy = api_client.conversations.members.return_value + members_proxy.get.return_value = expected - context = _make_context(mock_client=mock_client) + context = _make_context(api_client=api_client) - result = await TeamsInfo.get_team_member(context, "team1", "user1") + with patch(_PATCH_TARGET, return_value=api_client): + result = await TeamsInfo.get_team_member(context, "team1", "user1") - mock_client.get_conversation_member.assert_called_once_with("team1", "user1") + api_client.conversations.members.assert_called_once_with("team1") + members_proxy.get.assert_called_once_with("user1") assert result is expected @@ -425,202 +368,35 @@ async def test_calls_client_with_team_and_user_id(self): class TestGetMember: @pytest.mark.asyncio async def test_routes_to_get_team_member_when_team_id_set(self): - mock_client = AsyncMock(spec=TeamsConnectorClient) + api_client = _make_api_client() expected = MagicMock(spec=TeamsChannelAccount) - mock_client.get_conversation_member.return_value = expected + members_proxy = api_client.conversations.members.return_value + members_proxy.get.return_value = expected channel_data = _make_channel_data(team_id="team1") - context = _make_context(channel_data, mock_client) - - result = await TeamsInfo.get_member(context, "user1") - - mock_client.get_conversation_member.assert_called_once_with("team1", "user1") - assert result is expected - - -# --- send_meeting_notification --- - - -class TestSendMeetingNotification: - @pytest.mark.asyncio - async def test_calls_client_with_meeting_id(self): - mock_client = AsyncMock(spec=TeamsConnectorClient) - expected = MagicMock(spec=MeetingNotificationResponse) - mock_client.send_meeting_notification.return_value = expected - - channel_data = _make_channel_data(meeting_id="m1") - context = _make_context(channel_data, mock_client) - notification = MagicMock(spec=MeetingNotification) - - result = await TeamsInfo.send_meeting_notification(context, notification) - - mock_client.send_meeting_notification.assert_called_once_with( - "m1", notification - ) - assert result is expected - - -# --- send_message_to_list_of_users --- - - -class TestSendMessageToListOfUsers: - @pytest.mark.asyncio - async def test_raises_if_members_empty(self): - context = _make_context() - activity = MagicMock(spec=Activity) - with pytest.raises(ValueError, match="members"): - await TeamsInfo.send_message_to_list_of_users( - context, activity, "tenant1", [] - ) - - @pytest.mark.asyncio - async def test_calls_client_with_members(self): - mock_client = AsyncMock(spec=TeamsConnectorClient) - expected = MagicMock(spec=TeamsBatchOperationResponse) - mock_client.send_message_to_list_of_users.return_value = expected - - context = _make_context(mock_client=mock_client) - activity = MagicMock(spec=Activity) - members = [MagicMock(spec=TeamsMember)] - - result = await TeamsInfo.send_message_to_list_of_users( - context, activity, "tenant1", members - ) - - mock_client.send_message_to_list_of_users.assert_called_once_with( - activity, "tenant1", members - ) - assert result is expected - - -# --- send_message_to_all_users_in_tenant --- - - -class TestSendMessageToAllUsersInTenant: - @pytest.mark.asyncio - async def test_calls_client(self): - mock_client = AsyncMock(spec=TeamsConnectorClient) - expected = MagicMock(spec=TeamsBatchOperationResponse) - mock_client.send_message_to_all_users_in_tenant.return_value = expected - - context = _make_context(mock_client=mock_client) - activity = MagicMock(spec=Activity) - - result = await TeamsInfo.send_message_to_all_users_in_tenant( - context, activity, "tenant1" - ) - - mock_client.send_message_to_all_users_in_tenant.assert_called_once_with( - activity, "tenant1" - ) - assert result is expected - - -# --- send_message_to_all_users_in_team --- - - -class TestSendMessageToAllUsersInTeam: - @pytest.mark.asyncio - async def test_calls_client(self): - mock_client = AsyncMock(spec=TeamsConnectorClient) - expected = MagicMock(spec=TeamsBatchOperationResponse) - mock_client.send_message_to_all_users_in_team.return_value = expected - - context = _make_context(mock_client=mock_client) - activity = MagicMock(spec=Activity) - - result = await TeamsInfo.send_message_to_all_users_in_team( - context, activity, "tenant1", "team1" - ) - - mock_client.send_message_to_all_users_in_team.assert_called_once_with( - activity, "tenant1", "team1" - ) - assert result is expected + context = _make_context(channel_data, api_client) + with patch(_PATCH_TARGET, return_value=api_client): + result = await TeamsInfo.get_member(context, "user1") -# --- send_message_to_list_of_channels --- - - -class TestSendMessageToListOfChannels: - @pytest.mark.asyncio - async def test_raises_if_members_empty(self): - context = _make_context() - activity = MagicMock(spec=Activity) - with pytest.raises(ValueError, match="members"): - await TeamsInfo.send_message_to_list_of_channels( - context, activity, "tenant1", [] - ) - - @pytest.mark.asyncio - async def test_calls_client_with_members(self): - mock_client = AsyncMock(spec=TeamsConnectorClient) - expected = MagicMock(spec=TeamsBatchOperationResponse) - mock_client.send_message_to_list_of_channels.return_value = expected - - context = _make_context(mock_client=mock_client) - activity = MagicMock(spec=Activity) - members = [MagicMock(spec=TeamsMember)] - - result = await TeamsInfo.send_message_to_list_of_channels( - context, activity, "tenant1", members - ) - - mock_client.send_message_to_list_of_channels.assert_called_once_with( - activity, "tenant1", members - ) + api_client.conversations.members.assert_called_once_with("team1") + members_proxy.get.assert_called_once_with("user1") assert result is expected - -# --- get_operation_state --- - - -class TestGetOperationState: @pytest.mark.asyncio - async def test_calls_client_with_operation_id(self): - mock_client = AsyncMock(spec=TeamsConnectorClient) - expected = MagicMock(spec=BatchOperationStateResponse) - mock_client.get_operation_state.return_value = expected - - context = _make_context(mock_client=mock_client) - - result = await TeamsInfo.get_operation_state(context, "op1") - - mock_client.get_operation_state.assert_called_once_with("op1") - assert result is expected - - -# --- get_failed_entries --- - - -class TestGetFailedEntries: - @pytest.mark.asyncio - async def test_calls_client_with_operation_id(self): - mock_client = AsyncMock(spec=TeamsConnectorClient) - expected = MagicMock(spec=BatchFailedEntriesResponse) - mock_client.get_failed_entries.return_value = expected - - context = _make_context(mock_client=mock_client) - - result = await TeamsInfo.get_failed_entries(context, "op1") - - mock_client.get_failed_entries.assert_called_once_with("op1") - assert result is expected - - -# --- cancel_operation --- - - -class TestCancelOperation: - @pytest.mark.asyncio - async def test_calls_client_with_operation_id(self): - mock_client = AsyncMock(spec=TeamsConnectorClient) - expected = MagicMock(spec=CancelOperationResponse) - mock_client.cancel_operation.return_value = expected + async def test_routes_to_conversation_when_no_team_id(self): + api_client = _make_api_client() + expected = MagicMock(spec=TeamsChannelAccount) + members_proxy = api_client.conversations.members.return_value + members_proxy.get.return_value = expected - context = _make_context(mock_client=mock_client) + channel_data = _make_channel_data() # no team_id + context = _make_context(channel_data, api_client) + context.activity.conversation.id = "conv-id" - result = await TeamsInfo.cancel_operation(context, "op1") + with patch(_PATCH_TARGET, return_value=api_client): + result = await TeamsInfo.get_member(context, "user1") - mock_client.cancel_operation.assert_called_once_with("op1") + api_client.conversations.members.assert_called_once_with("conv-id") + members_proxy.get.assert_called_once_with("user1") assert result is expected From 97b8aeda62f35d0a37d4a42f2ff6108ad2d15c46 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Mon, 22 Jun 2026 14:44:14 -0700 Subject: [PATCH 19/46] Authorization.connection_manager --- .../microsoft_agents/hosting/core/app/agent_application.py | 7 ++++++- .../microsoft_agents/hosting/core/app/app_options.py | 3 ++- .../hosting/core/app/oauth/authorization.py | 4 ++++ .../microsoft_agents/hosting/teams/teams_turn_context.py | 2 +- 4 files changed, 13 insertions(+), 3 deletions(-) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/agent_application.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/agent_application.py index 21b1dca54..9d48c7444 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/agent_application.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/agent_application.py @@ -73,7 +73,7 @@ class AgentApplication(Agent, Generic[StateT]): _options: ApplicationOptions _adapter: Optional[ChannelServiceAdapter] = None - _auth: Optional[Authorization] = None + _auth: Authorization _proactive: Optional[Proactive] = None _internal_before_turn: list[Callable[[TurnContext, StateT], Awaitable[bool]]] = [] _internal_after_turn: list[Callable[[TurnContext, StateT], Awaitable[bool]]] = [] @@ -163,6 +163,11 @@ def __init__( if authorization: self._auth = authorization else: + if not connection_manager: + raise ApplicationError(""" + connection_manager is required to initialize the Authorization + """) + auth_options = { key: value for key, value in configuration.items() diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/app_options.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/app_options.py index 27a2013f7..87dd7d5c8 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/app_options.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/app_options.py @@ -10,6 +10,7 @@ from typing import Callable, Optional from microsoft_agents.hosting.core.app.oauth import AuthHandler +from microsoft_agents.hosting.core.authorization import Connections from microsoft_agents.hosting.core.storage import Storage # from .auth import AuthOptions @@ -104,4 +105,4 @@ class ApplicationOptions: Optional. Options for the proactive messaging subsystem. When set, :attr:`microsoft_agents.hosting.core.AgentApplication.proactive` is available for storing conversations and initiating proactive turns. - """ + """ \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/authorization.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/authorization.py index a69f74e3a..030db4296 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/authorization.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/authorization.py @@ -125,6 +125,10 @@ def _init_handlers(self) -> None: connection_manager=self._connection_manager, auth_handler=auth_handler, ) + + @property + def connection_manager(self) -> Connections: + return self._connection_manager @staticmethod def _sign_in_state_key(context: TurnContext) -> str: diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/teams_turn_context.py b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/teams_turn_context.py index bb57fb2ca..818e8469a 100644 --- a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/teams_turn_context.py +++ b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/teams_turn_context.py @@ -46,7 +46,7 @@ async def create( :return: A new Teams turn context. """ instance = TeamsTurnContext(context, app) - await get_teams_api_client(context, app.auth._connection_manager) + await get_teams_api_client(context, app.auth.connection_manager) return instance @property From 19817977f3fb245b52d32f355d727f7d44b6fd77 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Tue, 23 Jun 2026 10:14:09 -0700 Subject: [PATCH 20/46] renaming teams to msteams --- .../hosting/core/app/oauth/authorization.py | 14 +- .../LICENSE | 0 .../MANIFEST.in | 0 .../hosting/msteams}/__init__.py | 0 .../hosting/msteams}/_teams_api_client.py | 0 .../hosting/msteams}/_utils.py | 0 .../hosting/msteams}/channel/__init__.py | 0 .../hosting/msteams}/channel/channel.py | 0 .../msteams}/channel/route_handlers.py | 0 .../hosting/msteams}/config/__init__.py | 0 .../hosting/msteams}/config/config.py | 0 .../hosting/msteams}/config/route_handlers.py | 0 .../hosting/msteams}/errors/__init__.py | 0 .../msteams}/errors/error_resources.py | 0 .../hosting/msteams}/file_consent/__init__.py | 0 .../msteams}/file_consent/file_consent.py | 0 .../msteams}/file_consent/route_handlers.py | 0 .../hosting/msteams}/meeting/__init__.py | 0 .../hosting/msteams}/meeting/meeting.py | 0 .../msteams}/meeting/route_handlers.py | 0 .../hosting/msteams}/message/__init__.py | 0 .../hosting/msteams}/message/message.py | 0 .../msteams}/message/route_handlers.py | 0 .../msteams}/message_extension/__init__.py | 0 .../message_extension/message_extension.py | 0 .../message_extension/route_handlers.py | 0 .../hosting/msteams}/route_handlers.py | 22 +- .../hosting/msteams}/task_module/__init__.py | 0 .../msteams}/task_module/route_handlers.py | 0 .../msteams}/task_module/task_module.py | 0 .../hosting/msteams}/team/__init__.py | 0 .../hosting/msteams}/team/route_handlers.py | 0 .../hosting/msteams}/team/team.py | 0 .../hosting/msteams/teams_activity.py | 5 + .../hosting/msteams}/teams_agent_extension.py | 6 + .../hosting/msteams}/teams_info.py | 0 .../hosting/msteams}/teams_turn_context.py | 0 .../hosting/msteams}/type_defs.py | 0 .../pyproject.toml | 0 .../readme.md | 0 .../setup.py | 0 .../hosting/teams/_models/__init__.py | 0 .../hosting/teams/teams_activity_handler.py | 911 ------------------ 43 files changed, 29 insertions(+), 929 deletions(-) rename libraries/{microsoft-agents-hosting-teams => microsoft-agents-hosting-msteams}/LICENSE (100%) rename libraries/{microsoft-agents-hosting-teams => microsoft-agents-hosting-msteams}/MANIFEST.in (100%) rename libraries/{microsoft-agents-hosting-teams/microsoft_agents/hosting/teams => microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams}/__init__.py (100%) rename libraries/{microsoft-agents-hosting-teams/microsoft_agents/hosting/teams => microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams}/_teams_api_client.py (100%) rename libraries/{microsoft-agents-hosting-teams/microsoft_agents/hosting/teams => microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams}/_utils.py (100%) rename libraries/{microsoft-agents-hosting-teams/microsoft_agents/hosting/teams => microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams}/channel/__init__.py (100%) rename libraries/{microsoft-agents-hosting-teams/microsoft_agents/hosting/teams => microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams}/channel/channel.py (100%) rename libraries/{microsoft-agents-hosting-teams/microsoft_agents/hosting/teams => microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams}/channel/route_handlers.py (100%) rename libraries/{microsoft-agents-hosting-teams/microsoft_agents/hosting/teams => microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams}/config/__init__.py (100%) rename libraries/{microsoft-agents-hosting-teams/microsoft_agents/hosting/teams => microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams}/config/config.py (100%) rename libraries/{microsoft-agents-hosting-teams/microsoft_agents/hosting/teams => microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams}/config/route_handlers.py (100%) rename libraries/{microsoft-agents-hosting-teams/microsoft_agents/hosting/teams => microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams}/errors/__init__.py (100%) rename libraries/{microsoft-agents-hosting-teams/microsoft_agents/hosting/teams => microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams}/errors/error_resources.py (100%) rename libraries/{microsoft-agents-hosting-teams/microsoft_agents/hosting/teams => microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams}/file_consent/__init__.py (100%) rename libraries/{microsoft-agents-hosting-teams/microsoft_agents/hosting/teams => microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams}/file_consent/file_consent.py (100%) rename libraries/{microsoft-agents-hosting-teams/microsoft_agents/hosting/teams => microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams}/file_consent/route_handlers.py (100%) rename libraries/{microsoft-agents-hosting-teams/microsoft_agents/hosting/teams => microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams}/meeting/__init__.py (100%) rename libraries/{microsoft-agents-hosting-teams/microsoft_agents/hosting/teams => microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams}/meeting/meeting.py (100%) rename libraries/{microsoft-agents-hosting-teams/microsoft_agents/hosting/teams => microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams}/meeting/route_handlers.py (100%) rename libraries/{microsoft-agents-hosting-teams/microsoft_agents/hosting/teams => microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams}/message/__init__.py (100%) rename libraries/{microsoft-agents-hosting-teams/microsoft_agents/hosting/teams => microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams}/message/message.py (100%) rename libraries/{microsoft-agents-hosting-teams/microsoft_agents/hosting/teams => microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams}/message/route_handlers.py (100%) rename libraries/{microsoft-agents-hosting-teams/microsoft_agents/hosting/teams => microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams}/message_extension/__init__.py (100%) rename libraries/{microsoft-agents-hosting-teams/microsoft_agents/hosting/teams => microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams}/message_extension/message_extension.py (100%) rename libraries/{microsoft-agents-hosting-teams/microsoft_agents/hosting/teams => microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams}/message_extension/route_handlers.py (100%) rename libraries/{microsoft-agents-hosting-teams/microsoft_agents/hosting/teams => microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams}/route_handlers.py (75%) rename libraries/{microsoft-agents-hosting-teams/microsoft_agents/hosting/teams => microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams}/task_module/__init__.py (100%) rename libraries/{microsoft-agents-hosting-teams/microsoft_agents/hosting/teams => microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams}/task_module/route_handlers.py (100%) rename libraries/{microsoft-agents-hosting-teams/microsoft_agents/hosting/teams => microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams}/task_module/task_module.py (100%) rename libraries/{microsoft-agents-hosting-teams/microsoft_agents/hosting/teams => microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams}/team/__init__.py (100%) rename libraries/{microsoft-agents-hosting-teams/microsoft_agents/hosting/teams => microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams}/team/route_handlers.py (100%) rename libraries/{microsoft-agents-hosting-teams/microsoft_agents/hosting/teams => microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams}/team/team.py (100%) create mode 100644 libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_activity.py rename libraries/{microsoft-agents-hosting-teams/microsoft_agents/hosting/teams => microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams}/teams_agent_extension.py (98%) rename libraries/{microsoft-agents-hosting-teams/microsoft_agents/hosting/teams => microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams}/teams_info.py (100%) rename libraries/{microsoft-agents-hosting-teams/microsoft_agents/hosting/teams => microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams}/teams_turn_context.py (100%) rename libraries/{microsoft-agents-hosting-teams/microsoft_agents/hosting/teams => microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams}/type_defs.py (100%) rename libraries/{microsoft-agents-hosting-teams => microsoft-agents-hosting-msteams}/pyproject.toml (100%) rename libraries/{microsoft-agents-hosting-teams => microsoft-agents-hosting-msteams}/readme.md (100%) rename libraries/{microsoft-agents-hosting-teams => microsoft-agents-hosting-msteams}/setup.py (100%) delete mode 100644 libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/_models/__init__.py delete mode 100644 libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/teams_activity_handler.py diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/authorization.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/authorization.py index 030db4296..6a8e23870 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/authorization.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/authorization.py @@ -39,7 +39,7 @@ class Authorization: """Class responsible for managing authorization flows.""" _storage: Storage - _connection_manager: Connections + _connections: Connections _handlers: dict[str, _AuthorizationHandler] def __init__( @@ -59,8 +59,8 @@ def __init__( :param storage: The storage system to use for state management. :type storage: :class:`microsoft_agents.hosting.core.storage.Storage` - :param connection_manager: The connection manager for OAuth providers. - :type connection_manager: :class:`microsoft_agents.hosting.core.authorization.Connections` + :param connections: The connection manager for OAuth providers. + :type connections: :class:`microsoft_agents.hosting.core.authorization.Connections` :param auth_handlers: Configuration for OAuth providers. :type auth_handlers: dict[str, :class:`microsoft_agents.hosting.core.app.oauth.auth_handler.AuthHandler`], Optional :raises ValueError: When storage is None or no auth handlers provided. @@ -69,7 +69,7 @@ def __init__( raise ValueError("Storage is required for Authorization") self._storage = storage - self._connection_manager = connection_manager + self._connections = connection_manager self._sign_in_success_handler: Optional[ Callable[[TurnContext, TurnState, Optional[str]], Awaitable[None]] @@ -122,13 +122,13 @@ def _init_handlers(self) -> None: self._handlers[name] = AUTHORIZATION_TYPE_MAP[auth_type]( storage=self._storage, - connection_manager=self._connection_manager, + connections=self._connections, auth_handler=auth_handler, ) @property - def connection_manager(self) -> Connections: - return self._connection_manager + def connections(self) -> Connections: + return self._connections @staticmethod def _sign_in_state_key(context: TurnContext) -> str: diff --git a/libraries/microsoft-agents-hosting-teams/LICENSE b/libraries/microsoft-agents-hosting-msteams/LICENSE similarity index 100% rename from libraries/microsoft-agents-hosting-teams/LICENSE rename to libraries/microsoft-agents-hosting-msteams/LICENSE diff --git a/libraries/microsoft-agents-hosting-teams/MANIFEST.in b/libraries/microsoft-agents-hosting-msteams/MANIFEST.in similarity index 100% rename from libraries/microsoft-agents-hosting-teams/MANIFEST.in rename to libraries/microsoft-agents-hosting-msteams/MANIFEST.in diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/__init__.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/__init__.py similarity index 100% rename from libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/__init__.py rename to libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/__init__.py diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/_teams_api_client.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/_teams_api_client.py similarity index 100% rename from libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/_teams_api_client.py rename to libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/_teams_api_client.py diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/_utils.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/_utils.py similarity index 100% rename from libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/_utils.py rename to libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/_utils.py diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/channel/__init__.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/channel/__init__.py similarity index 100% rename from libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/channel/__init__.py rename to libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/channel/__init__.py diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/channel/channel.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/channel/channel.py similarity index 100% rename from libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/channel/channel.py rename to libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/channel/channel.py diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/channel/route_handlers.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/channel/route_handlers.py similarity index 100% rename from libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/channel/route_handlers.py rename to libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/channel/route_handlers.py diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/config/__init__.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/config/__init__.py similarity index 100% rename from libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/config/__init__.py rename to libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/config/__init__.py diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/config/config.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/config/config.py similarity index 100% rename from libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/config/config.py rename to libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/config/config.py diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/config/route_handlers.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/config/route_handlers.py similarity index 100% rename from libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/config/route_handlers.py rename to libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/config/route_handlers.py diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/errors/__init__.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/errors/__init__.py similarity index 100% rename from libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/errors/__init__.py rename to libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/errors/__init__.py diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/errors/error_resources.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/errors/error_resources.py similarity index 100% rename from libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/errors/error_resources.py rename to libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/errors/error_resources.py diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/file_consent/__init__.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/file_consent/__init__.py similarity index 100% rename from libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/file_consent/__init__.py rename to libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/file_consent/__init__.py diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/file_consent/file_consent.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/file_consent/file_consent.py similarity index 100% rename from libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/file_consent/file_consent.py rename to libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/file_consent/file_consent.py diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/file_consent/route_handlers.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/file_consent/route_handlers.py similarity index 100% rename from libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/file_consent/route_handlers.py rename to libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/file_consent/route_handlers.py diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/meeting/__init__.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/meeting/__init__.py similarity index 100% rename from libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/meeting/__init__.py rename to libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/meeting/__init__.py diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/meeting/meeting.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/meeting/meeting.py similarity index 100% rename from libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/meeting/meeting.py rename to libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/meeting/meeting.py diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/meeting/route_handlers.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/meeting/route_handlers.py similarity index 100% rename from libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/meeting/route_handlers.py rename to libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/meeting/route_handlers.py diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/message/__init__.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/message/__init__.py similarity index 100% rename from libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/message/__init__.py rename to libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/message/__init__.py diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/message/message.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/message/message.py similarity index 100% rename from libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/message/message.py rename to libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/message/message.py diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/message/route_handlers.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/message/route_handlers.py similarity index 100% rename from libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/message/route_handlers.py rename to libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/message/route_handlers.py diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/message_extension/__init__.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/message_extension/__init__.py similarity index 100% rename from libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/message_extension/__init__.py rename to libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/message_extension/__init__.py diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/message_extension/message_extension.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/message_extension/message_extension.py similarity index 100% rename from libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/message_extension/message_extension.py rename to libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/message_extension/message_extension.py diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/message_extension/route_handlers.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/message_extension/route_handlers.py similarity index 100% rename from libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/message_extension/route_handlers.py rename to libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/message_extension/route_handlers.py diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/route_handlers.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/route_handlers.py similarity index 75% rename from libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/route_handlers.py rename to libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/route_handlers.py index fbbc2667e..14f81f7b3 100644 --- a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/route_handlers.py +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/route_handlers.py @@ -17,13 +17,13 @@ ) from .teams_turn_context import TeamsTurnContext -from .type_defs import StateT +from .type_defs import _StateContra -class TeamsRouteHandler(Protocol[StateT]): +class TeamsRouteHandler(Protocol[_StateContra]): """Protocol for a Teams route handler that receives a :class:`TeamsTurnContext`.""" - def __call__(self, context: TeamsTurnContext, state: StateT, /) -> Awaitable[None]: + def __call__(self, context: TeamsTurnContext, state: _StateContra, /) -> Awaitable[None]: """Handle a turn with Teams context. :param context: Teams-aware turn context. @@ -33,8 +33,8 @@ def __call__(self, context: TeamsTurnContext, state: StateT, /) -> Awaitable[Non def wrap_teams_route_handler( - handler: TeamsRouteHandler[StateT], app: AgentApplication -) -> RouteHandler[StateT]: + handler: TeamsRouteHandler[_StateContra], app: AgentApplication +) -> RouteHandler[_StateContra]: """Adapt a :class:`TeamsRouteHandler` into a plain :class:`RouteHandler`. Wraps *handler* so that the core routing engine (which passes a plain @@ -45,18 +45,18 @@ def wrap_teams_route_handler( :return: A :class:`RouteHandler` that upgrades the context before delegating. """ - async def __func(context: TurnContext, state: StateT) -> None: + async def __func(context: TurnContext, state: _StateContra) -> None: teams_context = TeamsTurnContext(context, app) await handler(teams_context, state) return __func -class TeamsHandoffHandler(Protocol[StateT]): +class TeamsHandoffHandler(Protocol[_StateContra]): """Protocol for a Teams handoff handler that receives handoff continuation data.""" def __call__( - self, context: TeamsTurnContext, state: StateT, handoff_data: str, / + self, context: TeamsTurnContext, state: _StateContra, handoff_data: str, / ) -> Awaitable[None]: """Handle a handoff activity with Teams context. @@ -68,8 +68,8 @@ def __call__( def wrap_teams_handoff_handler( - handler: TeamsHandoffHandler[StateT], app: AgentApplication -) -> HandoffHandler[StateT]: + handler: TeamsHandoffHandler[_StateContra], app: AgentApplication +) -> HandoffHandler[_StateContra]: """Adapt a :class:`TeamsHandoffHandler` into a plain :class:`HandoffHandler`. :param handler: The Teams-specific handoff handler to wrap. @@ -77,7 +77,7 @@ def wrap_teams_handoff_handler( :return: A :class:`HandoffHandler` that upgrades the context before delegating. """ - async def __func(context: TurnContext, state: StateT, handoff_data: str) -> None: + async def __func(context: TurnContext, state: _StateContra, handoff_data: str) -> None: teams_context = TeamsTurnContext(context, app) await handler(teams_context, state, handoff_data) diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/task_module/__init__.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/task_module/__init__.py similarity index 100% rename from libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/task_module/__init__.py rename to libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/task_module/__init__.py diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/task_module/route_handlers.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/task_module/route_handlers.py similarity index 100% rename from libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/task_module/route_handlers.py rename to libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/task_module/route_handlers.py diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/task_module/task_module.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/task_module/task_module.py similarity index 100% rename from libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/task_module/task_module.py rename to libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/task_module/task_module.py diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/team/__init__.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/team/__init__.py similarity index 100% rename from libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/team/__init__.py rename to libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/team/__init__.py diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/team/route_handlers.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/team/route_handlers.py similarity index 100% rename from libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/team/route_handlers.py rename to libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/team/route_handlers.py diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/team/team.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/team/team.py similarity index 100% rename from libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/team/team.py rename to libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/team/team.py diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_activity.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_activity.py new file mode 100644 index 000000000..9b7a5745d --- /dev/null +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_activity.py @@ -0,0 +1,5 @@ +from microsoft_agents.activity import Activity + +class TeamsActivity(Activity): + + pass \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/teams_agent_extension.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_agent_extension.py similarity index 98% rename from libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/teams_agent_extension.py rename to libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_agent_extension.py index 180ac3f61..214809951 100644 --- a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/teams_agent_extension.py +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_agent_extension.py @@ -93,6 +93,12 @@ def __init__(self, app: AgentApplication[StateT]) -> None: self._task_module: TaskModule[StateT] = TaskModule(app) self._team: Team[StateT] = Team(app) + self._configure_app() + + def _configure_app(self): + """Configure the underlying AgentApplication with Teams-specific routes.""" + self._app. + @property def channels(self) -> Channel[StateT]: """Route registration for Channel events.""" diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/teams_info.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_info.py similarity index 100% rename from libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/teams_info.py rename to libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_info.py diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/teams_turn_context.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_turn_context.py similarity index 100% rename from libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/teams_turn_context.py rename to libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_turn_context.py diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/type_defs.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/type_defs.py similarity index 100% rename from libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/type_defs.py rename to libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/type_defs.py diff --git a/libraries/microsoft-agents-hosting-teams/pyproject.toml b/libraries/microsoft-agents-hosting-msteams/pyproject.toml similarity index 100% rename from libraries/microsoft-agents-hosting-teams/pyproject.toml rename to libraries/microsoft-agents-hosting-msteams/pyproject.toml diff --git a/libraries/microsoft-agents-hosting-teams/readme.md b/libraries/microsoft-agents-hosting-msteams/readme.md similarity index 100% rename from libraries/microsoft-agents-hosting-teams/readme.md rename to libraries/microsoft-agents-hosting-msteams/readme.md diff --git a/libraries/microsoft-agents-hosting-teams/setup.py b/libraries/microsoft-agents-hosting-msteams/setup.py similarity index 100% rename from libraries/microsoft-agents-hosting-teams/setup.py rename to libraries/microsoft-agents-hosting-msteams/setup.py diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/_models/__init__.py b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/_models/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/teams_activity_handler.py b/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/teams_activity_handler.py deleted file mode 100644 index dd636f167..000000000 --- a/libraries/microsoft-agents-hosting-teams/microsoft_agents/hosting/teams/teams_activity_handler.py +++ /dev/null @@ -1,911 +0,0 @@ -""" -Copyright (c) Microsoft Corporation. All rights reserved. -Licensed under the MIT License. -""" - -from http import HTTPStatus -from typing import Any - -from microsoft_agents.hosting.core import ActivityHandler, TurnContext -from microsoft_agents.hosting.teams.errors import teams_errors -from microsoft_agents.activity import ( - InvokeResponse, - ChannelAccount, -) - -from microsoft_agents.activity.teams import ( - AppBasedLinkQuery, - TeamInfo, - ChannelInfo, - ConfigResponse, - FileConsentCardResponse, - MeetingEndEventDetails, - MeetingParticipantsEventDetails, - MeetingStartEventDetails, - MessagingExtensionAction, - MessagingExtensionActionResponse, - MessagingExtensionQuery, - MessagingExtensionResponse, - O365ConnectorCardActionQuery, - ReadReceiptInfo, - SigninStateVerificationQuery, - TabRequest, - TabResponse, - TabSubmit, - TaskModuleRequest, - TaskModuleResponse, - TeamsChannelAccount, - TeamsChannelData, -) - -from .teams_info import TeamsInfo - - -class TeamsActivityHandler(ActivityHandler): - """ - The TeamsActivityHandler is derived from the ActivityHandler class and adds support for - Microsoft Teams-specific functionality. - """ - - async def on_invoke_activity(self, turn_context: TurnContext) -> InvokeResponse: - """ - Handles invoke activities. - - :param turn_context: The context object for the turn. - :return: An InvokeResponse. - """ - - try: - if ( - not turn_context.activity.name - and turn_context.activity.channel_id == "msteams" - ): - return await self.on_teams_card_action_invoke(turn_context) - else: - name = turn_context.activity.name - value = turn_context.activity.value - - if name == "config/fetch": - return self._create_invoke_response( - await self.on_teams_config_fetch(turn_context, value) - ) - elif name == "config/submit": - return self._create_invoke_response( - await self.on_teams_config_submit(turn_context, value) - ) - elif name == "fileConsent/invoke": - return self._create_invoke_response( - await self.on_teams_file_consent(turn_context, value) - ) - elif name == "actionableMessage/executeAction": - await self.on_execute_action(turn_context, value) - return self._create_invoke_response() - elif name == "composeExtension/queryLink": - return self._create_invoke_response( - await self.on_teams_app_based_link_query(turn_context, value) - ) - elif name == "composeExtension/anonymousQueryLink": - return self._create_invoke_response( - await self.on_teams_anonymous_app_based_link_query( - turn_context, value - ) - ) - elif name == "composeExtension/query": - query = MessagingExtensionQuery.model_validate(value) - return self._create_invoke_response( - await self.on_teams_messaging_extension_query( - turn_context, query - ) - ) - elif name == "composeExtension/selectItem": - return self._create_invoke_response( - await self.on_teams_messaging_extension_select_item( - turn_context, value - ) - ) - elif name == "composeExtension/submitAction": - return self._create_invoke_response( - await self.on_teams_messaging_extension_submit_action_dispatch( - turn_context, value - ) - ) - elif name == "composeExtension/fetchTask": - return self._create_invoke_response( - await self.on_teams_messaging_extension_fetch_task( - turn_context, value - ) - ) - elif name == "composeExtension/querySettingUrl": - return self._create_invoke_response( - await self.on_teams_messaging_extension_config_query_setting_url( - turn_context, value - ) - ) - elif name == "composeExtension/setting": - await self.on_teams_messaging_extension_config_setting( - turn_context, value - ) - return self._create_invoke_response() - elif name == "composeExtension/onCardButtonClicked": - await self.on_teams_messaging_extension_card_button_clicked( - turn_context, value - ) - return self._create_invoke_response() - elif name == "task/fetch": - task_module_request = TaskModuleRequest.model_validate(value) - return self._create_invoke_response( - await self.on_teams_task_module_fetch( - turn_context, task_module_request - ) - ) - elif name == "task/submit": - task_module_request = TaskModuleRequest.model_validate(value) - return self._create_invoke_response( - await self.on_teams_task_module_submit( - turn_context, task_module_request - ) - ) - elif name == "tab/fetch": - return self._create_invoke_response( - await self.on_teams_tab_fetch(turn_context, value) - ) - elif name == "tab/submit": - return self._create_invoke_response( - await self.on_teams_tab_submit(turn_context, value) - ) - else: - return await super().on_invoke_activity(turn_context) - except Exception as err: - if str(err) == str(teams_errors.TeamsNotImplemented): - return InvokeResponse(status=int(HTTPStatus.NOT_IMPLEMENTED)) - elif str(err) == str(teams_errors.TeamsBadRequest): - return InvokeResponse(status=int(HTTPStatus.BAD_REQUEST)) - raise - - async def on_teams_card_action_invoke( - self, turn_context: TurnContext - ) -> InvokeResponse: - """ - Handles card action invoke. - - :param turn_context: The context object for the turn. - :return: An InvokeResponse. - """ - raise NotImplementedError(str(teams_errors.TeamsNotImplemented)) - - async def on_teams_config_fetch( - self, turn_context: TurnContext, config_data: Any - ) -> ConfigResponse: - """ - Handles config fetch. - - :param turn_context: The context object for the turn. - :param config_data: The config data. - :return: A ConfigResponse. - """ - raise NotImplementedError(str(teams_errors.TeamsNotImplemented)) - - async def on_teams_config_submit( - self, turn_context: TurnContext, config_data: Any - ) -> ConfigResponse: - """ - Handles config submit. - - :param turn_context: The context object for the turn. - :param config_data: The config data. - :return: A ConfigResponse. - """ - raise NotImplementedError(str(teams_errors.TeamsNotImplemented)) - - async def on_teams_file_consent( - self, - turn_context: TurnContext, - file_consent_card_response: FileConsentCardResponse, - ) -> None: - """ - Handles file consent. - - :param turn_context: The context object for the turn. - :param file_consent_card_response: The file consent card response. - :return: None - """ - if file_consent_card_response.action == "accept": - return await self.on_teams_file_consent_accept( - turn_context, file_consent_card_response - ) - elif file_consent_card_response.action == "decline": - return await self.on_teams_file_consent_decline( - turn_context, file_consent_card_response - ) - else: - raise ValueError(str(teams_errors.TeamsBadRequest)) - - async def on_teams_file_consent_accept( - self, - turn_context: TurnContext, - file_consent_card_response: FileConsentCardResponse, - ) -> None: - """ - Handles file consent accept. - - :param turn_context: The context object for the turn. - :param file_consent_card_response: The file consent card response. - :return: None - """ - raise NotImplementedError(str(teams_errors.TeamsNotImplemented)) - - async def on_teams_file_consent_decline( - self, - turn_context: TurnContext, - file_consent_card_response: FileConsentCardResponse, - ) -> None: - """ - Handles file consent decline. - - :param turn_context: The context object for the turn. - :param file_consent_card_response: The file consent card response. - :return: None - """ - raise NotImplementedError(str(teams_errors.TeamsNotImplemented)) - - async def on_execute_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_config_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_config_setting( - self, turn_context: TurnContext, settings: Any - ) -> None: - """ - Handles messaging extension configuration setting. - - :param turn_context: The context object for the turn. - :param settings: The settings. - :return: None - """ - raise NotImplementedError(str(teams_errors.TeamsNotImplemented)) - - async def on_teams_messaging_extension_card_button_clicked( - self, turn_context: TurnContext, card_data: Any - ) -> None: - """ - Handles messaging extension card button clicked. - - :param turn_context: The context object for the turn. - :param card_data: The card data. - :return: None - """ - raise NotImplementedError(str(teams_errors.TeamsNotImplemented)) - - async def on_teams_task_module_fetch( - self, turn_context: TurnContext, task_module_request: TaskModuleRequest - ) -> TaskModuleResponse: - """ - Handles task module fetch. - - :param turn_context: The context object for the turn. - :param task_module_request: The task module request. - :return: A TaskModuleResponse. - """ - raise NotImplementedError(str(teams_errors.TeamsNotImplemented)) - - async def on_teams_task_module_submit( - self, turn_context: TurnContext, task_module_request: TaskModuleRequest - ) -> TaskModuleResponse: - """ - Handles task module submit. - - :param turn_context: The context object for the turn. - :param task_module_request: The task module request. - :return: A TaskModuleResponse. - """ - raise NotImplementedError(str(teams_errors.TeamsNotImplemented)) - - async def on_teams_tab_fetch( - self, turn_context: TurnContext, tab_request: TabRequest - ) -> TabResponse: - """ - Handles tab fetch. - - :param turn_context: The context object for the turn. - :param tab_request: The tab request. - :return: A TabResponse. - """ - raise NotImplementedError(str(teams_errors.TeamsNotImplemented)) - - async def on_teams_tab_submit( - self, turn_context: TurnContext, tab_submit: TabSubmit - ) -> TabResponse: - """ - Handles tab submit. - - :param turn_context: The context object for the turn. - :param tab_submit: The tab submit. - :return: A TabResponse. - """ - raise NotImplementedError(str(teams_errors.TeamsNotImplemented)) - - async def on_conversation_update_activity(self, turn_context: TurnContext): - """ - Dispatches conversation update activity. - - :param turn_context: The context object for the turn. - :return: None - """ - if turn_context.activity.channel_id == "msteams": - channel_data = ( - TeamsChannelData.model_validate(turn_context.activity.channel_data) - if turn_context.activity.channel_data - else None - ) - - if ( - turn_context.activity.members_added - and len(turn_context.activity.members_added) > 0 - ): - return await self.on_teams_members_added_dispatch( - turn_context.activity.members_added, - channel_data.team if channel_data else None, - turn_context, - ) - - if ( - turn_context.activity.members_removed - and len(turn_context.activity.members_removed) > 0 - ): - return await self.on_teams_members_removed(turn_context) - - if not channel_data or not channel_data.event_type: - return await super().on_conversation_update_activity(turn_context) - - event_type = channel_data.event_type - - if event_type == "channelCreated": - return await self.on_teams_channel_created(turn_context) - elif event_type == "channelDeleted": - return await self.on_teams_channel_deleted(turn_context) - elif event_type == "channelRenamed": - return await self.on_teams_channel_renamed(turn_context) - elif event_type == "teamArchived": - return await self.on_teams_team_archived(turn_context) - elif event_type == "teamDeleted": - return await self.on_teams_team_deleted(turn_context) - elif event_type == "teamHardDeleted": - return await self.on_teams_team_hard_deleted(turn_context) - elif event_type == "channelRestored": - return await self.on_teams_channel_restored(turn_context) - elif event_type == "teamRenamed": - return await self.on_teams_team_renamed(turn_context) - elif event_type == "teamRestored": - return await self.on_teams_team_restored(turn_context) - elif event_type == "teamUnarchived": - return await self.on_teams_team_unarchived(turn_context) - - return await super().on_conversation_update_activity(turn_context) - - async def on_message_update_activity(self, turn_context: TurnContext): - """ - Dispatches message update activity. - - :param turn_context: The context object for the turn. - :return: None - """ - if turn_context.activity.channel_id == "msteams": - channel_data = ( - TeamsChannelData.model_validate(turn_context.activity.channel_data) - if turn_context.activity.channel_data - else None - ) - - event_type = channel_data.event_type if channel_data else None - - if event_type == "undeleteMessage": - return await self.on_teams_message_undelete(turn_context) - elif event_type == "editMessage": - return await self.on_teams_message_edit(turn_context) - - return await super().on_message_update_activity(turn_context) - - async def on_message_delete_activity(self, turn_context: TurnContext) -> None: - """ - Dispatches message delete activity. - - :param turn_context: The context object for the turn. - :return: None - """ - if turn_context.activity.channel_id == "msteams": - channel_data = channel_data = ( - TeamsChannelData.model_validate(turn_context.activity.channel_data) - if turn_context.activity.channel_data - else None - ) - - event_type = channel_data.event_type if channel_data else None - - if event_type == "softDeleteMessage": - return await self.on_teams_message_soft_delete(turn_context) - - return await super().on_message_delete_activity(turn_context) - - async def on_teams_message_undelete(self, turn_context: TurnContext) -> None: - """ - Handles Teams message undelete. - - :param turn_context: The context object for the turn. - :return: None - """ - return - - async def on_teams_message_edit(self, turn_context: TurnContext) -> None: - """ - Handles Teams message edit. - - :param turn_context: The context object for the turn. - :return: None - """ - return - - async def on_teams_message_soft_delete(self, turn_context: TurnContext) -> None: - """ - Handles Teams message soft delete. - - :param turn_context: The context object for the turn. - :return: None - """ - return - - async def on_teams_members_added_dispatch( - self, - members_added: list[ChannelAccount], - team_info: TeamInfo, - turn_context: TurnContext, - ) -> None: - """ - Dispatches processing of Teams members added to the conversation. - Processes the members_added collection to get full member information when possible. - - :param members_added: The list of members being added to the conversation. - :param team_info: The team info object. - :param turn_context: The context object for the turn. - :return: None - """ - teams_members_added = [] - - for member in members_added: - # If the member has properties or is the agent/bot being added to the conversation - if len(member.properties) or ( - turn_context.activity.recipient - and turn_context.activity.recipient.id == member.id - ): - - # Convert the ChannelAccount to TeamsChannelAccount - # TODO: Converter between these two classes - teams_member = TeamsChannelAccount.model_validate( - member.model_dump(by_alias=True, exclude_unset=True) - ) - teams_members_added.append(teams_member) - else: - # Try to get the full member details from Teams - try: - teams_member = await TeamsInfo.get_member(turn_context, member.id) - teams_members_added.append(teams_member) - except Exception as err: - # Handle case where conversation is not found - if "ConversationNotFound" in str(err): - teams_channel_account = TeamsChannelAccount( - id=member.id, - name=member.name, - aad_object_id=getattr(member, "aad_object_id", None), - role=getattr(member, "role", None), - ) - teams_members_added.append(teams_channel_account) - else: - # Propagate any other errors - raise - - await self.on_teams_members_added(teams_members_added, team_info, turn_context) - - async def on_teams_members_added( - self, - teams_members_added: list[TeamsChannelAccount], - team_info: TeamInfo, - turn_context: TurnContext, - ) -> None: - """ - Handles Teams members added. - - :param turn_context: The context object for the turn. - :return: None - """ - await self.on_members_added_activity(teams_members_added, turn_context) - - async def on_teams_members_removed_dispatch( - self, - members_removed: list[ChannelAccount], - team_info: TeamInfo, - turn_context: TurnContext, - ) -> None: - """ - Dispatches processing of Teams members removed from the conversation. - """ - teams_members_removed = [] - for member in members_removed: - teams_members_removed.append( - TeamsChannelAccount.model_validate( - member.model_dump(by_alias=True, exclude_unset=True) - ) - ) - return await self.on_teams_members_removed( - teams_members_removed, team_info, turn_context - ) - - async def on_teams_members_removed( - self, - teams_members_removed: list[TeamsChannelAccount], - team_info: TeamInfo, - turn_context: TurnContext, - ) -> None: - """ - Handles Teams members removed. - - :param turn_context: The context object for the turn. - :return: None - """ - await self.on_members_removed_activity(teams_members_removed, turn_context) - - async def on_teams_channel_created( - self, channel_info: ChannelInfo, team_info: TeamInfo, turn_context: TurnContext - ) -> None: - """ - Handles Teams channel created. - - :param turn_context: The context object for the turn. - :return: None - """ - return - - async def on_teams_channel_deleted( - self, channel_info: ChannelInfo, team_info: TeamInfo, turn_context: TurnContext - ) -> None: - """ - Handles Teams channel deleted. - - :param turn_context: The context object for the turn. - :return: None - """ - return - - async def on_teams_channel_renamed( - self, channel_info: ChannelInfo, team_info: TeamInfo, turn_context: TurnContext - ) -> None: - """ - Handles Teams channel renamed. - - :param turn_context: The context object for the turn. - :return: None - """ - return - - async def on_teams_team_archived( - self, team_info: TeamInfo, turn_context: TurnContext - ) -> None: - """ - Handles Teams team archived. - - :param turn_context: The context object for the turn. - :return: None - """ - return - - async def on_teams_team_deleted( - self, team_info: TeamInfo, turn_context: TurnContext - ) -> None: - """ - Handles Teams team deleted. - - :param turn_context: The context object for the turn. - :return: None - """ - return - - async def on_teams_team_hard_deleted( - self, team_info: TeamInfo, turn_context: TurnContext - ) -> None: - """ - Handles Teams team hard deleted. - - :param turn_context: The context object for the turn. - :return: None - """ - return - - async def on_teams_channel_restored( - self, channel_info: ChannelInfo, team_info: TeamInfo, turn_context: TurnContext - ) -> None: - """ - Handles Teams channel restored. - - :param turn_context: The context object for the turn. - :return: None - """ - return - - async def on_teams_team_renamed( - self, team_info: TeamInfo, turn_context: TurnContext - ) -> None: - """ - Handles Teams team renamed. - - :param turn_context: The context object for the turn. - :return: None - """ - return - - async def on_teams_team_restored( - self, team_info: TeamInfo, turn_context: TurnContext - ) -> None: - """ - Handles Teams team restored. - - :param turn_context: The context object for the turn. - :return: None - """ - return - - async def on_teams_team_unarchived( - self, team_info: TeamInfo, turn_context: TurnContext - ) -> None: - """ - Handles Teams team unarchived. - - :param turn_context: The context object for the turn. - :return: None - """ - return - - async def on_event_activity(self, turn_context: TurnContext) -> None: - """ - Dispatches event activity. - - :param turn_context: The context object for the turn. - :return: None - """ - if turn_context.activity.channel_id == "msteams": - if turn_context.activity.name == "application/vnd.microsoft.readReceipt": - return await self.on_teams_read_receipt(turn_context) - elif turn_context.activity.name == "application/vnd.microsoft.meetingStart": - return await self.on_teams_meeting_start(turn_context) - elif turn_context.activity.name == "application/vnd.microsoft.meetingEnd": - return await self.on_teams_meeting_end(turn_context) - elif ( - turn_context.activity.name - == "application/vnd.microsoft.meetingParticipantJoin" - ): - return await self.on_teams_meeting_participants_join(turn_context) - elif ( - turn_context.activity.name - == "application/vnd.microsoft.meetingParticipantLeave" - ): - return await self.on_teams_meeting_participants_leave(turn_context) - - return await super().on_event_activity(turn_context) - - async def on_teams_meeting_start( - self, meeting: MeetingStartEventDetails, turn_context: TurnContext - ) -> None: - """ - Handles Teams meeting start. - - :param turn_context: The context object for the turn. - :return: None - """ - return - - async def on_teams_meeting_end( - self, meeting: MeetingEndEventDetails, turn_context: TurnContext - ) -> None: - """ - Handles Teams meeting end. - - :param turn_context: The context object for the turn. - :return: None - """ - return - - async def on_teams_read_receipt( - self, read_receipt: ReadReceiptInfo, turn_context: TurnContext - ) -> None: - """ - Handles Teams read receipt. - - :param turn_context: The context object for the turn. - :return: None - """ - return - - async def on_teams_meeting_participants_join( - self, meeting: MeetingParticipantsEventDetails, turn_context: TurnContext - ) -> None: - """ - Handles Teams meeting participants join. - - :param turn_context: The context object for the turn. - :return: None - """ - return - - async def on_teams_meeting_participants_leave( - self, meeting: MeetingParticipantsEventDetails, turn_context: TurnContext - ) -> None: - """ - Handles Teams meeting participants leave. - - :param turn_context: The context object for the turn. - :return: None - """ - return From 5fc9e372afca5e057609f6f98206c35df2543609 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Tue, 23 Jun 2026 11:08:20 -0700 Subject: [PATCH 21/46] Renaming teams to msteams --- .../microsoft_agents/hosting/msteams/channel/channel.py | 6 +++--- .../hosting/msteams/channel/route_handlers.py | 6 +++--- .../microsoft_agents/hosting/msteams/config/config.py | 6 +++--- .../hosting/msteams/config/route_handlers.py | 4 ++-- .../hosting/msteams/file_consent/file_consent.py | 6 +++--- .../hosting/msteams/file_consent/route_handlers.py | 4 ++-- .../microsoft_agents/hosting/msteams/meeting/meeting.py | 4 ++-- .../hosting/msteams/meeting/route_handlers.py | 4 ++-- .../microsoft_agents/hosting/msteams/message/message.py | 8 ++++---- .../hosting/msteams/message/route_handlers.py | 4 ++-- .../msteams/message_extension/message_extension.py | 6 +++--- .../hosting/msteams/message_extension/route_handlers.py | 4 ++-- .../hosting/msteams/task_module/route_handlers.py | 4 ++-- .../hosting/msteams/task_module/task_module.py | 6 +++--- .../hosting/msteams/team/route_handlers.py | 4 ++-- .../microsoft_agents/hosting/msteams/team/team.py | 6 +++--- .../microsoft_agents/hosting/msteams/teams_info.py | 2 +- libraries/microsoft-agents-hosting-msteams/pyproject.toml | 2 +- libraries/microsoft-agents-hosting-msteams/readme.md | 6 +++--- tests/{hosting_teams => hosting_msteams}/__init__.py | 0 tests/{hosting_teams => hosting_msteams}/helpers.py | 0 tests/{hosting_teams => hosting_msteams}/test_channels.py | 0 tests/{hosting_teams => hosting_msteams}/test_config.py | 0 .../test_file_consent.py | 0 tests/{hosting_teams => hosting_msteams}/test_meetings.py | 0 .../test_message_extensions.py | 0 tests/{hosting_teams => hosting_msteams}/test_messages.py | 0 .../test_task_modules.py | 0 .../test_team_lifecycle.py | 0 .../test_teams_agent_extension.py | 0 .../{hosting_teams => hosting_msteams}/test_teams_info.py | 0 tests/{hosting_teams => hosting_msteams}/test_utils.py | 0 32 files changed, 46 insertions(+), 46 deletions(-) rename tests/{hosting_teams => hosting_msteams}/__init__.py (100%) rename tests/{hosting_teams => hosting_msteams}/helpers.py (100%) rename tests/{hosting_teams => hosting_msteams}/test_channels.py (100%) rename tests/{hosting_teams => hosting_msteams}/test_config.py (100%) rename tests/{hosting_teams => hosting_msteams}/test_file_consent.py (100%) rename tests/{hosting_teams => hosting_msteams}/test_meetings.py (100%) rename tests/{hosting_teams => hosting_msteams}/test_message_extensions.py (100%) rename tests/{hosting_teams => hosting_msteams}/test_messages.py (100%) rename tests/{hosting_teams => hosting_msteams}/test_task_modules.py (100%) rename tests/{hosting_teams => hosting_msteams}/test_team_lifecycle.py (100%) rename tests/{hosting_teams => hosting_msteams}/test_teams_agent_extension.py (100%) rename tests/{hosting_teams => hosting_msteams}/test_teams_info.py (100%) rename tests/{hosting_teams => hosting_msteams}/test_utils.py (100%) diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/channel/channel.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/channel/channel.py index 90f2ee6b4..975f3b164 100644 --- a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/channel/channel.py +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/channel/channel.py @@ -13,12 +13,12 @@ TurnContext, ) -from microsoft_agents.hosting.teams.teams_turn_context import TeamsTurnContext -from microsoft_agents.hosting.teams.type_defs import ( +from microsoft_agents.hosting.msteams.teams_turn_context import TeamsTurnContext +from microsoft_agents.hosting.msteams.type_defs import ( _RouteDecorator, StateT, ) -from microsoft_agents.hosting.teams._utils import ( +from microsoft_agents.hosting.msteams._utils import ( _get_channel_data, _get_channel_event_type, ) diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/channel/route_handlers.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/channel/route_handlers.py index 403414664..4c9af573f 100644 --- a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/channel/route_handlers.py +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/channel/route_handlers.py @@ -3,12 +3,12 @@ """Protocol definition for Teams channel update route handlers.""" -from typing import Any, Awaitable, Protocol +from typing import Awaitable, Protocol from microsoft_teams.api.models.channel_data import ChannelData -from microsoft_agents.hosting.teams.teams_turn_context import TeamsTurnContext -from microsoft_agents.hosting.teams.type_defs import _StateContra +from microsoft_agents.hosting.msteams.teams_turn_context import TeamsTurnContext +from microsoft_agents.hosting.msteams.type_defs import _StateContra class ChannelUpdateHandler(Protocol[_StateContra]): diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/config/config.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/config/config.py index c41cd43cc..adb1a0ff6 100644 --- a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/config/config.py +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/config/config.py @@ -12,12 +12,12 @@ TurnContext, ) -from microsoft_agents.hosting.teams.teams_turn_context import TeamsTurnContext -from microsoft_agents.hosting.teams.type_defs import ( +from microsoft_agents.hosting.msteams.teams_turn_context import TeamsTurnContext +from microsoft_agents.hosting.msteams.type_defs import ( _RouteDecorator, StateT, ) -from microsoft_agents.hosting.teams._utils import _send_invoke_response +from microsoft_agents.hosting.msteams._utils import _send_invoke_response from .route_handlers import ConfigHandler diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/config/route_handlers.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/config/route_handlers.py index ecb2dd0b2..c6c096b34 100644 --- a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/config/route_handlers.py +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/config/route_handlers.py @@ -7,8 +7,8 @@ from microsoft_teams.api.models.config import ConfigResponse -from microsoft_agents.hosting.teams.teams_turn_context import TeamsTurnContext -from microsoft_agents.hosting.teams.type_defs import _StateContra +from microsoft_agents.hosting.msteams.teams_turn_context import TeamsTurnContext +from microsoft_agents.hosting.msteams.type_defs import _StateContra class ConfigHandler(Protocol[_StateContra]): diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/file_consent/file_consent.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/file_consent/file_consent.py index 57396a26b..2ed92221f 100644 --- a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/file_consent/file_consent.py +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/file_consent/file_consent.py @@ -14,12 +14,12 @@ TurnContext, ) -from microsoft_agents.hosting.teams.teams_turn_context import TeamsTurnContext -from microsoft_agents.hosting.teams.type_defs import ( +from microsoft_agents.hosting.msteams.teams_turn_context import TeamsTurnContext +from microsoft_agents.hosting.msteams.type_defs import ( _RouteDecorator, StateT, ) -from microsoft_agents.hosting.teams._utils import _send_invoke_response +from microsoft_agents.hosting.msteams._utils import _send_invoke_response from .route_handlers import FileConsentHandler diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/file_consent/route_handlers.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/file_consent/route_handlers.py index 9a0726046..0dd8a491c 100644 --- a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/file_consent/route_handlers.py +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/file_consent/route_handlers.py @@ -7,8 +7,8 @@ from microsoft_teams.api.models import FileConsentCardResponse -from microsoft_agents.hosting.teams.teams_turn_context import TeamsTurnContext -from microsoft_agents.hosting.teams.type_defs import _StateContra +from microsoft_agents.hosting.msteams.teams_turn_context import TeamsTurnContext +from microsoft_agents.hosting.msteams.type_defs import _StateContra class FileConsentHandler(Protocol[_StateContra]): diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/meeting/meeting.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/meeting/meeting.py index 76d8db3b3..97a3e5c27 100644 --- a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/meeting/meeting.py +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/meeting/meeting.py @@ -15,8 +15,8 @@ TurnContext, ) -from microsoft_agents.hosting.teams.teams_turn_context import TeamsTurnContext -from microsoft_agents.hosting.teams.type_defs import ( +from microsoft_agents.hosting.msteams.teams_turn_context import TeamsTurnContext +from microsoft_agents.hosting.msteams.type_defs import ( _RouteDecorator, StateT, ) diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/meeting/route_handlers.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/meeting/route_handlers.py index 22cdfe5de..fe59aa7a8 100644 --- a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/meeting/route_handlers.py +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/meeting/route_handlers.py @@ -8,8 +8,8 @@ from microsoft_teams.api.models.meetings import MeetingDetails from microsoft_agents.activity.teams import MeetingParticipantsEventDetails -from microsoft_agents.hosting.teams.teams_turn_context import TeamsTurnContext -from microsoft_agents.hosting.teams.type_defs import _StateContra +from microsoft_agents.hosting.msteams.teams_turn_context import TeamsTurnContext +from microsoft_agents.hosting.msteams.type_defs import _StateContra class MeetingStartHandler(Protocol[_StateContra]): diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/message/message.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/message/message.py index c234f5eb4..4010a538b 100644 --- a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/message/message.py +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/message/message.py @@ -14,16 +14,16 @@ TurnContext, ) -from microsoft_agents.hosting.teams.route_handlers import ( +from microsoft_agents.hosting.msteams.route_handlers import ( TeamsRouteHandler, wrap_teams_route_handler, ) -from microsoft_agents.hosting.teams.teams_turn_context import TeamsTurnContext -from microsoft_agents.hosting.teams.type_defs import ( +from microsoft_agents.hosting.msteams.teams_turn_context import TeamsTurnContext +from microsoft_agents.hosting.msteams.type_defs import ( _RouteDecorator, StateT, ) -from microsoft_agents.hosting.teams._utils import ( +from microsoft_agents.hosting.msteams._utils import ( _get_channel_event_type, _send_invoke_response, ) diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/message/route_handlers.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/message/route_handlers.py index 7731ff0ed..2ef8f7438 100644 --- a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/message/route_handlers.py +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/message/route_handlers.py @@ -7,8 +7,8 @@ from microsoft_teams.api.models.o365 import O365ConnectorCardActionQuery -from microsoft_agents.hosting.teams.teams_turn_context import TeamsTurnContext -from microsoft_agents.hosting.teams.type_defs import _StateContra +from microsoft_agents.hosting.msteams.teams_turn_context import TeamsTurnContext +from microsoft_agents.hosting.msteams.type_defs import _StateContra class ExecuteActionHandler(Protocol[_StateContra]): diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/message_extension/message_extension.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/message_extension/message_extension.py index b3c07cb3a..e1127f0c3 100644 --- a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/message_extension/message_extension.py +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/message_extension/message_extension.py @@ -19,14 +19,14 @@ TurnContext, ) -from microsoft_agents.hosting.teams.teams_turn_context import TeamsTurnContext -from microsoft_agents.hosting.teams.type_defs import ( +from microsoft_agents.hosting.msteams.teams_turn_context import TeamsTurnContext +from microsoft_agents.hosting.msteams.type_defs import ( StateT, CommandSelector, _RouteDecorator, ) -from microsoft_agents.hosting.teams._utils import _match_selector, _send_invoke_response +from microsoft_agents.hosting.msteams._utils import _match_selector, _send_invoke_response from .route_handlers import ( FetchTaskHandler, diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/message_extension/route_handlers.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/message_extension/route_handlers.py index 77fcc3d69..bdffca192 100644 --- a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/message_extension/route_handlers.py +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/message_extension/route_handlers.py @@ -19,8 +19,8 @@ from microsoft_agents.activity import Activity -from microsoft_agents.hosting.teams.teams_turn_context import TeamsTurnContext -from microsoft_agents.hosting.teams.type_defs import _StateContra +from microsoft_agents.hosting.msteams.teams_turn_context import TeamsTurnContext +from microsoft_agents.hosting.msteams.type_defs import _StateContra class FetchTaskHandler(Protocol[_StateContra]): diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/task_module/route_handlers.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/task_module/route_handlers.py index 057395188..684142e1e 100644 --- a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/task_module/route_handlers.py +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/task_module/route_handlers.py @@ -7,8 +7,8 @@ from microsoft_teams.api.models import TaskModuleRequest, TaskModuleResponse -from microsoft_agents.hosting.teams.teams_turn_context import TeamsTurnContext -from microsoft_agents.hosting.teams.type_defs import _StateContra +from microsoft_agents.hosting.msteams.teams_turn_context import TeamsTurnContext +from microsoft_agents.hosting.msteams.type_defs import _StateContra class FetchHandler(Protocol[_StateContra]): diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/task_module/task_module.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/task_module/task_module.py index 1c937a7ec..68d622399 100644 --- a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/task_module/task_module.py +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/task_module/task_module.py @@ -11,13 +11,13 @@ from microsoft_agents.hosting.core import AgentApplication, RouteRank, TurnContext -from microsoft_agents.hosting.teams.teams_turn_context import TeamsTurnContext -from microsoft_agents.hosting.teams.type_defs import ( +from microsoft_agents.hosting.msteams.teams_turn_context import TeamsTurnContext +from microsoft_agents.hosting.msteams.type_defs import ( CommandSelector, _RouteDecorator, StateT, ) -from microsoft_agents.hosting.teams._utils import ( +from microsoft_agents.hosting.msteams._utils import ( _match_selector, _send_invoke_response, ) diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/team/route_handlers.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/team/route_handlers.py index 0c9ee95b4..e8f0ad376 100644 --- a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/team/route_handlers.py +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/team/route_handlers.py @@ -7,8 +7,8 @@ from microsoft_teams.api.models.channel_data import ChannelData -from microsoft_agents.hosting.teams.teams_turn_context import TeamsTurnContext -from microsoft_agents.hosting.teams.type_defs import _StateContra +from microsoft_agents.hosting.msteams.teams_turn_context import TeamsTurnContext +from microsoft_agents.hosting.msteams.type_defs import _StateContra class TeamUpdateHandler(Protocol[_StateContra]): diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/team/team.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/team/team.py index e2ca9b228..bc766bfaa 100644 --- a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/team/team.py +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/team/team.py @@ -13,12 +13,12 @@ TurnContext, ) -from microsoft_agents.hosting.teams.teams_turn_context import TeamsTurnContext -from microsoft_agents.hosting.teams.type_defs import ( +from microsoft_agents.hosting.msteams.teams_turn_context import TeamsTurnContext +from microsoft_agents.hosting.msteams.type_defs import ( _RouteDecorator, StateT, ) -from microsoft_agents.hosting.teams._utils import ( +from microsoft_agents.hosting.msteams._utils import ( _get_channel_data, _get_channel_event_type, ) diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_info.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_info.py index 5f193c788..42e00322a 100644 --- a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_info.py +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_info.py @@ -14,8 +14,8 @@ ) from microsoft_agents.hosting.core import TurnContext -from microsoft_agents.hosting.teams.errors import teams_errors +from .errors import teams_errors from ._teams_api_client import get_cached_teams_api_client from ._utils import _get_channel_data diff --git a/libraries/microsoft-agents-hosting-msteams/pyproject.toml b/libraries/microsoft-agents-hosting-msteams/pyproject.toml index 3ab2c1459..6ce646f0c 100644 --- a/libraries/microsoft-agents-hosting-msteams/pyproject.toml +++ b/libraries/microsoft-agents-hosting-msteams/pyproject.toml @@ -3,7 +3,7 @@ requires = ["setuptools"] build-backend = "setuptools.build_meta" [project] -name = "microsoft-agents-hosting-teams" +name = "microsoft-agents-hosting-msteams" dynamic = ["version", "dependencies"] description = "Integration library for Microsoft Agents with Teams" readme = {file = "readme.md", content-type = "text/markdown"} diff --git a/libraries/microsoft-agents-hosting-msteams/readme.md b/libraries/microsoft-agents-hosting-msteams/readme.md index e789bbdca..1719bcbe3 100644 --- a/libraries/microsoft-agents-hosting-msteams/readme.md +++ b/libraries/microsoft-agents-hosting-msteams/readme.md @@ -1,6 +1,6 @@ # Microsoft Agents Hosting - Teams -[![PyPI version](https://img.shields.io/pypi/v/microsoft-agents-hosting-teams)](https://pypi.org/project/microsoft-agents-hosting-teams/) +[![PyPI version](https://img.shields.io/pypi/v/microsoft-agents-hosting-msteams)](https://pypi.org/project/microsoft-agents-hosting-msteams/) 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. @@ -92,7 +92,7 @@ We offer the following PyPI packages to create conversational experiences based | `microsoft-agents-activity` | [![PyPI](https://img.shields.io/pypi/v/microsoft-agents-activity)](https://pypi.org/project/microsoft-agents-activity/) | Types and validators implementing the Activity protocol spec. | | `microsoft-agents-hosting-core` | [![PyPI](https://img.shields.io/pypi/v/microsoft-agents-hosting-core)](https://pypi.org/project/microsoft-agents-hosting-core/) | Core library for Microsoft Agents hosting. | | `microsoft-agents-hosting-aiohttp` | [![PyPI](https://img.shields.io/pypi/v/microsoft-agents-hosting-aiohttp)](https://pypi.org/project/microsoft-agents-hosting-aiohttp/) | Configures aiohttp to run the Agent. | -| `microsoft-agents-hosting-teams` | [![PyPI](https://img.shields.io/pypi/v/microsoft-agents-hosting-teams)](https://pypi.org/project/microsoft-agents-hosting-teams/) | Provides classes to host an Agent for Teams. | +| `microsoft-agents-hosting-msteams` | [![PyPI](https://img.shields.io/pypi/v/microsoft-agents-hosting-msteams)](https://pypi.org/project/microsoft-agents-hosting-msteams/) | Provides classes to host an Agent for Teams. | | `microsoft-agents-storage-blob` | [![PyPI](https://img.shields.io/pypi/v/microsoft-agents-storage-blob)](https://pypi.org/project/microsoft-agents-storage-blob/) | Extension to use Azure Blob as storage. | | `microsoft-agents-storage-cosmos` | [![PyPI](https://img.shields.io/pypi/v/microsoft-agents-storage-cosmos)](https://pypi.org/project/microsoft-agents-storage-cosmos/) | Extension to use CosmosDB as storage. | | `microsoft-agents-authentication-msal` | [![PyPI](https://img.shields.io/pypi/v/microsoft-agents-authentication-msal)](https://pypi.org/project/microsoft-agents-authentication-msal/) | MSAL-based authentication for Microsoft Agents. | @@ -107,7 +107,7 @@ Additionally we provide a Copilot Studio Client, to interact with Agents created ## Installation ```bash -pip install microsoft-agents-hosting-teams +pip install microsoft-agents-hosting-msteams ``` ## Key Classes Reference diff --git a/tests/hosting_teams/__init__.py b/tests/hosting_msteams/__init__.py similarity index 100% rename from tests/hosting_teams/__init__.py rename to tests/hosting_msteams/__init__.py diff --git a/tests/hosting_teams/helpers.py b/tests/hosting_msteams/helpers.py similarity index 100% rename from tests/hosting_teams/helpers.py rename to tests/hosting_msteams/helpers.py diff --git a/tests/hosting_teams/test_channels.py b/tests/hosting_msteams/test_channels.py similarity index 100% rename from tests/hosting_teams/test_channels.py rename to tests/hosting_msteams/test_channels.py diff --git a/tests/hosting_teams/test_config.py b/tests/hosting_msteams/test_config.py similarity index 100% rename from tests/hosting_teams/test_config.py rename to tests/hosting_msteams/test_config.py diff --git a/tests/hosting_teams/test_file_consent.py b/tests/hosting_msteams/test_file_consent.py similarity index 100% rename from tests/hosting_teams/test_file_consent.py rename to tests/hosting_msteams/test_file_consent.py diff --git a/tests/hosting_teams/test_meetings.py b/tests/hosting_msteams/test_meetings.py similarity index 100% rename from tests/hosting_teams/test_meetings.py rename to tests/hosting_msteams/test_meetings.py diff --git a/tests/hosting_teams/test_message_extensions.py b/tests/hosting_msteams/test_message_extensions.py similarity index 100% rename from tests/hosting_teams/test_message_extensions.py rename to tests/hosting_msteams/test_message_extensions.py diff --git a/tests/hosting_teams/test_messages.py b/tests/hosting_msteams/test_messages.py similarity index 100% rename from tests/hosting_teams/test_messages.py rename to tests/hosting_msteams/test_messages.py diff --git a/tests/hosting_teams/test_task_modules.py b/tests/hosting_msteams/test_task_modules.py similarity index 100% rename from tests/hosting_teams/test_task_modules.py rename to tests/hosting_msteams/test_task_modules.py diff --git a/tests/hosting_teams/test_team_lifecycle.py b/tests/hosting_msteams/test_team_lifecycle.py similarity index 100% rename from tests/hosting_teams/test_team_lifecycle.py rename to tests/hosting_msteams/test_team_lifecycle.py diff --git a/tests/hosting_teams/test_teams_agent_extension.py b/tests/hosting_msteams/test_teams_agent_extension.py similarity index 100% rename from tests/hosting_teams/test_teams_agent_extension.py rename to tests/hosting_msteams/test_teams_agent_extension.py diff --git a/tests/hosting_teams/test_teams_info.py b/tests/hosting_msteams/test_teams_info.py similarity index 100% rename from tests/hosting_teams/test_teams_info.py rename to tests/hosting_msteams/test_teams_info.py diff --git a/tests/hosting_teams/test_utils.py b/tests/hosting_msteams/test_utils.py similarity index 100% rename from tests/hosting_teams/test_utils.py rename to tests/hosting_msteams/test_utils.py From f663e4cb783fe3b27a66d47df4d75feacc2a4293 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Tue, 23 Jun 2026 14:22:13 -0700 Subject: [PATCH 22/46] Another commit --- .../hosting/msteams/__init__.py | 2 - .../hosting/msteams/_teams_api_client.py | 43 +- .../hosting/msteams/_utils.py | 27 +- .../hosting/msteams/teams_agent_extension.py | 15 +- .../hosting/msteams/teams_info.py | 270 ----- tests/hosting_msteams/test_teams_info.py | 402 ------- .../test_teams_agent_extension.py | 1022 ----------------- 7 files changed, 54 insertions(+), 1727 deletions(-) delete mode 100644 libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_info.py delete mode 100644 tests/hosting_msteams/test_teams_info.py delete mode 100644 tests/hosting_teams/test_teams_agent_extension.py diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/__init__.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/__init__.py index 00ccf8a31..b581586d0 100644 --- a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/__init__.py +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/__init__.py @@ -14,7 +14,6 @@ from .message_extension import MessageExtension from .task_module import TaskModule from .team import Team -from .teams_info import TeamsInfo __all__ = [ "TeamsAgentExtension", @@ -26,5 +25,4 @@ "MessageExtension", "TaskModule", "Team", - "TeamsInfo", ] diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/_teams_api_client.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/_teams_api_client.py index 65f51a16a..0673386d2 100644 --- a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/_teams_api_client.py +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/_teams_api_client.py @@ -11,30 +11,33 @@ _TEAMS_API_CLIENT_KEY = "TeamsApiClient" - -def get_cached_teams_api_client(context: TurnContext) -> ApiClient: - client = context.turn_state.get(_TEAMS_API_CLIENT_KEY) - if isinstance(client, ApiClient): - return client +def get_teams_api_client(context: TurnContext) -> ApiClient: + """ + Get the cached Teams API client from the context. + + :param context: The turn context. + :return: The cached Teams API client. + :raises ValueError: If the Teams API client is not found. + """ + api_client = context.turn_state.get(_TEAMS_API_CLIENT_KEY) + if isinstance(api_client, ApiClient): + return api_client raise ValueError("Unable to retrieve Teams API client.") +async def _set_teams_api_client( + context: TurnContext, + connection_manager: Connections + ) -> None: + """ + Set the Teams API client in the context. -async def get_teams_api_client( - context: TurnContext, - connections: Connections | None = None, -) -> ApiClient: - - state = context.turn_state.get(_TEAMS_API_CLIENT_KEY) - if isinstance(state, ApiClient): - return state - - if not connections: - raise ValueError("Unable to retrieve Teams API client.") - + :param context: The turn context. + :param connection_manager: The connection manager. + """ token: str | None = None if context.identity: assert context.identity is not None - provider = connections.get_token_provider( + provider = connection_manager.get_token_provider( context.identity, context.activity.service_url ) token = await provider.get_access_token( @@ -55,6 +58,4 @@ async def get_teams_api_client( options, ) - context.turn_state[_TEAMS_API_CLIENT_KEY] = api_client - - return api_client + context.turn_state[_TEAMS_API_CLIENT_KEY] = api_client \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/_utils.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/_utils.py index 5288efb54..e51964a46 100644 --- a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/_utils.py +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/_utils.py @@ -2,7 +2,7 @@ import re -from typing import Any, Optional +from typing import Any from http import HTTPStatus @@ -17,6 +17,19 @@ from .type_defs import CommandSelector +def _try_get_channel_data(context: TurnContext) -> ChannelData | None: + """Attempt to extract and parse Teams channel data from the activity's channel_data. + + :param context: The current turn context. + :return: A :class:`ChannelData` instance parsed from channel_data, or None if absent. + """ + data = context.activity.channel_data + if data is None: + return None + if isinstance(data, ChannelData): + return data + return ChannelData.model_validate(data) + def _get_channel_data(context: TurnContext) -> ChannelData: """Extract and parse Teams channel data from the activity's channel_data. @@ -26,15 +39,13 @@ def _get_channel_data(context: TurnContext) -> ChannelData: :raises ValueError: If channel_data is absent. :raises pydantic.ValidationError: If channel_data cannot be deserialized. """ - data = context.activity.channel_data - if data is None: + channel_data = _try_get_channel_data(context) + if channel_data is None: raise ValueError("channel_data is required") - if isinstance(data, ChannelData): - return data - return ChannelData.model_validate(data) + return channel_data -def _match_selector(selector: CommandSelector, value: Optional[str]) -> bool: +def _match_selector(selector: CommandSelector, value: str | None) -> bool: """Return True if *value* matches *selector*. :param selector: A literal string, compiled regex, or None. None matches anything. @@ -49,7 +60,7 @@ def _match_selector(selector: CommandSelector, value: Optional[str]) -> bool: return bool(re.fullmatch(selector, value)) -def _get_channel_event_type(context: TurnContext) -> Optional[str]: +def _get_channel_event_type(context: TurnContext) -> str | None: """Extract the Teams channel event type from the activity's channel_data. :param context: The current turn context. diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_agent_extension.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_agent_extension.py index 214809951..e787da691 100644 --- a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_agent_extension.py +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_agent_extension.py @@ -18,11 +18,15 @@ from microsoft_agents.activity import ( ActivityTypes, + Channels, ConversationUpdateTypes, MessageReactionTypes, MessageUpdateTypes, ) -from microsoft_agents.hosting.core import AgentApplication +from microsoft_agents.hosting.core import ( + AgentApplication, + TurnContext, +) from microsoft_agents.hosting.core.app._type_defs import RouteHandler, HandoffHandler from .channel import Channel @@ -42,6 +46,8 @@ from .type_defs import StateT +from ._teams_api_client import _set_teams_api_client +from ._utils import _try_get_channel_data class _AppRouteDecorator(Protocol[StateT]): """Protocol for a decorator returned by :class:`TeamsAgentExtension` route methods.""" @@ -97,7 +103,12 @@ def __init__(self, app: AgentApplication[StateT]) -> None: def _configure_app(self): """Configure the underlying AgentApplication with Teams-specific routes.""" - self._app. + + async def before_handler(context: TurnContext, state: StateT) -> bool: + if context.activity.channel_id == Channels.ms_teams: + await _set_teams_api_client(context, self._app.connection_manager) + context.activity.channel_data = _try_get_channel_data(context) + return True @property def channels(self) -> Channel[StateT]: diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_info.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_info.py deleted file mode 100644 index 42e00322a..000000000 --- a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_info.py +++ /dev/null @@ -1,270 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -"""Teams information utilities for Microsoft Agents.""" - -from microsoft_teams.api.models import ( - ChannelData, - ChannelInfo, - MeetingInfo, - MeetingParticipant, - PagedMembersResult, - TeamsChannelAccount, - TeamDetails, -) - -from microsoft_agents.hosting.core import TurnContext - -from .errors import teams_errors -from ._teams_api_client import get_cached_teams_api_client -from ._utils import _get_channel_data - - -class TeamsInfo: - """Teams information utilities for interacting with Teams-specific data.""" - - @staticmethod - def _get_meeting_id( - channel_data: ChannelData, meeting_id: str | None = None - ) -> str: - if not meeting_id: - meeting_id = getattr(channel_data.meeting, "id", None) - if not meeting_id: - raise ValueError(str(teams_errors.TeamsMeetingIdRequired)) - return meeting_id - - @staticmethod - def _get_tenant_id(channel_data: ChannelData, tenant_id: str | None = None) -> str: - if not tenant_id: - tenant_id = getattr(channel_data.tenant, "id", None) - if not tenant_id: - raise ValueError(str(teams_errors.TeamsTenantIdRequired)) - return tenant_id - - @staticmethod - def _get_team_id(channel_data: ChannelData, team_id: str | None = None) -> str: - if not team_id: - team_id = getattr(channel_data.team, "id", None) - if not team_id: - raise ValueError(str(teams_errors.TeamsTeamIdRequired)) - return team_id - - @staticmethod - def _get_conversation_id(context: TurnContext) -> str: - conversation_id = ( - context.activity.conversation.id if context.activity.conversation else None - ) - if not conversation_id: - raise ValueError(str(teams_errors.TeamsConversationIdRequired)) - return conversation_id - - @staticmethod - async def get_meeting_participant( - context: TurnContext, - meeting_id: str | None = None, - participant_id: str | None = None, - tenant_id: str | None = None, - ) -> MeetingParticipant: - """ - 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. - """ - channel_data: ChannelData = _get_channel_data(context) - - meeting_id = TeamsInfo._get_meeting_id(channel_data, meeting_id) - - if not tenant_id: - tenant_id = getattr(channel_data.tenant, "id", None) - if not tenant_id: - raise ValueError(str(teams_errors.TeamsTenantIdRequired)) - - if not participant_id: - participant_id = getattr( - context.activity.from_property, "aad_object_id", None - ) - if not participant_id: - raise ValueError(str(teams_errors.TeamsParticipantIdRequired)) - - api_client = get_cached_teams_api_client(context) - return await api_client.meetings.get_participant( - meeting_id, participant_id, tenant_id - ) - - @staticmethod - async def get_meeting_info( - context: TurnContext, meeting_id: str | None = 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. - """ - channel_data: ChannelData = _get_channel_data(context) - meeting_id = TeamsInfo._get_meeting_id(channel_data, meeting_id) - - api_client = get_cached_teams_api_client(context) - return await api_client.meetings.get_by_id(meeting_id) - - @staticmethod - async def get_team_details( - context: TurnContext, team_id: str | None = 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. - """ - channel_data: ChannelData = _get_channel_data(context) - team_id = TeamsInfo._get_team_id(channel_data, team_id) - - api_client = get_cached_teams_api_client(context) - return await api_client.teams.get_by_id(team_id) - - @staticmethod - async def get_team_channels( - context: TurnContext, team_id: str | None = 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. - """ - channel_data = _get_channel_data(context) - team_id = TeamsInfo._get_team_id(channel_data, team_id) - - api_client = get_cached_teams_api_client(context) - return await api_client.teams.get_conversations(team_id) - - @staticmethod - async def get_paged_members( - context: TurnContext, - page_size: int | None = None, - continuation_token: str = "", - ) -> PagedMembersResult: - """ - 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. - """ - channel_data = _get_channel_data(context) - team_id = getattr(channel_data.team, "id", None) - - conversation_id = team_id or TeamsInfo._get_conversation_id(context) - - api_client = get_cached_teams_api_client(context) - return await api_client.conversations.members(conversation_id).get_paged( - 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. - """ - channel_data = _get_channel_data(context) - team_id = getattr(channel_data.team, "id", None) - - if team_id: - return await TeamsInfo.get_team_member(context, team_id, user_id) - else: - conversation_id = TeamsInfo._get_conversation_id(context) - - return await TeamsInfo._get_member_internal( - context, conversation_id, user_id - ) - - @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. - """ - api_client = get_cached_teams_api_client(context) - return await api_client.conversations.members(team_id).get(user_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. - """ - api_client = get_cached_teams_api_client(context) - return await api_client.conversations.members(conversation_id).get(user_id) diff --git a/tests/hosting_msteams/test_teams_info.py b/tests/hosting_msteams/test_teams_info.py deleted file mode 100644 index d4aae98fc..000000000 --- a/tests/hosting_msteams/test_teams_info.py +++ /dev/null @@ -1,402 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -"""Unit tests for TeamsInfo.""" - -import pytest -from unittest.mock import AsyncMock, MagicMock, patch - -from .helpers import is_supported_version - -pytestmark = pytest.mark.skipif( - not is_supported_version, - reason="microsoft-agents-hosting-teams tests require Python 3.12+", -) - -if is_supported_version: - from microsoft_teams.api.models import ( - ChannelData, - ChannelInfo, - MeetingInfo, - MeetingParticipant, - PagedMembersResult, - TeamsChannelAccount, - TeamDetails, - ) - from microsoft_agents.activity import Activity, ChannelAccount - from microsoft_agents.hosting.core import TurnContext - from microsoft_agents.hosting.teams.teams_info import TeamsInfo - - -# --- helpers --- - - -def _make_channel_data(team_id=None, tenant_id=None, meeting_id=None) -> "ChannelData": - data = {} - if team_id: - data["team"] = {"id": team_id} - if tenant_id: - data["tenant"] = {"id": tenant_id} - if meeting_id: - data["meeting"] = {"id": meeting_id} - return ChannelData.model_validate(data) - - -def _make_api_client(): - """Build a mock ApiClient with meetings/teams/conversations sub-objects.""" - members_proxy = MagicMock() - members_proxy.get_paged = AsyncMock() - members_proxy.get = AsyncMock() - - api_client = MagicMock() - api_client.meetings = MagicMock() - api_client.meetings.get_participant = AsyncMock() - api_client.meetings.get_by_id = AsyncMock() - api_client.teams = MagicMock() - api_client.teams.get_by_id = AsyncMock() - api_client.teams.get_conversations = AsyncMock() - api_client.conversations = MagicMock() - api_client.conversations.members = MagicMock(return_value=members_proxy) - return api_client - - -def _make_context(channel_data=None, api_client=None) -> "TurnContext": - context = MagicMock(spec=TurnContext) - activity = MagicMock(spec=Activity) - activity.channel_data = channel_data - from_prop = MagicMock() - from_prop.aad_object_id = "user-aad-id" - activity.from_property = from_prop - conversation = MagicMock() - conversation.id = "conv-id" - activity.conversation = conversation - activity.recipient = MagicMock(spec=ChannelAccount) - activity.service_url = "https://example.com" - activity.id = "activity-id" - context.activity = activity - context.turn_state = {"TeamsApiClient": api_client} - context.adapter = MagicMock() - return context - - -_PATCH_TARGET = "microsoft_agents.hosting.teams.teams_info.get_cached_teams_api_client" - - -# --- _get_meeting_id --- - - -class TestGetMeetingIdHelper: - def test_returns_explicit_meeting_id(self): - channel_data = _make_channel_data() - assert TeamsInfo._get_meeting_id(channel_data, "explicit-id") == "explicit-id" - - def test_falls_back_to_channel_data_meeting(self): - channel_data = _make_channel_data(meeting_id="from-data") - assert TeamsInfo._get_meeting_id(channel_data) == "from-data" - - def test_raises_if_both_missing(self): - channel_data = _make_channel_data() - with pytest.raises(ValueError, match="meeting_id"): - TeamsInfo._get_meeting_id(channel_data) - - -# --- _get_tenant_id --- - - -class TestGetTenantIdHelper: - def test_returns_explicit_tenant_id(self): - channel_data = _make_channel_data() - assert ( - TeamsInfo._get_tenant_id(channel_data, "explicit-tenant") - == "explicit-tenant" - ) - - def test_falls_back_to_channel_data_tenant(self): - channel_data = _make_channel_data(tenant_id="tenant-from-data") - assert TeamsInfo._get_tenant_id(channel_data) == "tenant-from-data" - - def test_raises_if_both_missing(self): - channel_data = _make_channel_data() - with pytest.raises(ValueError, match="tenant_id"): - TeamsInfo._get_tenant_id(channel_data) - - -# --- _get_team_id --- - - -class TestGetTeamIdHelper: - def test_returns_explicit_team_id(self): - channel_data = _make_channel_data() - assert TeamsInfo._get_team_id(channel_data, "explicit-team") == "explicit-team" - - def test_falls_back_to_channel_data_team(self): - channel_data = _make_channel_data(team_id="team-from-data") - assert TeamsInfo._get_team_id(channel_data) == "team-from-data" - - def test_raises_if_both_missing(self): - channel_data = _make_channel_data() - with pytest.raises(ValueError, match="team_id"): - TeamsInfo._get_team_id(channel_data) - - -# --- get_meeting_participant --- - - -class TestGetMeetingParticipant: - @pytest.mark.asyncio - async def test_calls_client_with_explicit_params(self): - api_client = _make_api_client() - expected = MagicMock(spec=MeetingParticipant) - api_client.meetings.get_participant.return_value = expected - - channel_data = _make_channel_data(tenant_id="t1", meeting_id="m1") - context = _make_context(channel_data, api_client) - - with patch(_PATCH_TARGET, return_value=api_client): - result = await TeamsInfo.get_meeting_participant( - context, meeting_id="m1", participant_id="p1", tenant_id="t1" - ) - - api_client.meetings.get_participant.assert_called_once_with("m1", "p1", "t1") - assert result is expected - - @pytest.mark.asyncio - async def test_extracts_params_from_activity(self): - api_client = _make_api_client() - api_client.meetings.get_participant.return_value = MagicMock() - - channel_data = _make_channel_data(tenant_id="t1", meeting_id="m1") - context = _make_context(channel_data, api_client) - context.activity.from_property.aad_object_id = "aad-user" - - with patch(_PATCH_TARGET, return_value=api_client): - await TeamsInfo.get_meeting_participant(context) - - api_client.meetings.get_participant.assert_called_once_with( - "m1", "aad-user", "t1" - ) - - @pytest.mark.asyncio - async def test_raises_if_participant_id_missing(self): - api_client = _make_api_client() - channel_data = _make_channel_data(tenant_id="t1", meeting_id="m1") - context = _make_context(channel_data, api_client) - context.activity.from_property.aad_object_id = None - - with patch(_PATCH_TARGET, return_value=api_client): - with pytest.raises(ValueError, match="participant_id"): - await TeamsInfo.get_meeting_participant(context) - - -# --- get_meeting_info --- - - -class TestGetMeetingInfo: - @pytest.mark.asyncio - async def test_calls_client_with_explicit_meeting_id(self): - api_client = _make_api_client() - expected = MagicMock(spec=MeetingInfo) - api_client.meetings.get_by_id.return_value = expected - - channel_data = _make_channel_data(meeting_id="m1") - context = _make_context(channel_data, api_client) - - with patch(_PATCH_TARGET, return_value=api_client): - result = await TeamsInfo.get_meeting_info(context, "m1") - - api_client.meetings.get_by_id.assert_called_once_with("m1") - assert result is expected - - @pytest.mark.asyncio - async def test_falls_back_to_channel_data_meeting_id(self): - api_client = _make_api_client() - api_client.meetings.get_by_id.return_value = MagicMock() - - channel_data = _make_channel_data(meeting_id="from-data") - context = _make_context(channel_data, api_client) - - with patch(_PATCH_TARGET, return_value=api_client): - await TeamsInfo.get_meeting_info(context) - - api_client.meetings.get_by_id.assert_called_once_with("from-data") - - -# --- get_team_details --- - - -class TestGetTeamDetails: - @pytest.mark.asyncio - async def test_calls_client_with_explicit_team_id(self): - api_client = _make_api_client() - expected = MagicMock(spec=TeamDetails) - api_client.teams.get_by_id.return_value = expected - - channel_data = _make_channel_data(team_id="team1") - context = _make_context(channel_data, api_client) - - with patch(_PATCH_TARGET, return_value=api_client): - result = await TeamsInfo.get_team_details(context, "team1") - - api_client.teams.get_by_id.assert_called_once_with("team1") - assert result is expected - - @pytest.mark.asyncio - async def test_falls_back_to_channel_data_team_id(self): - api_client = _make_api_client() - api_client.teams.get_by_id.return_value = MagicMock() - - channel_data = _make_channel_data(team_id="team-from-data") - context = _make_context(channel_data, api_client) - - with patch(_PATCH_TARGET, return_value=api_client): - await TeamsInfo.get_team_details(context) - - api_client.teams.get_by_id.assert_called_once_with("team-from-data") - - -# --- get_team_channels --- - - -class TestGetTeamChannels: - @pytest.mark.asyncio - async def test_calls_client_with_team_id(self): - api_client = _make_api_client() - expected = [MagicMock(spec=ChannelInfo)] - api_client.teams.get_conversations.return_value = expected - - channel_data = _make_channel_data(team_id="team1") - context = _make_context(channel_data, api_client) - - with patch(_PATCH_TARGET, return_value=api_client): - result = await TeamsInfo.get_team_channels(context, "team1") - - api_client.teams.get_conversations.assert_called_once_with("team1") - assert result is expected - - @pytest.mark.asyncio - async def test_falls_back_to_channel_data_team_id(self): - api_client = _make_api_client() - api_client.teams.get_conversations.return_value = [] - - channel_data = _make_channel_data(team_id="team-from-data") - context = _make_context(channel_data, api_client) - - with patch(_PATCH_TARGET, return_value=api_client): - await TeamsInfo.get_team_channels(context) - - api_client.teams.get_conversations.assert_called_once_with("team-from-data") - - -# --- get_paged_members --- - - -class TestGetPagedMembers: - @pytest.mark.asyncio - async def test_uses_team_id_when_present(self): - api_client = _make_api_client() - expected = MagicMock(spec=PagedMembersResult) - members_proxy = api_client.conversations.members.return_value - members_proxy.get_paged.return_value = expected - - channel_data = _make_channel_data(team_id="team1") - context = _make_context(channel_data, api_client) - - with patch(_PATCH_TARGET, return_value=api_client): - result = await TeamsInfo.get_paged_members(context) - - api_client.conversations.members.assert_called_once_with("team1") - members_proxy.get_paged.assert_called_once_with(None, "") - assert result is expected - - @pytest.mark.asyncio - async def test_falls_back_to_conversation_id_when_no_team(self): - api_client = _make_api_client() - expected = MagicMock(spec=PagedMembersResult) - members_proxy = api_client.conversations.members.return_value - members_proxy.get_paged.return_value = expected - - channel_data = _make_channel_data() # no team_id - context = _make_context(channel_data, api_client) - - with patch(_PATCH_TARGET, return_value=api_client): - result = await TeamsInfo.get_paged_members(context) - - api_client.conversations.members.assert_called_once_with("conv-id") - assert result is expected - - @pytest.mark.asyncio - async def test_passes_page_size_and_continuation_token(self): - api_client = _make_api_client() - members_proxy = api_client.conversations.members.return_value - members_proxy.get_paged.return_value = MagicMock(spec=PagedMembersResult) - - channel_data = _make_channel_data(team_id="team1") - context = _make_context(channel_data, api_client) - - with patch(_PATCH_TARGET, return_value=api_client): - await TeamsInfo.get_paged_members( - context, page_size=10, continuation_token="tok" - ) - - members_proxy.get_paged.assert_called_once_with(10, "tok") - - -# --- get_team_member --- - - -class TestGetTeamMember: - @pytest.mark.asyncio - async def test_calls_client_with_team_and_user_id(self): - api_client = _make_api_client() - expected = MagicMock(spec=TeamsChannelAccount) - members_proxy = api_client.conversations.members.return_value - members_proxy.get.return_value = expected - - context = _make_context(api_client=api_client) - - with patch(_PATCH_TARGET, return_value=api_client): - result = await TeamsInfo.get_team_member(context, "team1", "user1") - - api_client.conversations.members.assert_called_once_with("team1") - members_proxy.get.assert_called_once_with("user1") - assert result is expected - - -# --- get_member --- - - -class TestGetMember: - @pytest.mark.asyncio - async def test_routes_to_get_team_member_when_team_id_set(self): - api_client = _make_api_client() - expected = MagicMock(spec=TeamsChannelAccount) - members_proxy = api_client.conversations.members.return_value - members_proxy.get.return_value = expected - - channel_data = _make_channel_data(team_id="team1") - context = _make_context(channel_data, api_client) - - with patch(_PATCH_TARGET, return_value=api_client): - result = await TeamsInfo.get_member(context, "user1") - - api_client.conversations.members.assert_called_once_with("team1") - members_proxy.get.assert_called_once_with("user1") - assert result is expected - - @pytest.mark.asyncio - async def test_routes_to_conversation_when_no_team_id(self): - api_client = _make_api_client() - expected = MagicMock(spec=TeamsChannelAccount) - members_proxy = api_client.conversations.members.return_value - members_proxy.get.return_value = expected - - channel_data = _make_channel_data() # no team_id - context = _make_context(channel_data, api_client) - context.activity.conversation.id = "conv-id" - - with patch(_PATCH_TARGET, return_value=api_client): - result = await TeamsInfo.get_member(context, "user1") - - api_client.conversations.members.assert_called_once_with("conv-id") - members_proxy.get.assert_called_once_with("user1") - assert result is expected diff --git a/tests/hosting_teams/test_teams_agent_extension.py b/tests/hosting_teams/test_teams_agent_extension.py deleted file mode 100644 index f16b514dd..000000000 --- a/tests/hosting_teams/test_teams_agent_extension.py +++ /dev/null @@ -1,1022 +0,0 @@ -""" -Copyright (c) Microsoft Corporation. All rights reserved. -Licensed under the MIT License. -""" - -import re -import sys -import pytest -from unittest.mock import AsyncMock, MagicMock, patch - -is_supported_version = sys.version_info >= (3, 12) - -pytestmark = pytest.mark.skipif( - not is_supported_version, - reason="microsoft-agents-hosting-teams tests require Python 3.12+", -) - -from microsoft_agents.activity import Activity, ActivityTypes -from microsoft_agents.hosting.core import TurnContext -from microsoft_agents.hosting.core.app import AgentApplication, RouteRank - -if is_supported_version: - from microsoft_agents.activity.teams import ( - MeetingParticipantsEventDetails, - ReadReceiptInfo, - ) - from microsoft_teams.api.models import ( - MeetingDetails, - MessagingExtensionQuery, - MessagingExtensionResponse, - O365ConnectorCardActionQuery, - TaskModuleRequest, - TaskModuleResponse, - ) - from microsoft_agents.hosting.teams import TeamsAgentExtension - - -def _make_app() -> AgentApplication: - app = MagicMock(spec=AgentApplication) - app._routes = [] - - def _add_route( - selector, handler, is_invoke=False, rank=RouteRank.DEFAULT, auth_handlers=None - ): - app._routes.append( - dict( - selector=selector, - handler=handler, - is_invoke=is_invoke, - rank=rank, - auth_handlers=auth_handlers, - ) - ) - - app.add_route.side_effect = _add_route - return app - - -def _meeting_details_value() -> dict: - return { - "id": "meeting-id", - "type": "scheduled", - "joinUrl": "https://example.com/meet", - "title": "Test Meeting", - "msGraphResourceId": "graph-id", - } - - -def _make_context( - activity_type: str, - name: str = None, - value: dict = None, - channel_id: str = "msteams", - channel_data: dict = None, - members_added=None, - members_removed=None, -) -> TurnContext: - context = MagicMock(spec=TurnContext) - activity = MagicMock(spec=Activity) - activity.type = activity_type - activity.name = name - activity.value = value - activity.channel_id = channel_id - activity.channel_data = channel_data - activity.members_added = members_added - activity.members_removed = members_removed - context.activity = activity - context.send_activity = AsyncMock() - return context - - -class TestTaskModule: - - def setup_method(self): - self.app = _make_app() - self.teams = TeamsAgentExtension(self.app) - - # ── Selector tests ────────────────────────────────────────────────────── - - @pytestmark - def test_on_fetch_no_verb_matches_any(self): - @self.teams.task_module.on_fetch() - async def handler(ctx, state, req): ... - - selector = self.app._routes[0]["selector"] - ctx = _make_context(ActivityTypes.invoke, name="task/fetch", value={}) - assert selector(ctx) is True - - @pytestmark - def test_on_fetch_verb_matches_exact(self): - @self.teams.task_module.on_fetch("myVerb") - async def handler(ctx, state, req): ... - - selector = self.app._routes[0]["selector"] - ctx_match = _make_context( - ActivityTypes.invoke, name="task/fetch", value={"data": {"verb": "myVerb"}} - ) - ctx_no_match = _make_context( - ActivityTypes.invoke, - name="task/fetch", - value={"data": {"verb": "otherVerb"}}, - ) - assert selector(ctx_match) is True - assert selector(ctx_no_match) is False - - @pytestmark - def test_on_fetch_verb_matches_regex(self): - @self.teams.task_module.on_fetch(re.compile(r"my.*")) - async def handler(ctx, state, req): ... - - selector = self.app._routes[0]["selector"] - ctx_match = _make_context( - ActivityTypes.invoke, - name="task/fetch", - value={"data": {"verb": "mySpecialVerb"}}, - ) - ctx_no_match = _make_context( - ActivityTypes.invoke, - name="task/fetch", - value={"data": {"verb": "otherVerb"}}, - ) - assert selector(ctx_match) is True - assert selector(ctx_no_match) is False - - @pytestmark - def test_on_fetch_wrong_invoke_name(self): - @self.teams.task_module.on_fetch() - async def handler(ctx, state, req): ... - - selector = self.app._routes[0]["selector"] - ctx = _make_context(ActivityTypes.invoke, name="task/submit", value={}) - assert selector(ctx) is False - - @pytestmark - def test_on_fetch_wrong_activity_type(self): - @self.teams.task_module.on_fetch() - async def handler(ctx, state, req): ... - - selector = self.app._routes[0]["selector"] - ctx = _make_context(ActivityTypes.message, name="task/fetch") - assert selector(ctx) is False - - @pytestmark - def test_on_submit_selector(self): - @self.teams.task_module.on_submit("submitVerb") - async def handler(ctx, state, req): ... - - selector = self.app._routes[0]["selector"] - ctx_match = _make_context( - ActivityTypes.invoke, - name="task/submit", - value={"data": {"verb": "submitVerb"}}, - ) - ctx_no_match = _make_context( - ActivityTypes.invoke, name="task/submit", value={"data": {"verb": "other"}} - ) - assert selector(ctx_match) is True - assert selector(ctx_no_match) is False - - @pytestmark - def test_on_fetch_is_invoke(self): - @self.teams.task_module.on_fetch() - async def handler(ctx, state, req): ... - - assert self.app._routes[0]["is_invoke"] is True - - @pytestmark - def test_on_submit_is_invoke(self): - @self.teams.task_module.on_submit() - async def handler(ctx, state, req): ... - - assert self.app._routes[0]["is_invoke"] is True - - # ── Handler tests ─────────────────────────────────────────────────────── - - @pytestmark - @pytest.mark.asyncio - async def test_on_fetch_handler_passes_request_and_sends_response(self): - response = TaskModuleResponse() - user_handler = AsyncMock(return_value=response) - - @self.teams.task_module.on_fetch() - async def handler(ctx, state, req: TaskModuleRequest): - return await user_handler(ctx, state, req) - - route_handler = self.app._routes[0]["handler"] - state = MagicMock() - ctx = _make_context( - ActivityTypes.invoke, - name="task/fetch", - value={"data": {"verb": "myVerb"}, "context": None}, - ) - with patch( - "microsoft_agents.hosting.teams.teams_agent_extension._send_invoke_response", - new_callable=AsyncMock, - ) as mock_send: - await route_handler(ctx, state) - assert user_handler.called - args = user_handler.call_args[0] - assert isinstance(args[2], TaskModuleRequest) - mock_send.assert_awaited_once_with(ctx, response) - - @pytestmark - @pytest.mark.asyncio - async def test_on_fetch_handler_skips_send_when_none_returned(self): - @self.teams.task_module.on_fetch() - async def handler(ctx, state, req): ... - - route_handler = self.app._routes[0]["handler"] - ctx = _make_context( - ActivityTypes.invoke, - name="task/fetch", - value={"data": None, "context": None}, - ) - with patch( - "microsoft_agents.hosting.teams.teams_agent_extension._send_invoke_response", - new_callable=AsyncMock, - ) as mock_send: - await route_handler(ctx, MagicMock()) - mock_send.assert_not_awaited() - - -class TestMessageExtension: - - def setup_method(self): - self.app = _make_app() - self.teams = TeamsAgentExtension(self.app) - - @pytestmark - def test_on_query_matches_by_command_id(self): - @self.teams.message_extension.on_query("searchCmd") - async def handler(ctx, state, query): ... - - selector = self.app._routes[0]["selector"] - ctx_match = _make_context( - ActivityTypes.invoke, - name="composeExtension/query", - value={ - "commandId": "searchCmd", - "parameters": [{"name": "searchQuery", "value": "pizza"}], - }, - ) - ctx_no_match = _make_context( - ActivityTypes.invoke, - name="composeExtension/query", - value={ - "commandId": "other", - "parameters": [{"name": "searchQuery", "value": "searchCmd"}], - }, - ) - assert selector(ctx_match) is True - assert selector(ctx_no_match) is False - - @pytestmark - def test_on_query_no_selector_matches_all(self): - @self.teams.message_extension.on_query() - async def handler(ctx, state, query): ... - - selector = self.app._routes[0]["selector"] - ctx = _make_context( - ActivityTypes.invoke, - name="composeExtension/query", - value={"parameters": [{"name": "searchQuery", "value": "anything"}]}, - ) - ctx_no_params = _make_context( - ActivityTypes.invoke, - name="composeExtension/query", - value={}, - ) - assert selector(ctx) is True - assert selector(ctx_no_params) is True - - @pytestmark - def test_on_query_is_invoke(self): - @self.teams.message_extension.on_query() - async def handler(ctx, state, query): ... - - assert self.app._routes[0]["is_invoke"] is True - - @pytestmark - def test_on_submit_action_excludes_preview(self): - @self.teams.message_extension.on_submit_action() - async def handler(ctx, state, action): ... - - selector = self.app._routes[0]["selector"] - ctx_normal = _make_context( - ActivityTypes.invoke, - name="composeExtension/submitAction", - value={"commandId": "cmd"}, - ) - ctx_preview = _make_context( - ActivityTypes.invoke, - name="composeExtension/submitAction", - value={"commandId": "cmd", "botMessagePreviewAction": "edit"}, - ) - assert selector(ctx_normal) is True - assert selector(ctx_preview) is False - - @pytestmark - def test_on_agent_message_preview_edit_selector(self): - @self.teams.message_extension.on_agent_message_preview_edit() - async def handler(ctx, state, action): ... - - selector = self.app._routes[0]["selector"] - ctx_edit = _make_context( - ActivityTypes.invoke, - name="composeExtension/submitAction", - value={"commandId": "cmd", "botMessagePreviewAction": "edit"}, - ) - ctx_send = _make_context( - ActivityTypes.invoke, - name="composeExtension/submitAction", - value={"commandId": "cmd", "botMessagePreviewAction": "send"}, - ) - assert selector(ctx_edit) is True - assert selector(ctx_send) is False - - @pytestmark - def test_on_agent_message_preview_send_selector(self): - @self.teams.message_extension.on_agent_message_preview_send() - async def handler(ctx, state, action): ... - - selector = self.app._routes[0]["selector"] - ctx_send = _make_context( - ActivityTypes.invoke, - name="composeExtension/submitAction", - value={"commandId": "cmd", "botMessagePreviewAction": "send"}, - ) - ctx_edit = _make_context( - ActivityTypes.invoke, - name="composeExtension/submitAction", - value={"commandId": "cmd", "botMessagePreviewAction": "edit"}, - ) - assert selector(ctx_send) is True - assert selector(ctx_edit) is False - - @pytestmark - def test_on_fetch_task_matches_command_id(self): - @self.teams.message_extension.on_fetch_task("myCmd") - async def handler(ctx, state, action): ... - - selector = self.app._routes[0]["selector"] - ctx_match = _make_context( - ActivityTypes.invoke, - name="composeExtension/fetchTask", - value={"commandId": "myCmd"}, - ) - ctx_no_match = _make_context( - ActivityTypes.invoke, - name="composeExtension/fetchTask", - value={"commandId": "other"}, - ) - assert selector(ctx_match) is True - assert selector(ctx_no_match) is False - - @pytestmark - def test_on_query_link_selector(self): - @self.teams.message_extension.on_query_link - async def handler(ctx, state, query): ... - - selector = self.app._routes[0]["selector"] - ctx = _make_context(ActivityTypes.invoke, name="composeExtension/queryLink") - ctx_other = _make_context(ActivityTypes.invoke, name="composeExtension/query") - assert selector(ctx) is True - assert selector(ctx_other) is False - - @pytestmark - def test_on_anonymous_query_link_selector(self): - @self.teams.message_extension.on_anonymous_query_link - async def handler(ctx, state, query): ... - - selector = self.app._routes[0]["selector"] - ctx = _make_context( - ActivityTypes.invoke, name="composeExtension/anonymousQueryLink" - ) - assert selector(ctx) is True - - @pytestmark - def test_on_select_item_selector(self): - @self.teams.message_extension.on_select_item - async def handler(ctx, state, item): ... - - selector = self.app._routes[0]["selector"] - ctx = _make_context(ActivityTypes.invoke, name="composeExtension/selectItem") - ctx_other = _make_context(ActivityTypes.invoke, name="composeExtension/query") - assert selector(ctx) is True - assert selector(ctx_other) is False - - @pytestmark - def test_on_configure_settings_sends_empty_response(self): - """on_configure_settings always sends a 200 regardless of handler return value.""" - - @self.teams.message_extension.on_configure_settings - async def handler(ctx, state, settings): ... - - assert self.app._routes[0]["is_invoke"] is True - - @pytestmark - def test_on_card_button_clicked_selector(self): - @self.teams.message_extension.on_card_button_clicked - async def handler(ctx, state, data): ... - - selector = self.app._routes[0]["selector"] - ctx = _make_context( - ActivityTypes.invoke, name="composeExtension/onCardButtonClicked" - ) - assert selector(ctx) is True - - @pytestmark - @pytest.mark.asyncio - async def test_on_query_handler_parses_query_and_sends_response(self): - response = MessagingExtensionResponse() - user_handler = AsyncMock(return_value=response) - - @self.teams.message_extension.on_query() - async def handler(ctx, state, query: MessagingExtensionQuery): - return await user_handler(ctx, state, query) - - route_handler = self.app._routes[0]["handler"] - ctx = _make_context( - ActivityTypes.invoke, - name="composeExtension/query", - value={"commandId": "search"}, - ) - with patch( - "microsoft_agents.hosting.teams.teams_agent_extension._send_invoke_response", - new_callable=AsyncMock, - ) as mock_send: - await route_handler(ctx, MagicMock()) - args = user_handler.call_args[0] - assert isinstance(args[2], MessagingExtensionQuery) - mock_send.assert_awaited_once_with(ctx, response) - - @pytestmark - @pytest.mark.asyncio - async def test_on_configure_settings_always_sends_response(self): - @self.teams.message_extension.on_configure_settings - async def handler(ctx, state, settings): ... - - route_handler = self.app._routes[0]["handler"] - ctx = _make_context( - ActivityTypes.invoke, name="composeExtension/setting", value={} - ) - with patch( - "microsoft_agents.hosting.teams.teams_agent_extension._send_invoke_response", - new_callable=AsyncMock, - ) as mock_send: - await route_handler(ctx, MagicMock()) - mock_send.assert_awaited_once_with(ctx) - - -class TestMeeting: - - def setup_method(self): - self.app = _make_app() - self.teams = TeamsAgentExtension(self.app) - - @pytestmark - def test_on_start_selector(self): - @self.teams.meeting.on_start - async def handler(ctx, state, meeting): ... - - selector = self.app._routes[0]["selector"] - ctx = _make_context( - ActivityTypes.event, name="application/vnd.microsoft.meetingStart" - ) - ctx_other = _make_context( - ActivityTypes.event, name="application/vnd.microsoft.meetingEnd" - ) - assert selector(ctx) is True - assert selector(ctx_other) is False - - @pytestmark - def test_on_end_selector(self): - @self.teams.meeting.on_end - async def handler(ctx, state, meeting): ... - - selector = self.app._routes[0]["selector"] - ctx = _make_context( - ActivityTypes.event, name="application/vnd.microsoft.meetingEnd" - ) - assert selector(ctx) is True - - @pytestmark - def test_on_participants_join_selector(self): - @self.teams.meeting.on_participants_join - async def handler(ctx, state, details): ... - - selector = self.app._routes[0]["selector"] - ctx = _make_context( - ActivityTypes.event, - name="application/vnd.microsoft.meetingParticipantJoin", - ) - assert selector(ctx) is True - - @pytestmark - def test_on_participants_leave_selector(self): - @self.teams.meeting.on_participants_leave - async def handler(ctx, state, details): ... - - selector = self.app._routes[0]["selector"] - ctx = _make_context( - ActivityTypes.event, - name="application/vnd.microsoft.meetingParticipantLeave", - ) - assert selector(ctx) is True - - @pytestmark - def test_on_start_is_not_invoke(self): - @self.teams.meeting.on_start - async def handler(ctx, state, meeting): ... - - assert self.app._routes[0]["is_invoke"] is False - - @pytestmark - @pytest.mark.asyncio - async def test_on_start_handler_parses_meeting_details(self): - user_handler = AsyncMock() - - @self.teams.meeting.on_start - async def handler(ctx, state, meeting: MeetingDetails): - await user_handler(ctx, state, meeting) - - route_handler = self.app._routes[0]["handler"] - ctx = _make_context( - ActivityTypes.event, - name="application/vnd.microsoft.meetingStart", - value=_meeting_details_value(), - ) - await route_handler(ctx, MagicMock()) - args = user_handler.call_args[0] - assert isinstance(args[2], MeetingDetails) - - @pytestmark - @pytest.mark.asyncio - async def test_on_end_handler_parses_meeting_details(self): - user_handler = AsyncMock() - - @self.teams.meeting.on_end - async def handler(ctx, state, meeting: MeetingDetails): - await user_handler(ctx, state, meeting) - - route_handler = self.app._routes[0]["handler"] - ctx = _make_context( - ActivityTypes.event, - name="application/vnd.microsoft.meetingEnd", - value=_meeting_details_value(), - ) - await route_handler(ctx, MagicMock()) - args = user_handler.call_args[0] - assert isinstance(args[2], MeetingDetails) - - @pytestmark - @pytest.mark.asyncio - async def test_on_participants_join_handler_parses_details(self): - user_handler = AsyncMock() - - @self.teams.meeting.on_participants_join - async def handler(ctx, state, details: MeetingParticipantsEventDetails): - await user_handler(ctx, state, details) - - route_handler = self.app._routes[0]["handler"] - ctx = _make_context( - ActivityTypes.event, - name="application/vnd.microsoft.meetingParticipantJoin", - value={}, - ) - await route_handler(ctx, MagicMock()) - args = user_handler.call_args[0] - assert isinstance(args[2], MeetingParticipantsEventDetails) - - -class TestTeamsAgentExtensionTopLevel: - - def setup_method(self): - self.app = _make_app() - self.teams = TeamsAgentExtension(self.app) - - # ── Message edit / undelete / soft delete ─────────────────────────────── - - @pytestmark - def test_on_message_edit_selector(self): - @self.teams.on_message_edit - async def handler(ctx, state): ... - - selector = self.app._routes[0]["selector"] - ctx = _make_context( - ActivityTypes.message_update, - channel_data={"eventType": "editMessage"}, - ) - assert selector(ctx) is True - - @pytestmark - def test_on_message_edit_wrong_event_type(self): - @self.teams.on_message_edit - async def handler(ctx, state): ... - - selector = self.app._routes[0]["selector"] - ctx = _make_context( - ActivityTypes.message_update, - channel_data={"eventType": "undeleteMessage"}, - ) - assert selector(ctx) is False - - @pytestmark - def test_on_message_edit_wrong_channel(self): - @self.teams.on_message_edit - async def handler(ctx, state): ... - - selector = self.app._routes[0]["selector"] - ctx = _make_context( - ActivityTypes.message_update, - channel_id="webchat", - channel_data={"eventType": "editMessage"}, - ) - assert selector(ctx) is False - - @pytestmark - def test_on_message_undelete_selector(self): - @self.teams.on_message_undelete - async def handler(ctx, state): ... - - selector = self.app._routes[0]["selector"] - ctx = _make_context( - ActivityTypes.message_update, - channel_data={"eventType": "undeleteMessage"}, - ) - assert selector(ctx) is True - - @pytestmark - def test_on_message_soft_delete_selector(self): - @self.teams.on_message_soft_delete - async def handler(ctx, state): ... - - selector = self.app._routes[0]["selector"] - ctx = _make_context( - ActivityTypes.message_delete, - channel_data={"eventType": "softDeleteMessage"}, - ) - assert selector(ctx) is True - - # ── Read receipt ─────────────────────────────────────────────────────── - - @pytestmark - def test_on_read_receipt_selector(self): - @self.teams.on_read_receipt - async def handler(ctx, state, receipt): ... - - selector = self.app._routes[0]["selector"] - ctx = _make_context( - ActivityTypes.event, name="application/vnd.microsoft.readReceipt" - ) - assert selector(ctx) is True - - @pytestmark - @pytest.mark.asyncio - async def test_on_read_receipt_handler_parses_receipt(self): - user_handler = AsyncMock() - - @self.teams.on_read_receipt - async def handler(ctx, state, receipt: ReadReceiptInfo): - await user_handler(ctx, state, receipt) - - route_handler = self.app._routes[0]["handler"] - ctx = _make_context( - ActivityTypes.event, - name="application/vnd.microsoft.readReceipt", - value={}, - ) - await route_handler(ctx, MagicMock()) - args = user_handler.call_args[0] - assert isinstance(args[2], ReadReceiptInfo) - - # ── Config ───────────────────────────────────────────────────────────── - - @pytestmark - def test_on_config_fetch_selector(self): - @self.teams.on_config_fetch - async def handler(ctx, state, data): ... - - selector = self.app._routes[0]["selector"] - ctx = _make_context(ActivityTypes.invoke, name="config/fetch") - ctx_other = _make_context(ActivityTypes.invoke, name="config/submit") - assert selector(ctx) is True - assert selector(ctx_other) is False - - @pytestmark - def test_on_config_fetch_is_invoke(self): - @self.teams.on_config_fetch - async def handler(ctx, state, data): ... - - assert self.app._routes[0]["is_invoke"] is True - - @pytestmark - def test_on_config_submit_selector(self): - @self.teams.on_config_submit - async def handler(ctx, state, data): ... - - selector = self.app._routes[0]["selector"] - ctx = _make_context(ActivityTypes.invoke, name="config/submit") - assert selector(ctx) is True - - # ── File consent ─────────────────────────────────────────────────────── - - @pytestmark - def test_on_file_consent_accept_selector(self): - @self.teams.on_file_consent_accept - async def handler(ctx, state, data): ... - - selector = self.app._routes[0]["selector"] - ctx_accept = _make_context( - ActivityTypes.invoke, - name="fileConsent/invoke", - value={"action": "accept"}, - ) - ctx_decline = _make_context( - ActivityTypes.invoke, - name="fileConsent/invoke", - value={"action": "decline"}, - ) - assert selector(ctx_accept) is True - assert selector(ctx_decline) is False - - @pytestmark - def test_on_file_consent_decline_selector(self): - @self.teams.on_file_consent_decline - async def handler(ctx, state, data): ... - - selector = self.app._routes[0]["selector"] - ctx_decline = _make_context( - ActivityTypes.invoke, - name="fileConsent/invoke", - value={"action": "decline"}, - ) - ctx_accept = _make_context( - ActivityTypes.invoke, - name="fileConsent/invoke", - value={"action": "accept"}, - ) - assert selector(ctx_decline) is True - assert selector(ctx_accept) is False - - @pytestmark - @pytest.mark.asyncio - async def test_on_file_consent_accept_sends_empty_response(self): - @self.teams.on_file_consent_accept - async def handler(ctx, state, data): ... - - route_handler = self.app._routes[0]["handler"] - ctx = _make_context( - ActivityTypes.invoke, - name="fileConsent/invoke", - value={"action": "accept", "context": None, "uploadInfo": None}, - ) - with patch( - "microsoft_agents.hosting.teams.teams_agent_extension._send_invoke_response", - new_callable=AsyncMock, - ) as mock_send: - await route_handler(ctx, MagicMock()) - mock_send.assert_awaited_once_with(ctx) - - # ── O365 ─────────────────────────────────────────────────────────────── - - @pytestmark - def test_on_o365_connector_card_action_selector(self): - @self.teams.on_o365_connector_card_action - async def handler(ctx, state, query): ... - - selector = self.app._routes[0]["selector"] - ctx = _make_context( - ActivityTypes.invoke, name="actionableMessage/executeAction" - ) - assert selector(ctx) is True - - @pytestmark - @pytest.mark.asyncio - async def test_on_o365_connector_card_action_parses_query(self): - user_handler = AsyncMock() - - @self.teams.on_o365_connector_card_action - async def handler(ctx, state, query: O365ConnectorCardActionQuery): - await user_handler(ctx, state, query) - - route_handler = self.app._routes[0]["handler"] - ctx = _make_context( - ActivityTypes.invoke, - name="actionableMessage/executeAction", - value={}, - ) - with patch( - "microsoft_agents.hosting.teams.teams_agent_extension._send_invoke_response", - new_callable=AsyncMock, - ): - await route_handler(ctx, MagicMock()) - args = user_handler.call_args[0] - assert isinstance(args[2], O365ConnectorCardActionQuery) - - # ── Conversation update events ───────────────────────────────────────── - - @pytestmark - def test_on_members_added_selector(self): - @self.teams.on_members_added - async def handler(ctx, state): ... - - selector = self.app._routes[0]["selector"] - member = MagicMock() - ctx = _make_context( - ActivityTypes.conversation_update, - members_added=[member], - ) - ctx_empty = _make_context( - ActivityTypes.conversation_update, - members_added=[], - ) - assert selector(ctx) is True - assert selector(ctx_empty) is False - - @pytestmark - def test_on_members_added_requires_teams_channel(self): - @self.teams.on_members_added - async def handler(ctx, state): ... - - selector = self.app._routes[0]["selector"] - member = MagicMock() - ctx = _make_context( - ActivityTypes.conversation_update, - channel_id="webchat", - members_added=[member], - ) - assert selector(ctx) is False - - @pytestmark - def test_on_members_removed_selector(self): - @self.teams.on_members_removed - async def handler(ctx, state): ... - - selector = self.app._routes[0]["selector"] - member = MagicMock() - ctx = _make_context( - ActivityTypes.conversation_update, - members_removed=[member], - ) - assert selector(ctx) is True - - @pytestmark - def test_on_channel_created_selector(self): - @self.teams.on_channel_created - async def handler(ctx, state): ... - - selector = self.app._routes[0]["selector"] - ctx = _make_context( - ActivityTypes.conversation_update, - channel_data={"eventType": "channelCreated"}, - ) - ctx_other = _make_context( - ActivityTypes.conversation_update, - channel_data={"eventType": "channelDeleted"}, - ) - assert selector(ctx) is True - assert selector(ctx_other) is False - - @pytestmark - def test_on_channel_deleted_selector(self): - @self.teams.on_channel_deleted - async def handler(ctx, state): ... - - selector = self.app._routes[0]["selector"] - ctx = _make_context( - ActivityTypes.conversation_update, - channel_data={"eventType": "channelDeleted"}, - ) - assert selector(ctx) is True - - @pytestmark - def test_on_channel_renamed_selector(self): - @self.teams.on_channel_renamed - async def handler(ctx, state): ... - - selector = self.app._routes[0]["selector"] - ctx = _make_context( - ActivityTypes.conversation_update, - channel_data={"eventType": "channelRenamed"}, - ) - assert selector(ctx) is True - - @pytestmark - def test_on_channel_restored_selector(self): - @self.teams.on_channel_restored - async def handler(ctx, state): ... - - selector = self.app._routes[0]["selector"] - ctx = _make_context( - ActivityTypes.conversation_update, - channel_data={"eventType": "channelRestored"}, - ) - assert selector(ctx) is True - - @pytestmark - def test_on_team_archived_selector(self): - @self.teams.on_team_archived - async def handler(ctx, state): ... - - selector = self.app._routes[0]["selector"] - ctx = _make_context( - ActivityTypes.conversation_update, - channel_data={"eventType": "teamArchived"}, - ) - assert selector(ctx) is True - - @pytestmark - def test_on_team_deleted_selector(self): - @self.teams.on_team_deleted - async def handler(ctx, state): ... - - selector = self.app._routes[0]["selector"] - ctx = _make_context( - ActivityTypes.conversation_update, - channel_data={"eventType": "teamDeleted"}, - ) - assert selector(ctx) is True - - @pytestmark - def test_on_team_hard_deleted_selector(self): - @self.teams.on_team_hard_deleted - async def handler(ctx, state): ... - - selector = self.app._routes[0]["selector"] - ctx = _make_context( - ActivityTypes.conversation_update, - channel_data={"eventType": "teamHardDeleted"}, - ) - assert selector(ctx) is True - - @pytestmark - def test_on_team_renamed_selector(self): - @self.teams.on_team_renamed - async def handler(ctx, state): ... - - selector = self.app._routes[0]["selector"] - ctx = _make_context( - ActivityTypes.conversation_update, - channel_data={"eventType": "teamRenamed"}, - ) - assert selector(ctx) is True - - @pytestmark - def test_on_team_restored_selector(self): - @self.teams.on_team_restored - async def handler(ctx, state): ... - - selector = self.app._routes[0]["selector"] - ctx = _make_context( - ActivityTypes.conversation_update, - channel_data={"eventType": "teamRestored"}, - ) - assert selector(ctx) is True - - @pytestmark - def test_on_team_unarchived_selector(self): - @self.teams.on_team_unarchived - async def handler(ctx, state): ... - - selector = self.app._routes[0]["selector"] - ctx = _make_context( - ActivityTypes.conversation_update, - channel_data={"eventType": "teamUnarchived"}, - ) - assert selector(ctx) is True - - # ── Sub-object accessors ──────────────────────────────────────────────── - - @pytestmark - def test_properties_return_sub_objects(self): - from microsoft_agents.hosting.teams.teams_agent_extension import ( - MessageExtension, - TaskModule, - Meeting, - ) - - assert isinstance(self.teams.message_extension, MessageExtension) - assert isinstance(self.teams.task_module, TaskModule) - assert isinstance(self.teams.meeting, Meeting) - - # ── Decorator style without args ──────────────────────────────────────── - - @pytestmark - def test_on_message_edit_direct_decorator(self): - """Verify on_message_edit works as @teams.on_message_edit (no call parens).""" - - @self.teams.on_message_edit - async def handler(ctx, state): ... - - assert len(self.app._routes) == 1 - - @pytestmark - def test_on_message_edit_factory_decorator(self): - """Verify on_message_edit works as @teams.on_message_edit() (with call parens).""" - - @self.teams.on_message_edit() - async def handler(ctx, state): ... - - assert len(self.app._routes) == 1 From 6349c6c0926fdf159e4f0e39afa8f40fee83f308 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Tue, 23 Jun 2026 14:42:35 -0700 Subject: [PATCH 23/46] Revising ApiClient setting --- .../hosting/msteams/__init__.py | 3 ++ .../message_extension/message_extension.py | 8 ++-- .../message_extension/route_handlers.py | 2 +- .../hosting/msteams/teams_agent_extension.py | 10 ++--- ...eams_api_client.py => teams_api_client.py} | 37 ++++++++++++------- .../hosting/msteams/teams_turn_context.py | 28 ++------------ .../test_message_extensions.py | 14 +++---- 7 files changed, 48 insertions(+), 54 deletions(-) rename libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/{_teams_api_client.py => teams_api_client.py} (69%) diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/__init__.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/__init__.py index b581586d0..3f8100c5e 100644 --- a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/__init__.py +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/__init__.py @@ -15,6 +15,8 @@ from .task_module import TaskModule from .team import Team +from .teams_api_client import get_teams_api_client + __all__ = [ "TeamsAgentExtension", "Channel", @@ -25,4 +27,5 @@ "MessageExtension", "TaskModule", "Team", + "get_teams_api_client", ] diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/message_extension/message_extension.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/message_extension/message_extension.py index e1127f0c3..bf3984ca7 100644 --- a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/message_extension/message_extension.py +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/message_extension/message_extension.py @@ -29,7 +29,7 @@ from microsoft_agents.hosting.msteams._utils import _match_selector, _send_invoke_response from .route_handlers import ( - FetchTaskHandler, + FetchActionHandler, QueryHandler, SubmitActionHandler, MessagePreviewEditHandler, @@ -296,13 +296,13 @@ async def __handler(context: TurnContext, state: StateT) -> None: return __call - def fetch_task( + def fetch_action( self, command_id: CommandSelector = None, *, auth_handlers: Optional[list[str]] = None, rank: RouteRank = RouteRank.DEFAULT, - ) -> _RouteDecorator[FetchTaskHandler[StateT]]: + ) -> _RouteDecorator[FetchActionHandler[StateT]]: """Register a handler for composeExtension/fetchTask invokes.""" def __selector(context: TurnContext) -> bool: @@ -314,7 +314,7 @@ def __selector(context: TurnContext) -> bool: and _match_selector(command_id, value.get("commandId")) ) - def __call(func: FetchTaskHandler[StateT]) -> FetchTaskHandler[StateT]: + def __call(func: FetchActionHandler[StateT]) -> FetchActionHandler[StateT]: async def __handler(context: TurnContext, state: StateT) -> None: teams_context = TeamsTurnContext(context, self._app) action = MessagingExtensionAction.model_validate( diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/message_extension/route_handlers.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/message_extension/route_handlers.py index bdffca192..37b1d445a 100644 --- a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/message_extension/route_handlers.py +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/message_extension/route_handlers.py @@ -23,7 +23,7 @@ from microsoft_agents.hosting.msteams.type_defs import _StateContra -class FetchTaskHandler(Protocol[_StateContra]): +class FetchActionHandler(Protocol[_StateContra]): """Protocol for a handler invoked on composeExtension/fetchTask activities.""" def __call__( diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_agent_extension.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_agent_extension.py index e787da691..88e37c59d 100644 --- a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_agent_extension.py +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_agent_extension.py @@ -46,8 +46,7 @@ from .type_defs import StateT -from ._teams_api_client import _set_teams_api_client -from ._utils import _try_get_channel_data +from .teams_api_client import set_teams_api_client class _AppRouteDecorator(Protocol[StateT]): """Protocol for a decorator returned by :class:`TeamsAgentExtension` route methods.""" @@ -104,11 +103,12 @@ def __init__(self, app: AgentApplication[StateT]) -> None: def _configure_app(self): """Configure the underlying AgentApplication with Teams-specific routes.""" - async def before_handler(context: TurnContext, state: StateT) -> bool: + async def on_before_turn(context: TurnContext, state: StateT) -> bool: if context.activity.channel_id == Channels.ms_teams: - await _set_teams_api_client(context, self._app.connection_manager) - context.activity.channel_data = _try_get_channel_data(context) + set_teams_api_client(context, self._app.connection_manager) return True + + self._app.before_turn(on_before_turn) @property def channels(self) -> Channel[StateT]: diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/_teams_api_client.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_api_client.py similarity index 69% rename from libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/_teams_api_client.py rename to libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_api_client.py index 0673386d2..fdce70619 100644 --- a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/_teams_api_client.py +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_api_client.py @@ -24,34 +24,45 @@ def get_teams_api_client(context: TurnContext) -> ApiClient: return api_client raise ValueError("Unable to retrieve Teams API client.") -async def _set_teams_api_client( +def set_teams_api_client( context: TurnContext, connection_manager: Connections ) -> None: """ - Set the Teams API client in the context. + Set the Teams API client in the context if it is not already set. :param context: The turn context. :param connection_manager: The connection manager. """ - token: str | None = None + + if _TEAMS_API_CLIENT_KEY in context.turn_state: + return + + headers = { + "Accept": "application/json", + "Content-Type": "application/json", + } + + options: ClientOptions + if context.identity: assert context.identity is not None provider = connection_manager.get_token_provider( context.identity, context.activity.service_url ) - token = await provider.get_access_token( - "https://api.botframework.com", ["https://api.botframework.com/.default"] - ) - headers = { - "Accept": "application/json", - "Content-Type": "application/json", - } + async def token_factory() -> str: + return await provider.get_access_token( + "https://api.botframework.com", ["https://api.botframework.com/.default"] + ) - options = ClientOptions( - base_url=context.activity.service_url, headers=headers, token=token - ) + options = ClientOptions( + base_url=context.activity.service_url, + headers=headers, + token=token_factory + ) + else: + options = ClientOptions(base_url=context.activity.service_url, headers=headers) api_client = ApiClient( context.activity.service_url, diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_turn_context.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_turn_context.py index 818e8469a..e149722fb 100644 --- a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_turn_context.py +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_turn_context.py @@ -15,8 +15,7 @@ ) from microsoft_agents.hosting.core import AgentApplication, TurnContext -from ._teams_api_client import get_teams_api_client, _TEAMS_API_CLIENT_KEY - +from .teams_api_client import get_teams_api_client, set_teams_api_client class TeamsTurnContext(TurnContext): """A context object for handling Teams-specific turn functionality. @@ -25,7 +24,7 @@ class TeamsTurnContext(TurnContext): receive a typed context without changing the core routing engine. """ - def __init__(self, context: TurnContext, app: AgentApplication) -> None: + def __init__(self, context: TurnContext, app: AgentApplication, _sentinel=None) -> None: """Initialise the Teams turn context from a plain turn context. :param context: The base turn context provided by the core runtime. @@ -34,31 +33,12 @@ def __init__(self, context: TurnContext, app: AgentApplication) -> None: super().__init__(context) self._app = app self._turn_state.update(context.turn_state) - - @classmethod - async def create( - cls, context: TurnContext, app: AgentApplication - ) -> TeamsTurnContext: - """Create a Teams turn context from a plain turn context. - - :param context: The base turn context provided by the core runtime. - :param app: The agent application that is handling the turn. - :return: A new Teams turn context. - """ - instance = TeamsTurnContext(context, app) - await get_teams_api_client(context, app.auth.connection_manager) - return instance + set_teams_api_client(context, app.connection_manager) @property def api_client(self) -> ApiClient: """Get the API client for the Teams turn context.""" - - api_client = self._turn_state.get(_TEAMS_API_CLIENT_KEY) - if not isinstance(api_client, ApiClient): - raise ValueError( - "Teams ApiClient unavailable. Use TeamsTurnContext.create() to create it." - ) - return api_client + return get_teams_api_client(self) @staticmethod def _make_targeted_activity(activity: Activity) -> Activity: diff --git a/tests/hosting_msteams/test_message_extensions.py b/tests/hosting_msteams/test_message_extensions.py index d2a689770..e38bfe1a2 100644 --- a/tests/hosting_msteams/test_message_extensions.py +++ b/tests/hosting_msteams/test_message_extensions.py @@ -339,14 +339,14 @@ async def handler(ctx, state, preview): ... await route_handler(ctx, MagicMock()) -class TestMessageExtensionFetchTask: +class TestMessageExtensionFetchAction: def setup_method(self): self.app = _make_app() self.ext = TeamsAgentExtension(self.app) - def test_fetch_task_matches_command_id(self): - @self.ext.message_extensions.fetch_task("myCmd") + def test_fetch_action_matches_command_id(self): + @self.ext.message_extensions.fetch_action("myCmd") async def handler(ctx, state, action): ... selector = self.app._routes[0]["selector"] @@ -371,8 +371,8 @@ async def handler(ctx, state, action): ... is False ) - def test_fetch_task_no_command_id_matches_all(self): - @self.ext.message_extensions.fetch_task() + def test_fetch_action_no_command_id_matches_all(self): + @self.ext.message_extensions.fetch_action() async def handler(ctx, state, action): ... selector = self.app._routes[0]["selector"] @@ -387,8 +387,8 @@ async def handler(ctx, state, action): ... is True ) - def test_fetch_task_is_invoke(self): - @self.ext.message_extensions.fetch_task() + def test_fetch_action_is_invoke(self): + @self.ext.message_extensions.fetch_action() async def handler(ctx, state, action): ... assert self.app._routes[0]["is_invoke"] is True From 0495df0f381e462b533172d3eb6b7050b076ac80 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Tue, 23 Jun 2026 15:09:27 -0700 Subject: [PATCH 24/46] Adding TeamsActivity static helper --- .../hosting/core/app/app_options.py | 2 +- .../hosting/core/app/oauth/authorization.py | 2 +- .../hosting/msteams/_utils.py | 1 + .../message_extension/message_extension.py | 5 +- .../hosting/msteams/route_handlers.py | 8 +- .../hosting/msteams/teams_activity.py | 93 +++++++- .../hosting/msteams/teams_agent_extension.py | 3 +- .../hosting/msteams/teams_api_client.py | 16 +- .../hosting/msteams/teams_turn_context.py | 10 +- .../readme.md | 220 +++++++++++++++--- 10 files changed, 304 insertions(+), 56 deletions(-) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/app_options.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/app_options.py index 87dd7d5c8..bb3ebc6ff 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/app_options.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/app_options.py @@ -105,4 +105,4 @@ class ApplicationOptions: Optional. Options for the proactive messaging subsystem. When set, :attr:`microsoft_agents.hosting.core.AgentApplication.proactive` is available for storing conversations and initiating proactive turns. - """ \ No newline at end of file + """ diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/authorization.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/authorization.py index 89909ffe2..21549321f 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/authorization.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/authorization.py @@ -123,7 +123,7 @@ def _init_handlers(self) -> None: connections=self._connections, auth_handler=auth_handler, ) - + @property def connections(self) -> Connections: return self._connections diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/_utils.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/_utils.py index e51964a46..b435bce3b 100644 --- a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/_utils.py +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/_utils.py @@ -17,6 +17,7 @@ from .type_defs import CommandSelector + def _try_get_channel_data(context: TurnContext) -> ChannelData | None: """Attempt to extract and parse Teams channel data from the activity's channel_data. diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/message_extension/message_extension.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/message_extension/message_extension.py index bf3984ca7..659f5d22c 100644 --- a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/message_extension/message_extension.py +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/message_extension/message_extension.py @@ -26,7 +26,10 @@ _RouteDecorator, ) -from microsoft_agents.hosting.msteams._utils import _match_selector, _send_invoke_response +from microsoft_agents.hosting.msteams._utils import ( + _match_selector, + _send_invoke_response, +) from .route_handlers import ( FetchActionHandler, diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/route_handlers.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/route_handlers.py index 14f81f7b3..3df2e0f89 100644 --- a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/route_handlers.py +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/route_handlers.py @@ -23,7 +23,9 @@ class TeamsRouteHandler(Protocol[_StateContra]): """Protocol for a Teams route handler that receives a :class:`TeamsTurnContext`.""" - def __call__(self, context: TeamsTurnContext, state: _StateContra, /) -> Awaitable[None]: + def __call__( + self, context: TeamsTurnContext, state: _StateContra, / + ) -> Awaitable[None]: """Handle a turn with Teams context. :param context: Teams-aware turn context. @@ -77,7 +79,9 @@ def wrap_teams_handoff_handler( :return: A :class:`HandoffHandler` that upgrades the context before delegating. """ - async def __func(context: TurnContext, state: _StateContra, handoff_data: str) -> None: + async def __func( + context: TurnContext, state: _StateContra, handoff_data: str + ) -> None: teams_context = TeamsTurnContext(context, app) await handler(teams_context, state, handoff_data) diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_activity.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_activity.py index 9b7a5745d..7832a93df 100644 --- a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_activity.py +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_activity.py @@ -1,5 +1,94 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from typing import Literal + +from microsoft_teams.api.models import ( + ChannelData, + FeedbackLoop, + MeetingInfo, + NotificationInfo, + OnBehalfOf, + TeamInfo, +) + from microsoft_agents.activity import Activity -class TeamsActivity(Activity): - pass \ No newline at end of file +def _get_channel_data(activity: Activity) -> ChannelData | None: + """Get the channel data from the activity, if it exists.""" + if isinstance(activity.channel_data, ChannelData): + return activity.channel_data + elif isinstance(activity.channel_data, dict): + return ChannelData.model_validate(activity.channel_data) + return None + + +class TeamsActivity: + + @staticmethod + def get_selected_channel_id(self, activity: Activity) -> str | None: + """Get the ID of the selected channel from the activity, if it exists.""" + channel_data = _get_channel_data(activity) + if ( + channel_data + and channel_data.settings + and channel_data.settings.selected_channel + ): + return channel_data.settings.selected_channel.id + return None + + @staticmethod + def get_channel_id(activity: Activity) -> str | None: + """Get the ID of the channel from the activity, if it exists.""" + channel_data = _get_channel_data(activity) + if channel_data and channel_data.channel: + return channel_data.channel.id + return None + + @staticmethod + def get_meeting_info(activity: Activity) -> MeetingInfo | None: + """Get the meeting info from the activity, if it exists.""" + channel_data = _get_channel_data(activity) + if channel_data: + return channel_data.meeting + return None + + @staticmethod + def get_team_info(activity: Activity) -> TeamInfo | None: + """Get the team info from the activity, if it exists.""" + channel_data = _get_channel_data(activity) + if channel_data: + return channel_data.team + return None + + @staticmethod + def notify_user( + activity: Activity, + alert_in_meeting: bool = False, + external_resource_url: str | None = None, + ): + channel_data = _get_channel_data(activity) + if not channel_data: + channel_data = ChannelData() + activity.channel_data = channel_data + + channel_data.notification = NotificationInfo( + alert=not alert_in_meeting, + alert_in_meeting=alert_in_meeting, + external_resource_url=external_resource_url, + ) + + @staticmethod + def enable_feedback_loop( + activity: Activity, feedback_loop_type: Literal["default", "custom"] = "default" + ) -> bool: + + channel_data = _get_channel_data(activity) + if channel_data is not None: + return False + + activity.channel_data = ChannelData( + feedback_loop=FeedbackLoop(type=feedback_loop_type) + ) + return True diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_agent_extension.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_agent_extension.py index 88e37c59d..a076da65b 100644 --- a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_agent_extension.py +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_agent_extension.py @@ -48,6 +48,7 @@ from .teams_api_client import set_teams_api_client + class _AppRouteDecorator(Protocol[StateT]): """Protocol for a decorator returned by :class:`TeamsAgentExtension` route methods.""" @@ -107,7 +108,7 @@ async def on_before_turn(context: TurnContext, state: StateT) -> bool: if context.activity.channel_id == Channels.ms_teams: set_teams_api_client(context, self._app.connection_manager) return True - + self._app.before_turn(on_before_turn) @property diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_api_client.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_api_client.py index fdce70619..7a8757edd 100644 --- a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_api_client.py +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_api_client.py @@ -11,6 +11,7 @@ _TEAMS_API_CLIENT_KEY = "TeamsApiClient" + def get_teams_api_client(context: TurnContext) -> ApiClient: """ Get the cached Teams API client from the context. @@ -24,10 +25,8 @@ def get_teams_api_client(context: TurnContext) -> ApiClient: return api_client raise ValueError("Unable to retrieve Teams API client.") -def set_teams_api_client( - context: TurnContext, - connection_manager: Connections - ) -> None: + +def set_teams_api_client(context: TurnContext, connection_manager: Connections) -> None: """ Set the Teams API client in the context if it is not already set. @@ -53,13 +52,12 @@ def set_teams_api_client( async def token_factory() -> str: return await provider.get_access_token( - "https://api.botframework.com", ["https://api.botframework.com/.default"] + "https://api.botframework.com", + ["https://api.botframework.com/.default"], ) options = ClientOptions( - base_url=context.activity.service_url, - headers=headers, - token=token_factory + base_url=context.activity.service_url, headers=headers, token=token_factory ) else: options = ClientOptions(base_url=context.activity.service_url, headers=headers) @@ -69,4 +67,4 @@ async def token_factory() -> str: options, ) - context.turn_state[_TEAMS_API_CLIENT_KEY] = api_client \ No newline at end of file + context.turn_state[_TEAMS_API_CLIENT_KEY] = api_client diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_turn_context.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_turn_context.py index e149722fb..cdec91ee9 100644 --- a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_turn_context.py +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_turn_context.py @@ -17,6 +17,7 @@ from .teams_api_client import get_teams_api_client, set_teams_api_client + class TeamsTurnContext(TurnContext): """A context object for handling Teams-specific turn functionality. @@ -24,7 +25,9 @@ class TeamsTurnContext(TurnContext): receive a typed context without changing the core routing engine. """ - def __init__(self, context: TurnContext, app: AgentApplication, _sentinel=None) -> None: + def __init__( + self, context: TurnContext, app: AgentApplication, _sentinel=None + ) -> None: """Initialise the Teams turn context from a plain turn context. :param context: The base turn context provided by the core runtime. @@ -35,6 +38,11 @@ def __init__(self, context: TurnContext, app: AgentApplication, _sentinel=None) self._turn_state.update(context.turn_state) set_teams_api_client(context, app.connection_manager) + @property + def activity(self) -> TeamsActivity: + """Get the Teams activity for the turn context.""" + return TeamsActivity(self._activity) + @property def api_client(self) -> ApiClient: """Get the API client for the Teams turn context.""" diff --git a/libraries/microsoft-agents-hosting-msteams/readme.md b/libraries/microsoft-agents-hosting-msteams/readme.md index 1e85b391d..50f830f01 100644 --- a/libraries/microsoft-agents-hosting-msteams/readme.md +++ b/libraries/microsoft-agents-hosting-msteams/readme.md @@ -1,17 +1,17 @@ -# Microsoft Agents Hosting - Teams +# microsoft-agents-hosting-msteams [![PyPI version](https://img.shields.io/pypi/v/microsoft-agents-hosting-msteams)](https://pypi.org/project/microsoft-agents-hosting-msteams/) -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. +Teams-specific extension for the Microsoft 365 Agents SDK for Python. Provides the `TeamsAgentExtension` and a full set of typed, decorator-based route registrars for every Teams invoke and event activity — messaging extensions, task modules, meeting lifecycle events, channel and team management, file consent, config invokes, and more. -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. +All handlers receive a `TeamsTurnContext`, a Teams-aware wrapper around the base `TurnContext` that surfaces the Teams API client and will serve as the primary surface for Teams-specific functionality going forward. -This library is still in flux, as the interfaces to Teams continue to evolve. +## What is this? -# 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. +This library is part of the **Microsoft 365 Agents SDK for Python** — a comprehensive framework for building enterprise-grade conversational AI agents. The SDK enables developers to create intelligent agents that work across Microsoft Teams, M365 Copilot, Copilot Studio, and web chat. ## Release Notes + @@ -94,19 +94,13 @@ This library is part of the **Microsoft 365 Agents SDK for Python** - a comprehe ## Packages Overview -We offer the following PyPI packages to create conversational experiences based on Agents: - | Package Name | PyPI Version | Description | |--------------|-------------|-------------| | `microsoft-agents-activity` | [![PyPI](https://img.shields.io/pypi/v/microsoft-agents-activity)](https://pypi.org/project/microsoft-agents-activity/) | Types and validators implementing the Activity protocol spec. | | `microsoft-agents-hosting-core` | [![PyPI](https://img.shields.io/pypi/v/microsoft-agents-hosting-core)](https://pypi.org/project/microsoft-agents-hosting-core/) | Core library for Microsoft Agents hosting. | | `microsoft-agents-hosting-aiohttp` | [![PyPI](https://img.shields.io/pypi/v/microsoft-agents-hosting-aiohttp)](https://pypi.org/project/microsoft-agents-hosting-aiohttp/) | Configures aiohttp to run the Agent. | -<<<<<<< HEAD:libraries/microsoft-agents-hosting-msteams/readme.md | `microsoft-agents-hosting-msteams` | [![PyPI](https://img.shields.io/pypi/v/microsoft-agents-hosting-msteams)](https://pypi.org/project/microsoft-agents-hosting-msteams/) | Provides classes to host an Agent for Teams. | -======= -| `microsoft-agents-hosting-teams` | [![PyPI](https://img.shields.io/pypi/v/microsoft-agents-hosting-teams)](https://pypi.org/project/microsoft-agents-hosting-teams/) | Provides classes to host an Agent for Teams. | | `microsoft-agents-hosting-dialogs` | [![PyPI](https://img.shields.io/pypi/v/microsoft-agents-hosting-dialogs)](https://pypi.org/project/microsoft-agents-hosting-dialogs/) | Dialog system with waterfall dialogs, prompts, and multi-turn conversation management. | ->>>>>>> c5739c3ecbe90d0e7cbecd5fbb0f0aeafde09eb4:libraries/microsoft-agents-hosting-teams/readme.md | `microsoft-agents-storage-blob` | [![PyPI](https://img.shields.io/pypi/v/microsoft-agents-storage-blob)](https://pypi.org/project/microsoft-agents-storage-blob/) | Extension to use Azure Blob as storage. | | `microsoft-agents-storage-cosmos` | [![PyPI](https://img.shields.io/pypi/v/microsoft-agents-storage-cosmos)](https://pypi.org/project/microsoft-agents-storage-cosmos/) | Extension to use CosmosDB as storage. | | `microsoft-agents-authentication-msal` | [![PyPI](https://img.shields.io/pypi/v/microsoft-agents-authentication-msal)](https://pypi.org/project/microsoft-agents-authentication-msal/) | MSAL-based authentication for Microsoft Agents. | @@ -117,41 +111,191 @@ Additionally we provide a Copilot Studio Client, to interact with Agents created |--------------|-------------|-------------| | `microsoft-agents-copilotstudio-client` | [![PyPI](https://img.shields.io/pypi/v/microsoft-agents-copilotstudio-client)](https://pypi.org/project/microsoft-agents-copilotstudio-client/) | Direct to Engine client to interact with Agents created in CopilotStudio | - ## Installation ```bash pip install microsoft-agents-hosting-msteams ``` -## Key Classes Reference +## Key Classes + +| Class | Description | +|-------|-------------| +| `TeamsAgentExtension` | Attaches to an `AgentApplication` and exposes all Teams route registrars as typed properties. | +| `TeamsTurnContext` | Teams-aware context passed to every handler. Provides the Teams API client and will be the primary surface for new Teams-specific capabilities. | +| `MessageExtension` | Route registrar for all `composeExtension/*` invokes (query, fetch action, submit, link unfurling, settings, etc.). | +| `TaskModule` | Route registrar for `task/fetch` and `task/submit` invokes. | +| `Meeting` | Route registrar for meeting start/end and participant join/leave events. | +| `Channel` | Route registrar for channel created/deleted/renamed/shared events. | +| `Team` | Route registrar for team archived/renamed/restored/deleted events. | +| `FileConsent` | Route registrar for file consent accept/decline invokes. | +| `Config` | Route registrar for `config/fetch` and `config/submit` invokes. | +| `Message` | Route registrar for message edit, delete, undelete, read receipts, and actionable message execute. | + +## Usage + +### Setup + +Wrap your `AgentApplication` with `TeamsAgentExtension` to access all Teams-specific route registrars: + +```python +from microsoft_agents.hosting.msteams import TeamsAgentExtension +from microsoft_agents.hosting.core import AgentApplication, TurnState + +app = AgentApplication[TurnState]() +teams = TeamsAgentExtension(app) +``` + +Every handler receives a `TeamsTurnContext` as its first argument, giving access to the Teams API client alongside all standard `TurnContext` capabilities. + +### Messaging Extensions + +```python +# Search-based extension +@teams.message_extensions.query("searchCmd") +async def on_search(context: TeamsTurnContext, state, query: MessagingExtensionQuery): + # build and return results + return MessagingExtensionResponse(...) + +# Action-based extension — open a task module to collect input +@teams.message_extensions.fetch_action("myCmd") +async def on_fetch_action(context: TeamsTurnContext, state, action: MessagingExtensionAction): + return MessagingExtensionActionResponse(...) + +# Handle submission from the task module +@teams.message_extensions.submit_action("myCmd") +async def on_submit(context: TeamsTurnContext, state, action: MessagingExtensionAction): + return MessagingExtensionResponse(...) + +# Link unfurling +@teams.message_extensions.query_link +async def on_query_link(context: TeamsTurnContext, state, query: AppBasedLinkQuery): + return MessagingExtensionResponse(...) + +# Bot message preview — edit stage +@teams.message_extensions.message_preview_edit("myCmd") +async def on_preview_edit(context: TeamsTurnContext, state, preview: Activity): + return MessagingExtensionResponse(...) + +# Bot message preview — send stage +@teams.message_extensions.message_preview_send("myCmd") +async def on_preview_send(context: TeamsTurnContext, state, preview: Activity): + return MessagingExtensionResponse(...) +``` + +All `command_id` arguments accept a plain string, a compiled `re.Pattern`, or `None` to match all commands. + +### Task Modules + +```python +@teams.task_modules.fetch("myVerb") +async def on_task_fetch(context: TeamsTurnContext, state, request: TaskModuleRequest): + return TaskModuleResponse(...) + +@teams.task_modules.submit("myVerb") +async def on_task_submit(context: TeamsTurnContext, state, request: TaskModuleRequest): + return TaskModuleResponse(...) +``` + +### Meeting Events + +```python +@teams.meetings.start +async def on_meeting_start(context: TeamsTurnContext, state, meeting: MeetingDetails): + ... + +@teams.meetings.end +async def on_meeting_end(context: TeamsTurnContext, state, meeting: MeetingDetails): + ... + +@teams.meetings.participants_join +async def on_participants_join(context: TeamsTurnContext, state, details: MeetingParticipantsEventDetails): + ... + +@teams.meetings.participants_leave +async def on_participants_leave(context: TeamsTurnContext, state, details: MeetingParticipantsEventDetails): + ... +``` + +### Channel Events + +```python +@teams.channels.created +async def on_channel_created(context: TeamsTurnContext, state, data: ChannelData): + ... + +@teams.channels.renamed +async def on_channel_renamed(context: TeamsTurnContext, state, data: ChannelData): + ... + +# Also available: deleted, shared, unshared, restored, members_added, members_removed +``` + +### Team Events + +```python +@teams.teams.renamed +async def on_team_renamed(context: TeamsTurnContext, state, data: ChannelData): + ... + +# Also available: archived, unarchived, deleted, hard_deleted, restored +``` + +### File Consent + +```python +@teams.file_consent.accept +async def on_file_accept(context: TeamsTurnContext, state, consent: FileConsentCardResponse): + ... + +@teams.file_consent.decline +async def on_file_decline(context: TeamsTurnContext, state, consent: FileConsentCardResponse): + ... +``` -- **`TeamsActivityHandler`** - Main handler class with Teams-specific event methods -- **`TeamsInfo`** - Utility class for Teams operations (members, meetings, channels) -- **`MessagingExtensionQuery/Response`** - Handle search and messaging extensions -- **`TaskModuleRequest/Response`** - Interactive dialogs and forms -- **`TabRequest/Response`** - Tab application interactions +### Config Invokes + +```python +@teams.config.fetch +async def on_config_fetch(context: TeamsTurnContext, state, data): + return ConfigResponse(...) + +@teams.config.submit +async def on_config_submit(context: TeamsTurnContext, state, data): + return ConfigResponse(...) +``` + +### Message Updates + +```python +@teams.messages.edit +async def on_message_edit(context: TeamsTurnContext, state): + ... + +@teams.messages.delete +async def on_message_delete(context: TeamsTurnContext, state): + ... + +@teams.messages.read_receipt +async def on_read_receipt(context: TeamsTurnContext, state, data: dict): + ... + +@teams.messages.execute_action +async def on_execute_action(context: TeamsTurnContext, state, query: O365ConnectorCardActionQuery): + ... +``` ## Features Supported -✅ **Messaging Extensions** - Search and action-based extensions -✅ **Task Modules** - Interactive dialogs and forms -✅ **Adaptive Cards** - Rich card interactions -✅ **Meeting Events** - Start, end, participant changes -✅ **Team Management** - Member operations, channel messaging -✅ **File Handling** - Upload/download with consent flow -✅ **Tab Apps** - Personal and team tab interactions -✅ **Proactive Messaging** - Send messages to channels/users - -## Migration from Bot Framework - -| Bot Framework Teams | Microsoft Agents Teams | -|-------------------|------------------------| -| `TeamsActivityHandler` | `TeamsActivityHandler` | -| `TeamsInfo` | `TeamsInfo` | -| `on_teams_members_added` | `on_teams_members_added_activity` | -| `MessagingExtensionQuery` | `MessagingExtensionQuery` | -| `TaskModuleRequest` | `TaskModuleRequest` | +- **Messaging Extensions** — query, fetch action, submit action, link unfurling, anonymous link unfurling, settings URL, configure settings, card button clicked, bot message preview (edit/send) +- **Task Modules** — fetch and submit with optional verb matching +- **Meeting Lifecycle** — start, end, participant join/leave +- **Channel Management** — created, deleted, renamed, shared, unshared, restored, members added/removed +- **Team Management** — archived, unarchived, deleted, hard deleted, renamed, restored +- **File Consent** — accept and decline flows +- **Config Invokes** — `config/fetch` and `config/submit` +- **Message Updates** — edit, delete, undelete, read receipts, actionable message execute +- **TeamsAgentExtension routing** — wraps any `AgentApplication` with zero configuration # Quick Links @@ -170,4 +314,4 @@ pip install microsoft-agents-hosting-msteams |Semantic Kernel Integration|A weather agent built with Semantic Kernel|[semantic-kernel-multiturn](https://github.com/microsoft/Agents/blob/main/samples/python/semantic-kernel-multiturn/README.md)| |Streaming Agent|Streams OpenAI responses|[azure-ai-streaming](https://github.com/microsoft/Agents/blob/main/samples/python/azureai-streaming/README.md)| |Copilot Studio Client|Console app to consume a Copilot Studio Agent|[copilotstudio-client](https://github.com/microsoft/Agents/blob/main/samples/python/copilotstudio-client/README.md)| -|Cards Agent|Agent that uses rich cards to enhance conversation design |[cards](https://github.com/microsoft/Agents/blob/main/samples/python/cards/README.md)| \ No newline at end of file +|Cards Agent|Agent that uses rich cards to enhance conversation design|[cards](https://github.com/microsoft/Agents/blob/main/samples/python/cards/README.md)| From 864209414f649c743b876d776c1820e7e6e10ca5 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Tue, 23 Jun 2026 15:40:47 -0700 Subject: [PATCH 25/46] Graph service client creation --- .../microsoft_agents/hosting/msteams/graph.py | 49 +++++++++++++++++++ .../hosting/msteams/teams_agent_extension.py | 23 +++++---- .../microsoft-agents-hosting-msteams/setup.py | 1 + 3 files changed, 64 insertions(+), 9 deletions(-) create mode 100644 libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/graph.py diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/graph.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/graph.py new file mode 100644 index 000000000..28aeff107 --- /dev/null +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/graph.py @@ -0,0 +1,49 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from typing import Any + +from kiota_abstractions import RequestInformation +from kiota_abstractions.authentication import AuthenticationProvider + +from msgraph import GraphServiceClient, GraphRequestAdapter + +from microsoft_agents.hosting.core import ( + AgentApplication, + TurnContext, +) + + +class _SDKAuthenticationProvider(AuthenticationProvider): + + def __init__(self, app: AgentApplication, context: TurnContext, handler_name: str): + self._app = app + self._context = context + self._handler_name = handler_name + + async def authenticate_request( + self, + request: RequestInformation, + additional_authentication_context: dict[str, Any] = {}, + ) -> None: + """Authenticates the application request + + Args: + request (RequestInformation): The request to authenticate + additional_authentication_context (dict): + """ + token = await self._app.auth.get_token(self._context, self._handler_name) + if token: + request.headers["Authorization"] = f"Bearer {token}" + + +def create_graph_service_client( + app: AgentApplication, + context: TurnContext, + handler_name: str, +) -> GraphServiceClient: + return GraphServiceClient( + request_adapter=GraphRequestAdapter( + _SDKAuthenticationProvider(app, context, handler_name) + ) + ) diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_agent_extension.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_agent_extension.py index a076da65b..756894f09 100644 --- a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_agent_extension.py +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_agent_extension.py @@ -1,10 +1,5 @@ -""" -Copyright (c) Microsoft Corporation. All rights reserved. -Licensed under the MIT License. - -Teams-specific extension for :class:`AgentApplication` that exposes route -registration helpers for every Teams invoke / event surface. -""" +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. from __future__ import annotations @@ -16,6 +11,8 @@ Protocol, ) +from msgraph import GraphServiceClient + from microsoft_agents.activity import ( ActivityTypes, Channels, @@ -44,9 +41,9 @@ from .task_module import TaskModule from .team import Team -from .type_defs import StateT - +from .graph import create_graph_service_client from .teams_api_client import set_teams_api_client +from .type_defs import StateT class _AppRouteDecorator(Protocol[StateT]): @@ -275,3 +272,11 @@ def __call(func: TeamsHandoffHandler[StateT]) -> HandoffHandler[StateT]: ) return __call + + def get_graph( + self, + context: TurnContext, + handler_name: str | None = None, + graph_base_url="https://graph.microsoft.com/v1.0", + ) -> GraphServiceClient: + return create_graph_service_client(self._app, context, handler_name) diff --git a/libraries/microsoft-agents-hosting-msteams/setup.py b/libraries/microsoft-agents-hosting-msteams/setup.py index 62611d341..2cb83478c 100644 --- a/libraries/microsoft-agents-hosting-msteams/setup.py +++ b/libraries/microsoft-agents-hosting-msteams/setup.py @@ -15,5 +15,6 @@ f"microsoft-agents-hosting-core=={package_version}", "aiohttp>=3.11.11", "microsoft-teams-api>=2.0.0,<3", + "msgraph-sdk>=1.58.0", ], ) From 6bbcdc5f9ccca2e48f3867927682d9258abffc19 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Wed, 24 Jun 2026 09:10:23 -0700 Subject: [PATCH 26/46] client getters --- .../hosting/msteams/{graph.py => _graph.py} | 2 +- ...ams_api_client.py => _teams_api_client.py} | 6 ++-- .../hosting/msteams/teams_activity.py | 34 ++++++++++++++++--- .../hosting/msteams/teams_agent_extension.py | 32 ++++++++++++----- .../hosting/msteams/teams_turn_context.py | 33 ++++++++++++------ 5 files changed, 79 insertions(+), 28 deletions(-) rename libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/{graph.py => _graph.py} (97%) rename libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/{teams_api_client.py => _teams_api_client.py} (92%) diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/graph.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/_graph.py similarity index 97% rename from libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/graph.py rename to libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/_graph.py index 28aeff107..2cc5a4d0c 100644 --- a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/graph.py +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/_graph.py @@ -37,7 +37,7 @@ async def authenticate_request( request.headers["Authorization"] = f"Bearer {token}" -def create_graph_service_client( +def _create_graph_service_client( app: AgentApplication, context: TurnContext, handler_name: str, diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_api_client.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/_teams_api_client.py similarity index 92% rename from libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_api_client.py rename to libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/_teams_api_client.py index 7a8757edd..3e318e888 100644 --- a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_api_client.py +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/_teams_api_client.py @@ -12,7 +12,7 @@ _TEAMS_API_CLIENT_KEY = "TeamsApiClient" -def get_teams_api_client(context: TurnContext) -> ApiClient: +def _get_teams_api_client(context: TurnContext) -> ApiClient: """ Get the cached Teams API client from the context. @@ -26,7 +26,9 @@ def get_teams_api_client(context: TurnContext) -> ApiClient: raise ValueError("Unable to retrieve Teams API client.") -def set_teams_api_client(context: TurnContext, connection_manager: Connections) -> None: +def _set_teams_api_client( + context: TurnContext, connection_manager: Connections +) -> None: """ Set the Teams API client in the context if it is not already set. diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_activity.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_activity.py index 7832a93df..aa64f956f 100644 --- a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_activity.py +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_activity.py @@ -8,7 +8,6 @@ FeedbackLoop, MeetingInfo, NotificationInfo, - OnBehalfOf, TeamInfo, ) @@ -24,11 +23,16 @@ def _get_channel_data(activity: Activity) -> ChannelData | None: return None -class TeamsActivity: +class TeamsActivity(Activity): + """A class for working with Teams activities.""" @staticmethod def get_selected_channel_id(self, activity: Activity) -> str | None: - """Get the ID of the selected channel from the activity, if it exists.""" + """Get the ID of the selected channel from the activity, if it exists. + + :param activity: The activity from which to get the selected channel ID. + :return: The ID of the selected channel, or None if it doesn't exist. + """ channel_data = _get_channel_data(activity) if ( channel_data @@ -40,7 +44,11 @@ def get_selected_channel_id(self, activity: Activity) -> str | None: @staticmethod def get_channel_id(activity: Activity) -> str | None: - """Get the ID of the channel from the activity, if it exists.""" + """Get the ID of the channel from the activity, if it exists. + + :param activity: The activity from which to get the channel ID. + :return: The ID of the channel, or None if it doesn't exist. + """ channel_data = _get_channel_data(activity) if channel_data and channel_data.channel: return channel_data.channel.id @@ -48,7 +56,11 @@ def get_channel_id(activity: Activity) -> str | None: @staticmethod def get_meeting_info(activity: Activity) -> MeetingInfo | None: - """Get the meeting info from the activity, if it exists.""" + """Get the meeting info from the activity, if it exists. + + :param activity: The activity from which to get the meeting info. + :return: The meeting info, or None if it doesn't exist. + """ channel_data = _get_channel_data(activity) if channel_data: return channel_data.meeting @@ -68,6 +80,12 @@ def notify_user( alert_in_meeting: bool = False, external_resource_url: str | None = None, ): + """Notify the user about the activity. + + :param activity: The activity to notify the user about. + :param alert_in_meeting: Whether to alert the user in a meeting. + :param external_resource_url: The URL of an external resource to link to. + """ channel_data = _get_channel_data(activity) if not channel_data: channel_data = ChannelData() @@ -83,6 +101,12 @@ def notify_user( def enable_feedback_loop( activity: Activity, feedback_loop_type: Literal["default", "custom"] = "default" ) -> bool: + """Enable a feedback loop for the activity. + + :param activity: The activity for which to enable the feedback loop. + :param feedback_loop_type: The type of feedback loop to enable. + :return: True if the feedback loop was enabled, False otherwise. + """ channel_data = _get_channel_data(activity) if channel_data is not None: diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_agent_extension.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_agent_extension.py index 756894f09..6fc95bce2 100644 --- a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_agent_extension.py +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_agent_extension.py @@ -12,6 +12,7 @@ ) from msgraph import GraphServiceClient +from microsoft_teams.api import ApiClient from microsoft_agents.activity import ( ActivityTypes, @@ -41,8 +42,11 @@ from .task_module import TaskModule from .team import Team -from .graph import create_graph_service_client -from .teams_api_client import set_teams_api_client +from ._graph import _create_graph_service_client +from ._teams_api_client import ( + _get_teams_api_client, + _set_teams_api_client, +) from .type_defs import StateT @@ -103,7 +107,7 @@ def _configure_app(self): async def on_before_turn(context: TurnContext, state: StateT) -> bool: if context.activity.channel_id == Channels.ms_teams: - set_teams_api_client(context, self._app.connection_manager) + _set_teams_api_client(context, self._app.connection_manager) return True self._app.before_turn(on_before_turn) @@ -273,10 +277,20 @@ def __call(func: TeamsHandoffHandler[StateT]) -> HandoffHandler[StateT]: return __call - def get_graph( - self, - context: TurnContext, - handler_name: str | None = None, - graph_base_url="https://graph.microsoft.com/v1.0", + def get_teams_api_client(self, context: TurnContext) -> ApiClient: + """Get the Teams API client. + + :return: The Teams API client. + """ + return _get_teams_api_client(context) + + def get_graph_client( + self, context: TurnContext, handler_name: str | None = None ) -> GraphServiceClient: - return create_graph_service_client(self._app, context, handler_name) + """Get the Graph Service client. + + :param context: The turn context. + :param handler_name: The name of the handler. + :return: The Graph Service client. + """ + return _create_graph_service_client(self._app, context, handler_name) diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_turn_context.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_turn_context.py index cdec91ee9..3c765c655 100644 --- a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_turn_context.py +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_turn_context.py @@ -15,7 +15,7 @@ ) from microsoft_agents.hosting.core import AgentApplication, TurnContext -from .teams_api_client import get_teams_api_client, set_teams_api_client +from ._teams_api_client import _get_teams_api_client, _set_teams_api_client class TeamsTurnContext(TurnContext): @@ -25,9 +25,7 @@ class TeamsTurnContext(TurnContext): receive a typed context without changing the core routing engine. """ - def __init__( - self, context: TurnContext, app: AgentApplication, _sentinel=None - ) -> None: + def __init__(self, context: TurnContext, app: AgentApplication) -> None: """Initialise the Teams turn context from a plain turn context. :param context: The base turn context provided by the core runtime. @@ -36,20 +34,21 @@ def __init__( super().__init__(context) self._app = app self._turn_state.update(context.turn_state) - set_teams_api_client(context, app.connection_manager) - - @property - def activity(self) -> TeamsActivity: - """Get the Teams activity for the turn context.""" - return TeamsActivity(self._activity) + _set_teams_api_client(context, app.connection_manager) @property def api_client(self) -> ApiClient: """Get the API client for the Teams turn context.""" - return get_teams_api_client(self) + return _get_teams_api_client(self) @staticmethod def _make_targeted_activity(activity: Activity) -> Activity: + """ + Make an activity targeted. + + :param activity: The activity to make targeted. + :return: The targeted activity. + """ activity = activity.model_copy() activity.entities = activity.entities or [] activity.entities.append( @@ -58,6 +57,12 @@ def _make_targeted_activity(activity: Activity) -> Activity: return activity async def send_targeted_activity(self, activity: Activity) -> ResourceResponse: + """ + Send a targeted activity. + + :param activity: The activity to send. + :return: The resource response. + """ return await self.send_activity( TeamsTurnContext._make_targeted_activity(activity) ) @@ -65,6 +70,12 @@ async def send_targeted_activity(self, activity: Activity) -> ResourceResponse: async def send_targeted_activities( self, activities: list[Activity] ) -> list[ResourceResponse]: + """ + Send a list of targeted activities. + + :param activities: The list of activities to send. + :return: A list of resource responses. + """ return await self.send_activities( [TeamsTurnContext._make_targeted_activity(act) for act in activities] ) From 5be4b3f38adea39828ae652401c94b1de308a6d9 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Wed, 24 Jun 2026 10:20:24 -0700 Subject: [PATCH 27/46] Adding TeamsActivity connection --- .../hosting/msteams/_utils.py | 12 ++--- .../hosting/msteams/channel/channel.py | 6 +-- .../hosting/msteams/team/team.py | 2 +- .../hosting/msteams/teams_activity.py | 53 +++++++++---------- .../hosting/msteams/teams_agent_extension.py | 5 ++ .../hosting/msteams/teams_turn_context.py | 28 ++++++++++ 6 files changed, 67 insertions(+), 39 deletions(-) diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/_utils.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/_utils.py index b435bce3b..fb3e5edfc 100644 --- a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/_utils.py +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/_utils.py @@ -18,13 +18,13 @@ from .type_defs import CommandSelector -def _try_get_channel_data(context: TurnContext) -> ChannelData | None: +def _try_get_channel_data(activity: Activity) -> ChannelData | None: """Attempt to extract and parse Teams channel data from the activity's channel_data. - :param context: The current turn context. + :param activity: The current activity. :return: A :class:`ChannelData` instance parsed from channel_data, or None if absent. """ - data = context.activity.channel_data + data = activity.channel_data if data is None: return None if isinstance(data, ChannelData): @@ -32,15 +32,15 @@ def _try_get_channel_data(context: TurnContext) -> ChannelData | None: return ChannelData.model_validate(data) -def _get_channel_data(context: TurnContext) -> ChannelData: +def _get_channel_data(activity: Activity) -> ChannelData: """Extract and parse Teams channel data from the activity's channel_data. - :param context: The current turn context. + :param activity: The current activity. :return: A :class:`ChannelData` instance parsed from channel_data. :raises ValueError: If channel_data is absent. :raises pydantic.ValidationError: If channel_data cannot be deserialized. """ - channel_data = _try_get_channel_data(context) + channel_data = _try_get_channel_data(activity) if channel_data is None: raise ValueError("channel_data is required") return channel_data diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/channel/channel.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/channel/channel.py index 975f3b164..110a0b9d3 100644 --- a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/channel/channel.py +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/channel/channel.py @@ -68,7 +68,7 @@ def __selector(context: TurnContext) -> bool: def __call(func: ChannelUpdateHandler[StateT]) -> ChannelUpdateHandler[StateT]: async def __handler(context: TurnContext, state: StateT) -> None: teams_context = TeamsTurnContext(context, self._app) - channel_data = _get_channel_data(context) + channel_data = _get_channel_data(teams_context.activity) await func(teams_context, state, channel_data) self._app.add_route( @@ -267,7 +267,7 @@ def __selector(context: TurnContext) -> bool: def __call(func: ChannelUpdateHandler[StateT]) -> ChannelUpdateHandler[StateT]: async def __func(context: TurnContext, state: StateT) -> None: teams_context = TeamsTurnContext(context, self._app) - channel_data = _get_channel_data(context) + channel_data = _get_channel_data(teams_context.activity) await func(teams_context, state, channel_data) self._app.add_route( @@ -307,7 +307,7 @@ def __selector(context: TurnContext) -> bool: def __call(func: ChannelUpdateHandler[StateT]) -> ChannelUpdateHandler[StateT]: async def __func(context: TurnContext, state: StateT) -> None: teams_context = TeamsTurnContext(context, self._app) - channel_data = _get_channel_data(context) + channel_data = _get_channel_data(teams_context.activity) await func(teams_context, state, channel_data) self._app.add_route( diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/team/team.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/team/team.py index bc766bfaa..7232bdbf9 100644 --- a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/team/team.py +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/team/team.py @@ -69,7 +69,7 @@ def __selector(context: TurnContext) -> bool: def __call(func: TeamUpdateHandler[StateT]) -> TeamUpdateHandler[StateT]: async def __handler(context: TurnContext, state: StateT) -> None: teams_context = TeamsTurnContext(context, self._app) - team_data = _get_channel_data(context) + team_data = _get_channel_data(teams_context.activity) await func(teams_context, state, team_data) self._app.add_route( diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_activity.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_activity.py index aa64f956f..e34dbed45 100644 --- a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_activity.py +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_activity.py @@ -13,27 +13,27 @@ from microsoft_agents.activity import Activity - -def _get_channel_data(activity: Activity) -> ChannelData | None: - """Get the channel data from the activity, if it exists.""" - if isinstance(activity.channel_data, ChannelData): - return activity.channel_data - elif isinstance(activity.channel_data, dict): - return ChannelData.model_validate(activity.channel_data) - return None +from ._utils import _try_get_channel_data class TeamsActivity(Activity): - """A class for working with Teams activities.""" + """A class for working with Teams activities. + + note: + Because this class is dynamically swapped in, no new instance fields can be added. + """ + + def _get_channel_data(self) -> ChannelData | None: + """Get the channel data from the activity, if it exists.""" + return _try_get_channel_data(self) - @staticmethod - def get_selected_channel_id(self, activity: Activity) -> str | None: + def get_selected_channel_id(self) -> str | None: """Get the ID of the selected channel from the activity, if it exists. :param activity: The activity from which to get the selected channel ID. :return: The ID of the selected channel, or None if it doesn't exist. """ - channel_data = _get_channel_data(activity) + channel_data = self._get_channel_data() if ( channel_data and channel_data.settings @@ -42,41 +42,37 @@ def get_selected_channel_id(self, activity: Activity) -> str | None: return channel_data.settings.selected_channel.id return None - @staticmethod - def get_channel_id(activity: Activity) -> str | None: + def get_channel_id(self) -> str | None: """Get the ID of the channel from the activity, if it exists. :param activity: The activity from which to get the channel ID. :return: The ID of the channel, or None if it doesn't exist. """ - channel_data = _get_channel_data(activity) + channel_data = self._get_channel_data() if channel_data and channel_data.channel: return channel_data.channel.id return None - @staticmethod - def get_meeting_info(activity: Activity) -> MeetingInfo | None: + def get_meeting_info(self) -> MeetingInfo | None: """Get the meeting info from the activity, if it exists. :param activity: The activity from which to get the meeting info. :return: The meeting info, or None if it doesn't exist. """ - channel_data = _get_channel_data(activity) + channel_data = self._get_channel_data() if channel_data: return channel_data.meeting return None - @staticmethod - def get_team_info(activity: Activity) -> TeamInfo | None: + def get_team_info(self) -> TeamInfo | None: """Get the team info from the activity, if it exists.""" - channel_data = _get_channel_data(activity) + channel_data = self._get_channel_data() if channel_data: return channel_data.team return None - @staticmethod def notify_user( - activity: Activity, + self, alert_in_meeting: bool = False, external_resource_url: str | None = None, ): @@ -86,10 +82,10 @@ def notify_user( :param alert_in_meeting: Whether to alert the user in a meeting. :param external_resource_url: The URL of an external resource to link to. """ - channel_data = _get_channel_data(activity) + channel_data = self._get_channel_data() if not channel_data: channel_data = ChannelData() - activity.channel_data = channel_data + self.channel_data = channel_data channel_data.notification = NotificationInfo( alert=not alert_in_meeting, @@ -97,9 +93,8 @@ def notify_user( external_resource_url=external_resource_url, ) - @staticmethod def enable_feedback_loop( - activity: Activity, feedback_loop_type: Literal["default", "custom"] = "default" + self, feedback_loop_type: Literal["default", "custom"] = "default" ) -> bool: """Enable a feedback loop for the activity. @@ -108,11 +103,11 @@ def enable_feedback_loop( :return: True if the feedback loop was enabled, False otherwise. """ - channel_data = _get_channel_data(activity) + channel_data = self._get_channel_data() if channel_data is not None: return False - activity.channel_data = ChannelData( + self.channel_data = ChannelData( feedback_loop=FeedbackLoop(type=feedback_loop_type) ) return True diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_agent_extension.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_agent_extension.py index 6fc95bce2..06d54d88a 100644 --- a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_agent_extension.py +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_agent_extension.py @@ -47,6 +47,9 @@ _get_teams_api_client, _set_teams_api_client, ) +from ._utils import _get_channel_data + +from .teams_activity import TeamsActivity from .type_defs import StateT @@ -108,6 +111,8 @@ def _configure_app(self): async def on_before_turn(context: TurnContext, state: StateT) -> bool: if context.activity.channel_id == Channels.ms_teams: _set_teams_api_client(context, self._app.connection_manager) + # caches the deserialized version of ChannelData + context.activity.channel_data = _get_channel_data(context.activity) return True self._app.before_turn(on_before_turn) diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_turn_context.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_turn_context.py index 3c765c655..6e6da5db7 100644 --- a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_turn_context.py +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_turn_context.py @@ -5,6 +5,8 @@ from __future__ import annotations +from typing import cast + from microsoft_teams.api import ApiClient from microsoft_agents.activity import ( @@ -16,6 +18,7 @@ from microsoft_agents.hosting.core import AgentApplication, TurnContext from ._teams_api_client import _get_teams_api_client, _set_teams_api_client +from .teams_activity import TeamsActivity class TeamsTurnContext(TurnContext): @@ -34,8 +37,33 @@ def __init__(self, context: TurnContext, app: AgentApplication) -> None: super().__init__(context) self._app = app self._turn_state.update(context.turn_state) + + self._set_teams_activity() + _set_teams_api_client(context, app.connection_manager) + def _set_teams_activity(self) -> None: + """ + Replace the default Activity class with TeamsActivity + + This allows the activity to be treated as a TeamsActivity, which provides + additional methods for working with Teams-specific data. + + This is a bit of a hack, but it's the only way to get the TeamsActivity class to be used. + There are possible workarounds, but because: + 1. we do not expect new properties to be added to the TeamsActivity class + 2. we define the TeamsActivity class, and we manage its lifecycle here + this is the most straightforward approach for now. + The main pitfall beyond the obvious ones is if a user defines a custom Activity + and brings in their own Adapter that creates it. This is a tradeoff. + """ + self._activity.__class__ = TeamsActivity + self._teams_activity = cast(TeamsActivity, self._activity) + + @property + def activity(self) -> TeamsActivity: + return self._teams_activity + @property def api_client(self) -> ApiClient: """Get the API client for the Teams turn context.""" From 2390f12ab6c3e438122a982b4a7c11910bde00bd Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Wed, 24 Jun 2026 11:11:01 -0700 Subject: [PATCH 28/46] Bug fixes --- .../hosting/msteams/__init__.py | 6 +++-- .../hosting/msteams/_graph.py | 9 ++++--- .../hosting/msteams/_teams_api_client.py | 1 - .../hosting/msteams/_utils.py | 19 ++++++++++++++- .../hosting/msteams/channel/channel.py | 8 +++---- .../hosting/msteams/errors/error_resources.py | 4 ---- .../hosting/msteams/message/message.py | 4 ++-- .../message_extension/message_extension.py | 24 +++++++------------ .../hosting/msteams/team/team.py | 4 ++-- .../hosting/msteams/teams_activity.py | 10 ++++---- .../hosting/msteams/teams_agent_extension.py | 4 ++-- .../hosting/msteams/type_defs.py | 6 +++-- tests/hosting_msteams/helpers.py | 2 +- tests/hosting_msteams/test_channels.py | 2 +- tests/hosting_msteams/test_config.py | 4 ++-- tests/hosting_msteams/test_file_consent.py | 4 ++-- tests/hosting_msteams/test_meetings.py | 2 +- .../test_message_extensions.py | 4 ++-- tests/hosting_msteams/test_messages.py | 4 ++-- tests/hosting_msteams/test_task_modules.py | 6 +++-- tests/hosting_msteams/test_team_lifecycle.py | 2 +- .../test_teams_agent_extension.py | 18 +++++++------- tests/hosting_msteams/test_utils.py | 2 +- 23 files changed, 80 insertions(+), 69 deletions(-) diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/__init__.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/__init__.py index 3f8100c5e..ec2be82b5 100644 --- a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/__init__.py +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/__init__.py @@ -15,7 +15,8 @@ from .task_module import TaskModule from .team import Team -from .teams_api_client import get_teams_api_client +from .teams_activity import TeamsActivity +from .teams_turn_context import TeamsTurnContext __all__ = [ "TeamsAgentExtension", @@ -27,5 +28,6 @@ "MessageExtension", "TaskModule", "Team", - "get_teams_api_client", + "TeamsActivity", + "TeamsTurnContext", ] diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/_graph.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/_graph.py index 2cc5a4d0c..00847767d 100644 --- a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/_graph.py +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/_graph.py @@ -3,7 +3,7 @@ from typing import Any -from kiota_abstractions import RequestInformation +from kiota_abstractions.request_information import RequestInformation from kiota_abstractions.authentication import AuthenticationProvider from msgraph import GraphServiceClient, GraphRequestAdapter @@ -24,7 +24,7 @@ def __init__(self, app: AgentApplication, context: TurnContext, handler_name: st async def authenticate_request( self, request: RequestInformation, - additional_authentication_context: dict[str, Any] = {}, + additional_authentication_context: dict[str, Any] | None = None, ) -> None: """Authenticates the application request @@ -32,6 +32,9 @@ async def authenticate_request( request (RequestInformation): The request to authenticate additional_authentication_context (dict): """ + if additional_authentication_context is None: + additional_authentication_context = {} + token = await self._app.auth.get_token(self._context, self._handler_name) if token: request.headers["Authorization"] = f"Bearer {token}" @@ -40,7 +43,7 @@ async def authenticate_request( def _create_graph_service_client( app: AgentApplication, context: TurnContext, - handler_name: str, + handler_name: str | None = None, ) -> GraphServiceClient: return GraphServiceClient( request_adapter=GraphRequestAdapter( diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/_teams_api_client.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/_teams_api_client.py index 3e318e888..e704bee6d 100644 --- a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/_teams_api_client.py +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/_teams_api_client.py @@ -47,7 +47,6 @@ def _set_teams_api_client( options: ClientOptions if context.identity: - assert context.identity is not None provider = connection_manager.get_token_provider( context.identity, context.activity.service_url ) diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/_utils.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/_utils.py index fb3e5edfc..18aeeb94e 100644 --- a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/_utils.py +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/_utils.py @@ -61,6 +61,23 @@ def _match_selector(selector: CommandSelector, value: str | None) -> bool: return bool(re.fullmatch(selector, value)) +def _get_command_id(value: Any) -> str | None: + """Extract the message extension command id from an activity value. + + Handles both raw dicts (camelCase ``commandId`` or snake_case ``command_id``) + and parsed objects exposing either attribute, so command-id matching is + consistent across all message extension selectors. + + :param value: The raw activity value. + :return: The command id if present, otherwise None. + """ + if isinstance(value, dict): + return value.get("commandId") or value.get("command_id") + if value is not None: + return getattr(value, "commandId", None) or getattr(value, "command_id", None) + return None + + def _get_channel_event_type(context: TurnContext) -> str | None: """Extract the Teams channel event type from the activity's channel_data. @@ -72,7 +89,7 @@ def _get_channel_event_type(context: TurnContext) -> str | None: return None if isinstance(data, dict): return data.get("eventType") or data.get("event_type") - return getattr(data, "event_type", None) + return getattr(data, "event_type", None) or getattr(data, "eventType", None) async def _send_invoke_response(context: TurnContext, body: Any = None) -> None: diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/channel/channel.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/channel/channel.py index 110a0b9d3..8d6abb306 100644 --- a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/channel/channel.py +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/channel/channel.py @@ -6,7 +6,7 @@ import re from typing import Generic, Optional, overload -from microsoft_agents.activity import ActivityTypes +from microsoft_agents.activity import ActivityTypes, Channels from microsoft_agents.hosting.core import ( AgentApplication, RouteRank, @@ -61,7 +61,7 @@ def __selector(context: TurnContext) -> bool: return ( context.activity.type == ActivityTypes.conversation_update - and context.activity.channel_id == "msteams" + and context.activity.channel_id == Channels.ms_teams and event_match ) @@ -259,7 +259,7 @@ def members_added( def __selector(context: TurnContext) -> bool: return ( context.activity.type == ActivityTypes.conversation_update - and context.activity.channel_id == "msteams" + and context.activity.channel_id == Channels.ms_teams and isinstance(context.activity.members_added, list) and len(context.activity.members_added) > 0 ) @@ -299,7 +299,7 @@ def members_removed( def __selector(context: TurnContext) -> bool: return ( context.activity.type == ActivityTypes.conversation_update - and context.activity.channel_id == "msteams" + and context.activity.channel_id == Channels.ms_teams and isinstance(context.activity.members_removed, list) and len(context.activity.members_removed) > 0 ) diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/errors/error_resources.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/errors/error_resources.py index 167226c8a..a119bcd5f 100644 --- a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/errors/error_resources.py +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/errors/error_resources.py @@ -71,7 +71,3 @@ class TeamsErrorResources: "tenant_id is required.", -62010, ) - - def __init__(self): - """Initialize TeamsErrorResources.""" - pass diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/message/message.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/message/message.py index 4010a538b..b4c62cfe5 100644 --- a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/message/message.py +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/message/message.py @@ -7,7 +7,7 @@ from microsoft_teams.api.models.o365 import O365ConnectorCardActionQuery -from microsoft_agents.activity import ActivityTypes +from microsoft_agents.activity import ActivityTypes, Channels from microsoft_agents.hosting.core import ( AgentApplication, RouteRank, @@ -57,7 +57,7 @@ def _create_basic_decorator( def __selector(context: TurnContext) -> bool: return ( context.activity.type == message_type - and context.activity.channel_id == "msteams" + and context.activity.channel_id == Channels.ms_teams and _get_channel_event_type(context) == event_type ) diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/message_extension/message_extension.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/message_extension/message_extension.py index 659f5d22c..0fdb7e819 100644 --- a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/message_extension/message_extension.py +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/message_extension/message_extension.py @@ -27,6 +27,7 @@ ) from microsoft_agents.hosting.msteams._utils import ( + _get_command_id, _match_selector, _send_invoke_response, ) @@ -88,15 +89,7 @@ def __selector(context: TurnContext) -> bool: 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) + return _match_selector(command_id, _get_command_id(value)) def __call(func: QueryHandler[StateT]) -> QueryHandler[StateT]: async def __handler(context: TurnContext, state: StateT) -> None: @@ -180,17 +173,13 @@ def __selector(context: TurnContext) -> bool: 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) + return _match_selector(command_id, _get_command_id(value)) def __call(func: SubmitActionHandler[StateT]) -> SubmitActionHandler[StateT]: async def __handler(context: TurnContext, state: StateT) -> None: @@ -505,8 +494,11 @@ def __selector(context: TurnContext) -> bool: def __call(func: Callable) -> Callable: async def __handler(context: TurnContext, state: StateT) -> None: teams_context = TeamsTurnContext(context, self._app) - await func(teams_context, state, context.activity.value) - await _send_invoke_response(context) + query = MessagingExtensionQuery.model_validate( + context.activity.value or {} + ) + res = await func(teams_context, state, query) + await _send_invoke_response(context, res) self._app.add_route( __selector, diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/team/team.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/team/team.py index 7232bdbf9..7cf42a965 100644 --- a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/team/team.py +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/team/team.py @@ -6,7 +6,7 @@ import re from typing import Generic, Optional, overload -from microsoft_agents.activity import ActivityTypes +from microsoft_agents.activity import ActivityTypes, Channels from microsoft_agents.hosting.core import ( AgentApplication, RouteRank, @@ -62,7 +62,7 @@ def __selector(context: TurnContext) -> bool: return ( context.activity.type == ActivityTypes.conversation_update - and context.activity.channel_id == "msteams" + and context.activity.channel_id == Channels.ms_teams and event_match ) diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_activity.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_activity.py index e34dbed45..27449bdca 100644 --- a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_activity.py +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_activity.py @@ -30,7 +30,6 @@ def _get_channel_data(self) -> ChannelData | None: def get_selected_channel_id(self) -> str | None: """Get the ID of the selected channel from the activity, if it exists. - :param activity: The activity from which to get the selected channel ID. :return: The ID of the selected channel, or None if it doesn't exist. """ channel_data = self._get_channel_data() @@ -45,7 +44,6 @@ def get_selected_channel_id(self) -> str | None: def get_channel_id(self) -> str | None: """Get the ID of the channel from the activity, if it exists. - :param activity: The activity from which to get the channel ID. :return: The ID of the channel, or None if it doesn't exist. """ channel_data = self._get_channel_data() @@ -56,7 +54,6 @@ def get_channel_id(self) -> str | None: def get_meeting_info(self) -> MeetingInfo | None: """Get the meeting info from the activity, if it exists. - :param activity: The activity from which to get the meeting info. :return: The meeting info, or None if it doesn't exist. """ channel_data = self._get_channel_data() @@ -78,14 +75,16 @@ def notify_user( ): """Notify the user about the activity. - :param activity: The activity to notify the user about. :param alert_in_meeting: Whether to alert the user in a meeting. :param external_resource_url: The URL of an external resource to link to. """ channel_data = self._get_channel_data() if not channel_data: channel_data = ChannelData() - self.channel_data = channel_data + + # in both cases need to re-set it because _get_channel_data() can + # return a serialized version of the stored data + self.channel_data = channel_data channel_data.notification = NotificationInfo( alert=not alert_in_meeting, @@ -98,7 +97,6 @@ def enable_feedback_loop( ) -> bool: """Enable a feedback loop for the activity. - :param activity: The activity for which to enable the feedback loop. :param feedback_loop_type: The type of feedback loop to enable. :return: True if the feedback loop was enabled, False otherwise. """ diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_agent_extension.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_agent_extension.py index 06d54d88a..694a3388b 100644 --- a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_agent_extension.py +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_agent_extension.py @@ -47,7 +47,7 @@ _get_teams_api_client, _set_teams_api_client, ) -from ._utils import _get_channel_data +from ._utils import _try_get_channel_data from .teams_activity import TeamsActivity from .type_defs import StateT @@ -112,7 +112,7 @@ async def on_before_turn(context: TurnContext, state: StateT) -> bool: if context.activity.channel_id == Channels.ms_teams: _set_teams_api_client(context, self._app.connection_manager) # caches the deserialized version of ChannelData - context.activity.channel_data = _get_channel_data(context.activity) + context.activity.channel_data = _try_get_channel_data(context.activity) return True self._app.before_turn(on_before_turn) diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/type_defs.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/type_defs.py index c89df7489..7b689f787 100644 --- a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/type_defs.py +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/type_defs.py @@ -5,6 +5,7 @@ from typing import ( Callable, + TYPE_CHECKING, TypeVar, Pattern, Protocol, @@ -12,9 +13,10 @@ from microsoft_agents.hosting.core import TurnState -from .teams_turn_context import TeamsTurnContext +if TYPE_CHECKING: + from .teams_turn_context import TeamsTurnContext -TeamsRouteSelector = Callable[[TeamsTurnContext], bool] +TeamsRouteSelector = Callable[["TeamsTurnContext"], bool] StateT = TypeVar("StateT", bound=TurnState) _StateContra = TypeVar("_StateContra", bound=TurnState, contravariant=True) diff --git a/tests/hosting_msteams/helpers.py b/tests/hosting_msteams/helpers.py index dbc40052b..3264ee98e 100644 --- a/tests/hosting_msteams/helpers.py +++ b/tests/hosting_msteams/helpers.py @@ -14,7 +14,7 @@ is_supported_version = sys.version_info >= (3, 12) if is_supported_version: - from microsoft_agents.hosting.teams.teams_turn_context import TeamsTurnContext + from microsoft_agents.hosting.msteams.teams_turn_context import TeamsTurnContext def _make_app() -> Any: diff --git a/tests/hosting_msteams/test_channels.py b/tests/hosting_msteams/test_channels.py index 9755ef1a3..a53812745 100644 --- a/tests/hosting_msteams/test_channels.py +++ b/tests/hosting_msteams/test_channels.py @@ -16,7 +16,7 @@ ) if is_supported_version: - from microsoft_agents.hosting.teams import TeamsAgentExtension + from microsoft_agents.hosting.msteams import TeamsAgentExtension class TestChannelLifecycle: diff --git a/tests/hosting_msteams/test_config.py b/tests/hosting_msteams/test_config.py index 29e9202ac..9c212c7d7 100644 --- a/tests/hosting_msteams/test_config.py +++ b/tests/hosting_msteams/test_config.py @@ -16,9 +16,9 @@ ) if is_supported_version: - from microsoft_agents.hosting.teams import TeamsAgentExtension + from microsoft_agents.hosting.msteams import TeamsAgentExtension -_PATCH = "microsoft_agents.hosting.teams.config.config._send_invoke_response" +_PATCH = "microsoft_agents.hosting.msteams.config.config._send_invoke_response" class TestConfig: diff --git a/tests/hosting_msteams/test_file_consent.py b/tests/hosting_msteams/test_file_consent.py index 635283bf6..5dfd81552 100644 --- a/tests/hosting_msteams/test_file_consent.py +++ b/tests/hosting_msteams/test_file_consent.py @@ -17,10 +17,10 @@ if is_supported_version: from microsoft_teams.api.models import FileConsentCardResponse - from microsoft_agents.hosting.teams import TeamsAgentExtension + from microsoft_agents.hosting.msteams import TeamsAgentExtension _PATCH = ( - "microsoft_agents.hosting.teams.file_consent.file_consent._send_invoke_response" + "microsoft_agents.hosting.msteams.file_consent.file_consent._send_invoke_response" ) diff --git a/tests/hosting_msteams/test_meetings.py b/tests/hosting_msteams/test_meetings.py index 951337c87..d7b1a770f 100644 --- a/tests/hosting_msteams/test_meetings.py +++ b/tests/hosting_msteams/test_meetings.py @@ -18,7 +18,7 @@ if is_supported_version: from microsoft_teams.api.models.meetings import MeetingDetails from microsoft_agents.activity.teams import MeetingParticipantsEventDetails - from microsoft_agents.hosting.teams import TeamsAgentExtension + from microsoft_agents.hosting.msteams import TeamsAgentExtension def _meeting_details_value() -> dict: diff --git a/tests/hosting_msteams/test_message_extensions.py b/tests/hosting_msteams/test_message_extensions.py index e38bfe1a2..20439d680 100644 --- a/tests/hosting_msteams/test_message_extensions.py +++ b/tests/hosting_msteams/test_message_extensions.py @@ -20,9 +20,9 @@ MessagingExtensionQuery, MessagingExtensionResponse, ) - from microsoft_agents.hosting.teams import TeamsAgentExtension + from microsoft_agents.hosting.msteams import TeamsAgentExtension -_PATCH = "microsoft_agents.hosting.teams.message_extension.message_extension._send_invoke_response" +_PATCH = "microsoft_agents.hosting.msteams.message_extension.message_extension._send_invoke_response" class TestMessageExtensionQuery: diff --git a/tests/hosting_msteams/test_messages.py b/tests/hosting_msteams/test_messages.py index ddfc63819..4bfad5531 100644 --- a/tests/hosting_msteams/test_messages.py +++ b/tests/hosting_msteams/test_messages.py @@ -17,9 +17,9 @@ if is_supported_version: from microsoft_teams.api.models import O365ConnectorCardActionQuery - from microsoft_agents.hosting.teams import TeamsAgentExtension + from microsoft_agents.hosting.msteams import TeamsAgentExtension -_PATCH = "microsoft_agents.hosting.teams.message.message._send_invoke_response" +_PATCH = "microsoft_agents.hosting.msteams.message.message._send_invoke_response" class TestMessageEdit: diff --git a/tests/hosting_msteams/test_task_modules.py b/tests/hosting_msteams/test_task_modules.py index ce1a4b1cd..e4de09412 100644 --- a/tests/hosting_msteams/test_task_modules.py +++ b/tests/hosting_msteams/test_task_modules.py @@ -18,9 +18,11 @@ if is_supported_version: from microsoft_teams.api.models import TaskModuleRequest, TaskModuleResponse - from microsoft_agents.hosting.teams import TeamsAgentExtension + from microsoft_agents.hosting.msteams import TeamsAgentExtension -_PATCH = "microsoft_agents.hosting.teams.task_module.task_module._send_invoke_response" +_PATCH = ( + "microsoft_agents.hosting.msteams.task_module.task_module._send_invoke_response" +) class TestTaskModuleFetch: diff --git a/tests/hosting_msteams/test_team_lifecycle.py b/tests/hosting_msteams/test_team_lifecycle.py index 115d37eeb..d7aabe5b3 100644 --- a/tests/hosting_msteams/test_team_lifecycle.py +++ b/tests/hosting_msteams/test_team_lifecycle.py @@ -15,7 +15,7 @@ ) if is_supported_version: - from microsoft_agents.hosting.teams import TeamsAgentExtension + from microsoft_agents.hosting.msteams import TeamsAgentExtension class TestTeamLifecycle: diff --git a/tests/hosting_msteams/test_teams_agent_extension.py b/tests/hosting_msteams/test_teams_agent_extension.py index 55fac396b..baacc7248 100644 --- a/tests/hosting_msteams/test_teams_agent_extension.py +++ b/tests/hosting_msteams/test_teams_agent_extension.py @@ -13,15 +13,15 @@ ) if is_supported_version: - from microsoft_agents.hosting.teams import TeamsAgentExtension - from microsoft_agents.hosting.teams.channel import Channel - from microsoft_agents.hosting.teams.config import Config - from microsoft_agents.hosting.teams.file_consent import FileConsent - from microsoft_agents.hosting.teams.meeting import Meeting - from microsoft_agents.hosting.teams.message import Message - from microsoft_agents.hosting.teams.message_extension import MessageExtension - from microsoft_agents.hosting.teams.task_module import TaskModule - from microsoft_agents.hosting.teams.team import Team + from microsoft_agents.hosting.msteams import TeamsAgentExtension + from microsoft_agents.hosting.msteams.channel import Channel + from microsoft_agents.hosting.msteams.config import Config + from microsoft_agents.hosting.msteams.file_consent import FileConsent + from microsoft_agents.hosting.msteams.meeting import Meeting + from microsoft_agents.hosting.msteams.message import Message + from microsoft_agents.hosting.msteams.message_extension import MessageExtension + from microsoft_agents.hosting.msteams.task_module import TaskModule + from microsoft_agents.hosting.msteams.team import Team class TestTeamsAgentExtensionProperties: diff --git a/tests/hosting_msteams/test_utils.py b/tests/hosting_msteams/test_utils.py index 2075132b5..c39130fef 100644 --- a/tests/hosting_msteams/test_utils.py +++ b/tests/hosting_msteams/test_utils.py @@ -17,7 +17,7 @@ if is_supported_version: from microsoft_agents.activity import Activity, ActivityTypes from microsoft_teams.api.models.channel_data import ChannelData - from microsoft_agents.hosting.teams._utils import ( + from microsoft_agents.hosting.msteams._utils import ( _get_channel_data, _get_channel_event_type, _match_selector, From 33e2bf3b9faec737636cd08a775041dae885504d Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Wed, 24 Jun 2026 11:24:08 -0700 Subject: [PATCH 29/46] Fixing unit tests --- .../microsoft_agents/hosting/msteams/channel/channel.py | 2 +- .../msteams/message_extension/message_extension.py | 6 +++--- .../microsoft_agents/hosting/msteams/team/team.py | 2 +- tests/hosting_msteams/helpers.py | 1 + tests/hosting_msteams/test_message_extensions.py | 2 +- tests/hosting_msteams/test_utils.py | 8 ++++---- 6 files changed, 11 insertions(+), 10 deletions(-) diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/channel/channel.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/channel/channel.py index 8d6abb306..a4d62eba5 100644 --- a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/channel/channel.py +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/channel/channel.py @@ -95,7 +95,7 @@ def event( ) -> ChannelUpdateHandler[StateT] | _RouteDecorator[ChannelUpdateHandler[StateT]]: """Register a handler for Teams team event conversation update events.""" decorator = self._create_decorator( - r"channel.*", auth_handlers=auth_handlers, rank=rank + re.compile(r"channel.*"), auth_handlers=auth_handlers, rank=rank ) if handler is not None: return decorator(handler) diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/message_extension/message_extension.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/message_extension/message_extension.py index 0fdb7e819..5072b09c8 100644 --- a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/message_extension/message_extension.py +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/message_extension/message_extension.py @@ -222,7 +222,7 @@ def __selector(context: TurnContext) -> bool: return False if value.get("botMessagePreviewAction") != "edit": return False - return _match_selector(command_id, value.get("commandId")) + return _match_selector(command_id, _get_command_id(value)) def __call( func: MessagePreviewEditHandler[StateT], @@ -265,7 +265,7 @@ def __selector(context: TurnContext) -> bool: return False if value.get("botMessagePreviewAction") != "send": return False - return _match_selector(command_id, value.get("commandId")) + return _match_selector(command_id, _get_command_id(value)) def __call( func: MessagePreviewSendHandler[StateT], @@ -303,7 +303,7 @@ def __selector(context: TurnContext) -> bool: context.activity.type == ActivityTypes.invoke and context.activity.name == "composeExtension/fetchTask" and isinstance(value, dict) - and _match_selector(command_id, value.get("commandId")) + and _match_selector(command_id, _get_command_id(value)) ) def __call(func: FetchActionHandler[StateT]) -> FetchActionHandler[StateT]: diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/team/team.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/team/team.py index 7cf42a965..221c85525 100644 --- a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/team/team.py +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/team/team.py @@ -96,7 +96,7 @@ def event( ) -> TeamUpdateHandler[StateT] | _RouteDecorator[TeamUpdateHandler[StateT]]: """Register a handler for Teams team event conversation update events.""" decorator = self._create_decorator( - r"team.*", auth_handlers=auth_handlers, rank=rank + re.compile(r"team.*"), auth_handlers=auth_handlers, rank=rank ) if handler is not None: return decorator(handler) diff --git a/tests/hosting_msteams/helpers.py b/tests/hosting_msteams/helpers.py index 3264ee98e..3ccab94d1 100644 --- a/tests/hosting_msteams/helpers.py +++ b/tests/hosting_msteams/helpers.py @@ -52,6 +52,7 @@ def _make_context( activity.type = activity_type activity.name = name activity.value = value + activity.service_url = "https://smba.trafficmanager.net/teams/" activity.channel_id = channel_id activity.channel_data = channel_data activity.members_added = members_added diff --git a/tests/hosting_msteams/test_message_extensions.py b/tests/hosting_msteams/test_message_extensions.py index 20439d680..9e7627a73 100644 --- a/tests/hosting_msteams/test_message_extensions.py +++ b/tests/hosting_msteams/test_message_extensions.py @@ -498,7 +498,7 @@ async def handler(ctx, state, settings): ... ) with patch(_PATCH, new_callable=AsyncMock) as mock_send: await route_handler(ctx, MagicMock()) - mock_send.assert_awaited_once_with(ctx) + mock_send.assert_awaited_once_with(ctx, None) class TestMessageExtensionCardButtonClicked: diff --git a/tests/hosting_msteams/test_utils.py b/tests/hosting_msteams/test_utils.py index c39130fef..1707ebe3a 100644 --- a/tests/hosting_msteams/test_utils.py +++ b/tests/hosting_msteams/test_utils.py @@ -94,20 +94,20 @@ class TestGetChannelData: def test_raises_when_channel_data_is_none(self): ctx = _make_context(ActivityTypes.conversation_update) with pytest.raises(ValueError, match="channel_data"): - _get_channel_data(ctx) + _get_channel_data(ctx.activity) def test_returns_existing_channel_data_unchanged(self): channel_data = ChannelData() ctx = _make_context(ActivityTypes.conversation_update) ctx.activity.channel_data = channel_data - assert _get_channel_data(ctx) is channel_data + assert _get_channel_data(ctx.activity) is channel_data def test_model_validates_from_dict(self): ctx = _make_context( ActivityTypes.conversation_update, channel_data={"eventType": "channelCreated"}, ) - result = _get_channel_data(ctx) + result = _get_channel_data(ctx.activity) assert isinstance(result, ChannelData) def test_model_validates_camel_case_dict_keys(self): @@ -115,7 +115,7 @@ def test_model_validates_camel_case_dict_keys(self): ActivityTypes.conversation_update, channel_data={"eventType": "teamArchived"}, ) - result = _get_channel_data(ctx) + result = _get_channel_data(ctx.activity) assert isinstance(result, ChannelData) From d87e2b22aec9d789c4dc279e745a157d42566b25 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Wed, 24 Jun 2026 12:53:51 -0700 Subject: [PATCH 30/46] Adding routing integration tests --- .../hosting/core/turn_context.py | 2 +- .../hosting/msteams/teams_turn_context.py | 17 +- .../pyproject.toml | 3 +- tests/hosting_msteams/helpers.py | 2 +- tests/hosting_msteams/test_channels.py | 2 +- tests/hosting_msteams/test_config.py | 2 +- tests/hosting_msteams/test_file_consent.py | 2 +- tests/hosting_msteams/test_internal.py | 68 +++++ tests/hosting_msteams/test_meetings.py | 2 +- .../test_message_extensions.py | 202 ++++++++++++- tests/hosting_msteams/test_messages.py | 2 +- .../test_routing_integration.py | 271 ++++++++++++++++++ tests/hosting_msteams/test_task_modules.py | 2 +- tests/hosting_msteams/test_team_lifecycle.py | 2 +- tests/hosting_msteams/test_teams_activity.py | 175 +++++++++++ .../test_teams_agent_extension.py | 74 ++++- .../test_teams_turn_context.py | 54 ++++ tests/hosting_msteams/test_utils.py | 2 +- 18 files changed, 861 insertions(+), 23 deletions(-) create mode 100644 tests/hosting_msteams/test_internal.py create mode 100644 tests/hosting_msteams/test_routing_integration.py create mode 100644 tests/hosting_msteams/test_teams_activity.py create mode 100644 tests/hosting_msteams/test_teams_turn_context.py diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/turn_context.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/turn_context.py index b9d609adb..3bd9a7cd5 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/turn_context.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/turn_context.py @@ -85,7 +85,7 @@ def copy_to(self, context: "TurnContext") -> None: """ for attribute in [ "adapter", - "activity", + "_activity", "_responded", "_services", "_on_send_activities", diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_turn_context.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_turn_context.py index 6e6da5db7..5d50dfaf7 100644 --- a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_turn_context.py +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_turn_context.py @@ -70,19 +70,17 @@ def api_client(self) -> ApiClient: return _get_teams_api_client(self) @staticmethod - def _make_targeted_activity(activity: Activity) -> Activity: + def _make_targeted_activity(activity: Activity) -> None: """ Make an activity targeted. :param activity: The activity to make targeted. - :return: The targeted activity. + :return: None """ - activity = activity.model_copy() activity.entities = activity.entities or [] activity.entities.append( ActivityTreatment(treatment=ActivityTreatmentTypes.TARGETED) ) - return activity async def send_targeted_activity(self, activity: Activity) -> ResourceResponse: """ @@ -91,9 +89,8 @@ async def send_targeted_activity(self, activity: Activity) -> ResourceResponse: :param activity: The activity to send. :return: The resource response. """ - return await self.send_activity( - TeamsTurnContext._make_targeted_activity(activity) - ) + TeamsTurnContext._make_targeted_activity(activity) + return await self.send_activity(activity) async def send_targeted_activities( self, activities: list[Activity] @@ -104,6 +101,6 @@ async def send_targeted_activities( :param activities: The list of activities to send. :return: A list of resource responses. """ - return await self.send_activities( - [TeamsTurnContext._make_targeted_activity(act) for act in activities] - ) + for activity in activities: + TeamsTurnContext._make_targeted_activity(activity) + return await self.send_activities(activities) diff --git a/libraries/microsoft-agents-hosting-msteams/pyproject.toml b/libraries/microsoft-agents-hosting-msteams/pyproject.toml index 6ce646f0c..a21a04f28 100644 --- a/libraries/microsoft-agents-hosting-msteams/pyproject.toml +++ b/libraries/microsoft-agents-hosting-msteams/pyproject.toml @@ -10,9 +10,10 @@ readme = {file = "readme.md", content-type = "text/markdown"} authors = [{name = "Microsoft Corporation"}] license = "MIT" license-files = ["LICENSE"] -requires-python = ">=3.12" +requires-python = ">=3.11" classifiers = [ "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", diff --git a/tests/hosting_msteams/helpers.py b/tests/hosting_msteams/helpers.py index 3ccab94d1..b6c2f62c1 100644 --- a/tests/hosting_msteams/helpers.py +++ b/tests/hosting_msteams/helpers.py @@ -11,7 +11,7 @@ from microsoft_agents.hosting.core import TurnContext from microsoft_agents.hosting.core.app import AgentApplication, RouteRank -is_supported_version = sys.version_info >= (3, 12) +is_supported_version = sys.version_info >= (3, 11) if is_supported_version: from microsoft_agents.hosting.msteams.teams_turn_context import TeamsTurnContext diff --git a/tests/hosting_msteams/test_channels.py b/tests/hosting_msteams/test_channels.py index a53812745..d1887d4f0 100644 --- a/tests/hosting_msteams/test_channels.py +++ b/tests/hosting_msteams/test_channels.py @@ -12,7 +12,7 @@ pytestmark = pytest.mark.skipif( not is_supported_version, - reason="microsoft-agents-hosting-teams tests require Python 3.12+", + reason="microsoft-agents-hosting-teams tests require Python 3.11+", ) if is_supported_version: diff --git a/tests/hosting_msteams/test_config.py b/tests/hosting_msteams/test_config.py index 9c212c7d7..78264e0ca 100644 --- a/tests/hosting_msteams/test_config.py +++ b/tests/hosting_msteams/test_config.py @@ -12,7 +12,7 @@ pytestmark = pytest.mark.skipif( not is_supported_version, - reason="microsoft-agents-hosting-teams tests require Python 3.12+", + reason="microsoft-agents-hosting-teams tests require Python 3.11+", ) if is_supported_version: diff --git a/tests/hosting_msteams/test_file_consent.py b/tests/hosting_msteams/test_file_consent.py index 5dfd81552..e654d8a32 100644 --- a/tests/hosting_msteams/test_file_consent.py +++ b/tests/hosting_msteams/test_file_consent.py @@ -12,7 +12,7 @@ pytestmark = pytest.mark.skipif( not is_supported_version, - reason="microsoft-agents-hosting-teams tests require Python 3.12+", + reason="microsoft-agents-hosting-teams tests require Python 3.11+", ) if is_supported_version: diff --git a/tests/hosting_msteams/test_internal.py b/tests/hosting_msteams/test_internal.py new file mode 100644 index 000000000..4b840d993 --- /dev/null +++ b/tests/hosting_msteams/test_internal.py @@ -0,0 +1,68 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""Tests for internal helpers: the Teams API client accessor and error resources.""" + +import pytest + +from .helpers import is_supported_version + +pytestmark = pytest.mark.skipif( + not is_supported_version, + reason="microsoft-agents-hosting-teams tests require Python 3.11+", +) + +if is_supported_version: + from microsoft_teams.api import ApiClient + + from microsoft_agents.hosting.msteams._teams_api_client import ( + _TEAMS_API_CLIENT_KEY, + _get_teams_api_client, + ) + from microsoft_agents.hosting.msteams.errors.error_resources import ( + TeamsErrorResources, + ) + + +class _FakeContext: + """Minimal stand-in exposing only the ``turn_state`` dict the accessor reads.""" + + def __init__(self, turn_state): + self.turn_state = turn_state + + +class TestGetTeamsApiClient: + + def test_returns_cached_api_client(self): + client = ApiClient("https://smba.trafficmanager.net/teams/") + ctx = _FakeContext({_TEAMS_API_CLIENT_KEY: client}) + assert _get_teams_api_client(ctx) is client + + def test_raises_when_missing(self): + ctx = _FakeContext({}) + with pytest.raises(ValueError, match="Teams API client"): + _get_teams_api_client(ctx) + + def test_raises_when_wrong_type(self): + ctx = _FakeContext({_TEAMS_API_CLIENT_KEY: object()}) + with pytest.raises(ValueError, match="Teams API client"): + _get_teams_api_client(ctx) + + +class TestTeamsErrorResources: + + def _error_messages(self): + return [ + value + for name, value in vars(TeamsErrorResources).items() + if not name.startswith("_") + ] + + def test_error_codes_are_unique(self): + codes = [msg.error_code for msg in self._error_messages()] + assert len(codes) == len(set(codes)) + + def test_error_codes_within_reserved_range(self): + # The module documents the reserved range as -62000 to -62999. + for msg in self._error_messages(): + assert -62999 <= msg.error_code <= -62000 diff --git a/tests/hosting_msteams/test_meetings.py b/tests/hosting_msteams/test_meetings.py index d7b1a770f..cea09d000 100644 --- a/tests/hosting_msteams/test_meetings.py +++ b/tests/hosting_msteams/test_meetings.py @@ -12,7 +12,7 @@ pytestmark = pytest.mark.skipif( not is_supported_version, - reason="microsoft-agents-hosting-teams tests require Python 3.12+", + reason="microsoft-agents-hosting-teams tests require Python 3.11+", ) if is_supported_version: diff --git a/tests/hosting_msteams/test_message_extensions.py b/tests/hosting_msteams/test_message_extensions.py index 9e7627a73..b5474b972 100644 --- a/tests/hosting_msteams/test_message_extensions.py +++ b/tests/hosting_msteams/test_message_extensions.py @@ -4,6 +4,7 @@ """Tests for TeamsAgentExtension.message_extensions (composeExtension invokes).""" import pytest +from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock, patch from microsoft_agents.activity import ActivityTypes @@ -12,11 +13,13 @@ pytestmark = pytest.mark.skipif( not is_supported_version, - reason="microsoft-agents-hosting-teams tests require Python 3.12+", + reason="microsoft-agents-hosting-teams tests require Python 3.11+", ) if is_supported_version: from microsoft_teams.api.models import ( + AppBasedLinkQuery, + MessagingExtensionAction, MessagingExtensionQuery, MessagingExtensionResponse, ) @@ -573,3 +576,200 @@ def test_card_button_clicked_direct(self): async def handler(ctx, state, value): ... assert self.app._routes[0]["selector"] is not None + + +class TestMessageExtensionHandlerExecution: + """Exercises the handler closures (payload parsing + response sending), not + just the selectors, for the message-extension methods that lacked such tests.""" + + def setup_method(self): + self.app = _make_app() + self.ext = TeamsAgentExtension(self.app) + + @pytest.mark.asyncio + async def test_select_item_passes_raw_value_and_sends_response(self): + response = MessagingExtensionResponse() + user_handler = AsyncMock(return_value=response) + + @self.ext.message_extensions.select_item() + async def handler(ctx, state, value): + return await user_handler(ctx, state, value) + + route_handler = self.app._routes[0]["handler"] + ctx = _make_context( + ActivityTypes.invoke, + name="composeExtension/selectItem", + value={"item": "42"}, + ) + with patch(_PATCH, new_callable=AsyncMock) as mock_send: + await route_handler(ctx, MagicMock()) + # select_item forwards the raw, unvalidated value + assert user_handler.call_args[0][2] == {"item": "42"} + mock_send.assert_awaited_once_with(ctx, response) + + @pytest.mark.asyncio + async def test_select_item_skips_send_when_handler_returns_none(self): + @self.ext.message_extensions.select_item() + async def handler(ctx, state, value): + return None + + route_handler = self.app._routes[0]["handler"] + ctx = _make_context( + ActivityTypes.invoke, name="composeExtension/selectItem", value={} + ) + with patch(_PATCH, new_callable=AsyncMock) as mock_send: + await route_handler(ctx, MagicMock()) + mock_send.assert_not_awaited() + + @pytest.mark.asyncio + async def test_submit_action_parses_action_and_sends_response(self): + response = MessagingExtensionResponse() + user_handler = AsyncMock(return_value=response) + + @self.ext.message_extensions.submit_action() + async def handler(ctx, state, action): + return await user_handler(ctx, state, action) + + route_handler = self.app._routes[0]["handler"] + ctx = _make_context( + ActivityTypes.invoke, + name="composeExtension/submitAction", + value={"commandId": "c", "commandContext": "compose"}, + ) + with patch(_PATCH, new_callable=AsyncMock) as mock_send: + await route_handler(ctx, MagicMock()) + assert isinstance(user_handler.call_args[0][2], MessagingExtensionAction) + mock_send.assert_awaited_once_with(ctx, response) + + def test_submit_action_selector_handles_non_dict_value(self): + # The selector reads botMessagePreviewAction via getattr when value is an + # object rather than a dict; a preview action must exclude this route. + @self.ext.message_extensions.submit_action() + async def handler(ctx, state, action): ... + + selector = self.app._routes[0]["selector"] + + preview_value = SimpleNamespace(botMessagePreviewAction="edit", commandId="c") + assert ( + selector( + _make_context( + ActivityTypes.invoke, + name="composeExtension/submitAction", + value=preview_value, + ) + ) + is False + ) + + non_preview_value = SimpleNamespace(botMessagePreviewAction=None, commandId="c") + assert ( + selector( + _make_context( + ActivityTypes.invoke, + name="composeExtension/submitAction", + value=non_preview_value, + ) + ) + is True + ) + + @pytest.mark.asyncio + async def test_fetch_action_parses_action_and_sends_response(self): + response = MessagingExtensionResponse() + user_handler = AsyncMock(return_value=response) + + @self.ext.message_extensions.fetch_action() + async def handler(ctx, state, action): + return await user_handler(ctx, state, action) + + route_handler = self.app._routes[0]["handler"] + ctx = _make_context( + ActivityTypes.invoke, + name="composeExtension/fetchTask", + value={"commandId": "c", "commandContext": "compose"}, + ) + with patch(_PATCH, new_callable=AsyncMock) as mock_send: + await route_handler(ctx, MagicMock()) + assert isinstance(user_handler.call_args[0][2], MessagingExtensionAction) + mock_send.assert_awaited_once_with(ctx, response) + + @pytest.mark.asyncio + async def test_query_link_parses_query_and_sends_response(self): + response = MessagingExtensionResponse() + user_handler = AsyncMock(return_value=response) + + @self.ext.message_extensions.query_link() + async def handler(ctx, state, query): + return await user_handler(ctx, state, query) + + route_handler = self.app._routes[0]["handler"] + ctx = _make_context( + ActivityTypes.invoke, + name="composeExtension/queryLink", + value={"url": "https://example.com"}, + ) + with patch(_PATCH, new_callable=AsyncMock) as mock_send: + await route_handler(ctx, MagicMock()) + assert isinstance(user_handler.call_args[0][2], AppBasedLinkQuery) + assert user_handler.call_args[0][2].url == "https://example.com" + mock_send.assert_awaited_once_with(ctx, response) + + @pytest.mark.asyncio + async def test_anonymous_query_link_parses_query_and_sends_response(self): + response = MessagingExtensionResponse() + user_handler = AsyncMock(return_value=response) + + @self.ext.message_extensions.anonymous_query_link() + async def handler(ctx, state, query): + return await user_handler(ctx, state, query) + + route_handler = self.app._routes[0]["handler"] + ctx = _make_context( + ActivityTypes.invoke, + name="composeExtension/anonymousQueryLink", + value={"url": "https://example.com"}, + ) + with patch(_PATCH, new_callable=AsyncMock) as mock_send: + await route_handler(ctx, MagicMock()) + assert isinstance(user_handler.call_args[0][2], AppBasedLinkQuery) + mock_send.assert_awaited_once_with(ctx, response) + + @pytest.mark.asyncio + async def test_query_setting_url_parses_query_and_sends_response(self): + response = MessagingExtensionResponse() + user_handler = AsyncMock(return_value=response) + + @self.ext.message_extensions.query_setting_url() + async def handler(ctx, state, query): + return await user_handler(ctx, state, query) + + route_handler = self.app._routes[0]["handler"] + ctx = _make_context( + ActivityTypes.invoke, + name="composeExtension/querySettingUrl", + value={"commandId": "c"}, + ) + with patch(_PATCH, new_callable=AsyncMock) as mock_send: + await route_handler(ctx, MagicMock()) + assert isinstance(user_handler.call_args[0][2], MessagingExtensionQuery) + mock_send.assert_awaited_once_with(ctx, response) + + @pytest.mark.asyncio + async def test_card_button_clicked_forwards_value_and_sends_empty_response(self): + user_handler = AsyncMock() + + @self.ext.message_extensions.card_button_clicked() + async def handler(ctx, state, value): + await user_handler(ctx, state, value) + + route_handler = self.app._routes[0]["handler"] + ctx = _make_context( + ActivityTypes.invoke, + name="composeExtension/onCardButtonClicked", + value={"id": "btn-1"}, + ) + with patch(_PATCH, new_callable=AsyncMock) as mock_send: + await route_handler(ctx, MagicMock()) + # raw value is forwarded; an empty invoke response is always sent + assert user_handler.call_args[0][2] == {"id": "btn-1"} + mock_send.assert_awaited_once_with(ctx) diff --git a/tests/hosting_msteams/test_messages.py b/tests/hosting_msteams/test_messages.py index 4bfad5531..b9c39c225 100644 --- a/tests/hosting_msteams/test_messages.py +++ b/tests/hosting_msteams/test_messages.py @@ -12,7 +12,7 @@ pytestmark = pytest.mark.skipif( not is_supported_version, - reason="microsoft-agents-hosting-teams tests require Python 3.12+", + reason="microsoft-agents-hosting-teams tests require Python 3.11+", ) if is_supported_version: diff --git a/tests/hosting_msteams/test_routing_integration.py b/tests/hosting_msteams/test_routing_integration.py new file mode 100644 index 000000000..6c4166266 --- /dev/null +++ b/tests/hosting_msteams/test_routing_integration.py @@ -0,0 +1,271 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""End-to-end routing integration tests for ``TeamsAgentExtension``. + +Unlike the other ``hosting_msteams`` test modules (which register routes on a +mocked ``AgentApplication`` and invoke handlers/selectors directly), these tests +wire a **real** :class:`AgentApplication` to a real :class:`TeamsAgentExtension` +and drive activities through :meth:`AgentApplication.on_turn`. They verify that +Teams routes are actually registered, selected, and executed by the core routing +engine -- and that non-matching activities do not trigger them. + +The only test double is a minimal adapter that records sent activities; the turn +context, routing, state initialization, and Teams context upgrade all run for +real. +""" + +import pytest + +from microsoft_agents.activity import Activity, ActivityTypes +from microsoft_agents.hosting.core import MemoryStorage, TurnContext +from microsoft_agents.hosting.core.app import ( + AgentApplication, + ApplicationOptions, + TurnState, +) +from tests._common.testing_objects import TestingConnectionManager as _ConnectionManager + +from .helpers import is_supported_version + +pytestmark = pytest.mark.skipif( + not is_supported_version, + reason="microsoft-agents-hosting-teams tests require Python 3.11+", +) + +if is_supported_version: + from microsoft_agents.hosting.msteams import TeamsAgentExtension + from microsoft_agents.hosting.msteams.teams_turn_context import TeamsTurnContext + + +class _StubAdapter: + """Minimal adapter that records activities sent during a turn.""" + + def __init__(self): + self.sent_activities: list[Activity] = [] + + async def send_activities(self, context, activities): + self.sent_activities.extend(activities) + return [None] * len(activities) + + +def _make_app() -> "AgentApplication[TurnState]": + """Build a real AgentApplication wired for on_turn integration tests.""" + storage = MemoryStorage() + app = AgentApplication[TurnState]( + options=ApplicationOptions( + storage=storage, + start_typing_timer=False, + remove_recipient_mention=False, + ), + connection_manager=_ConnectionManager(), + ) + # Shadow class-level middleware lists with fresh instance-level lists so the + # Teams before_turn hook registered below does not leak across app instances. + app._internal_before_turn = [] + app._internal_after_turn = [] + return app + + +def _make_activity(**kwargs) -> Activity: + """Create an Activity with the fields required for state loading/routing.""" + return Activity( + channel_id=kwargs.pop("channel_id", "msteams"), + conversation={"id": "conv1"}, + from_property={"id": "user1"}, + recipient={"id": "bot1"}, + service_url="https://smba.trafficmanager.net/teams/", + **kwargs, + ) + + +def _make_context(activity: Activity) -> TurnContext: + return TurnContext(_StubAdapter(), activity) + + +class TestMessageRouteIntegration: + def setup_method(self): + self.app = _make_app() + self.ext = TeamsAgentExtension(self.app) + self.calls: list[str] = [] + + @pytest.mark.asyncio + async def test_message_route_fires_when_text_matches(self): + @self.ext.message("hello") + async def handler(context, state): + self.calls.append(context.activity.text) + + await self.app.on_turn( + _make_context(_make_activity(type=ActivityTypes.message, text="hello")) + ) + assert self.calls == ["hello"] + + @pytest.mark.asyncio + async def test_message_handler_receives_teams_turn_context(self): + captured: list[object] = [] + + @self.ext.message("hello") + async def handler(context, state): + captured.append(context) + + await self.app.on_turn( + _make_context(_make_activity(type=ActivityTypes.message, text="hello")) + ) + assert len(captured) == 1 + assert isinstance(captured[0], TeamsTurnContext) + + @pytest.mark.asyncio + async def test_message_route_not_fired_when_text_differs(self): + @self.ext.message("hello") + async def handler(context, state): + self.calls.append(context.activity.text) + + await self.app.on_turn( + _make_context(_make_activity(type=ActivityTypes.message, text="goodbye")) + ) + assert self.calls == [] + + @pytest.mark.asyncio + async def test_non_message_activity_does_not_fire_message_route(self): + @self.ext.message("hello") + async def handler(context, state): + self.calls.append("called") + + await self.app.on_turn( + _make_context(_make_activity(type=ActivityTypes.event, text="hello")) + ) + assert self.calls == [] + + +class TestActivityRouteIntegration: + def setup_method(self): + self.app = _make_app() + self.ext = TeamsAgentExtension(self.app) + self.calls: list[str] = [] + + @pytest.mark.asyncio + async def test_activity_route_fires_for_matching_type(self): + @self.ext.activity(ActivityTypes.event) + async def handler(context, state): + self.calls.append(context.activity.type) + + await self.app.on_turn(_make_context(_make_activity(type=ActivityTypes.event))) + assert self.calls == [ActivityTypes.event] + + @pytest.mark.asyncio + async def test_activity_route_not_fired_for_other_type(self): + @self.ext.activity(ActivityTypes.event) + async def handler(context, state): + self.calls.append("called") + + await self.app.on_turn( + _make_context(_make_activity(type=ActivityTypes.message, text="hi")) + ) + assert self.calls == [] + + +class TestConversationUpdateRouteIntegration: + """Drives a Teams-specific conversation update route (message edit).""" + + def setup_method(self): + self.app = _make_app() + self.ext = TeamsAgentExtension(self.app) + self.calls: list[str] = [] + + def _register_edit(self): + @self.ext.messages.edit() + async def handler(context, state): + self.calls.append(type(context).__name__) + + @pytest.mark.asyncio + async def test_edit_route_fires_for_edit_event(self): + self._register_edit() + await self.app.on_turn( + _make_context( + _make_activity( + type=ActivityTypes.message_update, + channel_data={"eventType": "editMessage"}, + ) + ) + ) + assert self.calls == ["TeamsTurnContext"] + + @pytest.mark.asyncio + async def test_edit_route_not_fired_for_other_event(self): + self._register_edit() + await self.app.on_turn( + _make_context( + _make_activity( + type=ActivityTypes.message_update, + channel_data={"eventType": "undeleteMessage"}, + ) + ) + ) + assert self.calls == [] + + @pytest.mark.asyncio + async def test_edit_route_not_fired_on_non_teams_channel(self): + self._register_edit() + await self.app.on_turn( + _make_context( + _make_activity( + type=ActivityTypes.message_update, + channel_id="webchat", + channel_data={"eventType": "editMessage"}, + ) + ) + ) + assert self.calls == [] + + +class TestRouteSelectionOrderingIntegration: + def setup_method(self): + self.app = _make_app() + self.ext = TeamsAgentExtension(self.app) + self.call_order: list[str] = [] + + @pytest.mark.asyncio + async def test_only_first_matching_route_fires(self): + @self.ext.message("hello") + async def first(context, state): + self.call_order.append("first") + + @self.ext.message("hello") + async def second(context, state): + self.call_order.append("second") + + await self.app.on_turn( + _make_context(_make_activity(type=ActivityTypes.message, text="hello")) + ) + assert self.call_order == ["first"] + + @pytest.mark.asyncio + async def test_non_matching_route_is_skipped(self): + @self.ext.message("bye") + async def first(context, state): + self.call_order.append("first") + + @self.ext.message("hello") + async def second(context, state): + self.call_order.append("second") + + await self.app.on_turn( + _make_context(_make_activity(type=ActivityTypes.message, text="hello")) + ) + assert self.call_order == ["second"] + + @pytest.mark.asyncio + async def test_distinct_routes_each_fire_for_their_own_activity(self): + @self.ext.message("hello") + async def on_message(context, state): + self.call_order.append("message") + + @self.ext.activity(ActivityTypes.event) + async def on_event(context, state): + self.call_order.append("event") + + await self.app.on_turn(_make_context(_make_activity(type=ActivityTypes.event))) + await self.app.on_turn( + _make_context(_make_activity(type=ActivityTypes.message, text="hello")) + ) + assert self.call_order == ["event", "message"] diff --git a/tests/hosting_msteams/test_task_modules.py b/tests/hosting_msteams/test_task_modules.py index e4de09412..9f3bfdce7 100644 --- a/tests/hosting_msteams/test_task_modules.py +++ b/tests/hosting_msteams/test_task_modules.py @@ -13,7 +13,7 @@ pytestmark = pytest.mark.skipif( not is_supported_version, - reason="microsoft-agents-hosting-teams tests require Python 3.12+", + reason="microsoft-agents-hosting-teams tests require Python 3.11+", ) if is_supported_version: diff --git a/tests/hosting_msteams/test_team_lifecycle.py b/tests/hosting_msteams/test_team_lifecycle.py index d7aabe5b3..dc1a64493 100644 --- a/tests/hosting_msteams/test_team_lifecycle.py +++ b/tests/hosting_msteams/test_team_lifecycle.py @@ -11,7 +11,7 @@ pytestmark = pytest.mark.skipif( not is_supported_version, - reason="microsoft-agents-hosting-teams tests require Python 3.12+", + reason="microsoft-agents-hosting-teams tests require Python 3.11+", ) if is_supported_version: diff --git a/tests/hosting_msteams/test_teams_activity.py b/tests/hosting_msteams/test_teams_activity.py new file mode 100644 index 000000000..d08d679d3 --- /dev/null +++ b/tests/hosting_msteams/test_teams_activity.py @@ -0,0 +1,175 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""Tests for the TeamsActivity helper methods. + +These tests operate on real ``TeamsActivity`` / ``ChannelData`` objects rather +than mocks, so they exercise the actual parsing and mutation logic. +""" + +import pytest + +from .helpers import is_supported_version + +pytestmark = pytest.mark.skipif( + not is_supported_version, + reason="microsoft-agents-hosting-teams tests require Python 3.11+", +) + +if is_supported_version: + from microsoft_teams.api.models import ( + ChannelData, + ChannelInfo, + FeedbackLoop, + MeetingInfo, + TeamInfo, + ) + from microsoft_teams.api.models.channel_data.settings import ChannelDataSettings + + from microsoft_agents.hosting.msteams import TeamsActivity + + +def _activity(channel_data=None) -> "TeamsActivity": + return TeamsActivity(type="message", channel_data=channel_data) + + +class TestGetChannelData: + + def test_returns_none_when_channel_data_absent(self): + assert _activity()._get_channel_data() is None + + def test_returns_channel_data_instance_unchanged(self): + cd = ChannelData(channel=ChannelInfo(id="c1")) + assert _activity(cd)._get_channel_data() is cd + + def test_model_validates_dict_channel_data(self): + result = _activity({"channel": {"id": "c1"}})._get_channel_data() + assert isinstance(result, ChannelData) + assert result.channel.id == "c1" + + +class TestGetSelectedChannelId: + + def test_returns_selected_channel_id(self): + cd = ChannelData( + settings=ChannelDataSettings(selected_channel=ChannelInfo(id="sel-1")) + ) + assert _activity(cd).get_selected_channel_id() == "sel-1" + + def test_returns_none_when_no_channel_data(self): + assert _activity().get_selected_channel_id() is None + + def test_returns_none_when_settings_absent(self): + cd = ChannelData(channel=ChannelInfo(id="c1")) + assert _activity(cd).get_selected_channel_id() is None + + +class TestGetChannelId: + + def test_returns_channel_id(self): + cd = ChannelData(channel=ChannelInfo(id="c1")) + assert _activity(cd).get_channel_id() == "c1" + + def test_returns_none_when_no_channel_data(self): + assert _activity().get_channel_id() is None + + def test_returns_none_when_channel_absent(self): + cd = ChannelData(team=TeamInfo(id="t1")) + assert _activity(cd).get_channel_id() is None + + +class TestGetMeetingInfo: + + def test_returns_meeting(self): + meeting = MeetingInfo(id="m1") + cd = ChannelData(meeting=meeting) + assert _activity(cd).get_meeting_info() is meeting + + def test_returns_none_when_no_channel_data(self): + assert _activity().get_meeting_info() is None + + def test_returns_none_when_meeting_absent(self): + cd = ChannelData(channel=ChannelInfo(id="c1")) + assert _activity(cd).get_meeting_info() is None + + +class TestGetTeamInfo: + + def test_returns_team(self): + team = TeamInfo(id="t1") + cd = ChannelData(team=team) + assert _activity(cd).get_team_info() is team + + def test_returns_none_when_no_channel_data(self): + assert _activity().get_team_info() is None + + def test_returns_none_when_team_absent(self): + cd = ChannelData(channel=ChannelInfo(id="c1")) + assert _activity(cd).get_team_info() is None + + +class TestNotifyUser: + + def test_creates_channel_data_when_absent(self): + activity = _activity() + activity.notify_user() + assert isinstance(activity.channel_data, ChannelData) + assert activity.channel_data.notification is not None + + def test_alert_true_when_not_in_meeting(self): + activity = _activity() + activity.notify_user(alert_in_meeting=False) + notification = activity.channel_data.notification + assert notification.alert is True + assert notification.alert_in_meeting is False + + def test_alert_false_when_in_meeting(self): + activity = _activity() + activity.notify_user(alert_in_meeting=True) + notification = activity.channel_data.notification + assert notification.alert is False + assert notification.alert_in_meeting is True + + def test_external_resource_url_is_propagated(self): + activity = _activity() + activity.notify_user(external_resource_url="https://example.com/card") + assert ( + activity.channel_data.notification.external_resource_url + == "https://example.com/card" + ) + + def test_persists_notification_when_channel_data_is_dict(self): + # _try_get_channel_data returns a NEW ChannelData parsed from the dict; + # the notification must be written back so it is not lost. + activity = _activity({"channel": {"id": "c1"}}) + activity.notify_user(alert_in_meeting=True) + assert isinstance(activity.channel_data, ChannelData) + assert activity.channel_data.notification.alert_in_meeting is True + # existing channel data is preserved + assert activity.channel_data.channel.id == "c1" + + +class TestEnableFeedbackLoop: + + def test_enables_when_no_channel_data(self): + activity = _activity() + assert activity.enable_feedback_loop() is True + assert isinstance(activity.channel_data.feedback_loop, FeedbackLoop) + assert activity.channel_data.feedback_loop.type == "default" + + def test_uses_supplied_feedback_loop_type(self): + activity = _activity() + assert activity.enable_feedback_loop("custom") is True + assert activity.channel_data.feedback_loop.type == "custom" + + @pytest.mark.xfail( + reason="BUGS.md #1: enable_feedback_loop guard is inverted; it returns " + "False (no-op) when channel data already exists, which is the common case.", + strict=True, + ) + def test_enables_when_channel_data_already_present(self): + activity = _activity({"channel": {"id": "c1"}}) + assert activity.enable_feedback_loop() is True + assert activity.channel_data.feedback_loop is not None + # existing channel data should be preserved, not clobbered + assert activity.channel_data.channel.id == "c1" diff --git a/tests/hosting_msteams/test_teams_agent_extension.py b/tests/hosting_msteams/test_teams_agent_extension.py index baacc7248..fac8fae1a 100644 --- a/tests/hosting_msteams/test_teams_agent_extension.py +++ b/tests/hosting_msteams/test_teams_agent_extension.py @@ -9,11 +9,17 @@ pytestmark = pytest.mark.skipif( not is_supported_version, - reason="microsoft-agents-hosting-teams tests require Python 3.12+", + reason="microsoft-agents-hosting-teams tests require Python 3.11+", ) if is_supported_version: + from microsoft_agents.activity import Activity, Channels + from microsoft_teams.api.models import ChannelData + from microsoft_agents.hosting.msteams import TeamsAgentExtension + from microsoft_agents.hosting.msteams._teams_api_client import ( + _TEAMS_API_CLIENT_KEY, + ) from microsoft_agents.hosting.msteams.channel import Channel from microsoft_agents.hosting.msteams.config import Config from microsoft_agents.hosting.msteams.file_consent import FileConsent @@ -24,6 +30,15 @@ from microsoft_agents.hosting.msteams.team import Team +class _FakeContext: + """Minimal context stand-in for exercising the before_turn hook directly.""" + + def __init__(self, activity, identity=None): + self.activity = activity + self.identity = identity + self.turn_state = {} + + class TestTeamsAgentExtensionProperties: def setup_method(self): @@ -59,3 +74,60 @@ def test_properties_return_same_instance_each_call(self): assert self.ext.meetings is self.ext.meetings assert self.ext.message_extensions is self.ext.message_extensions assert self.ext.task_modules is self.ext.task_modules + + +class TestBeforeTurnHook: + + def setup_method(self): + self.app = _make_app() + self.ext = TeamsAgentExtension(self.app) + # The extension registers exactly one before_turn hook during construction. + self.hook = self.app.before_turn.call_args[0][0] + + def test_hook_is_registered(self): + self.app.before_turn.assert_called_once() + + @pytest.mark.asyncio + async def test_non_teams_channel_is_untouched(self): + activity = Activity( + type="message", channel_id="webchat", channel_data={"channel": {"id": "c"}} + ) + ctx = _FakeContext(activity) + + result = await self.hook(ctx, None) + + assert result is True + # channel_data left as the raw dict; no Teams API client cached + assert activity.channel_data == {"channel": {"id": "c"}} + assert _TEAMS_API_CLIENT_KEY not in ctx.turn_state + + @pytest.mark.asyncio + async def test_teams_channel_deserializes_channel_data(self): + activity = Activity( + type="conversationUpdate", + channel_id=Channels.ms_teams, + service_url="https://smba.trafficmanager.net/teams/", + channel_data={"channel": {"id": "c1"}}, + ) + ctx = _FakeContext(activity, identity=None) + + result = await self.hook(ctx, None) + + assert result is True + assert isinstance(activity.channel_data, ChannelData) + assert activity.channel_data.channel.id == "c1" + assert _TEAMS_API_CLIENT_KEY in ctx.turn_state + + @pytest.mark.asyncio + async def test_teams_channel_without_channel_data_sets_none(self): + activity = Activity( + type="conversationUpdate", + channel_id=Channels.ms_teams, + service_url="https://smba.trafficmanager.net/teams/", + ) + ctx = _FakeContext(activity, identity=None) + + result = await self.hook(ctx, None) + + assert result is True + assert activity.channel_data is None diff --git a/tests/hosting_msteams/test_teams_turn_context.py b/tests/hosting_msteams/test_teams_turn_context.py new file mode 100644 index 000000000..73df57206 --- /dev/null +++ b/tests/hosting_msteams/test_teams_turn_context.py @@ -0,0 +1,54 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""Tests for TeamsTurnContext helpers that can be exercised without a live adapter.""" + +import pytest + +from .helpers import is_supported_version + +pytestmark = pytest.mark.skipif( + not is_supported_version, + reason="microsoft-agents-hosting-teams tests require Python 3.11+", +) + +if is_supported_version: + from microsoft_agents.activity import ( + Activity, + ActivityTreatmentTypes, + Entity, + ) + + from microsoft_agents.hosting.msteams import TeamsTurnContext + + +class TestMakeTargetedActivity: + """``_make_targeted_activity`` mutates the supplied activity in place (returns + None) by appending a TARGETED activity-treatment entity.""" + + def test_appends_targeted_treatment_when_no_entities(self): + activity = Activity(type="message") + result = TeamsTurnContext._make_targeted_activity(activity) + assert result is None + assert len(activity.entities) == 1 + assert activity.entities[0].treatment == ActivityTreatmentTypes.TARGETED + + def test_preserves_existing_entities(self): + activity = Activity(type="message", entities=[Entity(type="mention")]) + TeamsTurnContext._make_targeted_activity(activity) + assert len(activity.entities) == 2 + assert activity.entities[0].type == "mention" + assert activity.entities[1].treatment == ActivityTreatmentTypes.TARGETED + + def test_each_call_appends_another_treatment(self): + # The helper is not idempotent: repeated calls accumulate treatments + # (see BUGS.md #4). This pins the current behaviour. + activity = Activity(type="message") + TeamsTurnContext._make_targeted_activity(activity) + TeamsTurnContext._make_targeted_activity(activity) + treatments = [ + e + for e in activity.entities + if getattr(e, "treatment", None) == ActivityTreatmentTypes.TARGETED + ] + assert len(treatments) == 2 diff --git a/tests/hosting_msteams/test_utils.py b/tests/hosting_msteams/test_utils.py index 1707ebe3a..fd6290ac6 100644 --- a/tests/hosting_msteams/test_utils.py +++ b/tests/hosting_msteams/test_utils.py @@ -11,7 +11,7 @@ pytestmark = pytest.mark.skipif( not is_supported_version, - reason="microsoft-agents-hosting-teams tests require Python 3.12+", + reason="microsoft-agents-hosting-teams tests require Python 3.11+", ) if is_supported_version: From f575e593cd04b8363fc8374d035bd87f891dad72 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Wed, 24 Jun 2026 12:59:59 -0700 Subject: [PATCH 31/46] Updating package README and minor bug --- .../hosting/core/app/oauth/authorization.py | 10 +- .../hosting/msteams/config/config.py | 30 ++- .../readme.md | 198 +++++++++++++++++- tests/hosting_msteams/test_config.py | 94 +++++++++ 4 files changed, 309 insertions(+), 23 deletions(-) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/authorization.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/authorization.py index 21549321f..d769583dd 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/authorization.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/authorization.py @@ -37,7 +37,7 @@ class Authorization: """Class responsible for managing authorization flows.""" _storage: Storage - _connections: Connections + _connection_manager: Connections _handlers: dict[str, _AuthorizationHandler] def __init__( @@ -67,7 +67,7 @@ def __init__( raise ValueError("Storage is required for Authorization") self._storage = storage - self._connections = connection_manager + self._connection_manager = connection_manager self._sign_in_success_handler: Optional[ Callable[[TurnContext, TurnState, Optional[str]], Awaitable[None]] @@ -120,14 +120,10 @@ def _init_handlers(self) -> None: self._handlers[name] = AUTHORIZATION_TYPE_MAP[auth_type]( storage=self._storage, - connections=self._connections, + connection_manager=self._connection_manager, auth_handler=auth_handler, ) - @property - def connections(self) -> Connections: - return self._connections - @property def connection_manager(self) -> Connections: """ diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/config/config.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/config/config.py index adb1a0ff6..e40dcbc48 100644 --- a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/config/config.py +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/config/config.py @@ -3,7 +3,7 @@ """Route registration helpers for Teams configuration (config/fetch, config/submit) invokes.""" -from typing import Generic, Optional +from typing import Generic, Optional, overload from microsoft_agents.activity import ActivityTypes from microsoft_agents.hosting.core import ( @@ -74,24 +74,44 @@ async def __handler(context: TurnContext, state: StateT) -> None: return __call + @overload + def fetch(self, handler: ConfigHandler[StateT]) -> ConfigHandler[StateT]: ... + @overload + def fetch( + self, *, auth_handlers: Optional[list[str]] = ..., rank: RouteRank = ... + ) -> _RouteDecorator[ConfigHandler[StateT]]: ... def fetch( self, + handler: Optional[ConfigHandler[StateT]] = None, *, auth_handlers: Optional[list[str]] = None, rank: RouteRank = RouteRank.DEFAULT, - ) -> _RouteDecorator[ConfigHandler[StateT]]: + ) -> ConfigHandler[StateT] | _RouteDecorator[ConfigHandler[StateT]]: """Register a handler for config/fetch invokes.""" - return self._create_decorator( + decorator = self._create_decorator( "config/fetch", auth_handlers=auth_handlers, rank=rank ) + if handler is not None: + return decorator(handler) + return decorator + @overload + def submit(self, handler: ConfigHandler[StateT]) -> ConfigHandler[StateT]: ... + @overload + def submit( + self, *, auth_handlers: Optional[list[str]] = ..., rank: RouteRank = ... + ) -> _RouteDecorator[ConfigHandler[StateT]]: ... def submit( self, + handler: Optional[ConfigHandler[StateT]] = None, *, auth_handlers: Optional[list[str]] = None, rank: RouteRank = RouteRank.DEFAULT, - ) -> _RouteDecorator[ConfigHandler[StateT]]: + ) -> ConfigHandler[StateT] | _RouteDecorator[ConfigHandler[StateT]]: """Register a handler for config/submit invokes.""" - return self._create_decorator( + decorator = self._create_decorator( "config/submit", auth_handlers=auth_handlers, rank=rank ) + if handler is not None: + return decorator(handler) + return decorator diff --git a/libraries/microsoft-agents-hosting-msteams/readme.md b/libraries/microsoft-agents-hosting-msteams/readme.md index 50f830f01..ee1838104 100644 --- a/libraries/microsoft-agents-hosting-msteams/readme.md +++ b/libraries/microsoft-agents-hosting-msteams/readme.md @@ -4,7 +4,7 @@ Teams-specific extension for the Microsoft 365 Agents SDK for Python. Provides the `TeamsAgentExtension` and a full set of typed, decorator-based route registrars for every Teams invoke and event activity — messaging extensions, task modules, meeting lifecycle events, channel and team management, file consent, config invokes, and more. -All handlers receive a `TeamsTurnContext`, a Teams-aware wrapper around the base `TurnContext` that surfaces the Teams API client and will serve as the primary surface for Teams-specific functionality going forward. +All handlers receive a `TeamsTurnContext`, a Teams-aware wrapper around the base `TurnContext` that surfaces the Teams API client and typed activity helpers. ## What is this? @@ -122,7 +122,7 @@ pip install microsoft-agents-hosting-msteams | Class | Description | |-------|-------------| | `TeamsAgentExtension` | Attaches to an `AgentApplication` and exposes all Teams route registrars as typed properties. | -| `TeamsTurnContext` | Teams-aware context passed to every handler. Provides the Teams API client and will be the primary surface for new Teams-specific capabilities. | +| `TeamsTurnContext` | Teams-aware context passed to every handler. Provides the Teams API client and typed `TeamsActivity` helpers. | | `MessageExtension` | Route registrar for all `composeExtension/*` invokes (query, fetch action, submit, link unfurling, settings, etc.). | | `TaskModule` | Route registrar for `task/fetch` and `task/submit` invokes. | | `Meeting` | Route registrar for meeting start/end and participant join/leave events. | @@ -139,22 +139,67 @@ pip install microsoft-agents-hosting-msteams Wrap your `AgentApplication` with `TeamsAgentExtension` to access all Teams-specific route registrars: ```python -from microsoft_agents.hosting.msteams import TeamsAgentExtension from microsoft_agents.hosting.core import AgentApplication, TurnState +from microsoft_agents.hosting.msteams import TeamsAgentExtension app = AgentApplication[TurnState]() teams = TeamsAgentExtension(app) ``` -Every handler receives a `TeamsTurnContext` as its first argument, giving access to the Teams API client alongside all standard `TurnContext` capabilities. +`TeamsAgentExtension` registers a `before_turn` hook that deserializes `activity.channel_data` into a typed `ChannelData` object and creates a pre-authenticated Teams API client on the turn state for every Teams activity. + +### The Teams context + +Every handler registered through `TeamsAgentExtension` receives a `TeamsTurnContext` instead of a plain `TurnContext`. + +#### `context.activity` → `TeamsActivity` + +The activity is upgraded to `TeamsActivity`, which adds helper methods on top of the standard `Activity`: + +```python +# Get the Teams channel the activity came from +channel_id = context.activity.get_channel_id() + +# Get the team info embedded in channel_data +team = context.activity.get_team_info() + +# Get the meeting the activity was sent in +meeting = context.activity.get_meeting_info() + +# Mark the outgoing reply to target a specific user in a meeting +context.activity.notify_user(alert_in_meeting=True) + +# Attach a feedback loop to a message being sent (Copilot scenarios) +context.activity.enable_feedback_loop() +``` + +#### `context.api_client` → `ApiClient` + +Direct access to the Teams REST API client, pre-authenticated for the current turn. See [Teams API client](#teams-api-client) below. + +#### Sending targeted activities + +`TeamsTurnContext` adds two methods for sending activities that target a specific user in a meeting: + +```python +await context.send_targeted_activity(activity) +await context.send_targeted_activities([activity1, activity2]) +``` ### Messaging Extensions ```python +from microsoft_teams.api.models import ( + MessagingExtensionQuery, + MessagingExtensionResponse, + MessagingExtensionAction, + MessagingExtensionActionResponse, + AppBasedLinkQuery, +) + # Search-based extension @teams.message_extensions.query("searchCmd") async def on_search(context: TeamsTurnContext, state, query: MessagingExtensionQuery): - # build and return results return MessagingExtensionResponse(...) # Action-based extension — open a task module to collect input @@ -172,6 +217,11 @@ async def on_submit(context: TeamsTurnContext, state, action: MessagingExtension async def on_query_link(context: TeamsTurnContext, state, query: AppBasedLinkQuery): return MessagingExtensionResponse(...) +# Anonymous link unfurling (before the user has authenticated) +@teams.message_extensions.anonymous_query_link +async def on_anon_link(context: TeamsTurnContext, state, query: AppBasedLinkQuery): + return MessagingExtensionResponse(...) + # Bot message preview — edit stage @teams.message_extensions.message_preview_edit("myCmd") async def on_preview_edit(context: TeamsTurnContext, state, preview: Activity): @@ -181,13 +231,39 @@ async def on_preview_edit(context: TeamsTurnContext, state, preview: Activity): @teams.message_extensions.message_preview_send("myCmd") async def on_preview_send(context: TeamsTurnContext, state, preview: Activity): return MessagingExtensionResponse(...) + +# Select item from search results +@teams.message_extensions.select_item +async def on_select_item(context: TeamsTurnContext, state, item): + return MessagingExtensionResponse(...) + +# Card button clicked +@teams.message_extensions.card_button_clicked +async def on_card_click(context: TeamsTurnContext, state, card): + ... ``` All `command_id` arguments accept a plain string, a compiled `re.Pattern`, or `None` to match all commands. +#### Settings page + +```python +@teams.message_extensions.query_setting_url +async def on_settings_url(context: TeamsTurnContext, state, query: MessagingExtensionQuery): + return MessagingExtensionResponse( + compose_extension=MessagingExtensionResult(type="config", suggested_actions=...) + ) + +@teams.message_extensions.setting +async def on_settings_submit(context: TeamsTurnContext, state, query: MessagingExtensionQuery): + await save_settings(context, query.state) +``` + ### Task Modules ```python +from microsoft_teams.api.models import TaskModuleRequest, TaskModuleResponse + @teams.task_modules.fetch("myVerb") async def on_task_fetch(context: TeamsTurnContext, state, request: TaskModuleRequest): return TaskModuleResponse(...) @@ -197,9 +273,14 @@ async def on_task_submit(context: TeamsTurnContext, state, request: TaskModuleRe return TaskModuleResponse(...) ``` +The first argument is a verb — a string or regex matched against `activity.value.data.verb`. Omit it to match all `task/fetch` or `task/submit` invokes. + ### Meeting Events ```python +from microsoft_teams.api.models import MeetingDetails +from microsoft_agents.activity.teams import MeetingParticipantsEventDetails + @teams.meetings.start async def on_meeting_start(context: TeamsTurnContext, state, meeting: MeetingDetails): ... @@ -220,9 +301,11 @@ async def on_participants_leave(context: TeamsTurnContext, state, details: Meeti ### Channel Events ```python +from microsoft_teams.api.models import ChannelData + @teams.channels.created async def on_channel_created(context: TeamsTurnContext, state, data: ChannelData): - ... + print(f"New channel: {data.channel.name}") @teams.channels.renamed async def on_channel_renamed(context: TeamsTurnContext, state, data: ChannelData): @@ -231,43 +314,56 @@ async def on_channel_renamed(context: TeamsTurnContext, state, data: ChannelData # Also available: deleted, shared, unshared, restored, members_added, members_removed ``` +`teams.channels.event()` is a catch-all that matches any `channel.*` event type via regex. + ### Team Events ```python @teams.teams.renamed -async def on_team_renamed(context: TeamsTurnContext, state, data: ChannelData): - ... +async def on_team_renamed(context: TeamsTurnContext, state, data: ChannelData): ... # Also available: archived, unarchived, deleted, hard_deleted, restored ``` +`teams.teams.event()` is a catch-all matching any `team.*` event type. + ### File Consent ```python +from microsoft_teams.api.models import FileConsentCardResponse + @teams.file_consent.accept async def on_file_accept(context: TeamsTurnContext, state, consent: FileConsentCardResponse): - ... + await upload_file(consent.upload_info) @teams.file_consent.decline async def on_file_decline(context: TeamsTurnContext, state, consent: FileConsentCardResponse): ... ``` +Teams expects a 200 OK with no body for both; the routing layer sends that automatically. + ### Config Invokes ```python +from microsoft_teams.api.models import ConfigResponse + @teams.config.fetch -async def on_config_fetch(context: TeamsTurnContext, state, data): +async def on_config_fetch(context: TeamsTurnContext, state, config_data): return ConfigResponse(...) @teams.config.submit -async def on_config_submit(context: TeamsTurnContext, state, data): +async def on_config_submit(context: TeamsTurnContext, state, config_data): return ConfigResponse(...) ``` +`config_data` is the raw `activity.value` payload. + ### Message Updates ```python +from microsoft_teams.api.models import O365ConnectorCardActionQuery + @teams.messages.edit async def on_message_edit(context: TeamsTurnContext, state): ... @@ -276,8 +372,13 @@ async def on_message_edit(context: TeamsTurnContext, state): async def on_message_delete(context: TeamsTurnContext, state): ... +@teams.messages.undelete +async def on_message_undelete(context: TeamsTurnContext, state): + ... + @teams.messages.read_receipt async def on_read_receipt(context: TeamsTurnContext, state, data: dict): + last_read_id = data.get("lastReadMessageId") ... @teams.messages.execute_action @@ -285,6 +386,81 @@ async def on_execute_action(context: TeamsTurnContext, state, query: O365Connect ... ``` +### Generic Teams routes + +`TeamsAgentExtension` also wraps the underlying `AgentApplication` route methods so all handlers automatically receive a `TeamsTurnContext`: + +```python +from microsoft_agents.activity import ActivityTypes, ConversationUpdateTypes + +@teams.activity(ActivityTypes.message) +async def on_any_message(context: TeamsTurnContext, state): + await context.send_activity(f"Echo: {context.activity.text}") + +@teams.message(r"^hello") +async def on_hello(context: TeamsTurnContext, state): + await context.send_activity("Hi there!") + +@teams.conversation_update(ConversationUpdateTypes.members_added) +async def on_members_added(context: TeamsTurnContext, state): + for member in context.activity.members_added: + if member.id != context.activity.recipient.id: + await context.send_activity(f"Welcome, {member.name}!") +``` + +## Teams API client + +`context.api_client` is a pre-authenticated `ApiClient` pointing at the Teams connector service for the current turn. It wraps the Teams REST API and is the right tool for operations on channels, conversations, and members. + +```python +# Get the list of members in the current conversation +members = await context.api_client.conversations.get_conversation_members( + context.activity.conversation.id +) + +# Send a proactive message to a specific conversation +await context.api_client.conversations.send_to_conversation( + context.activity.conversation.id, + activity, +) +``` + +You can also get the client directly from the extension if you have a context but aren't inside a Teams handler: + +```python +client = teams.get_teams_api_client(context) +``` + +## Microsoft Graph client + +For operations that go beyond the Teams connector service — user profiles, SharePoint, calendar, etc. — use the Graph client: + +```python +@teams.message(r"^profile") +async def on_profile(context: TeamsTurnContext, state): + graph = teams.get_graph_client(context, handler_name="UserAuth") + user = await graph.users.by_user_id(context.activity.from_.aad_object_id).get() + await context.send_activity(f"Display name: {user.display_name}") +``` + +The `handler_name` argument names an OAuth connection configured in the app's `auth` settings. If omitted, the app's default auth handler is used. + +## Invoke response conventions + +Teams expects a synchronous HTTP response for all invoke activities. The routing layer handles this automatically — return a value from your handler and the framework serializes and sends it. + +| Namespace | Sends response? | Response type | +|---|---|---| +| `task_modules.fetch/submit` | If handler returns non-`None` | `TaskModuleResponse` | +| `message_extensions.query/select_item/fetch_action/submit_action/message_preview_*` | If handler returns non-`None` | `MessagingExtensionResponse` / `MessagingExtensionActionResponse` | +| `message_extensions.query_link/anonymous_query_link/query_setting_url` | If handler returns non-`None` | `MessagingExtensionResponse` | +| `message_extensions.setting` | Always | `MessagingExtensionResponse` (or 200 with no body if handler returns `None`) | +| `message_extensions.card_button_clicked` | Always | empty 200 | +| `config.fetch/submit` | If handler returns non-`None` | `ConfigResponse` | +| `file_consent.accept/decline` | Always | empty 200 | +| `messages.execute_action` | Always | empty 200 | +| `meetings`, `channels`, `teams`, `messages.edit/undelete/delete` | Never — not invokes | — | + ## Features Supported - **Messaging Extensions** — query, fetch action, submit action, link unfurling, anonymous link unfurling, settings URL, configure settings, card button clicked, bot message preview (edit/send) diff --git a/tests/hosting_msteams/test_config.py b/tests/hosting_msteams/test_config.py index 78264e0ca..931eb0cb7 100644 --- a/tests/hosting_msteams/test_config.py +++ b/tests/hosting_msteams/test_config.py @@ -106,3 +106,97 @@ async def handler(ctx, state, data): with patch(_PATCH, new_callable=AsyncMock): await route_handler(ctx, MagicMock()) assert user_handler.call_args[0][2] == payload + + # --- direct decoration (no parentheses) --- + + def test_fetch_direct_decoration_registers_route(self): + @self.ext.config.fetch + async def handler(ctx, state, data): ... + + assert len(self.app._routes) == 1 + + def test_fetch_direct_decoration_selector(self): + @self.ext.config.fetch + async def handler(ctx, state, data): ... + + selector = self.app._routes[0]["selector"] + assert ( + selector(_make_context(ActivityTypes.invoke, name="config/fetch")) is True + ) + assert ( + selector(_make_context(ActivityTypes.invoke, name="config/submit")) is False + ) + + def test_fetch_direct_decoration_is_invoke(self): + @self.ext.config.fetch + async def handler(ctx, state, data): ... + + assert self.app._routes[0]["is_invoke"] is True + + def test_fetch_direct_decoration_returns_handler(self): + async def handler(ctx, state, data): ... + + result = self.ext.config.fetch(handler) + assert result is handler + + def test_submit_direct_decoration_registers_route(self): + @self.ext.config.submit + async def handler(ctx, state, data): ... + + assert len(self.app._routes) == 1 + + def test_submit_direct_decoration_selector(self): + @self.ext.config.submit + async def handler(ctx, state, data): ... + + selector = self.app._routes[0]["selector"] + assert ( + selector(_make_context(ActivityTypes.invoke, name="config/submit")) is True + ) + assert ( + selector(_make_context(ActivityTypes.invoke, name="config/fetch")) is False + ) + + def test_submit_direct_decoration_is_invoke(self): + @self.ext.config.submit + async def handler(ctx, state, data): ... + + assert self.app._routes[0]["is_invoke"] is True + + def test_submit_direct_decoration_returns_handler(self): + async def handler(ctx, state, data): ... + + result = self.ext.config.submit(handler) + assert result is handler + + @pytest.mark.asyncio + async def test_fetch_direct_decoration_handler_sends_response(self): + response = {"type": "auth"} + user_handler = AsyncMock(return_value=response) + + async def _handler(ctx, state, data): + return await user_handler(ctx, state, data) + + self.ext.config.fetch(_handler) + + route_handler = self.app._routes[0]["handler"] + ctx = _make_context(ActivityTypes.invoke, name="config/fetch", value={}) + with patch(_PATCH, new_callable=AsyncMock) as mock_send: + await route_handler(ctx, MagicMock()) + mock_send.assert_awaited_once_with(ctx, response) + + @pytest.mark.asyncio + async def test_submit_direct_decoration_handler_sends_response(self): + response = {"type": "message"} + user_handler = AsyncMock(return_value=response) + + async def _handler(ctx, state, data): + return await user_handler(ctx, state, data) + + self.ext.config.submit(_handler) + + route_handler = self.app._routes[0]["handler"] + ctx = _make_context(ActivityTypes.invoke, name="config/submit", value={}) + with patch(_PATCH, new_callable=AsyncMock) as mock_send: + await route_handler(ctx, MagicMock()) + mock_send.assert_awaited_once_with(ctx, response) From ac41caf2a3d7c2a2a419efe9d450790f77d2350e Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Wed, 24 Jun 2026 13:14:18 -0700 Subject: [PATCH 32/46] github workflow edit --- .github/workflows/python-package.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index 2f13f4e97..297e4cfd7 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -62,10 +62,10 @@ jobs: python -m pip install ./dist/microsoft_agents_copilotstudio_client*.whl python -m pip install ./dist/microsoft_agents_hosting_aiohttp*.whl python -m pip install ./dist/microsoft_agents_hosting_dialogs*.whl - if python -c "import sys; sys.exit(0 if sys.version_info >= (3, 12) else 1)"; then - python -m pip install ./dist/microsoft_agents_hosting_teams*.whl + if python -c "import sys; sys.exit(0 if sys.version_info >= (3, 11) else 1)"; then + python -m pip install ./dist/microsoft_agents_hosting_msteams*.whl else - echo "Skipping microsoft_agents_hosting_teams: requires Python 3.12+" + echo "Skipping microsoft_agents_hosting_msteams: requires Python 3.11+" fi python -m pip install ./dist/microsoft_agents_hosting_slack*.whl python -m pip install ./dist/microsoft_agents_storage_blob*.whl From 633d1434205b0cfe917192446e026cfb159e0fe7 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Wed, 24 Jun 2026 13:16:11 -0700 Subject: [PATCH 33/46] Updating documentation --- .../hosting/msteams/__init__.py | 14 ++- .../hosting/msteams/_graph.py | 35 +++++- .../hosting/msteams/_teams_api_client.py | 7 ++ .../hosting/msteams/channel/__init__.py | 2 + .../hosting/msteams/channel/channel.py | 90 ++++++++++++-- .../hosting/msteams/channel/route_handlers.py | 1 + .../hosting/msteams/config/__init__.py | 2 + .../hosting/msteams/config/config.py | 20 ++- .../hosting/msteams/errors/__init__.py | 6 +- .../hosting/msteams/errors/error_resources.py | 12 +- .../hosting/msteams/file_consent/__init__.py | 2 + .../msteams/file_consent/file_consent.py | 32 ++++- .../msteams/file_consent/route_handlers.py | 1 + .../hosting/msteams/meeting/__init__.py | 2 + .../hosting/msteams/meeting/meeting.py | 66 ++++++++-- .../hosting/msteams/meeting/route_handlers.py | 3 + .../hosting/msteams/message/__init__.py | 2 + .../hosting/msteams/message/message.py | 70 ++++++++++- .../hosting/msteams/message/route_handlers.py | 2 + .../msteams/message_extension/__init__.py | 2 + .../message_extension/message_extension.py | 117 +++++++++++++++--- .../message_extension/route_handlers.py | 5 +- .../msteams/proactive_service_endpoints.py | 22 ++++ .../hosting/msteams/task_module/__init__.py | 2 + .../msteams/task_module/task_module.py | 16 ++- .../hosting/msteams/team/__init__.py | 2 + .../hosting/msteams/team/team.py | 78 ++++++++++-- .../hosting/msteams/teams_activity.py | 22 +++- .../hosting/msteams/teams_agent_extension.py | 8 ++ .../hosting/msteams/teams_turn_context.py | 4 + 30 files changed, 563 insertions(+), 84 deletions(-) create mode 100644 libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/proactive_service_endpoints.py diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/__init__.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/__init__.py index ec2be82b5..dd8ab8626 100644 --- a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/__init__.py +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/__init__.py @@ -1,8 +1,14 @@ -""" -Microsoft Agents Hosting Teams package. +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""Microsoft 365 Agents SDK -- Microsoft Teams hosting extension. -Provides Teams-specific activity handlers, extensions, and utilities for building -Microsoft Teams bots and agents using the AgentApplication or ActivityHandler models. +This package layers Teams-specific functionality on top of an +:class:`~microsoft_agents.hosting.core.AgentApplication`. Its entry point is +:class:`TeamsAgentExtension`, which exposes namespaced route registration for +Teams events (channels, teams, meetings, messages, message extensions, task +modules, configuration, and file consent). It also provides the Teams-aware +:class:`TeamsTurnContext` and :class:`TeamsActivity` helpers. """ from .teams_agent_extension import TeamsAgentExtension diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/_graph.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/_graph.py index 00847767d..e34f21be0 100644 --- a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/_graph.py +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/_graph.py @@ -1,6 +1,13 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. +"""Microsoft Graph client integration for the Teams hosting layer. + +Builds a :class:`msgraph.GraphServiceClient` whose requests are authenticated +with tokens obtained from the agent's configured authorization, so handlers can +call Microsoft Graph on behalf of the current turn. +""" + from typing import Any from kiota_abstractions.request_information import RequestInformation @@ -15,8 +22,20 @@ class _SDKAuthenticationProvider(AuthenticationProvider): + """Kiota authentication provider backed by the agent's authorization. + + Acquires an access token for the current turn via + :meth:`AgentApplication.auth.get_token` and attaches it to outgoing Graph + requests as a bearer token. + """ def __init__(self, app: AgentApplication, context: TurnContext, handler_name: str): + """Capture the context needed to resolve a token at request time. + + :param app: The agent application whose authorization issues tokens. + :param context: The current turn context. + :param handler_name: The auth handler name used to acquire the token. + """ self._app = app self._context = context self._handler_name = handler_name @@ -26,11 +45,11 @@ async def authenticate_request( request: RequestInformation, additional_authentication_context: dict[str, Any] | None = None, ) -> None: - """Authenticates the application request + """Attach a bearer token to the outgoing Graph request. - Args: - request (RequestInformation): The request to authenticate - additional_authentication_context (dict): + :param request: The request to authenticate. + :param additional_authentication_context: Optional Kiota authentication + context; unused but accepted to satisfy the provider interface. """ if additional_authentication_context is None: additional_authentication_context = {} @@ -45,6 +64,14 @@ def _create_graph_service_client( context: TurnContext, handler_name: str | None = None, ) -> GraphServiceClient: + """Create a Graph client authenticated for the current turn. + + :param app: The agent application whose authorization issues tokens. + :param context: The current turn context. + :param handler_name: Optional auth handler name used to acquire the token. + :return: A :class:`GraphServiceClient` that authenticates each request via + the agent's authorization. + """ return GraphServiceClient( request_adapter=GraphRequestAdapter( _SDKAuthenticationProvider(app, context, handler_name) diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/_teams_api_client.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/_teams_api_client.py index e704bee6d..a19863230 100644 --- a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/_teams_api_client.py +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/_teams_api_client.py @@ -1,6 +1,13 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. +"""Construction and caching of the Teams :class:`ApiClient` for a turn. + +The client is cached on the turn state so it is built at most once per turn, and +is configured with a token factory derived from the turn's identity when one is +available. +""" + from microsoft_teams.common import ClientOptions from microsoft_teams.api import ApiClient diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/channel/__init__.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/channel/__init__.py index 49479e345..076f7a2b0 100644 --- a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/channel/__init__.py +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/channel/__init__.py @@ -1,6 +1,8 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. +"""Teams channel route registration exports.""" + from .channel import Channel __all__ = ["Channel"] diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/channel/channel.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/channel/channel.py index a4d62eba5..a3f91d1e4 100644 --- a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/channel/channel.py +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/channel/channel.py @@ -82,10 +82,12 @@ async def __handler(context: TurnContext, state: StateT) -> None: def event( self, handler: ChannelUpdateHandler[StateT] ) -> ChannelUpdateHandler[StateT]: ... + @overload def event( self, *, auth_handlers: Optional[list[str]] = ..., rank: RouteRank = ... ) -> _RouteDecorator[ChannelUpdateHandler[StateT]]: ... + def event( self, handler: Optional[ChannelUpdateHandler[StateT]] = None, @@ -93,7 +95,13 @@ def event( auth_handlers: Optional[list[str]] = None, rank: RouteRank = RouteRank.DEFAULT, ) -> ChannelUpdateHandler[StateT] | _RouteDecorator[ChannelUpdateHandler[StateT]]: - """Register a handler for Teams team event conversation update events.""" + """Register a handler for Teams channel conversation update events. + + :param handler: Optional handler to register directly; omit for decorator-style usage. + :param auth_handlers: Optional list of auth handler names to run before the route. + :param rank: Route priority rank. + :return: The registered handler, or a decorator when used without a handler. + """ decorator = self._create_decorator( re.compile(r"channel.*"), auth_handlers=auth_handlers, rank=rank ) @@ -105,10 +113,12 @@ def event( def created( self, handler: ChannelUpdateHandler[StateT] ) -> ChannelUpdateHandler[StateT]: ... + @overload def created( self, *, auth_handlers: Optional[list[str]] = ..., rank: RouteRank = ... ) -> _RouteDecorator[ChannelUpdateHandler[StateT]]: ... + def created( self, handler: Optional[ChannelUpdateHandler[StateT]] = None, @@ -116,7 +126,13 @@ def created( auth_handlers: Optional[list[str]] = None, rank: RouteRank = RouteRank.DEFAULT, ) -> ChannelUpdateHandler[StateT] | _RouteDecorator[ChannelUpdateHandler[StateT]]: - """Register a handler for Teams channelCreated conversation update events.""" + """Register a handler for Teams channelCreated conversation update events. + + :param handler: Optional handler to register directly; omit for decorator-style usage. + :param auth_handlers: Optional list of auth handler names to run before the route. + :param rank: Route priority rank. + :return: The registered handler, or a decorator when used without a handler. + """ decorator = self._create_decorator( "channelCreated", auth_handlers=auth_handlers, rank=rank ) @@ -128,10 +144,12 @@ def created( def deleted( self, handler: ChannelUpdateHandler[StateT] ) -> ChannelUpdateHandler[StateT]: ... + @overload def deleted( self, *, auth_handlers: Optional[list[str]] = ..., rank: RouteRank = ... ) -> _RouteDecorator[ChannelUpdateHandler[StateT]]: ... + def deleted( self, handler: Optional[ChannelUpdateHandler[StateT]] = None, @@ -139,7 +157,13 @@ def deleted( auth_handlers: Optional[list[str]] = None, rank: RouteRank = RouteRank.DEFAULT, ) -> ChannelUpdateHandler[StateT] | _RouteDecorator[ChannelUpdateHandler[StateT]]: - """Register a handler for Teams channelDeleted conversation update events.""" + """Register a handler for Teams channelDeleted conversation update events. + + :param handler: Optional handler to register directly; omit for decorator-style usage. + :param auth_handlers: Optional list of auth handler names to run before the route. + :param rank: Route priority rank. + :return: The registered handler, or a decorator when used without a handler. + """ decorator = self._create_decorator( "channelDeleted", auth_handlers=auth_handlers, rank=rank ) @@ -151,10 +175,12 @@ def deleted( def renamed( self, handler: ChannelUpdateHandler[StateT] ) -> ChannelUpdateHandler[StateT]: ... + @overload def renamed( self, *, auth_handlers: Optional[list[str]] = ..., rank: RouteRank = ... ) -> _RouteDecorator[ChannelUpdateHandler[StateT]]: ... + def renamed( self, handler: Optional[ChannelUpdateHandler[StateT]] = None, @@ -162,7 +188,13 @@ def renamed( auth_handlers: Optional[list[str]] = None, rank: RouteRank = RouteRank.DEFAULT, ) -> ChannelUpdateHandler[StateT] | _RouteDecorator[ChannelUpdateHandler[StateT]]: - """Register a handler for Teams channelRenamed conversation update events.""" + """Register a handler for Teams channelRenamed conversation update events. + + :param handler: Optional handler to register directly; omit for decorator-style usage. + :param auth_handlers: Optional list of auth handler names to run before the route. + :param rank: Route priority rank. + :return: The registered handler, or a decorator when used without a handler. + """ decorator = self._create_decorator( "channelRenamed", auth_handlers=auth_handlers, rank=rank ) @@ -174,10 +206,12 @@ def renamed( def shared( self, handler: ChannelUpdateHandler[StateT] ) -> ChannelUpdateHandler[StateT]: ... + @overload def shared( self, *, auth_handlers: Optional[list[str]] = ..., rank: RouteRank = ... ) -> _RouteDecorator[ChannelUpdateHandler[StateT]]: ... + def shared( self, handler: Optional[ChannelUpdateHandler[StateT]] = None, @@ -185,7 +219,13 @@ def shared( auth_handlers: Optional[list[str]] = None, rank: RouteRank = RouteRank.DEFAULT, ) -> ChannelUpdateHandler[StateT] | _RouteDecorator[ChannelUpdateHandler[StateT]]: - """Register a handler for Teams channelShared conversation update events.""" + """Register a handler for Teams channelShared conversation update events. + + :param handler: Optional handler to register directly; omit for decorator-style usage. + :param auth_handlers: Optional list of auth handler names to run before the route. + :param rank: Route priority rank. + :return: The registered handler, or a decorator when used without a handler. + """ decorator = self._create_decorator( "channelShared", auth_handlers=auth_handlers, rank=rank ) @@ -197,10 +237,12 @@ def shared( def unshared( self, handler: ChannelUpdateHandler[StateT] ) -> ChannelUpdateHandler[StateT]: ... + @overload def unshared( self, *, auth_handlers: Optional[list[str]] = ..., rank: RouteRank = ... ) -> _RouteDecorator[ChannelUpdateHandler[StateT]]: ... + def unshared( self, handler: Optional[ChannelUpdateHandler[StateT]] = None, @@ -208,7 +250,13 @@ def unshared( auth_handlers: Optional[list[str]] = None, rank: RouteRank = RouteRank.DEFAULT, ) -> ChannelUpdateHandler[StateT] | _RouteDecorator[ChannelUpdateHandler[StateT]]: - """Register a handler for Teams channelUnshared conversation update events.""" + """Register a handler for Teams channelUnshared conversation update events. + + :param handler: Optional handler to register directly; omit for decorator-style usage. + :param auth_handlers: Optional list of auth handler names to run before the route. + :param rank: Route priority rank. + :return: The registered handler, or a decorator when used without a handler. + """ decorator = self._create_decorator( "channelUnshared", auth_handlers=auth_handlers, rank=rank ) @@ -220,10 +268,12 @@ def unshared( def restored( self, handler: ChannelUpdateHandler[StateT] ) -> ChannelUpdateHandler[StateT]: ... + @overload def restored( self, *, auth_handlers: Optional[list[str]] = ..., rank: RouteRank = ... ) -> _RouteDecorator[ChannelUpdateHandler[StateT]]: ... + def restored( self, handler: Optional[ChannelUpdateHandler[StateT]] = None, @@ -231,7 +281,13 @@ def restored( auth_handlers: Optional[list[str]] = None, rank: RouteRank = RouteRank.DEFAULT, ) -> ChannelUpdateHandler[StateT] | _RouteDecorator[ChannelUpdateHandler[StateT]]: - """Register a handler for Teams channelRestored conversation update events.""" + """Register a handler for Teams channelRestored conversation update events. + + :param handler: Optional handler to register directly; omit for decorator-style usage. + :param auth_handlers: Optional list of auth handler names to run before the route. + :param rank: Route priority rank. + :return: The registered handler, or a decorator when used without a handler. + """ decorator = self._create_decorator( "channelRestored", auth_handlers=auth_handlers, rank=rank ) @@ -243,10 +299,12 @@ def restored( def members_added( self, handler: ChannelUpdateHandler[StateT] ) -> ChannelUpdateHandler[StateT]: ... + @overload def members_added( self, *, auth_handlers: Optional[list[str]] = ..., rank: RouteRank = ... ) -> _RouteDecorator[ChannelUpdateHandler[StateT]]: ... + def members_added( self, handler: Optional[ChannelUpdateHandler[StateT]] = None, @@ -254,7 +312,13 @@ def members_added( auth_handlers: Optional[list[str]] = None, rank: RouteRank = RouteRank.DEFAULT, ) -> ChannelUpdateHandler[StateT] | _RouteDecorator[ChannelUpdateHandler[StateT]]: - """Register a handler for Teams membersAdded conversation update events.""" + """Register a handler for Teams membersAdded conversation update events. + + :param handler: Optional handler to register directly; omit for decorator-style usage. + :param auth_handlers: Optional list of auth handler names to run before the route. + :param rank: Route priority rank. + :return: The registered handler, or a decorator when used without a handler. + """ def __selector(context: TurnContext) -> bool: return ( @@ -283,10 +347,12 @@ async def __func(context: TurnContext, state: StateT) -> None: def members_removed( self, handler: ChannelUpdateHandler[StateT] ) -> ChannelUpdateHandler[StateT]: ... + @overload def members_removed( self, *, auth_handlers: Optional[list[str]] = ..., rank: RouteRank = ... ) -> _RouteDecorator[ChannelUpdateHandler[StateT]]: ... + def members_removed( self, handler: Optional[ChannelUpdateHandler[StateT]] = None, @@ -294,7 +360,13 @@ def members_removed( auth_handlers: Optional[list[str]] = None, rank: RouteRank = RouteRank.DEFAULT, ) -> ChannelUpdateHandler[StateT] | _RouteDecorator[ChannelUpdateHandler[StateT]]: - """Register a handler for Teams membersRemoved conversation update events.""" + """Register a handler for Teams membersRemoved conversation update events. + + :param handler: Optional handler to register directly; omit for decorator-style usage. + :param auth_handlers: Optional list of auth handler names to run before the route. + :param rank: Route priority rank. + :return: The registered handler, or a decorator when used without a handler. + """ def __selector(context: TurnContext) -> bool: return ( diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/channel/route_handlers.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/channel/route_handlers.py index 4c9af573f..e018c45c8 100644 --- a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/channel/route_handlers.py +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/channel/route_handlers.py @@ -30,5 +30,6 @@ def __call__( :param context: Teams-aware turn context. :param state: The current turn state. :param data: Parsed channel data from the incoming activity. + :return: An awaitable that completes when the handler finishes. """ ... diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/config/__init__.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/config/__init__.py index ce912e97f..6ce4eb3c8 100644 --- a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/config/__init__.py +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/config/__init__.py @@ -1,6 +1,8 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. +"""Teams configuration route registration exports.""" + from .config import Config __all__ = ["Config"] diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/config/config.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/config/config.py index e40dcbc48..ce37a190d 100644 --- a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/config/config.py +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/config/config.py @@ -76,10 +76,12 @@ async def __handler(context: TurnContext, state: StateT) -> None: @overload def fetch(self, handler: ConfigHandler[StateT]) -> ConfigHandler[StateT]: ... + @overload def fetch( self, *, auth_handlers: Optional[list[str]] = ..., rank: RouteRank = ... ) -> _RouteDecorator[ConfigHandler[StateT]]: ... + def fetch( self, handler: Optional[ConfigHandler[StateT]] = None, @@ -87,7 +89,13 @@ def fetch( auth_handlers: Optional[list[str]] = None, rank: RouteRank = RouteRank.DEFAULT, ) -> ConfigHandler[StateT] | _RouteDecorator[ConfigHandler[StateT]]: - """Register a handler for config/fetch invokes.""" + """Register a handler for config/fetch invokes. + + :param handler: Optional handler to register directly; omit for decorator-style usage. + :param auth_handlers: Optional list of auth handler names to run before the route. + :param rank: Route priority rank. + :return: The registered handler, or a decorator when used without a handler. + """ decorator = self._create_decorator( "config/fetch", auth_handlers=auth_handlers, rank=rank ) @@ -97,10 +105,12 @@ def fetch( @overload def submit(self, handler: ConfigHandler[StateT]) -> ConfigHandler[StateT]: ... + @overload def submit( self, *, auth_handlers: Optional[list[str]] = ..., rank: RouteRank = ... ) -> _RouteDecorator[ConfigHandler[StateT]]: ... + def submit( self, handler: Optional[ConfigHandler[StateT]] = None, @@ -108,7 +118,13 @@ def submit( auth_handlers: Optional[list[str]] = None, rank: RouteRank = RouteRank.DEFAULT, ) -> ConfigHandler[StateT] | _RouteDecorator[ConfigHandler[StateT]]: - """Register a handler for config/submit invokes.""" + """Register a handler for config/submit invokes. + + :param handler: Optional handler to register directly; omit for decorator-style usage. + :param auth_handlers: Optional list of auth handler names to run before the route. + :param rank: Route priority rank. + :return: The registered handler, or a decorator when used without a handler. + """ decorator = self._create_decorator( "config/submit", auth_handlers=auth_handlers, rank=rank ) diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/errors/__init__.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/errors/__init__.py index 8d767abf6..91e4e3935 100644 --- a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/errors/__init__.py +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/errors/__init__.py @@ -1,15 +1,13 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. -""" -Error resources for Microsoft Agents Hosting Teams package. -""" +"""Teams error resource exports for the Microsoft Agents hosting package.""" from microsoft_agents.activity.errors import ErrorMessage from .error_resources import TeamsErrorResources -# Singleton instance +# Shared Teams error resource instance. teams_errors = TeamsErrorResources() __all__ = ["ErrorMessage", "TeamsErrorResources", "teams_errors"] diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/errors/error_resources.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/errors/error_resources.py index a119bcd5f..75f08eedd 100644 --- a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/errors/error_resources.py +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/errors/error_resources.py @@ -1,21 +1,13 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. -""" -Teams error resources for Microsoft Agents SDK. - -Error codes are in the range -62000 to -62999. -""" +"""Teams error resource definitions for Microsoft Agents SDK error codes.""" from microsoft_agents.activity.errors import ErrorMessage class TeamsErrorResources: - """ - Error messages for Teams operations. - - Error codes are organized in the range -62000 to -62999. - """ + """Provide error messages for Teams operations using the -62000 to -62999 range.""" TeamsBadRequest = ErrorMessage( "BadRequest", diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/file_consent/__init__.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/file_consent/__init__.py index 5f890f635..6bf788862 100644 --- a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/file_consent/__init__.py +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/file_consent/__init__.py @@ -1,6 +1,8 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. +"""Teams file consent route registration helpers exposed by the subpackage.""" + from .file_consent import FileConsent __all__ = ["FileConsent"] diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/file_consent/file_consent.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/file_consent/file_consent.py index 2ed92221f..c7db5b206 100644 --- a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/file_consent/file_consent.py +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/file_consent/file_consent.py @@ -44,9 +44,16 @@ def _create_decorator( auth_handlers: Optional[list[str]] = None, rank: RouteRank = RouteRank.DEFAULT, ) -> _RouteDecorator[FileConsentHandler[StateT]]: - """Build a route decorator for a fileConsent/invoke action.""" + """Build a route decorator for a fileConsent/invoke action. + + :param action: File consent action value to match. + :param auth_handlers: Optional list of auth handler names to run before the route. + :param rank: Route priority used when multiple routes match. + :return: A decorator that registers a file consent handler. + """ def __selector(context: TurnContext) -> bool: + """Return True when the activity is a matching file consent invoke.""" return ( context.activity.type == ActivityTypes.invoke and context.activity.name == "fileConsent/invoke" @@ -55,7 +62,10 @@ def __selector(context: TurnContext) -> bool: ) def __call(func: FileConsentHandler[StateT]) -> FileConsentHandler[StateT]: + """Register the supplied file consent handler.""" + async def __handler(context: TurnContext, state: StateT) -> None: + """Adapt the core turn context and dispatch to the file consent handler.""" teams_context = TeamsTurnContext(context, self._app) file_consent = FileConsentCardResponse.model_validate( context.activity.value or {} @@ -78,10 +88,12 @@ async def __handler(context: TurnContext, state: StateT) -> None: def accept( self, handler: FileConsentHandler[StateT] ) -> FileConsentHandler[StateT]: ... + @overload def accept( self, *, auth_handlers: Optional[list[str]] = ..., rank: RouteRank = ... ) -> _RouteDecorator[FileConsentHandler[StateT]]: ... + def accept( self, handler: Optional[FileConsentHandler[StateT]] = None, @@ -89,7 +101,13 @@ def accept( auth_handlers: Optional[list[str]] = None, rank: RouteRank = RouteRank.DEFAULT, ) -> FileConsentHandler[StateT] | _RouteDecorator[FileConsentHandler[StateT]]: - """Register a handler for fileConsent/invoke with action == 'accept'.""" + """Register a handler for accepted Teams file consent invokes. + + :param handler: Optional handler to register directly; omit for decorator use. + :param auth_handlers: Optional list of auth handler names to run before the route. + :param rank: Route priority used when multiple routes match. + :return: The registered handler, or a decorator when used without a handler. + """ decorator = self._create_decorator( "accept", auth_handlers=auth_handlers, rank=rank ) @@ -101,10 +119,12 @@ def accept( def decline( self, handler: FileConsentHandler[StateT] ) -> FileConsentHandler[StateT]: ... + @overload def decline( self, *, auth_handlers: Optional[list[str]] = ..., rank: RouteRank = ... ) -> _RouteDecorator[FileConsentHandler[StateT]]: ... + def decline( self, handler: Optional[FileConsentHandler[StateT]] = None, @@ -112,7 +132,13 @@ def decline( auth_handlers: Optional[list[str]] = None, rank: RouteRank = RouteRank.DEFAULT, ) -> FileConsentHandler[StateT] | _RouteDecorator[FileConsentHandler[StateT]]: - """Register a handler for fileConsent/invoke with action == 'decline'.""" + """Register a handler for declined Teams file consent invokes. + + :param handler: Optional handler to register directly; omit for decorator use. + :param auth_handlers: Optional list of auth handler names to run before the route. + :param rank: Route priority used when multiple routes match. + :return: The registered handler, or a decorator when used without a handler. + """ decorator = self._create_decorator( "decline", auth_handlers=auth_handlers, rank=rank ) diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/file_consent/route_handlers.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/file_consent/route_handlers.py index 0dd8a491c..a4bfb7084 100644 --- a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/file_consent/route_handlers.py +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/file_consent/route_handlers.py @@ -30,5 +30,6 @@ def __call__( :param context: Teams-aware turn context. :param state: The current turn state. :param file_consent: Parsed file consent card response from the invoke payload. + :return: An awaitable that completes when the handler finishes. """ ... diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/meeting/__init__.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/meeting/__init__.py index dd768ef94..74ed18871 100644 --- a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/meeting/__init__.py +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/meeting/__init__.py @@ -1,6 +1,8 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. +"""Teams meeting route registration helpers exposed by the subpackage.""" + from .meeting import Meeting __all__ = ["Meeting"] diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/meeting/meeting.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/meeting/meeting.py index 97a3e5c27..fed394b72 100644 --- a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/meeting/meeting.py +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/meeting/meeting.py @@ -29,22 +29,28 @@ class Meeting(Generic[StateT]): - """ - Route registration for Teams Meeting event activities. - Access via TeamsAgentExtension.meetings. + """Route registration for Teams meeting lifecycle event activities. + + Access via :attr:`TeamsAgentExtension.meetings`. """ def __init__(self, app: AgentApplication[StateT]) -> None: + """Initialise with the owning :class:`AgentApplication`. + + :param app: The application to register routes on. + """ self._app = app @overload def start( self, handler: MeetingStartHandler[StateT] ) -> MeetingStartHandler[StateT]: ... + @overload def start( self, *, auth_handlers: Optional[list[str]] = ..., rank: RouteRank = ... ) -> _RouteDecorator[MeetingStartHandler[StateT]]: ... + def start( self, handler: Optional[MeetingStartHandler[StateT]] = None, @@ -52,16 +58,26 @@ def start( auth_handlers: Optional[list[str]] = None, rank: RouteRank = RouteRank.DEFAULT, ) -> MeetingStartHandler[StateT] | _RouteDecorator[MeetingStartHandler[StateT]]: - """Register a handler for meeting start events.""" + """Register a handler for Teams meeting start events. + + :param handler: Optional handler to register directly; omit for decorator use. + :param auth_handlers: Optional list of auth handler names to run before the route. + :param rank: Route priority used when multiple routes match. + :return: The registered handler, or a decorator when used without a handler. + """ def __selector(context: TurnContext) -> bool: + """Return True when the activity is a Teams meeting start event.""" return ( context.activity.type == ActivityTypes.event and context.activity.name == "application/vnd.microsoft.meetingStart" ) def __call(func: MeetingStartHandler[StateT]) -> MeetingStartHandler[StateT]: + """Register the supplied meeting start handler.""" + async def __handler(context: TurnContext, state: StateT) -> None: + """Adapt the core turn context and dispatch to the meeting start handler.""" teams_context = TeamsTurnContext(context, self._app) meeting = MeetingDetails.model_validate(context.activity.value or {}) await func(teams_context, state, meeting) @@ -80,10 +96,12 @@ async def __handler(context: TurnContext, state: StateT) -> None: @overload def end(self, handler: MeetingEndHandler[StateT]) -> MeetingEndHandler[StateT]: ... + @overload def end( self, *, auth_handlers: Optional[list[str]] = ..., rank: RouteRank = ... ) -> _RouteDecorator[MeetingEndHandler[StateT]]: ... + def end( self, handler: Optional[MeetingEndHandler[StateT]] = None, @@ -91,16 +109,26 @@ def end( auth_handlers: Optional[list[str]] = None, rank: RouteRank = RouteRank.DEFAULT, ) -> MeetingEndHandler[StateT] | _RouteDecorator[MeetingEndHandler[StateT]]: - """Register a handler for meeting end events.""" + """Register a handler for Teams meeting end events. + + :param handler: Optional handler to register directly; omit for decorator use. + :param auth_handlers: Optional list of auth handler names to run before the route. + :param rank: Route priority used when multiple routes match. + :return: The registered handler, or a decorator when used without a handler. + """ def __selector(context: TurnContext) -> bool: + """Return True when the activity is a Teams meeting end event.""" return ( context.activity.type == ActivityTypes.event and context.activity.name == "application/vnd.microsoft.meetingEnd" ) def __call(func: MeetingEndHandler[StateT]) -> MeetingEndHandler[StateT]: + """Register the supplied meeting end handler.""" + async def __handler(context: TurnContext, state: StateT) -> None: + """Adapt the core turn context and dispatch to the meeting end handler.""" teams_context = TeamsTurnContext(context, self._app) meeting = MeetingDetails.model_validate(context.activity.value or {}) await func(teams_context, state, meeting) @@ -121,10 +149,12 @@ async def __handler(context: TurnContext, state: StateT) -> None: def participants_join( self, handler: MeetingParticipantsEventHandler[StateT] ) -> MeetingParticipantsEventHandler[StateT]: ... + @overload def participants_join( self, *, auth_handlers: Optional[list[str]] = ..., rank: RouteRank = ... ) -> _RouteDecorator[MeetingParticipantsEventHandler[StateT]]: ... + def participants_join( self, handler: Optional[MeetingParticipantsEventHandler[StateT]] = None, @@ -135,9 +165,16 @@ def participants_join( MeetingParticipantsEventHandler[StateT] | _RouteDecorator[MeetingParticipantsEventHandler[StateT]] ): - """Register a handler for meeting participant join events.""" + """Register a handler for Teams meeting participant join events. + + :param handler: Optional handler to register directly; omit for decorator use. + :param auth_handlers: Optional list of auth handler names to run before the route. + :param rank: Route priority used when multiple routes match. + :return: The registered handler, or a decorator when used without a handler. + """ def __selector(context: TurnContext) -> bool: + """Return True when the activity is a Teams participant join event.""" return ( context.activity.type == ActivityTypes.event and context.activity.name @@ -147,7 +184,10 @@ def __selector(context: TurnContext) -> bool: def __call( func: MeetingParticipantsEventHandler[StateT], ) -> MeetingParticipantsEventHandler[StateT]: + """Register the supplied meeting participants handler.""" + async def __handler(context: TurnContext, state: StateT) -> None: + """Adapt the core turn context and dispatch to the participants handler.""" teams_context = TeamsTurnContext(context, self._app) details = MeetingParticipantsEventDetails.model_validate( context.activity.value or {} @@ -170,10 +210,12 @@ async def __handler(context: TurnContext, state: StateT) -> None: def participants_leave( self, handler: MeetingParticipantsEventHandler[StateT] ) -> MeetingParticipantsEventHandler[StateT]: ... + @overload def participants_leave( self, *, auth_handlers: Optional[list[str]] = ..., rank: RouteRank = ... ) -> _RouteDecorator[MeetingParticipantsEventHandler[StateT]]: ... + def participants_leave( self, handler: Optional[MeetingParticipantsEventHandler[StateT]] = None, @@ -184,9 +226,16 @@ def participants_leave( MeetingParticipantsEventHandler[StateT] | _RouteDecorator[MeetingParticipantsEventHandler[StateT]] ): - """Register a handler for meeting participant leave events.""" + """Register a handler for Teams meeting participant leave events. + + :param handler: Optional handler to register directly; omit for decorator use. + :param auth_handlers: Optional list of auth handler names to run before the route. + :param rank: Route priority used when multiple routes match. + :return: The registered handler, or a decorator when used without a handler. + """ def __selector(context: TurnContext) -> bool: + """Return True when the activity is a Teams participant leave event.""" return ( context.activity.type == ActivityTypes.event and context.activity.name @@ -196,7 +245,10 @@ def __selector(context: TurnContext) -> bool: def __call( func: MeetingParticipantsEventHandler[StateT], ) -> MeetingParticipantsEventHandler[StateT]: + """Register the supplied meeting participants handler.""" + async def __handler(context: TurnContext, state: StateT) -> None: + """Adapt the core turn context and dispatch to the participants handler.""" teams_context = TeamsTurnContext(context, self._app) details = MeetingParticipantsEventDetails.model_validate( context.activity.value or {} diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/meeting/route_handlers.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/meeting/route_handlers.py index fe59aa7a8..adef1927f 100644 --- a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/meeting/route_handlers.py +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/meeting/route_handlers.py @@ -27,6 +27,7 @@ def __call__( :param context: Teams-aware turn context. :param state: The current turn state. :param meeting: Details of the meeting that started. + :return: An awaitable that completes when the handler finishes. """ ... @@ -46,6 +47,7 @@ def __call__( :param context: Teams-aware turn context. :param state: The current turn state. :param meeting: Details of the meeting that ended. + :return: An awaitable that completes when the handler finishes. """ ... @@ -65,5 +67,6 @@ def __call__( :param context: Teams-aware turn context. :param state: The current turn state. :param meeting: Details of the participants event. + :return: An awaitable that completes when the handler finishes. """ ... diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/message/__init__.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/message/__init__.py index fe0e05a99..b86375229 100644 --- a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/message/__init__.py +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/message/__init__.py @@ -1,6 +1,8 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. +"""Teams message route registration helpers exposed by the subpackage.""" + from .message import Message __all__ = [ diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/message/message.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/message/message.py index b4c62cfe5..01fff239a 100644 --- a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/message/message.py +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/message/message.py @@ -52,9 +52,17 @@ def _create_basic_decorator( auth_handlers: Optional[list[str]] = None, rank: RouteRank = RouteRank.DEFAULT, ) -> _RouteDecorator[TeamsRouteHandler[StateT]]: - """Build a route decorator for a Teams messageUpdate channel event type.""" + """Build a route decorator for a Teams message channel event type. + + :param event_type: Teams channel event type to match. + :param message_type: Activity type that must carry the channel event. + :param auth_handlers: Optional list of auth handler names to run before the route. + :param rank: Route priority used when multiple routes match. + :return: A decorator that registers a Teams route handler. + """ def __selector(context: TurnContext) -> bool: + """Return True when the activity matches the configured message event.""" return ( context.activity.type == message_type and context.activity.channel_id == Channels.ms_teams @@ -62,6 +70,7 @@ def __selector(context: TurnContext) -> bool: ) def __call(func: TeamsRouteHandler[StateT]) -> TeamsRouteHandler[StateT]: + """Register the supplied Teams route handler.""" self._app.add_route( __selector, wrap_teams_route_handler(func, self._app), @@ -74,10 +83,12 @@ def __call(func: TeamsRouteHandler[StateT]) -> TeamsRouteHandler[StateT]: @overload def edit(self, handler: TeamsRouteHandler[StateT]) -> TeamsRouteHandler[StateT]: ... + @overload def edit( self, *, auth_handlers: Optional[list[str]] = ..., rank: RouteRank = ... ) -> _RouteDecorator[TeamsRouteHandler[StateT]]: ... + def edit( self, handler: Optional[TeamsRouteHandler[StateT]] = None, @@ -85,7 +96,13 @@ def edit( auth_handlers: Optional[list[str]] = None, rank: RouteRank = RouteRank.DEFAULT, ) -> TeamsRouteHandler[StateT] | _RouteDecorator[TeamsRouteHandler[StateT]]: - """Register a handler for Teams editMessage events.""" + """Register a handler for Teams editMessage events. + + :param handler: Optional handler to register directly; omit for decorator use. + :param auth_handlers: Optional list of auth handler names to run before the route. + :param rank: Route priority used when multiple routes match. + :return: The registered handler, or a decorator when used without a handler. + """ decorator = self._create_basic_decorator( "editMessage", ActivityTypes.message_update, @@ -100,10 +117,12 @@ def edit( def undelete( self, handler: TeamsRouteHandler[StateT] ) -> TeamsRouteHandler[StateT]: ... + @overload def undelete( self, *, auth_handlers: Optional[list[str]] = ..., rank: RouteRank = ... ) -> _RouteDecorator[TeamsRouteHandler[StateT]]: ... + def undelete( self, handler: Optional[TeamsRouteHandler[StateT]] = None, @@ -111,7 +130,13 @@ def undelete( auth_handlers: Optional[list[str]] = None, rank: RouteRank = RouteRank.DEFAULT, ) -> TeamsRouteHandler[StateT] | _RouteDecorator[TeamsRouteHandler[StateT]]: - """Register a handler for Teams undeleteMessage events.""" + """Register a handler for Teams undeleteMessage events. + + :param handler: Optional handler to register directly; omit for decorator use. + :param auth_handlers: Optional list of auth handler names to run before the route. + :param rank: Route priority used when multiple routes match. + :return: The registered handler, or a decorator when used without a handler. + """ decorator = self._create_basic_decorator( "undeleteMessage", ActivityTypes.message_update, @@ -126,10 +151,12 @@ def undelete( def delete( self, handler: TeamsRouteHandler[StateT] ) -> TeamsRouteHandler[StateT]: ... + @overload def delete( self, *, auth_handlers: Optional[list[str]] = ..., rank: RouteRank = ... ) -> _RouteDecorator[TeamsRouteHandler[StateT]]: ... + def delete( self, handler: Optional[TeamsRouteHandler[StateT]] = None, @@ -137,7 +164,13 @@ def delete( auth_handlers: Optional[list[str]] = None, rank: RouteRank = RouteRank.DEFAULT, ) -> TeamsRouteHandler[StateT] | _RouteDecorator[TeamsRouteHandler[StateT]]: - """Register a handler for Teams softDeleteMessage events.""" + """Register a handler for Teams softDeleteMessage events. + + :param handler: Optional handler to register directly; omit for decorator use. + :param auth_handlers: Optional list of auth handler names to run before the route. + :param rank: Route priority used when multiple routes match. + :return: The registered handler, or a decorator when used without a handler. + """ decorator = self._create_basic_decorator( "softDeleteMessage", ActivityTypes.message_delete, @@ -152,10 +185,12 @@ def delete( def read_receipt( self, handler: ReadReceiptHandler[StateT] ) -> ReadReceiptHandler[StateT]: ... + @overload def read_receipt( self, *, auth_handlers: Optional[list[str]] = ..., rank: RouteRank = ... ) -> _RouteDecorator[ReadReceiptHandler[StateT]]: ... + def read_receipt( self, handler: Optional[ReadReceiptHandler[StateT]] = None, @@ -163,16 +198,27 @@ def read_receipt( auth_handlers: Optional[list[str]] = None, rank: RouteRank = RouteRank.DEFAULT, ) -> ReadReceiptHandler[StateT] | _RouteDecorator[ReadReceiptHandler[StateT]]: - """Register a handler for Teams readReceipt events.""" + """Register a handler for Teams readReceipt events. + + :param handler: Optional handler to register directly; omit for decorator use. + :param auth_handlers: Optional list of auth handler names to run before the route. + :param rank: Route priority used when multiple routes match. + :return: The registered handler, or a decorator when used without a handler. + :raises TypeError: If the incoming activity value is not a raw dict. + """ def __selector(context: TurnContext) -> bool: + """Return True when the activity is a Teams read receipt event.""" return ( context.activity.type == ActivityTypes.event and context.activity.name == "application/vnd.microsoft.readReceipt" ) def __call(func: ReadReceiptHandler[StateT]) -> ReadReceiptHandler[StateT]: + """Register the supplied read receipt handler.""" + async def __handler(context: TurnContext, state: StateT) -> None: + """Adapt the raw read receipt payload and dispatch to the handler.""" teams_context = TeamsTurnContext(context, self._app) value = context.activity.value if not isinstance(value, dict): @@ -194,10 +240,12 @@ async def __handler(context: TurnContext, state: StateT) -> None: def execute_action( self, handler: ExecuteActionHandler[StateT] ) -> ExecuteActionHandler[StateT]: ... + @overload def execute_action( self, *, auth_handlers: Optional[list[str]] = ..., rank: RouteRank = ... ) -> _RouteDecorator[ExecuteActionHandler[StateT]]: ... + def execute_action( self, handler: Optional[ExecuteActionHandler[StateT]] = None, @@ -205,16 +253,26 @@ def execute_action( auth_handlers: Optional[list[str]] = None, rank: RouteRank = RouteRank.DEFAULT, ) -> ExecuteActionHandler[StateT] | _RouteDecorator[ExecuteActionHandler[StateT]]: - """Register a handler for actionableMessage/executeAction invokes.""" + """Register a handler for actionableMessage/executeAction invokes. + + :param handler: Optional handler to register directly; omit for decorator use. + :param auth_handlers: Optional list of auth handler names to run before the route. + :param rank: Route priority used when multiple routes match. + :return: The registered handler, or a decorator when used without a handler. + """ def __selector(context: TurnContext) -> bool: + """Return True when the activity is an actionable message execute invoke.""" return ( context.activity.type == ActivityTypes.invoke and context.activity.name == "actionableMessage/executeAction" ) def __call(func: ExecuteActionHandler[StateT]) -> ExecuteActionHandler[StateT]: + """Register the supplied execute action handler.""" + async def __handler(context: TurnContext, state: StateT) -> None: + """Adapt the invoke payload and dispatch to the execute action handler.""" teams_context = TeamsTurnContext(context, self._app) query = O365ConnectorCardActionQuery.model_validate( context.activity.value or {} diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/message/route_handlers.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/message/route_handlers.py index 2ef8f7438..6d1473910 100644 --- a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/message/route_handlers.py +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/message/route_handlers.py @@ -26,6 +26,7 @@ def __call__( :param context: Teams-aware turn context. :param state: The current turn state. :param query: The parsed O365 connector card action query. + :return: An awaitable that completes when the handler finishes. """ ... @@ -45,5 +46,6 @@ def __call__( :param context: Teams-aware turn context. :param state: The current turn state. :param data: Raw event payload from the activity value. + :return: An awaitable that completes when the handler finishes. """ ... diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/message_extension/__init__.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/message_extension/__init__.py index d1f10e06f..de7e80be7 100644 --- a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/message_extension/__init__.py +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/message_extension/__init__.py @@ -1,6 +1,8 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. +"""Teams message extension subpackage exposing route registration helpers.""" + from .message_extension import MessageExtension __all__ = ["MessageExtension"] diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/message_extension/message_extension.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/message_extension/message_extension.py index 5072b09c8..bb4c347d9 100644 --- a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/message_extension/message_extension.py +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/message_extension/message_extension.py @@ -47,9 +47,12 @@ def _extract_activity_preview(value: object) -> Activity: - """Extract and deserialize the first botActivityPreview entry from a composeExtension/submitAction value. + """Extract and deserialize the first botActivityPreview entry. + :param value: The raw composeExtension/submitAction activity value. + :return: The first activity preview parsed as an :class:`Activity`. :raises ValueError: If value is not a dict or no preview entries are present. + :raises pydantic.ValidationError: If the preview cannot be deserialized. """ if not isinstance(value, dict): raise ValueError( @@ -64,12 +67,16 @@ def _extract_activity_preview(value: object) -> Activity: class MessageExtension(Generic[StateT]): - """ - Route registration for Teams Message Extension (composeExtension) invoke activities. - Access via TeamsAgentExtension.message_extension. + """Route registration for Teams Message Extension (composeExtension) invoke activities. + + Access via :attr:`TeamsAgentExtension.message_extensions`. """ def __init__(self, app: AgentApplication[StateT]) -> None: + """Initialise with the owning :class:`AgentApplication`. + + :param app: The application to register routes on. + """ self._app = app def query( @@ -79,7 +86,13 @@ def query( auth_handlers: Optional[list[str]] = None, rank: RouteRank = RouteRank.DEFAULT, ) -> _RouteDecorator[QueryHandler[StateT]]: - """Register a handler for composeExtension/query invokes.""" + """Register a handler for composeExtension/query invokes. + + :param command_id: Optional command identifier or regex to match a specific Teams command. + :param auth_handlers: Optional auth handler names to run before the route. + :param rank: Route priority. + :return: A decorator that registers the handler and returns it. + """ def __selector(context: TurnContext) -> bool: if ( @@ -116,10 +129,12 @@ async def __handler(context: TurnContext, state: StateT) -> None: def select_item( self, handler: SelectItemHandler[StateT] ) -> SelectItemHandler[StateT]: ... + @overload def select_item( self, *, auth_handlers: Optional[list[str]] = ..., rank: RouteRank = ... ) -> _RouteDecorator[SelectItemHandler[StateT]]: ... + def select_item( self, handler: Optional[SelectItemHandler[StateT]] = None, @@ -127,7 +142,13 @@ def select_item( auth_handlers: Optional[list[str]] = None, rank: RouteRank = RouteRank.DEFAULT, ) -> SelectItemHandler[StateT] | _RouteDecorator[SelectItemHandler[StateT]]: - """Register a handler for composeExtension/selectItem invokes.""" + """Register a handler for composeExtension/selectItem invokes. + + :param handler: Optional handler receiving the context, state, and selected item payload. + :param auth_handlers: Optional auth handler names to run before the route. + :param rank: Route priority. + :return: The registered handler when provided, otherwise a decorator that registers it. + """ def __selector(context: TurnContext) -> bool: return ( @@ -162,7 +183,15 @@ def submit_action( auth_handlers: Optional[list[str]] = None, rank: RouteRank = RouteRank.DEFAULT, ) -> _RouteDecorator[SubmitActionHandler[StateT]]: - """Register a handler for composeExtension/submitAction invokes (not bot message preview).""" + """Register a handler for composeExtension/submitAction invokes. + + Excludes submitAction invokes that carry a bot message preview action. + + :param command_id: Optional command identifier or regex to match a specific Teams command. + :param auth_handlers: Optional auth handler names to run before the route. + :param rank: Route priority. + :return: A decorator that registers the handler and returns it. + """ def __selector(context: TurnContext) -> bool: if ( @@ -209,7 +238,13 @@ def message_preview_edit( auth_handlers: Optional[list[str]] = None, rank: RouteRank = RouteRank.DEFAULT, ) -> _RouteDecorator[MessagePreviewEditHandler[StateT]]: - """Register a handler for composeExtension/submitAction with botMessagePreviewAction == 'edit'.""" + """Register a handler for composeExtension message preview edit invokes. + + :param command_id: Optional command identifier or regex to match a specific Teams command. + :param auth_handlers: Optional auth handler names to run before the route. + :param rank: Route priority. + :return: A decorator that registers the handler and returns it. + """ def __selector(context: TurnContext) -> bool: if ( @@ -252,7 +287,13 @@ def message_preview_send( auth_handlers: Optional[list[str]] = None, rank: RouteRank = RouteRank.DEFAULT, ) -> _RouteDecorator[MessagePreviewSendHandler[StateT]]: - """Register a handler for composeExtension/submitAction with botMessagePreviewAction == 'send'.""" + """Register a handler for composeExtension message preview send invokes. + + :param command_id: Optional command identifier or regex to match a specific Teams command. + :param auth_handlers: Optional auth handler names to run before the route. + :param rank: Route priority. + :return: A decorator that registers the handler and returns it. + """ def __selector(context: TurnContext) -> bool: if ( @@ -295,7 +336,13 @@ def fetch_action( auth_handlers: Optional[list[str]] = None, rank: RouteRank = RouteRank.DEFAULT, ) -> _RouteDecorator[FetchActionHandler[StateT]]: - """Register a handler for composeExtension/fetchTask invokes.""" + """Register a handler for composeExtension/fetchTask invokes. + + :param command_id: Optional command identifier or regex to match a specific Teams command. + :param auth_handlers: Optional auth handler names to run before the route. + :param rank: Route priority. + :return: A decorator that registers the handler and returns it. + """ def __selector(context: TurnContext) -> bool: value = context.activity.value @@ -331,10 +378,12 @@ async def __handler(context: TurnContext, state: StateT) -> None: def query_link( self, handler: QueryLinkHandler[StateT] ) -> QueryLinkHandler[StateT]: ... + @overload def query_link( self, *, auth_handlers: Optional[list[str]] = ..., rank: RouteRank = ... ) -> _RouteDecorator[QueryLinkHandler[StateT]]: ... + def query_link( self, handler: Optional[QueryLinkHandler[StateT]] = None, @@ -342,7 +391,13 @@ def query_link( auth_handlers: Optional[list[str]] = None, rank: RouteRank = RouteRank.DEFAULT, ) -> QueryLinkHandler[StateT] | _RouteDecorator[QueryLinkHandler[StateT]]: - """Register a handler for composeExtension/queryLink invokes.""" + """Register a handler for composeExtension/queryLink invokes. + + :param handler: Optional handler receiving the context, state, and app-based link query. + :param auth_handlers: Optional auth handler names to run before the route. + :param rank: Route priority. + :return: The registered handler when provided, otherwise a decorator that registers it. + """ def __selector(context: TurnContext) -> bool: return ( @@ -375,10 +430,12 @@ async def __handler(context: TurnContext, state: StateT) -> None: def anonymous_query_link( self, handler: QueryLinkHandler[StateT] ) -> QueryLinkHandler[StateT]: ... + @overload def anonymous_query_link( self, *, auth_handlers: Optional[list[str]] = ..., rank: RouteRank = ... ) -> _RouteDecorator[QueryLinkHandler[StateT]]: ... + def anonymous_query_link( self, handler: Optional[QueryLinkHandler[StateT]] = None, @@ -386,7 +443,13 @@ def anonymous_query_link( auth_handlers: Optional[list[str]] = None, rank: RouteRank = RouteRank.DEFAULT, ) -> QueryLinkHandler[StateT] | _RouteDecorator[QueryLinkHandler[StateT]]: - """Register a handler for composeExtension/anonymousQueryLink invokes.""" + """Register a handler for composeExtension/anonymousQueryLink invokes. + + :param handler: Optional handler receiving the context, state, and app-based link query. + :param auth_handlers: Optional auth handler names to run before the route. + :param rank: Route priority. + :return: The registered handler when provided, otherwise a decorator that registers it. + """ def __selector(context: TurnContext) -> bool: return ( @@ -419,10 +482,12 @@ async def __handler(context: TurnContext, state: StateT) -> None: def query_setting_url( self, handler: QueryUrlSettingHandler[StateT] ) -> QueryUrlSettingHandler[StateT]: ... + @overload def query_setting_url( self, *, auth_handlers: Optional[list[str]] = ..., rank: RouteRank = ... ) -> _RouteDecorator[QueryUrlSettingHandler[StateT]]: ... + def query_setting_url( self, handler: Optional[QueryUrlSettingHandler[StateT]] = None, @@ -432,7 +497,13 @@ def query_setting_url( ) -> ( QueryUrlSettingHandler[StateT] | _RouteDecorator[QueryUrlSettingHandler[StateT]] ): - """Register a handler for composeExtension/querySettingUrl invokes.""" + """Register a handler for composeExtension/querySettingUrl invokes. + + :param handler: Optional handler receiving the context, state, and settings URL query. + :param auth_handlers: Optional auth handler names to run before the route. + :param rank: Route priority. + :return: The registered handler when provided, otherwise a decorator that registers it. + """ def __selector(context: TurnContext) -> bool: return ( @@ -469,10 +540,12 @@ async def __handler(context: TurnContext, state: StateT) -> None: def setting( self, handler: ConfigureSettingsHandler[StateT] ) -> ConfigureSettingsHandler[StateT]: ... + @overload def setting( self, *, auth_handlers: Optional[list[str]] = ..., rank: RouteRank = ... ) -> _RouteDecorator[ConfigureSettingsHandler[StateT]]: ... + def setting( self, handler: Optional[Callable] = None, @@ -483,7 +556,13 @@ def setting( ConfigureSettingsHandler[StateT] | _RouteDecorator[ConfigureSettingsHandler[StateT]] ): - """Register a handler for composeExtension/setting invokes.""" + """Register a handler for composeExtension/setting invokes. + + :param handler: Optional handler receiving the context, state, and settings query payload. + :param auth_handlers: Optional auth handler names to run before the route. + :param rank: Route priority. + :return: The registered handler when provided, otherwise a decorator that registers it. + """ def __selector(context: TurnContext) -> bool: return ( @@ -517,10 +596,12 @@ async def __handler(context: TurnContext, state: StateT) -> None: def card_button_clicked( self, handler: CardButtonClickedHandler[StateT] ) -> CardButtonClickedHandler[StateT]: ... + @overload def card_button_clicked( self, *, auth_handlers: Optional[list[str]] = ..., rank: RouteRank = ... ) -> _RouteDecorator[CardButtonClickedHandler[StateT]]: ... + def card_button_clicked( self, handler: Optional[CardButtonClickedHandler[StateT]] = None, @@ -531,7 +612,13 @@ def card_button_clicked( CardButtonClickedHandler[StateT] | _RouteDecorator[CardButtonClickedHandler[StateT]] ): - """Register a handler for composeExtension/onCardButtonClicked invokes.""" + """Register a handler for composeExtension/onCardButtonClicked invokes. + + :param handler: Optional handler receiving the context, state, and card action payload. + :param auth_handlers: Optional auth handler names to run before the route. + :param rank: Route priority. + :return: The registered handler when provided, otherwise a decorator that registers it. + """ def __selector(context: TurnContext) -> bool: return ( diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/message_extension/route_handlers.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/message_extension/route_handlers.py index 37b1d445a..07627a787 100644 --- a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/message_extension/route_handlers.py +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/message_extension/route_handlers.py @@ -77,7 +77,7 @@ def __call__( :param context: Teams-aware turn context. :param state: The current turn state. - :param activity_preview: The raw botActivityPreview[0] dict from the invoke payload. + :param activity_preview: The parsed botActivityPreview[0] activity from the invoke payload. :return: A messaging extension response. """ ... @@ -97,7 +97,8 @@ def __call__( :param context: Teams-aware turn context. :param state: The current turn state. - :param activity_preview: The raw botActivityPreview[0] dict from the invoke payload. + :param activity_preview: The parsed botActivityPreview[0] activity from the invoke payload. + :return: A messaging extension response, or None when no response body is required. """ ... diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/proactive_service_endpoints.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/proactive_service_endpoints.py new file mode 100644 index 000000000..61bc8cc32 --- /dev/null +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/proactive_service_endpoints.py @@ -0,0 +1,22 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""Well-known Microsoft Teams service endpoint URLs for proactive messaging. + +These constants identify the Teams service endpoint for each cloud environment. +Use them only when the incoming request's ``serviceUrl`` is unavailable; once a +``serviceUrl`` has been returned from a prior conversation, cache and reuse that +value instead. +""" + +# Service endpoint for the public global Teams environment. +PUBLIC_GLOBAL = "https://smba.trafficmanager.net/teams/" + +# Service endpoint for the GCC (Government Community Cloud) Teams environment. +GCC = "https://smba.infra.gcc.teams.microsoft.com/teams" + +# Service endpoint for the GCC High Teams environment. +GCC_HIGH = "https://smba.infra.gov.teams.microsoft.us/teams" + +# Service endpoint for the DoD (Department of Defense) Teams environment. +DOD = "https://smba.infra.dod.teams.microsoft.us/teams" diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/task_module/__init__.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/task_module/__init__.py index 19da286c2..3343edb3b 100644 --- a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/task_module/__init__.py +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/task_module/__init__.py @@ -1,6 +1,8 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. +"""Teams task module subpackage exposing route registration helpers.""" + from .task_module import TaskModule __all__ = ["TaskModule"] diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/task_module/task_module.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/task_module/task_module.py index 68d622399..40671c935 100644 --- a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/task_module/task_module.py +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/task_module/task_module.py @@ -26,9 +26,9 @@ class TaskModule(Generic[StateT]): - """ - Route registration for Teams Task Module (task/fetch, task/submit) invoke activities. - Access via TeamsAgentExtension.task_modules. + """Route registration for Teams Task Module (task/fetch, task/submit) invoke activities. + + Access via :attr:`TeamsAgentExtension.task_modules`. """ def __init__(self, app: AgentApplication[StateT]) -> None: @@ -61,8 +61,11 @@ def fetch( ) -> _RouteDecorator[FetchHandler[StateT]]: """Register a handler for task/fetch invokes. - :param verb: Optional verb string or regex to match against task data. + :param verb: Optional verb string or regex to match a specific Teams task command. If None, matches all task/fetch invokes. + :param auth_handlers: Optional auth handler names to run before the route. + :param rank: Route priority. + :return: A decorator that registers the handler and returns it. """ def __selector(context: TurnContext) -> bool: @@ -101,8 +104,11 @@ def submit( ) -> _RouteDecorator[SubmitHandler[StateT]]: """Register a handler for task/submit invokes. - :param verb: Optional verb string or regex to match against task data. + :param verb: Optional verb string or regex to match a specific Teams task command. If None, matches all task/submit invokes. + :param auth_handlers: Optional auth handler names to run before the route. + :param rank: Route priority. + :return: A decorator that registers the handler and returns it. """ def __selector(context: TurnContext) -> bool: diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/team/__init__.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/team/__init__.py index f6fcbf364..54a1a58a8 100644 --- a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/team/__init__.py +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/team/__init__.py @@ -1,6 +1,8 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. +"""Teams team subpackage exposing team lifecycle route helpers.""" + from .team import Team __all__ = ["Team"] diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/team/team.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/team/team.py index 221c85525..723ec179d 100644 --- a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/team/team.py +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/team/team.py @@ -46,7 +46,13 @@ def _create_decorator( auth_handlers: Optional[list[str]] = None, rank: RouteRank = RouteRank.DEFAULT, ) -> _RouteDecorator[TeamUpdateHandler[StateT]]: - """Build a route decorator for a specific Teams team event type.""" + """Build a route decorator for a specific Teams team event type. + + :param event_type: Literal event type or regex to match against Teams channel data. + :param auth_handlers: Optional auth handler names to run before the route. + :param rank: Route priority. + :return: A decorator that registers the handler and returns it. + """ def __selector(context: TurnContext) -> bool: @@ -83,10 +89,12 @@ async def __handler(context: TurnContext, state: StateT) -> None: def event( self, handler: TeamUpdateHandler[StateT] ) -> TeamUpdateHandler[StateT]: ... + @overload def event( self, *, auth_handlers: Optional[list[str]] = ..., rank: RouteRank = ... ) -> _RouteDecorator[TeamUpdateHandler[StateT]]: ... + def event( self, handler: Optional[TeamUpdateHandler[StateT]] = None, @@ -94,7 +102,13 @@ def event( auth_handlers: Optional[list[str]] = None, rank: RouteRank = RouteRank.DEFAULT, ) -> TeamUpdateHandler[StateT] | _RouteDecorator[TeamUpdateHandler[StateT]]: - """Register a handler for Teams team event conversation update events.""" + """Register a handler for Teams team event conversation update events. + + :param handler: Optional handler receiving the context, state, and parsed team channel data. + :param auth_handlers: Optional auth handler names to run before the route. + :param rank: Route priority. + :return: The registered handler when provided, otherwise a decorator that registers it. + """ decorator = self._create_decorator( re.compile(r"team.*"), auth_handlers=auth_handlers, rank=rank ) @@ -106,10 +120,12 @@ def event( def archived( self, handler: TeamUpdateHandler[StateT] ) -> TeamUpdateHandler[StateT]: ... + @overload def archived( self, *, auth_handlers: Optional[list[str]] = ..., rank: RouteRank = ... ) -> _RouteDecorator[TeamUpdateHandler[StateT]]: ... + def archived( self, handler: Optional[TeamUpdateHandler[StateT]] = None, @@ -117,7 +133,13 @@ def archived( auth_handlers: Optional[list[str]] = None, rank: RouteRank = RouteRank.DEFAULT, ) -> TeamUpdateHandler[StateT] | _RouteDecorator[TeamUpdateHandler[StateT]]: - """Register a handler for Teams teamArchived conversation update events.""" + """Register a handler for Teams teamArchived conversation update events. + + :param handler: Optional handler receiving the context, state, and parsed team channel data. + :param auth_handlers: Optional auth handler names to run before the route. + :param rank: Route priority. + :return: The registered handler when provided, otherwise a decorator that registers it. + """ decorator = self._create_decorator( "teamArchived", auth_handlers=auth_handlers, rank=rank ) @@ -129,10 +151,12 @@ def archived( def deleted( self, handler: TeamUpdateHandler[StateT] ) -> TeamUpdateHandler[StateT]: ... + @overload def deleted( self, *, auth_handlers: Optional[list[str]] = ..., rank: RouteRank = ... ) -> _RouteDecorator[TeamUpdateHandler[StateT]]: ... + def deleted( self, handler: Optional[TeamUpdateHandler[StateT]] = None, @@ -140,7 +164,13 @@ def deleted( auth_handlers: Optional[list[str]] = None, rank: RouteRank = RouteRank.DEFAULT, ) -> TeamUpdateHandler[StateT] | _RouteDecorator[TeamUpdateHandler[StateT]]: - """Register a handler for Teams teamDeleted conversation update events.""" + """Register a handler for Teams teamDeleted conversation update events. + + :param handler: Optional handler receiving the context, state, and parsed team channel data. + :param auth_handlers: Optional auth handler names to run before the route. + :param rank: Route priority. + :return: The registered handler when provided, otherwise a decorator that registers it. + """ decorator = self._create_decorator( "teamDeleted", auth_handlers=auth_handlers, rank=rank ) @@ -152,10 +182,12 @@ def deleted( def hard_deleted( self, handler: TeamUpdateHandler[StateT] ) -> TeamUpdateHandler[StateT]: ... + @overload def hard_deleted( self, *, auth_handlers: Optional[list[str]] = ..., rank: RouteRank = ... ) -> _RouteDecorator[TeamUpdateHandler[StateT]]: ... + def hard_deleted( self, handler: Optional[TeamUpdateHandler[StateT]] = None, @@ -163,7 +195,13 @@ def hard_deleted( auth_handlers: Optional[list[str]] = None, rank: RouteRank = RouteRank.DEFAULT, ) -> TeamUpdateHandler[StateT] | _RouteDecorator[TeamUpdateHandler[StateT]]: - """Register a handler for Teams teamHardDeleted conversation update events.""" + """Register a handler for Teams teamHardDeleted conversation update events. + + :param handler: Optional handler receiving the context, state, and parsed team channel data. + :param auth_handlers: Optional auth handler names to run before the route. + :param rank: Route priority. + :return: The registered handler when provided, otherwise a decorator that registers it. + """ decorator = self._create_decorator( "teamHardDeleted", auth_handlers=auth_handlers, rank=rank ) @@ -175,10 +213,12 @@ def hard_deleted( def renamed( self, handler: TeamUpdateHandler[StateT] ) -> TeamUpdateHandler[StateT]: ... + @overload def renamed( self, *, auth_handlers: Optional[list[str]] = ..., rank: RouteRank = ... ) -> _RouteDecorator[TeamUpdateHandler[StateT]]: ... + def renamed( self, handler: Optional[TeamUpdateHandler[StateT]] = None, @@ -186,7 +226,13 @@ def renamed( auth_handlers: Optional[list[str]] = None, rank: RouteRank = RouteRank.DEFAULT, ) -> TeamUpdateHandler[StateT] | _RouteDecorator[TeamUpdateHandler[StateT]]: - """Register a handler for Teams teamRenamed conversation update events.""" + """Register a handler for Teams teamRenamed conversation update events. + + :param handler: Optional handler receiving the context, state, and parsed team channel data. + :param auth_handlers: Optional auth handler names to run before the route. + :param rank: Route priority. + :return: The registered handler when provided, otherwise a decorator that registers it. + """ decorator = self._create_decorator( "teamRenamed", auth_handlers=auth_handlers, rank=rank ) @@ -198,10 +244,12 @@ def renamed( def restored( self, handler: TeamUpdateHandler[StateT] ) -> TeamUpdateHandler[StateT]: ... + @overload def restored( self, *, auth_handlers: Optional[list[str]] = ..., rank: RouteRank = ... ) -> _RouteDecorator[TeamUpdateHandler[StateT]]: ... + def restored( self, handler: Optional[TeamUpdateHandler[StateT]] = None, @@ -209,7 +257,13 @@ def restored( auth_handlers: Optional[list[str]] = None, rank: RouteRank = RouteRank.DEFAULT, ) -> TeamUpdateHandler[StateT] | _RouteDecorator[TeamUpdateHandler[StateT]]: - """Register a handler for Teams teamRestored conversation update events.""" + """Register a handler for Teams teamRestored conversation update events. + + :param handler: Optional handler receiving the context, state, and parsed team channel data. + :param auth_handlers: Optional auth handler names to run before the route. + :param rank: Route priority. + :return: The registered handler when provided, otherwise a decorator that registers it. + """ decorator = self._create_decorator( "teamRestored", auth_handlers=auth_handlers, rank=rank ) @@ -221,10 +275,12 @@ def restored( def unarchived( self, handler: TeamUpdateHandler[StateT] ) -> TeamUpdateHandler[StateT]: ... + @overload def unarchived( self, *, auth_handlers: Optional[list[str]] = ..., rank: RouteRank = ... ) -> _RouteDecorator[TeamUpdateHandler[StateT]]: ... + def unarchived( self, handler: Optional[TeamUpdateHandler[StateT]] = None, @@ -232,7 +288,13 @@ def unarchived( auth_handlers: Optional[list[str]] = None, rank: RouteRank = RouteRank.DEFAULT, ) -> TeamUpdateHandler[StateT] | _RouteDecorator[TeamUpdateHandler[StateT]]: - """Register a handler for Teams teamUnarchived conversation update events.""" + """Register a handler for Teams teamUnarchived conversation update events. + + :param handler: Optional handler receiving the context, state, and parsed team channel data. + :param auth_handlers: Optional auth handler names to run before the route. + :param rank: Route priority. + :return: The registered handler when provided, otherwise a decorator that registers it. + """ decorator = self._create_decorator( "teamUnarchived", auth_handlers=auth_handlers, rank=rank ) diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_activity.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_activity.py index 27449bdca..fb47071df 100644 --- a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_activity.py +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_activity.py @@ -1,6 +1,8 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. +"""Teams-aware :class:`Activity` subclass exposing Teams channel data helpers.""" + from typing import Literal from microsoft_teams.api.models import ( @@ -17,14 +19,21 @@ class TeamsActivity(Activity): - """A class for working with Teams activities. + """An :class:`Activity` extended with Teams-specific accessors. - note: - Because this class is dynamically swapped in, no new instance fields can be added. + .. note:: + Instances of this class are produced by reassigning ``__class__`` on an + existing :class:`Activity` (see :class:`TeamsTurnContext`), so no new + instance fields may be added here -- only methods that derive values + from the activity's existing ``channel_data``. """ def _get_channel_data(self) -> ChannelData | None: - """Get the channel data from the activity, if it exists.""" + """Return the parsed Teams channel data, or None if absent. + + :return: The parsed :class:`ChannelData`, or None if the activity has no + channel data. + """ return _try_get_channel_data(self) def get_selected_channel_id(self) -> str | None: @@ -62,7 +71,10 @@ def get_meeting_info(self) -> MeetingInfo | None: return None def get_team_info(self) -> TeamInfo | None: - """Get the team info from the activity, if it exists.""" + """Get the team info from the activity, if it exists. + + :return: The team info, or None if it doesn't exist. + """ channel_data = self._get_channel_data() if channel_data: return channel_data.team diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_agent_extension.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_agent_extension.py index 694a3388b..68f620541 100644 --- a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_agent_extension.py +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_agent_extension.py @@ -1,6 +1,14 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. +"""The :class:`TeamsAgentExtension` entry point for Teams route registration. + +Attaches Teams-specific route namespaces (channels, teams, meetings, messages, +message extensions, task modules, configuration, and file consent) to an +:class:`AgentApplication`, wires the Teams before-turn hook, and exposes helpers +for obtaining Teams API and Microsoft Graph clients. +""" + from __future__ import annotations from typing import ( diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_turn_context.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_turn_context.py index 5d50dfaf7..4cd1d8b51 100644 --- a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_turn_context.py +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_turn_context.py @@ -62,6 +62,10 @@ def _set_teams_activity(self) -> None: @property def activity(self) -> TeamsActivity: + """The current activity, typed as a :class:`TeamsActivity`. + + :return: The turn's activity exposing Teams-specific accessors. + """ return self._teams_activity @property From 561519423daa89bdf688d357d68a70ca40f79627 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Wed, 24 Jun 2026 13:22:07 -0700 Subject: [PATCH 34/46] Addressing PR feedback --- .../activity/entity/activity_treatment.py | 2 +- .../microsoft_agents/hosting/msteams/_graph.py | 15 +++++++++++---- .../hosting/msteams/teams_turn_context.py | 2 +- 3 files changed, 13 insertions(+), 6 deletions(-) diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/activity_treatment.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/activity_treatment.py index 4ffe930ad..5318b29cd 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/activity_treatment.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/activity_treatment.py @@ -15,7 +15,7 @@ class ActivityTreatmentTypes(str, Enum): class ActivityTreatment(Entity): - """Activity treatment information (entity type: "activity_treatment"). + """Activity treatment information (entity type: "activityTreatment"). :param treatment: The type of treatment :type treatment: ~microsoft_agents.activity.ActivityTreatmentTypes diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/_graph.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/_graph.py index e34f21be0..4d4b18aba 100644 --- a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/_graph.py +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/_graph.py @@ -29,7 +29,12 @@ class _SDKAuthenticationProvider(AuthenticationProvider): requests as a bearer token. """ - def __init__(self, app: AgentApplication, context: TurnContext, handler_name: str): + def __init__( + self, + app: AgentApplication, + context: TurnContext, + handler_name: str | None = None, + ): """Capture the context needed to resolve a token at request time. :param app: The agent application whose authorization issues tokens. @@ -54,9 +59,11 @@ async def authenticate_request( if additional_authentication_context is None: additional_authentication_context = {} - token = await self._app.auth.get_token(self._context, self._handler_name) - if token: - request.headers["Authorization"] = f"Bearer {token}" + token_response = await self._app.auth.get_token( + self._context, self._handler_name + ) + if token_response: + request.headers["Authorization"] = f"Bearer {token_response.token}" def _create_graph_service_client( diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_turn_context.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_turn_context.py index 4cd1d8b51..5f9f95f19 100644 --- a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_turn_context.py +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_turn_context.py @@ -40,7 +40,7 @@ def __init__(self, context: TurnContext, app: AgentApplication) -> None: self._set_teams_activity() - _set_teams_api_client(context, app.connection_manager) + _set_teams_api_client(self, app.connection_manager) def _set_teams_activity(self) -> None: """ From e253f510e07fc048bba020c9976d20402476ee8d Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Wed, 24 Jun 2026 13:28:13 -0700 Subject: [PATCH 35/46] Removing test case --- tests/hosting_msteams/test_teams_activity.py | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/tests/hosting_msteams/test_teams_activity.py b/tests/hosting_msteams/test_teams_activity.py index d08d679d3..0a535c272 100644 --- a/tests/hosting_msteams/test_teams_activity.py +++ b/tests/hosting_msteams/test_teams_activity.py @@ -161,15 +161,3 @@ def test_uses_supplied_feedback_loop_type(self): activity = _activity() assert activity.enable_feedback_loop("custom") is True assert activity.channel_data.feedback_loop.type == "custom" - - @pytest.mark.xfail( - reason="BUGS.md #1: enable_feedback_loop guard is inverted; it returns " - "False (no-op) when channel data already exists, which is the common case.", - strict=True, - ) - def test_enables_when_channel_data_already_present(self): - activity = _activity({"channel": {"id": "c1"}}) - assert activity.enable_feedback_loop() is True - assert activity.channel_data.feedback_loop is not None - # existing channel data should be preserved, not clobbered - assert activity.channel_data.channel.id == "c1" From 1fb93c4cc67b3a08ed003e044750a19298b53cae Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Wed, 24 Jun 2026 13:38:59 -0700 Subject: [PATCH 36/46] Updating azdo --- .azdo/ci-pr.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.azdo/ci-pr.yaml b/.azdo/ci-pr.yaml index 2447de33d..4ff9e892f 100644 --- a/.azdo/ci-pr.yaml +++ b/.azdo/ci-pr.yaml @@ -73,10 +73,10 @@ steps: python -m pip install ./dist/microsoft_agents_copilotstudio_client*.whl python -m pip install ./dist/microsoft_agents_hosting_aiohttp*.whl python -m pip install ./dist/microsoft_agents_hosting_dialogs*.whl - if python -c "import sys; sys.exit(0 if sys.version_info >= (3, 12) else 1)"; then - python -m pip install ./dist/microsoft_agents_hosting_teams*.whl + if python -c "import sys; sys.exit(0 if sys.version_info >= (3, 11) else 1)"; then + python -m pip install ./dist/microsoft_agents_hosting_msteams*.whl else - echo "Skipping microsoft_agents_hosting_teams: requires Python 3.12+" + echo "Skipping microsoft_agents_hosting_msteams: requires Python 3.11+" fi python -m pip install ./dist/microsoft_agents_hosting_slack*.whl python -m pip install ./dist/microsoft_agents_storage_blob*.whl From 65baedcadf18e42af8a948c797fa5671e092633c Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Wed, 24 Jun 2026 14:14:44 -0700 Subject: [PATCH 37/46] UPdating conversation agent --- .../conversation-agent/src/__init__.py | 0 .../msteams/conversation-agent/src/agent.py | 411 ++++++++++++++++++ .../msteams/conversation-agent/src/main.py | 11 + .../conversation-agent/src/start_server.py | 32 ++ 4 files changed, 454 insertions(+) create mode 100644 test_samples/msteams/conversation-agent/src/__init__.py create mode 100644 test_samples/msteams/conversation-agent/src/agent.py create mode 100644 test_samples/msteams/conversation-agent/src/main.py create mode 100644 test_samples/msteams/conversation-agent/src/start_server.py diff --git a/test_samples/msteams/conversation-agent/src/__init__.py b/test_samples/msteams/conversation-agent/src/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/test_samples/msteams/conversation-agent/src/agent.py b/test_samples/msteams/conversation-agent/src/agent.py new file mode 100644 index 000000000..fea2e6a81 --- /dev/null +++ b/test_samples/msteams/conversation-agent/src/agent.py @@ -0,0 +1,411 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""Teams Conversation Agent — Python port of the .NET ConversationAgent sample. + +Demonstrates Teams conversation events and message commands: installation and +channel/team lifecycle events, member add/remove, card update/delete, mentions, +member lookups via the Teams API client, and proactive 1:1 messaging. Mirrors +src/samples/Teams/ConversationAgent from the microsoft/Agents-for-net repository. +""" + +import json +import logging +from os import environ +from typing import Optional + +from dotenv import load_dotenv + +from microsoft_teams.api.models import ChannelData, TeamsChannelAccount + +from microsoft_agents.activity import ( + ActionTypes, + CardAction, + ChannelAccount, + Channels, + ConversationParameters, + HeroCard, + Mention, + load_configuration_from_env, +) +from microsoft_agents.authentication.msal import MsalConnectionManager +from microsoft_agents.hosting.aiohttp import CloudAdapter +from microsoft_agents.hosting.core import ( + AgentApplication, + Authorization, + CardFactory, + MemoryStorage, + MessageFactory, + TurnContext, + TurnState, +) +from microsoft_agents.hosting.msteams import TeamsAgentExtension +from microsoft_agents.hosting.msteams.teams_turn_context import TeamsTurnContext + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) +load_dotenv() + +agents_sdk_config = load_configuration_from_env(environ) + +STORAGE = MemoryStorage() +CONNECTION_MANAGER = MsalConnectionManager(**agents_sdk_config) +ADAPTER = CloudAdapter(connection_manager=CONNECTION_MANAGER) +AUTHORIZATION = Authorization(STORAGE, CONNECTION_MANAGER, **agents_sdk_config) + +AGENT_APP = AgentApplication[TurnState]( + storage=STORAGE, + adapter=ADAPTER, + authorization=AUTHORIZATION, + **agents_sdk_config, +) + +teams = TeamsAgentExtension[TurnState](AGENT_APP) + +_MEMBER_NOT_FOUND = "MemberNotFoundInConversation" +_DEFAULT_AUDIENCE = "https://api.botframework.com" + + +def _welcome_card(title: str, count: int = 0) -> HeroCard: + """Build the demo welcome card with one button per message command.""" + return HeroCard( + title=title, + buttons=[ + CardAction( + type=ActionTypes.message_back, + title="Message all members", + text="messageall", + ), + CardAction(type=ActionTypes.message_back, title="Who am I?", text="whoami"), + CardAction( + type=ActionTypes.message_back, title="Mention Me", text="mentionme" + ), + CardAction( + type=ActionTypes.message_back, title="Delete Card", text="delete" + ), + CardAction( + type=ActionTypes.message_back, title="Send Targeted", text="targeted" + ), + CardAction( + type=ActionTypes.message_back, + title="Update Card", + text="update", + value=json.dumps({"count": count}), + ), + ], + ) + + +def _scope_id(context: TeamsTurnContext) -> str: + """Return the team id when in a team channel, otherwise the conversation id. + + The Teams member APIs are scoped to a roster; in a team channel that roster + is keyed by the team id, while in 1:1 / group chats it is the conversation id. + """ + channel_data = context.activity.channel_data + if ( + isinstance(channel_data, ChannelData) + and channel_data.team + and channel_data.team.id + ): + return channel_data.team.id + return context.activity.conversation.id + + +def _app_id(context: TeamsTurnContext) -> str: + return context.identity.get_app_id() if context.identity else "" + + +def _audience(context: TeamsTurnContext) -> str: + if context.identity: + return context.identity.get_token_audience() or _DEFAULT_AUDIENCE + return _DEFAULT_AUDIENCE + + +async def _get_member( + context: TeamsTurnContext, member_id: str +) -> Optional[TeamsChannelAccount]: + """Look up a single member, returning None if not found in the conversation.""" + api = teams.get_teams_api_client(context) + try: + return await api.conversations.members(context.activity.conversation.id).get(member_id) + except Exception as exc: # noqa: BLE001 - surface only the known "not found" case + if _MEMBER_NOT_FOUND in str(exc): + return None + raise + + +async def _create_one_on_one( + context: TeamsTurnContext, + app_id: str, + audience: str, + member: TeamsChannelAccount, + callback, +) -> None: + """Create a proactive 1:1 conversation with *member* and run *callback*.""" + tenant_id = ( + context.activity.conversation.tenant_id + if context.activity.conversation + else None + ) + params = ConversationParameters( + is_group=False, + members=[ChannelAccount(id=member.id, name=member.name)], + tenant_id=tenant_id, + agent=context.activity.recipient, + ) + await context.adapter.create_conversation( + app_id, + Channels.ms_teams, + context.activity.service_url, + audience, + params, + callback, + ) + + +# ── Installation update ────────────────────────────────────────────────────── + + +@teams.activity("installationUpdate") +async def on_installation_update(context: TeamsTurnContext, state: TurnState) -> None: + conv = context.activity.conversation + if conv and conv.conversation_type == "channel": + name = conv.name or "this channel" + await context.send_activity( + "Welcome to Microsoft Teams conversationUpdate events demo. " + f"This agent is configured in {name}" + ) + else: + await context.send_activity( + "Welcome to Microsoft Teams conversationUpdate events demo." + ) + + +# ── Member lifecycle ───────────────────────────────────────────────────────── + + +@teams.conversation_update("membersAdded") +async def on_members_added(context: TeamsTurnContext, state: TurnState) -> None: + conv = context.activity.conversation + recipient_id = context.activity.recipient.id if context.activity.recipient else None + for member in context.activity.members_added or []: + if member.id != recipient_id and ( + not conv or conv.conversation_type != "personal" + ): + await context.send_activity( + MessageFactory.text(f"Welcome to the team {member.name}.") + ) + + +@teams.conversation_update("membersRemoved") +async def on_members_removed(context: TeamsTurnContext, state: TurnState) -> None: + channel_data = context.activity.channel_data + team_name = "the team" + if ( + isinstance(channel_data, ChannelData) + and channel_data.team + and channel_data.team.name + ): + team_name = channel_data.team.name + + recipient_id = context.activity.recipient.id if context.activity.recipient else None + for member in context.activity.members_removed or []: + if member.id == recipient_id: + # The agent itself was removed — clear any cached team data here. + continue + card = HeroCard(text=f"{member.name} was removed from {team_name}") + await context.send_activity( + MessageFactory.attachment(CardFactory.hero_card(card)) + ) + + +# ── Channel events ─────────────────────────────────────────────────────────── + + +@teams.channels.created +async def on_channel_created( + context: TeamsTurnContext, state: TurnState, channel_data: ChannelData +) -> None: + name = channel_data.channel.name if channel_data.channel else "Unknown" + card = HeroCard(text=f"{name} is the Channel created") + await context.send_activity(MessageFactory.attachment(CardFactory.hero_card(card))) + + +@teams.channels.renamed +async def on_channel_renamed( + context: TeamsTurnContext, state: TurnState, channel_data: ChannelData +) -> None: + name = channel_data.channel.name if channel_data.channel else "Unknown" + card = HeroCard(text=f"{name} is the new Channel name") + await context.send_activity(MessageFactory.attachment(CardFactory.hero_card(card))) + + +@teams.channels.deleted +async def on_channel_deleted( + context: TeamsTurnContext, state: TurnState, channel_data: ChannelData +) -> None: + name = channel_data.channel.name if channel_data.channel else "Unknown" + card = HeroCard(text=f"{name} is the Channel deleted") + await context.send_activity(MessageFactory.attachment(CardFactory.hero_card(card))) + + +# ── Team events ────────────────────────────────────────────────────────────── + + +@teams.teams.renamed +async def on_team_renamed( + context: TeamsTurnContext, state: TurnState, channel_data: ChannelData +) -> None: + name = channel_data.team.name if channel_data.team else "Unknown" + card = HeroCard(text=f"{name} is the new Team name") + await context.send_activity(MessageFactory.attachment(CardFactory.hero_card(card))) + + +# ── Message commands ───────────────────────────────────────────────────────── + + +@teams.message("targeted") +async def on_targeted(context: TeamsTurnContext, state: TurnState) -> None: + """Send a 1:1 message to every member of the current conversation.""" + app_id = _app_id(context) + audience = _audience(context) + continuation_token: Optional[str] = None + while True: + paged = await teams.get_teams_api_client( + context + ).conversations.members(context.activity.conversation.id).get_paged( + 100, continuation_token + ) + for member in paged.members or []: + + async def _send(ctx: TurnContext, _name=member.name) -> None: + await ctx.send_activity( + f"{_name}, this is a **targeted message** — only you can see this." + ) + + await _create_one_on_one(context, app_id, audience, member, _send) + + continuation_token = paged.continuation_token + if not continuation_token: + break + + +@teams.message("update") +async def on_update_card(context: TeamsTurnContext, state: TurnState) -> None: + """Update the card that triggered this message with an incremented counter.""" + value = context.activity.value + if isinstance(value, str): + try: + value = json.loads(value) + except (json.JSONDecodeError, TypeError): + value = {} + count = (int(value.get("count") or 0) + 1) if isinstance(value, dict) else 1 + + card = _welcome_card("I've been updated", count=count) + card.text = f"Update count - {count}" + + activity = MessageFactory.attachment(CardFactory.hero_card(card)) + activity.id = context.activity.reply_to_id + await context.update_activity(activity) + + +@teams.message("whoami") +async def on_who_am_i(context: TeamsTurnContext, state: TurnState) -> None: + """Fetch and report the caller's Teams member profile.""" + member = await _get_member(context, context.activity.from_property.id) + if member is None: + await context.send_activity("Member not found.") + return + await context.send_activity(f"You are: {member.name}.") + + +@teams.message("delete") +async def on_delete_card(context: TeamsTurnContext, state: TurnState) -> None: + await context.delete_activity(context.activity.reply_to_id) + + +@teams.message("messageall") +async def on_message_all(context: TeamsTurnContext, state: TurnState) -> None: + """Proactively send a 1:1 greeting to every member of the team.""" + app_id = _app_id(context) + audience = _audience(context) + continuation_token: Optional[str] = None + while True: + paged = await teams.get_teams_api_client( + context + ).conversations.members(context.activity.conversation.id).get_paged(100, continuation_token) + for member in paged.members or []: + + async def _greet(ctx: TurnContext, _name=member.name) -> None: + await ctx.send_activity(f"Hello {_name}. I'm a Teams agent.") + + await _create_one_on_one(context, app_id, audience, member, _greet) + + continuation_token = paged.continuation_token + if not continuation_token: + break + + await context.send_activity("All messages have been sent.") + + +@teams.message("mentionme") +async def on_mention_me(context: TeamsTurnContext, state: TurnState) -> None: + """Reply with an Adaptive Card that @-mentions the caller (UPN and AAD).""" + member = await _get_member(context, context.activity.from_property.id) + if member is None: + await context.send_activity("Member not found.") + return + + card = { + "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", + "type": "AdaptiveCard", + "version": "1.5", + "body": [ + { + "type": "TextBlock", + "text": f"Mention by UPN: Hello {member.name} UPN", + }, + { + "type": "TextBlock", + "text": f"Mention by AAD Object Id: Hello {member.name} AAD", + }, + ], + "msteams": { + "entities": [ + { + "type": "mention", + "text": f"{member.name} UPN", + "mentioned": {"id": member.id, "name": member.name}, + }, + { + "type": "mention", + "text": f"{member.name} AAD", + "mentioned": {"id": member.aad_object_id, "name": member.name}, + }, + ] + }, + } + await context.send_activity( + MessageFactory.attachment(CardFactory.adaptive_card(card)) + ) + + +@teams.message("atmention") +async def on_at_mention(context: TeamsTurnContext, state: TurnState) -> None: + """Reply with a text message that @-mentions the caller.""" + from_account = context.activity.from_property + mention = Mention(mentioned=from_account, text=f"{from_account.name}") + reply = MessageFactory.text(f"Hello {mention.text}.") + reply.entities = [mention] + await context.send_activity(reply) + + +# ── Default message — send the welcome card ────────────────────────────────── + + +@teams.activity("message") +async def on_message(context: TeamsTurnContext, state: TurnState) -> None: + await context.send_activity( + MessageFactory.attachment(CardFactory.hero_card(_welcome_card("Welcome!"))) + ) diff --git a/test_samples/msteams/conversation-agent/src/main.py b/test_samples/msteams/conversation-agent/src/main.py new file mode 100644 index 000000000..d2c005a4c --- /dev/null +++ b/test_samples/msteams/conversation-agent/src/main.py @@ -0,0 +1,11 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from .agent import AGENT_APP, CONNECTION_MANAGER +from .start_server import start_server + +if __name__ == "__main__": + start_server( + agent_application=AGENT_APP, + auth_configuration=CONNECTION_MANAGER.get_default_connection_configuration(), + ) diff --git a/test_samples/msteams/conversation-agent/src/start_server.py b/test_samples/msteams/conversation-agent/src/start_server.py new file mode 100644 index 000000000..97792f47d --- /dev/null +++ b/test_samples/msteams/conversation-agent/src/start_server.py @@ -0,0 +1,32 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from os import environ + +from aiohttp.web import Application, Request, Response, run_app + +from microsoft_agents.hosting.aiohttp import ( + CloudAdapter, + jwt_authorization_middleware, + start_agent_process, +) +from microsoft_agents.hosting.core import AgentApplication + + +def start_server( + agent_application: AgentApplication, + auth_configuration, +) -> None: + async def entry_point(req: Request) -> Response: + agent: AgentApplication = req.app["agent_app"] + adapter: CloudAdapter = req.app["adapter"] + return await start_agent_process(req, agent, adapter) + + app = Application(middlewares=[jwt_authorization_middleware]) + app.router.add_post("/api/messages", entry_point) + app.router.add_get("/api/messages", lambda _: Response(status=200)) + app["agent_configuration"] = auth_configuration + app["agent_app"] = agent_application + app["adapter"] = agent_application.adapter + + run_app(app, host="localhost", port=int(environ.get("PORT", 3978))) From f3d096444bf40eab807c5669eb7d62a8dcdb1ab8 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Fri, 26 Jun 2026 11:38:02 -0700 Subject: [PATCH 38/46] Reorganizing test_samples --- .../conversation-agent/README.md | 46 +++ .../conversation-agent/env.TEMPLATE | 0 .../conversation-agent/pyproject.toml | 4 +- .../conversation-agent/src/__init__.py | 0 .../conversation-agent/src/agent.py | 0 .../conversation-agent/src/main.py | 0 .../conversation-agent/src/start_server.py | 0 .../message-extensions/README.md | 32 ++ .../message-extensions/env.TEMPLATE | 6 + .../message-extensions/pyproject.toml | 20 ++ .../message-extensions/src/__init__.py | 2 + .../message-extensions/src/agent.py | 296 ++++++++++++++++ .../message-extensions}/src/main.py | 0 .../message-extensions}/src/start_server.py | 16 +- .../message-extensions/static/settings.html | 118 +++++++ .../hosting_msteams/task-modules/README.md | 44 +++ .../hosting_msteams/task-modules/env.TEMPLATE | 7 + .../task-modules/pyproject.toml | 20 ++ .../task-modules/src/__init__.py | 2 + .../hosting_msteams/task-modules/src/agent.py | 219 ++++++++++++ .../task-modules/src/card_loader.py | 43 +++ .../hosting_msteams/task-modules/src/main.py | 11 + .../src/resources/dialog-form.html | 44 +++ .../src/resources/launcher-card.json | 55 +++ .../src/resources/multi-step-email-card.json | 30 ++ .../src/resources/multi-step-name-card.json | 29 ++ .../src/resources/simple-form-card.json | 29 ++ .../task-modules/src/start_server.py | 53 +++ .../teams/conversation-agent/src/__init__.py | 0 .../teams/conversation-agent/src/agent.py | 316 ------------------ 30 files changed, 1120 insertions(+), 322 deletions(-) create mode 100644 test_samples/hosting_msteams/conversation-agent/README.md rename test_samples/{teams => hosting_msteams}/conversation-agent/env.TEMPLATE (100%) rename test_samples/{teams => hosting_msteams}/conversation-agent/pyproject.toml (88%) rename test_samples/{msteams => hosting_msteams}/conversation-agent/src/__init__.py (100%) rename test_samples/{msteams => hosting_msteams}/conversation-agent/src/agent.py (100%) rename test_samples/{msteams => hosting_msteams}/conversation-agent/src/main.py (100%) rename test_samples/{msteams => hosting_msteams}/conversation-agent/src/start_server.py (100%) create mode 100644 test_samples/hosting_msteams/message-extensions/README.md create mode 100644 test_samples/hosting_msteams/message-extensions/env.TEMPLATE create mode 100644 test_samples/hosting_msteams/message-extensions/pyproject.toml create mode 100644 test_samples/hosting_msteams/message-extensions/src/__init__.py create mode 100644 test_samples/hosting_msteams/message-extensions/src/agent.py rename test_samples/{teams/conversation-agent => hosting_msteams/message-extensions}/src/main.py (100%) rename test_samples/{teams/conversation-agent => hosting_msteams/message-extensions}/src/start_server.py (67%) create mode 100644 test_samples/hosting_msteams/message-extensions/static/settings.html create mode 100644 test_samples/hosting_msteams/task-modules/README.md create mode 100644 test_samples/hosting_msteams/task-modules/env.TEMPLATE create mode 100644 test_samples/hosting_msteams/task-modules/pyproject.toml create mode 100644 test_samples/hosting_msteams/task-modules/src/__init__.py create mode 100644 test_samples/hosting_msteams/task-modules/src/agent.py create mode 100644 test_samples/hosting_msteams/task-modules/src/card_loader.py create mode 100644 test_samples/hosting_msteams/task-modules/src/main.py create mode 100644 test_samples/hosting_msteams/task-modules/src/resources/dialog-form.html create mode 100644 test_samples/hosting_msteams/task-modules/src/resources/launcher-card.json create mode 100644 test_samples/hosting_msteams/task-modules/src/resources/multi-step-email-card.json create mode 100644 test_samples/hosting_msteams/task-modules/src/resources/multi-step-name-card.json create mode 100644 test_samples/hosting_msteams/task-modules/src/resources/simple-form-card.json create mode 100644 test_samples/hosting_msteams/task-modules/src/start_server.py delete mode 100644 test_samples/teams/conversation-agent/src/__init__.py delete mode 100644 test_samples/teams/conversation-agent/src/agent.py diff --git a/test_samples/hosting_msteams/conversation-agent/README.md b/test_samples/hosting_msteams/conversation-agent/README.md new file mode 100644 index 000000000..00948565f --- /dev/null +++ b/test_samples/hosting_msteams/conversation-agent/README.md @@ -0,0 +1,46 @@ +# Teams Conversation Agent Sample + +Python port of the .NET `ConversationAgent` sample +(`src/samples/Teams/ConversationAgent` in microsoft/Agents-for-net), built on +`AgentApplication` + `TeamsAgentExtension`. + +## What it demonstrates + +Conversation-update and lifecycle events: + +| Event | Decorator | +|-------|-----------| +| installation update | `@teams.activity("installationUpdate")` | +| members added / removed | `@teams.conversation_update("membersAdded" / "membersRemoved")` | +| channel created / renamed / deleted | `@teams.channels.created` / `.renamed` / `.deleted` | +| team renamed | `@teams.teams.renamed` | + +Message commands (driven by the welcome card buttons, all `@teams.message(...)`): + +| Command | Behaviour | +|---------|-----------| +| (any message) | Sends the welcome card. | +| `update` | Updates the triggering card with an incremented counter. | +| `delete` | Deletes the triggering card. | +| `whoami` | Looks up the caller via the Teams API client. | +| `mentionme` | Replies with an Adaptive Card that @-mentions the caller. | +| `atmention` | Replies with a text message that @-mentions the caller. | +| `messageall` | Proactively sends a 1:1 greeting to every team member. | +| `targeted` | Sends a 1:1 message to every member of the conversation. | + +Member lookups use `teams.get_teams_api_client(context).conversations.members`, +and proactive messages use `adapter.create_conversation(...)`. + +## Running + +1. Copy `env.TEMPLATE` to `.env` and fill in your Azure Bot registration + (`CLIENTID`, `CLIENTSECRET`, `TENANTID`). +2. Install the SDK libraries (see the repository `README.md`). +3. Start the agent: + + ```bash + python -m src.main + ``` + +The server listens on `http://localhost:3978/api/messages`. Expose it with a +dev tunnel and side-load the app manifest into Teams. diff --git a/test_samples/teams/conversation-agent/env.TEMPLATE b/test_samples/hosting_msteams/conversation-agent/env.TEMPLATE similarity index 100% rename from test_samples/teams/conversation-agent/env.TEMPLATE rename to test_samples/hosting_msteams/conversation-agent/env.TEMPLATE diff --git a/test_samples/teams/conversation-agent/pyproject.toml b/test_samples/hosting_msteams/conversation-agent/pyproject.toml similarity index 88% rename from test_samples/teams/conversation-agent/pyproject.toml rename to test_samples/hosting_msteams/conversation-agent/pyproject.toml index 686d7aa3e..483a02db5 100644 --- a/test_samples/teams/conversation-agent/pyproject.toml +++ b/test_samples/hosting_msteams/conversation-agent/pyproject.toml @@ -8,13 +8,13 @@ version = "0.1.0" description = "Teams Conversation Agent sample — demonstrates channel/team lifecycle, member events, and message commands" authors = [{name = "Microsoft Corporation"}] license = "MIT" -requires-python = ">=3.12" +requires-python = ">=3.11" dependencies = [ "microsoft-agents-activity", "microsoft-agents-hosting-core", "microsoft-agents-authentication-msal", "microsoft-agents-hosting-aiohttp", - "microsoft-agents-hosting-teams", + "microsoft-agents-hosting-msteams", "python-dotenv", "aiohttp", ] diff --git a/test_samples/msteams/conversation-agent/src/__init__.py b/test_samples/hosting_msteams/conversation-agent/src/__init__.py similarity index 100% rename from test_samples/msteams/conversation-agent/src/__init__.py rename to test_samples/hosting_msteams/conversation-agent/src/__init__.py diff --git a/test_samples/msteams/conversation-agent/src/agent.py b/test_samples/hosting_msteams/conversation-agent/src/agent.py similarity index 100% rename from test_samples/msteams/conversation-agent/src/agent.py rename to test_samples/hosting_msteams/conversation-agent/src/agent.py diff --git a/test_samples/msteams/conversation-agent/src/main.py b/test_samples/hosting_msteams/conversation-agent/src/main.py similarity index 100% rename from test_samples/msteams/conversation-agent/src/main.py rename to test_samples/hosting_msteams/conversation-agent/src/main.py diff --git a/test_samples/msteams/conversation-agent/src/start_server.py b/test_samples/hosting_msteams/conversation-agent/src/start_server.py similarity index 100% rename from test_samples/msteams/conversation-agent/src/start_server.py rename to test_samples/hosting_msteams/conversation-agent/src/start_server.py diff --git a/test_samples/hosting_msteams/message-extensions/README.md b/test_samples/hosting_msteams/message-extensions/README.md new file mode 100644 index 000000000..3e99d04b7 --- /dev/null +++ b/test_samples/hosting_msteams/message-extensions/README.md @@ -0,0 +1,32 @@ +# Teams Message Extensions Sample + +Python port of the .NET `MessageExtensions` sample +(`src/samples/Teams/MessageExtensions` in microsoft/Agents-for-net), built on +`AgentApplication` + `TeamsAgentExtension`. + +## What it demonstrates + +| Command | Decorator | Behaviour | +|---------|-----------|-----------| +| `searchQuery` | `@teams.message_extensions.query("searchQuery")` | Search command. On `initialRun` shows a hint; otherwise returns 5 Adaptive Card results, each with a tappable thumbnail preview. | +| select item | `@teams.message_extensions.select_item` | Handles a tap on a search result preview and returns a detail card. | +| `createCard` | `@teams.message_extensions.submit_action("createCard")` | Action command that builds a card from a submitted title/description. | +| link unfurling | `@teams.message_extensions.query_link` | Returns a preview card for a pasted link. | +| settings url | `@teams.message_extensions.query_setting_url` | Returns a config result pointing at `SETTINGS_URL`. | +| settings saved | `@teams.message_extensions.setting` | Handles applied settings (no-op on `CancelledByUser`). | +| fetch task | `@teams.message_extensions.fetch_action()` | Returns a "not implemented" task module dialog. | +| message | `@teams.activity("message")` | Echoes text and explains how to use the extension. | + +## Running + +1. Copy `env.TEMPLATE` to `.env` and fill in your Azure Bot registration + (`CLIENTID`, `CLIENTSECRET`, `TENANTID`). Optionally set `SETTINGS_URL`. +2. Install the SDK libraries (see the repository `README.md`). +3. Start the agent: + + ```bash + python -m src.main + ``` + +The server listens on `http://localhost:3978/api/messages`. Expose it with a +dev tunnel and side-load the app manifest into Teams to exercise the commands. diff --git a/test_samples/hosting_msteams/message-extensions/env.TEMPLATE b/test_samples/hosting_msteams/message-extensions/env.TEMPLATE new file mode 100644 index 000000000..d97004116 --- /dev/null +++ b/test_samples/hosting_msteams/message-extensions/env.TEMPLATE @@ -0,0 +1,6 @@ +CONNECTIONS__SERVICE_CONNECTION__SETTINGS__CLIENTID= +CONNECTIONS__SERVICE_CONNECTION__SETTINGS__CLIENTSECRET= +CONNECTIONS__SERVICE_CONNECTION__SETTINGS__TENANTID= + +# Optional: public URL surfaced by the composeExtension/querySettingUrl command. +SETTINGS_URL= diff --git a/test_samples/hosting_msteams/message-extensions/pyproject.toml b/test_samples/hosting_msteams/message-extensions/pyproject.toml new file mode 100644 index 000000000..def19721e --- /dev/null +++ b/test_samples/hosting_msteams/message-extensions/pyproject.toml @@ -0,0 +1,20 @@ +[build-system] +requires = ["setuptools"] +build-backend = "setuptools.build_meta" + +[project] +name = "message-extensions" +version = "0.1.0" +description = "Teams Message Extensions sample — search/action commands, link unfurling, settings, and fetch task" +authors = [{name = "Microsoft Corporation"}] +license = "MIT" +requires-python = ">=3.11" +dependencies = [ + "microsoft-agents-activity", + "microsoft-agents-hosting-core", + "microsoft-agents-authentication-msal", + "microsoft-agents-hosting-aiohttp", + "microsoft-agents-hosting-msteams @ file:../../../libraries/microsoft-agents-hosting-msteams", + "python-dotenv", + "aiohttp", +] diff --git a/test_samples/hosting_msteams/message-extensions/src/__init__.py b/test_samples/hosting_msteams/message-extensions/src/__init__.py new file mode 100644 index 000000000..5b7f7a925 --- /dev/null +++ b/test_samples/hosting_msteams/message-extensions/src/__init__.py @@ -0,0 +1,2 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. diff --git a/test_samples/hosting_msteams/message-extensions/src/agent.py b/test_samples/hosting_msteams/message-extensions/src/agent.py new file mode 100644 index 000000000..54c568177 --- /dev/null +++ b/test_samples/hosting_msteams/message-extensions/src/agent.py @@ -0,0 +1,296 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""Teams Message Extensions agent — Python port of the .NET MessageExtensions sample. + +Demonstrates the composeExtension (message extension) surface: search-based +queries, item selection, action commands, link unfurling, settings, and a +fetch-task command. Mirrors src/samples/Teams/MessageExtensions from the +microsoft/Agents-for-net repository. +""" + +import logging +from os import environ + +from dotenv import load_dotenv + +from microsoft_teams.api.models import ( + AppBasedLinkQuery, + CardAction, + MessagingExtensionAction, + MessagingExtensionActionResponse, + MessagingExtensionAttachment, + MessagingExtensionAttachmentLayout, + MessagingExtensionQuery, + MessagingExtensionResponse, + MessagingExtensionResult, + MessagingExtensionResultType, + MessagingExtensionSuggestedAction, +) + +from microsoft_agents.activity import load_configuration_from_env +from microsoft_agents.authentication.msal import MsalConnectionManager +from microsoft_agents.hosting.aiohttp import CloudAdapter +from microsoft_agents.hosting.core import ( + AgentApplication, + Authorization, + MemoryStorage, + TurnState, +) +from microsoft_agents.hosting.msteams import TeamsAgentExtension +from microsoft_agents.hosting.msteams.teams_turn_context import TeamsTurnContext + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) +load_dotenv() + +agents_sdk_config = load_configuration_from_env(environ) + +# URL exposed by the agent (dev tunnel / public host) used by the settings command. +SETTINGS_URL = environ.get("SETTINGS_URL", "http://localhost:3978/settings") + +STORAGE = MemoryStorage() +CONNECTION_MANAGER = MsalConnectionManager(**agents_sdk_config) +ADAPTER = CloudAdapter(connection_manager=CONNECTION_MANAGER) +AUTHORIZATION = Authorization(STORAGE, CONNECTION_MANAGER, **agents_sdk_config) + +AGENT_APP = AgentApplication[TurnState]( + storage=STORAGE, + adapter=ADAPTER, + authorization=AUTHORIZATION, + **agents_sdk_config, +) + +teams = TeamsAgentExtension[TurnState](AGENT_APP) + +_ADAPTIVE_CONTENT_TYPE = "application/vnd.microsoft.card.adaptive" +_THUMBNAIL_CONTENT_TYPE = "application/vnd.microsoft.card.thumbnail" + + +def _adaptive_card(title: str, body_text: str) -> dict: + """Build a minimal Adaptive Card with a bold title and a wrapped body line.""" + return { + "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", + "type": "AdaptiveCard", + "version": "1.5", + "body": [ + {"type": "TextBlock", "text": title, "weight": "Bolder", "size": "Large"}, + {"type": "TextBlock", "text": body_text, "wrap": True, "isSubtle": True}, + ], + } + + +def _thumbnail(title: str, text: str, tap_value: dict) -> dict: + """Build a thumbnail-card preview whose tap fires a selectItem invoke.""" + return { + "title": title, + "text": text, + "tap": {"type": "invoke", "value": tap_value}, + } + + +def _list_result( + *attachments: MessagingExtensionAttachment, +) -> MessagingExtensionResponse: + """Wrap attachments in a list-layout composeExtension result.""" + return MessagingExtensionResponse( + compose_extension=MessagingExtensionResult( + type=MessagingExtensionResultType.RESULT, + attachment_layout=MessagingExtensionAttachmentLayout.LIST, + attachments=list(attachments), + ) + ) + + +# ── Default message — usage hint ───────────────────────────────────────────── + + +@teams.activity("message") +async def on_message(context: TeamsTurnContext, state: TurnState) -> None: + await context.send_activity( + f"Echo: {context.activity.text}\n\n" + "This is a message extension sample. Use the message extension commands " + "in Teams to test the functionality." + ) + + +# ── composeExtension/query (search command) ────────────────────────────────── + + +@teams.message_extensions.query("searchQuery") +async def on_search_query( + context: TeamsTurnContext, state: TurnState, query: MessagingExtensionQuery +) -> MessagingExtensionResponse: + params = {p.name: p.value for p in (query.parameters or [])} + + if str(params.get("initialRun", "")).lower() == "true": + return MessagingExtensionResponse( + compose_extension=MessagingExtensionResult( + type=MessagingExtensionResultType.MESSAGE, + text="Enter a search query to see results.", + ) + ) + + search_text = str(params.get("searchQuery", "") or "") + logger.info("Search query received: %s", search_text) + + attachments = [] + for i in range(1, 6): + card = _adaptive_card( + f"Search Result {i}", + f"Query: '{search_text}' — result description for item {i}.", + ) + preview = _thumbnail( + title=f"Result {i}", + text=f"Preview of result {i} for query '{search_text}'.", + tap_value={"index": str(i), "query": search_text}, + ) + attachments.append( + MessagingExtensionAttachment( + content_type=_ADAPTIVE_CONTENT_TYPE, + content=card, + preview=MessagingExtensionAttachment( + content_type=_THUMBNAIL_CONTENT_TYPE, + content=preview, + ), + ) + ) + + return _list_result(*attachments) + + +# ── composeExtension/selectItem (tap on a search result preview) ───────────── + + +@teams.message_extensions.select_item +async def on_select_item( + context: TeamsTurnContext, state: TurnState, item +) -> MessagingExtensionResponse: + item = item or {} + index = item.get("index", "No Index") + query = item.get("query", "No Query") + logger.info("Item selected: index=%s query=%s", index, query) + + card = _adaptive_card( + "Item Selected", + f"You selected item {index} for query '{query}'.", + ) + return _list_result( + MessagingExtensionAttachment(content_type=_ADAPTIVE_CONTENT_TYPE, content=card) + ) + + +# ── composeExtension/submitAction ("createCard") ───────────────────────────── + + +@teams.message_extensions.submit_action("createCard") +async def on_create_card( + context: TeamsTurnContext, state: TurnState, action: MessagingExtensionAction +) -> MessagingExtensionResponse: + data = action.data if isinstance(action.data, dict) else {} + title = data.get("title") or "Default Title" + description = data.get("description") or "Default Description" + logger.info("Creating card: title=%s description=%s", title, description) + + card = _adaptive_card(title, description) + return _list_result( + MessagingExtensionAttachment(content_type=_ADAPTIVE_CONTENT_TYPE, content=card) + ) + + +# ── composeExtension/queryLink (link unfurling) ────────────────────────────── + + +@teams.message_extensions.query_link +async def on_query_link( + context: TeamsTurnContext, state: TurnState, query: AppBasedLinkQuery +) -> MessagingExtensionResponse: + url = query.url or "" + logger.info("Link query: %s", url) + + if not url: + return MessagingExtensionResponse( + compose_extension=MessagingExtensionResult( + type=MessagingExtensionResultType.MESSAGE, + text="No URL provided.", + ) + ) + + card = _adaptive_card("Link Preview", f"URL: {url}") + return _list_result( + MessagingExtensionAttachment( + content_type=_ADAPTIVE_CONTENT_TYPE, + content=card, + preview=MessagingExtensionAttachment( + content_type=_THUMBNAIL_CONTENT_TYPE, + content=_thumbnail("Link Preview", url, {"url": url}), + ), + ) + ) + + +# ── composeExtension/querySettingUrl ───────────────────────────────────────── + + +@teams.message_extensions.query_setting_url +async def on_query_settings_url( + context: TeamsTurnContext, state: TurnState, query: MessagingExtensionQuery +) -> MessagingExtensionResponse: + logger.info("Settings URL requested") + return MessagingExtensionResponse( + compose_extension=MessagingExtensionResult( + type=MessagingExtensionResultType.CONFIG, + suggested_actions=MessagingExtensionSuggestedAction( + actions=[ + CardAction( + type="openUrl", + title="Configure", + value=SETTINGS_URL, + ) + ] + ), + ) + ) + + +# ── composeExtension/setting (settings applied) ────────────────────────────── + + +@teams.message_extensions.setting +async def on_configure_settings( + context: TeamsTurnContext, state: TurnState, settings +) -> None: + state_value = settings.get("state") if isinstance(settings, dict) else settings + if state_value == "CancelledByUser": + return + logger.info("Settings saved: %s", state_value) + + +# ── composeExtension/fetchTask ─────────────────────────────────────────────── + + +@teams.message_extensions.fetch_action() +async def on_fetch_task( + context: TeamsTurnContext, state: TurnState, action: MessagingExtensionAction +) -> MessagingExtensionActionResponse: + logger.info("FetchTask: command=%s", action.command_id) + card = _adaptive_card( + "Conversation Members", + "Conversation Members is not implemented in this sample.", + ) + return MessagingExtensionActionResponse.model_validate( + { + "task": { + "type": "continue", + "value": { + "title": "Conversation Members", + "height": "small", + "width": "small", + "card": { + "contentType": _ADAPTIVE_CONTENT_TYPE, + "content": card, + }, + }, + } + } + ) diff --git a/test_samples/teams/conversation-agent/src/main.py b/test_samples/hosting_msteams/message-extensions/src/main.py similarity index 100% rename from test_samples/teams/conversation-agent/src/main.py rename to test_samples/hosting_msteams/message-extensions/src/main.py diff --git a/test_samples/teams/conversation-agent/src/start_server.py b/test_samples/hosting_msteams/message-extensions/src/start_server.py similarity index 67% rename from test_samples/teams/conversation-agent/src/start_server.py rename to test_samples/hosting_msteams/message-extensions/src/start_server.py index 97792f47d..9e57bc30d 100644 --- a/test_samples/teams/conversation-agent/src/start_server.py +++ b/test_samples/hosting_msteams/message-extensions/src/start_server.py @@ -2,29 +2,37 @@ # Licensed under the MIT License. from os import environ +from pathlib import Path -from aiohttp.web import Application, Request, Response, run_app +from aiohttp.web import Application, FileResponse, Request, Response, run_app from microsoft_agents.hosting.aiohttp import ( CloudAdapter, - jwt_authorization_middleware, + jwt_authorization_decorator, start_agent_process, ) from microsoft_agents.hosting.core import AgentApplication +_STATIC_DIR = Path(__file__).parent.parent / "static" + def start_server( agent_application: AgentApplication, auth_configuration, ) -> None: + + @jwt_authorization_decorator async def entry_point(req: Request) -> Response: agent: AgentApplication = req.app["agent_app"] adapter: CloudAdapter = req.app["adapter"] return await start_agent_process(req, agent, adapter) - app = Application(middlewares=[jwt_authorization_middleware]) + async def serve_settings(req: Request): + return FileResponse(_STATIC_DIR / "settings.html") + + app = Application() app.router.add_post("/api/messages", entry_point) - app.router.add_get("/api/messages", lambda _: Response(status=200)) + app.router.add_get("/settings", serve_settings) app["agent_configuration"] = auth_configuration app["agent_app"] = agent_application app["adapter"] = agent_application.adapter diff --git a/test_samples/hosting_msteams/message-extensions/static/settings.html b/test_samples/hosting_msteams/message-extensions/static/settings.html new file mode 100644 index 000000000..c4f0aaaa4 --- /dev/null +++ b/test_samples/hosting_msteams/message-extensions/static/settings.html @@ -0,0 +1,118 @@ + + + + Message Extension Settings + + + + + + + +
+

Message Extension Settings

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

Webpage Dialog Form

+
+
+ + +
+
+ + +
+ + +
+ + + diff --git a/test_samples/hosting_msteams/task-modules/src/resources/launcher-card.json b/test_samples/hosting_msteams/task-modules/src/resources/launcher-card.json new file mode 100644 index 000000000..d4d540452 --- /dev/null +++ b/test_samples/hosting_msteams/task-modules/src/resources/launcher-card.json @@ -0,0 +1,55 @@ +{ + "type": "AdaptiveCard", + "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", + "version": "1.5", + "body": [ + { + "type": "TextBlock", + "text": "Select the examples you want to see!", + "size": "Large", + "weight": "Bolder" + } + ], + "actions": [ + { + "type": "Action.Submit", + "title": "Simple Form", + "data": { + "msteams": { + "type": "task/fetch" + }, + "verb": "simple_form" + } + }, + { + "type": "Action.Submit", + "title": "Webpage Dialog", + "data": { + "msteams": { + "type": "task/fetch" + }, + "verb": "webpage_dialog" + } + }, + { + "type": "Action.Submit", + "title": "Multi-Step Form", + "data": { + "msteams": { + "type": "task/fetch" + }, + "verb": "multi_step_form" + } + }, + { + "type": "Action.Submit", + "title": "Mixed Example", + "data": { + "msteams": { + "type": "task/fetch" + }, + "verb": "mixed_example" + } + } + ] +} diff --git a/test_samples/hosting_msteams/task-modules/src/resources/multi-step-email-card.json b/test_samples/hosting_msteams/task-modules/src/resources/multi-step-email-card.json new file mode 100644 index 000000000..4a3c01658 --- /dev/null +++ b/test_samples/hosting_msteams/task-modules/src/resources/multi-step-email-card.json @@ -0,0 +1,30 @@ +{ + "type": "AdaptiveCard", + "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", + "version": "1.5", + "body": [ + { + "type": "TextBlock", + "text": "Email, {{name}}!", + "size": "Large", + "weight": "Bolder" + }, + { + "type": "Input.Text", + "id": "email", + "label": "Email", + "isRequired": true, + "placeholder": "Enter your email" + } + ], + "actions": [ + { + "type": "Action.Submit", + "title": "Submit", + "data": { + "verb": "multi_step_form_submit_email", + "name": "{{name}}" + } + } + ] +} diff --git a/test_samples/hosting_msteams/task-modules/src/resources/multi-step-name-card.json b/test_samples/hosting_msteams/task-modules/src/resources/multi-step-name-card.json new file mode 100644 index 000000000..cefe29b27 --- /dev/null +++ b/test_samples/hosting_msteams/task-modules/src/resources/multi-step-name-card.json @@ -0,0 +1,29 @@ +{ + "type": "AdaptiveCard", + "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", + "version": "1.5", + "body": [ + { + "type": "TextBlock", + "text": "This is a multi-step form", + "size": "Large", + "weight": "Bolder" + }, + { + "type": "Input.Text", + "id": "name", + "label": "Name", + "isRequired": true, + "placeholder": "Enter your name" + } + ], + "actions": [ + { + "type": "Action.Submit", + "title": "Submit", + "data": { + "verb": "multi_step_form_submit_name" + } + } + ] +} diff --git a/test_samples/hosting_msteams/task-modules/src/resources/simple-form-card.json b/test_samples/hosting_msteams/task-modules/src/resources/simple-form-card.json new file mode 100644 index 000000000..e453865f3 --- /dev/null +++ b/test_samples/hosting_msteams/task-modules/src/resources/simple-form-card.json @@ -0,0 +1,29 @@ +{ + "type": "AdaptiveCard", + "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", + "version": "1.5", + "body": [ + { + "type": "TextBlock", + "text": "Simple Form", + "size": "Large", + "weight": "Bolder" + }, + { + "type": "Input.Text", + "id": "name", + "label": "Your Name", + "isRequired": true, + "placeholder": "Enter your name" + } + ], + "actions": [ + { + "type": "Action.Submit", + "title": "Submit", + "data": { + "verb": "simple_form" + } + } + ] +} diff --git a/test_samples/hosting_msteams/task-modules/src/start_server.py b/test_samples/hosting_msteams/task-modules/src/start_server.py new file mode 100644 index 000000000..6d6202776 --- /dev/null +++ b/test_samples/hosting_msteams/task-modules/src/start_server.py @@ -0,0 +1,53 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from os import environ + +from aiohttp.web import Application, Request, Response, middleware, run_app + +from microsoft_agents.hosting.aiohttp import ( + CloudAdapter, + jwt_authorization_middleware, + start_agent_process, +) +from microsoft_agents.hosting.core import AgentApplication + +from .card_loader import load_resource_text + +# The webpage dialog is loaded by Teams in an iframe without a bearer token, so +# it must be reachable without JWT authorization. +_PUBLIC_PATHS = {"/dialog-form"} + + +@middleware +async def _auth_unless_public(request: Request, handler): + if request.path in _PUBLIC_PATHS: + return await handler(request) + return await jwt_authorization_middleware(request, handler) + + +def start_server( + agent_application: AgentApplication, + auth_configuration, +) -> None: + async def entry_point(req: Request) -> Response: + agent: AgentApplication = req.app["agent_app"] + adapter: CloudAdapter = req.app["adapter"] + return await start_agent_process(req, agent, adapter) + + async def dialog_form(_req: Request) -> Response: + return Response( + text=load_resource_text("dialog-form.html"), + content_type="text/html", + ) + + app = Application(middlewares=[_auth_unless_public]) + app.router.add_post("/api/messages", entry_point) + app.router.add_get("/api/messages", lambda _: Response(status=200)) + # Served as the URL content for the "Webpage Dialog" task module. + app.router.add_get("/dialog-form", dialog_form) + app["agent_configuration"] = auth_configuration + app["agent_app"] = agent_application + app["adapter"] = agent_application.adapter + + run_app(app, host="localhost", port=int(environ.get("PORT", 3978))) diff --git a/test_samples/teams/conversation-agent/src/__init__.py b/test_samples/teams/conversation-agent/src/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/test_samples/teams/conversation-agent/src/agent.py b/test_samples/teams/conversation-agent/src/agent.py deleted file mode 100644 index c5b115ecb..000000000 --- a/test_samples/teams/conversation-agent/src/agent.py +++ /dev/null @@ -1,316 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -"""Teams Conversation Agent — Python port of the .NET ConversationAgent sample. - -Demonstrates Teams-specific events: channel lifecycle, team lifecycle, member -add/remove, and message commands (update card, who am I, mention, proactive -message-all, targeted send, etc.). -""" - -import json -import logging -from os import environ, path - -from dotenv import load_dotenv - -from microsoft_teams.api.models import ChannelData - -from microsoft_agents.activity import ( - ActionTypes, - CardAction, - ChannelAccount, - Channels, - ConversationParameters, - HeroCard, - Mention, - load_configuration_from_env, -) -from microsoft_agents.authentication.msal import MsalConnectionManager -from microsoft_agents.hosting.aiohttp import CloudAdapter -from microsoft_agents.hosting.core import ( - AgentApplication, - Authorization, - CardFactory, - MessageFactory, - MemoryStorage, - TurnContext, - TurnState, -) -from microsoft_agents.hosting.teams import TeamsAgentExtension, TeamsInfo -from microsoft_agents.hosting.teams.teams_turn_context import TeamsTurnContext - -logging.basicConfig(level=logging.INFO) -load_dotenv() - -agents_sdk_config = load_configuration_from_env(environ) - -STORAGE = MemoryStorage() -CONNECTION_MANAGER = MsalConnectionManager(**agents_sdk_config) -ADAPTER = CloudAdapter(connection_manager=CONNECTION_MANAGER) -AUTHORIZATION = Authorization(STORAGE, CONNECTION_MANAGER, **agents_sdk_config) - -AGENT_APP = AgentApplication[TurnState]( - storage=STORAGE, - adapter=ADAPTER, - authorization=AUTHORIZATION, - **agents_sdk_config, -) - -teams = TeamsAgentExtension[TurnState](AGENT_APP) - - -def _make_welcome_card(title: str, count: int = 0) -> HeroCard: - return HeroCard( - title=title, - buttons=[ - CardAction(type=ActionTypes.message_back, title="Message all members", text="messageall"), - CardAction(type=ActionTypes.message_back, title="Who am I?", text="whoami"), - CardAction(type=ActionTypes.message_back, title="Mention Me", text="mentionme"), - CardAction(type=ActionTypes.message_back, title="Delete Card", text="delete"), - CardAction(type=ActionTypes.message_back, title="Send Targeted", text="targeted"), - CardAction( - type=ActionTypes.message_back, - title="Update Card", - text="update", - value=json.dumps({"count": count}), - ), - ], - ) - - -# ── Installation update ────────────────────────────────────────────────────── - -@teams.activity("installationUpdate") -async def on_installation_update(context: TeamsTurnContext, state: TurnState) -> None: - conv = context.activity.conversation - if conv and conv.conversation_type == "channel": - name = conv.name or "this channel" - await context.send_activity( - f"Welcome to Microsoft Teams conversationUpdate events demo. " - f"This agent is configured in {name}" - ) - else: - await context.send_activity( - "Welcome to Microsoft Teams conversationUpdate events demo." - ) - - -# ── Member lifecycle ───────────────────────────────────────────────────────── - -@teams.conversation_update("membersAdded") -async def on_members_added(context: TeamsTurnContext, state: TurnState) -> None: - conv = context.activity.conversation - for member in context.activity.members_added or []: - if member.id != context.activity.recipient.id: - if not conv or conv.conversation_type != "personal": - await context.send_activity( - MessageFactory.text(f"Welcome to the team {member.name}.") - ) - - -@teams.conversation_update("membersRemoved") -async def on_members_removed(context: TeamsTurnContext, state: TurnState) -> None: - channel_data = context.activity.channel_data - team_name = "the team" - if isinstance(channel_data, dict): - team_name = (channel_data.get("team") or {}).get("name", team_name) - - for member in context.activity.members_removed or []: - if member.id == context.activity.recipient.id: - pass # bot removed — clear any cached data here if needed - else: - card = HeroCard(text=f"{member.name} was removed from {team_name}") - await context.send_activity( - MessageFactory.attachment(CardFactory.hero_card(card)) - ) - - -# ── Channel events ─────────────────────────────────────────────────────────── - -@teams.channels.created -async def on_channel_created(context: TeamsTurnContext, state: TurnState, channel_data: ChannelData) -> None: - name = channel_data.channel.name if channel_data.channel else "Unknown" - card = HeroCard(text=f"{name} is the Channel created") - await context.send_activity(MessageFactory.attachment(CardFactory.hero_card(card))) - - -@teams.channels.renamed -async def on_channel_renamed(context: TeamsTurnContext, state: TurnState, channel_data: ChannelData) -> None: - name = channel_data.channel.name if channel_data.channel else "Unknown" - card = HeroCard(text=f"{name} is the new Channel name") - await context.send_activity(MessageFactory.attachment(CardFactory.hero_card(card))) - - -@teams.channels.deleted() -async def on_channel_deleted(context: TeamsTurnContext, state: TurnState, channel_data) -> None: - name = channel_data.channel.name if channel_data.channel else "Unknown" - card = HeroCard(text=f"{name} is the Channel deleted") - await context.send_activity(MessageFactory.attachment(CardFactory.hero_card(card))) - - -# ── Team events ────────────────────────────────────────────────────────────── - -@teams.teams.renamed() -async def on_team_renamed(context: TeamsTurnContext, state: TurnState, channel_data) -> None: - name = channel_data.team.name if channel_data.team else "Unknown" - card = HeroCard(text=f"{name} is the new Team name") - await context.send_activity(MessageFactory.attachment(CardFactory.hero_card(card))) - - -# ── Message commands ───────────────────────────────────────────────────────── - -@teams.message("targeted") -async def on_targeted(context: TeamsTurnContext, state: TurnState) -> None: - """Send a 1:1 proactive message to every member in the conversation.""" - paged = await TeamsInfo.get_paged_members(context) - app_id = context.identity.get_app_id() if context.identity else "" - audience = ( - context.identity.get_token_audience() - if context.identity - else "https://api.botframework.com" - ) - for member in paged.members or []: - params = ConversationParameters( - is_group=False, - members=[ChannelAccount(id=member.id, name=member.name)], - channel_data={"tenant": {"id": context.activity.conversation.tenant_id}}, - agent=context.activity.recipient, - ) - - async def _send(ctx: TurnContext, _, _name=member.name) -> None: - await ctx.send_activity( - f"{_name}, this is a **targeted message** — only you can see this." - ) - - await context.adapter.create_conversation( - app_id or "", - Channels.ms_teams, - context.activity.service_url, - audience or "https://api.botframework.com", - params, - _send, - ) - - -@teams.message("update") -async def on_update_card(context: TeamsTurnContext, state: TurnState) -> None: - """Update the card that triggered this message with an incremented counter.""" - value = context.activity.value - if isinstance(value, str): - try: - value = json.loads(value) - except (json.JSONDecodeError, TypeError): - value = {} - count = (int(value.get("count") or 0) + 1) if isinstance(value, dict) else 1 - - card = _make_welcome_card("I've been updated", count=count) - card.text = f"Update count - {count}" - - activity = MessageFactory.attachment(CardFactory.hero_card(card)) - activity.id = context.activity.reply_to_id - await context.update_activity(activity) - - -@teams.message("whoami") -async def on_who_am_i(context: TeamsTurnContext, state: TurnState) -> None: - """Fetch the caller's Teams member profile.""" - try: - member = await TeamsInfo.get_member( - context, context.activity.from_property.id - ) - await context.send_activity(f"You are: {member.name}.") - except Exception as exc: - if "MemberNotFoundInConversation" in str(exc): - await context.send_activity("Member not found.") - else: - raise - - -@teams.message("delete") -async def on_delete_card(context: TeamsTurnContext, state: TurnState) -> None: - await context.delete_activity(context.activity.reply_to_id) - - -@teams.message("messageall") -async def on_message_all(context: TeamsTurnContext, state: TurnState) -> None: - """Proactively send a 1:1 greeting to every team member.""" - app_id = context.identity.get_app_id() if context.identity else "" - audience = ( - context.identity.get_token_audience() - if context.identity - else "https://api.botframework.com" - ) - continuation_token: str = "" - while True: - paged = await TeamsInfo.get_paged_members( - context, page_size=100, continuation_token=continuation_token - ) - for member in paged.members or []: - params = ConversationParameters( - is_group=False, - members=[ChannelAccount(id=member.id, name=member.name)], - channel_data={"tenant": {"id": context.activity.conversation.tenant_id}}, - bot=context.activity.recipient, - ) - - async def _greet(ctx: TurnContext, _, _name=member.name) -> None: - await ctx.send_activity(f"Hello {_name}. I'm a Teams agent.") - - await context.adapter.create_conversation( - app_id or "", - Channels.ms_teams, - context.activity.service_url, - audience or "https://api.botframework.com", - params, - _greet, - ) - continuation_token = paged.continuation_token - if not continuation_token: - break - - await context.send_activity("All messages have been sent.") - - -@teams.message("mentionme") -async def on_mention_me(context: TeamsTurnContext, state: TurnState) -> None: - """Mention the sender by name in the reply.""" - try: - member = await TeamsInfo.get_member( - context, context.activity.from_property.id - ) - except Exception as exc: - if "MemberNotFoundInConversation" in str(exc): - await context.send_activity("Member not found.") - return - raise - - mention = Mention( - mentioned=context.activity.from_property, - text=f"{member.name}", - ) - reply = MessageFactory.text(f"Hello {mention.text}.") - reply.entities = [mention] - await context.send_activity(reply) - - -@teams.message("atmention") -async def on_at_mention(context: TeamsTurnContext, state: TurnState) -> None: - from_account = context.activity.from_property - mention = Mention( - mentioned=from_account, - text=f"{from_account.name}", - ) - reply = MessageFactory.text(f"Hello {mention.text}.") - reply.entities = [mention] - await context.send_activity(reply) - - -# ── Default message — send the welcome card ────────────────────────────────── - -@teams.activity("message") -async def on_message(context: TeamsTurnContext, state: TurnState) -> None: - card = _make_welcome_card("Welcome!") - await context.send_activity( - MessageFactory.attachment(CardFactory.hero_card(card)) - ) \ No newline at end of file From e629ed876e476b763885f30bf9c44bbcff0b1c5b Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Fri, 26 Jun 2026 13:40:28 -0700 Subject: [PATCH 39/46] Finalizing msteams test samples --- .../message-extensions/README.md | 18 +++++++++++++++--- .../message-extensions/env.TEMPLATE | 3 +-- .../message-extensions/src/agent.py | 7 +++---- .../message-extensions/src/start_server.py | 9 ++++++--- .../hosting_msteams/task-modules/README.md | 19 +++++++++++++++---- 5 files changed, 40 insertions(+), 16 deletions(-) diff --git a/test_samples/hosting_msteams/message-extensions/README.md b/test_samples/hosting_msteams/message-extensions/README.md index 3e99d04b7..43cca06d4 100644 --- a/test_samples/hosting_msteams/message-extensions/README.md +++ b/test_samples/hosting_msteams/message-extensions/README.md @@ -17,12 +17,24 @@ Python port of the .NET `MessageExtensions` sample | fetch task | `@teams.message_extensions.fetch_action()` | Returns a "not implemented" task module dialog. | | message | `@teams.activity("message")` | Echoes text and explains how to use the extension. | +## Environment variables + +| Variable | Required | Default | Description | +|----------|----------|---------|-------------| +| `CONNECTIONS__SERVICE_CONNECTION__SETTINGS__CLIENTID` | Yes | — | Azure Bot / Entra app registration client ID | +| `CONNECTIONS__SERVICE_CONNECTION__SETTINGS__CLIENTSECRET` | Yes | — | Client secret for the above registration | +| `CONNECTIONS__SERVICE_CONNECTION__SETTINGS__TENANTID` | Yes | — | Entra tenant ID | +| `SETTINGS_URL` | No | `http://localhost:3978/settings` | Public URL of the settings page served by this agent. Teams loads this URL in the message-extension configuration pane, so it must be reachable from the internet — use your dev tunnel URL (e.g. `https://my-tunnel.devtunnels.ms/settings`). | + ## Running 1. Copy `env.TEMPLATE` to `.env` and fill in your Azure Bot registration - (`CLIENTID`, `CLIENTSECRET`, `TENANTID`). Optionally set `SETTINGS_URL`. -2. Install the SDK libraries (see the repository `README.md`). -3. Start the agent: + (`CLIENTID`, `CLIENTSECRET`, `TENANTID`). +2. If you want the **settings** command to work, set `SETTINGS_URL` to the + `/settings` path on your dev tunnel (e.g. + `SETTINGS_URL=https://my-tunnel.devtunnels.ms/settings`). +3. Install the SDK libraries (see the repository `README.md`). +4. Start the agent: ```bash python -m src.main diff --git a/test_samples/hosting_msteams/message-extensions/env.TEMPLATE b/test_samples/hosting_msteams/message-extensions/env.TEMPLATE index d97004116..21e33d342 100644 --- a/test_samples/hosting_msteams/message-extensions/env.TEMPLATE +++ b/test_samples/hosting_msteams/message-extensions/env.TEMPLATE @@ -2,5 +2,4 @@ CONNECTIONS__SERVICE_CONNECTION__SETTINGS__CLIENTID= CONNECTIONS__SERVICE_CONNECTION__SETTINGS__CLIENTSECRET= CONNECTIONS__SERVICE_CONNECTION__SETTINGS__TENANTID= -# Optional: public URL surfaced by the composeExtension/querySettingUrl command. -SETTINGS_URL= +SETTINGS_URL \ No newline at end of file diff --git a/test_samples/hosting_msteams/message-extensions/src/agent.py b/test_samples/hosting_msteams/message-extensions/src/agent.py index 54c568177..a90a304a0 100644 --- a/test_samples/hosting_msteams/message-extensions/src/agent.py +++ b/test_samples/hosting_msteams/message-extensions/src/agent.py @@ -258,12 +258,11 @@ async def on_query_settings_url( @teams.message_extensions.setting async def on_configure_settings( - context: TeamsTurnContext, state: TurnState, settings + context: TeamsTurnContext, state: TurnState, settings: MessagingExtensionQuery ) -> None: - state_value = settings.get("state") if isinstance(settings, dict) else settings - if state_value == "CancelledByUser": + if settings.state == "CancelledByUser": return - logger.info("Settings saved: %s", state_value) + logger.info("Settings saved: %s", settings.state) # ── composeExtension/fetchTask ─────────────────────────────────────────────── diff --git a/test_samples/hosting_msteams/message-extensions/src/start_server.py b/test_samples/hosting_msteams/message-extensions/src/start_server.py index 9e57bc30d..39b9f9039 100644 --- a/test_samples/hosting_msteams/message-extensions/src/start_server.py +++ b/test_samples/hosting_msteams/message-extensions/src/start_server.py @@ -20,18 +20,21 @@ def start_server( agent_application: AgentApplication, auth_configuration, ) -> None: - @jwt_authorization_decorator - async def entry_point(req: Request) -> Response: + async def entry_point(req: Request): agent: AgentApplication = req.app["agent_app"] adapter: CloudAdapter = req.app["adapter"] return await start_agent_process(req, agent, adapter) - async def serve_settings(req: Request): + async def serve_settings(_: Request) -> FileResponse: return FileResponse(_STATIC_DIR / "settings.html") + async def health_check(_: Request) -> Response: + return Response(status=200) + app = Application() app.router.add_post("/api/messages", entry_point) + app.router.add_get("/api/messages", health_check) app.router.add_get("/settings", serve_settings) app["agent_configuration"] = auth_configuration app["agent_app"] = agent_application diff --git a/test_samples/hosting_msteams/task-modules/README.md b/test_samples/hosting_msteams/task-modules/README.md index b05b2a567..1c2088e64 100644 --- a/test_samples/hosting_msteams/task-modules/README.md +++ b/test_samples/hosting_msteams/task-modules/README.md @@ -29,13 +29,24 @@ from JWT authorization because Teams loads it in an iframe without a bearer token. The page calls `microsoftTeams.dialog.url.submit(...)` which Teams delivers back as a `task/submit` invoke with verb `webpage_dialog`. +## Environment variables + +| Variable | Required | Default | Description | +|----------|----------|---------|-------------| +| `CONNECTIONS__SERVICE_CONNECTION__SETTINGS__CLIENTID` | Yes | — | Azure Bot / Entra app registration client ID | +| `CONNECTIONS__SERVICE_CONNECTION__SETTINGS__CLIENTSECRET` | Yes | — | Client secret for the above registration | +| `CONNECTIONS__SERVICE_CONNECTION__SETTINGS__TENANTID` | Yes | — | Entra tenant ID | +| `APP_BASE_URL` | No | `http://localhost:3978` | Public base URL of this agent. Used to build the webpage dialog URL (`{APP_BASE_URL}/dialog-form`). Teams loads that URL in an iframe, so it must be reachable from the internet — use your dev tunnel URL (e.g. `https://my-tunnel.devtunnels.ms`). | + ## Running 1. Copy `env.TEMPLATE` to `.env` and fill in your Azure Bot registration - (`CLIENTID`, `CLIENTSECRET`, `TENANTID`). Set `APP_BASE_URL` to your public - (dev tunnel) URL so the webpage dialog loads. -2. Install the SDK libraries (see the repository `README.md`). -3. Start the agent: + (`CLIENTID`, `CLIENTSECRET`, `TENANTID`). +2. Set `APP_BASE_URL` to your dev tunnel URL (e.g. + `APP_BASE_URL=https://my-tunnel.devtunnels.ms`) so Teams can load the + **Webpage Dialog** task module in an iframe. +3. Install the SDK libraries (see the repository `README.md`). +4. Start the agent: ```bash python -m src.main From 0165bda58fcdda485692a604ece2c9745fc1380d Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Tue, 30 Jun 2026 08:56:19 -0700 Subject: [PATCH 40/46] another commit --- test_samples/hosting_msteams/conversation-agent/src/agent.py | 5 +++++ test_samples/hosting_msteams/message-extensions/src/agent.py | 5 +++++ test_samples/hosting_msteams/task-modules/src/agent.py | 5 +++++ 3 files changed, 15 insertions(+) diff --git a/test_samples/hosting_msteams/conversation-agent/src/agent.py b/test_samples/hosting_msteams/conversation-agent/src/agent.py index fea2e6a81..0e6f0bddc 100644 --- a/test_samples/hosting_msteams/conversation-agent/src/agent.py +++ b/test_samples/hosting_msteams/conversation-agent/src/agent.py @@ -39,6 +39,10 @@ TurnContext, TurnState, ) +from microsoft_agents.hosting.core.storage import ( + ConsoleTranscriptLogger, + TranscriptLoggerMiddleware, +) from microsoft_agents.hosting.msteams import TeamsAgentExtension from microsoft_agents.hosting.msteams.teams_turn_context import TeamsTurnContext @@ -51,6 +55,7 @@ STORAGE = MemoryStorage() CONNECTION_MANAGER = MsalConnectionManager(**agents_sdk_config) ADAPTER = CloudAdapter(connection_manager=CONNECTION_MANAGER) +ADAPTER.use(TranscriptLoggerMiddleware(ConsoleTranscriptLogger())) AUTHORIZATION = Authorization(STORAGE, CONNECTION_MANAGER, **agents_sdk_config) AGENT_APP = AgentApplication[TurnState]( diff --git a/test_samples/hosting_msteams/message-extensions/src/agent.py b/test_samples/hosting_msteams/message-extensions/src/agent.py index a90a304a0..2d7f9c33f 100644 --- a/test_samples/hosting_msteams/message-extensions/src/agent.py +++ b/test_samples/hosting_msteams/message-extensions/src/agent.py @@ -37,6 +37,10 @@ MemoryStorage, TurnState, ) +from microsoft_agents.hosting.core.storage import ( + ConsoleTranscriptLogger, + TranscriptLoggerMiddleware, +) from microsoft_agents.hosting.msteams import TeamsAgentExtension from microsoft_agents.hosting.msteams.teams_turn_context import TeamsTurnContext @@ -52,6 +56,7 @@ STORAGE = MemoryStorage() CONNECTION_MANAGER = MsalConnectionManager(**agents_sdk_config) ADAPTER = CloudAdapter(connection_manager=CONNECTION_MANAGER) +ADAPTER.use(TranscriptLoggerMiddleware(ConsoleTranscriptLogger())) AUTHORIZATION = Authorization(STORAGE, CONNECTION_MANAGER, **agents_sdk_config) AGENT_APP = AgentApplication[TurnState]( diff --git a/test_samples/hosting_msteams/task-modules/src/agent.py b/test_samples/hosting_msteams/task-modules/src/agent.py index 9af431963..c4288035a 100644 --- a/test_samples/hosting_msteams/task-modules/src/agent.py +++ b/test_samples/hosting_msteams/task-modules/src/agent.py @@ -26,6 +26,10 @@ MessageFactory, TurnState, ) +from microsoft_agents.hosting.core.storage import ( + ConsoleTranscriptLogger, + TranscriptLoggerMiddleware, +) from microsoft_agents.hosting.msteams import TeamsAgentExtension from microsoft_agents.hosting.msteams.teams_turn_context import TeamsTurnContext @@ -43,6 +47,7 @@ STORAGE = MemoryStorage() CONNECTION_MANAGER = MsalConnectionManager(**agents_sdk_config) ADAPTER = CloudAdapter(connection_manager=CONNECTION_MANAGER) +ADAPTER.use(TranscriptLoggerMiddleware(ConsoleTranscriptLogger())) AUTHORIZATION = Authorization(STORAGE, CONNECTION_MANAGER, **agents_sdk_config) AGENT_APP = AgentApplication[TurnState]( From c98f7324c55c2ff24feb8b41a93b434fa101eeb6 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Tue, 30 Jun 2026 12:15:31 -0700 Subject: [PATCH 41/46] Addressing PR feedback --- .../microsoft_agents/hosting/msteams/_utils.py | 3 +++ .../hosting_msteams/message-extensions/pyproject.toml | 2 +- test_samples/hosting_msteams/task-modules/pyproject.toml | 2 +- tests/hosting_msteams/test_routing_integration.py | 4 ++-- 4 files changed, 7 insertions(+), 4 deletions(-) diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/_utils.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/_utils.py index 18aeeb94e..664731ab3 100644 --- a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/_utils.py +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/_utils.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + """Internal utility helpers for the Teams hosting layer.""" import re diff --git a/test_samples/hosting_msteams/message-extensions/pyproject.toml b/test_samples/hosting_msteams/message-extensions/pyproject.toml index def19721e..b01578412 100644 --- a/test_samples/hosting_msteams/message-extensions/pyproject.toml +++ b/test_samples/hosting_msteams/message-extensions/pyproject.toml @@ -14,7 +14,7 @@ dependencies = [ "microsoft-agents-hosting-core", "microsoft-agents-authentication-msal", "microsoft-agents-hosting-aiohttp", - "microsoft-agents-hosting-msteams @ file:../../../libraries/microsoft-agents-hosting-msteams", + "microsoft-agents-hosting-msteams", "python-dotenv", "aiohttp", ] diff --git a/test_samples/hosting_msteams/task-modules/pyproject.toml b/test_samples/hosting_msteams/task-modules/pyproject.toml index dd53dd182..1e815dfb7 100644 --- a/test_samples/hosting_msteams/task-modules/pyproject.toml +++ b/test_samples/hosting_msteams/task-modules/pyproject.toml @@ -14,7 +14,7 @@ dependencies = [ "microsoft-agents-hosting-core", "microsoft-agents-authentication-msal", "microsoft-agents-hosting-aiohttp", - "microsoft-agents-hosting-msteams @ file:../../../libraries/microsoft-agents-hosting-msteams", + "microsoft-agents-hosting-msteams", "python-dotenv", "aiohttp", ] diff --git a/tests/hosting_msteams/test_routing_integration.py b/tests/hosting_msteams/test_routing_integration.py index 6c4166266..a8fc602c8 100644 --- a/tests/hosting_msteams/test_routing_integration.py +++ b/tests/hosting_msteams/test_routing_integration.py @@ -17,7 +17,7 @@ import pytest -from microsoft_agents.activity import Activity, ActivityTypes +from microsoft_agents.activity import Activity, ActivityTypes, ResourceResponse from microsoft_agents.hosting.core import MemoryStorage, TurnContext from microsoft_agents.hosting.core.app import ( AgentApplication, @@ -46,7 +46,7 @@ def __init__(self): async def send_activities(self, context, activities): self.sent_activities.extend(activities) - return [None] * len(activities) + return [ResourceResponse()] * len(activities) def _make_app() -> "AgentApplication[TurnState]": From 10ec558a7cd0cdf0cb0da6817c6eb2aaabaee4e2 Mon Sep 17 00:00:00 2001 From: rodrigobr-msft Date: Tue, 30 Jun 2026 13:28:28 -0700 Subject: [PATCH 42/46] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../microsoft_agents/hosting/core/app/oauth/authorization.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/authorization.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/authorization.py index d769583dd..542b2866e 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/authorization.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/authorization.py @@ -57,8 +57,8 @@ def __init__( :param storage: The storage system to use for state management. :type storage: :class:`microsoft_agents.hosting.core.storage.Storage` - :param connections: The connection manager for OAuth providers. - :type connections: :class:`microsoft_agents.hosting.core.authorization.Connections` + :param connection_manager: The connection manager for OAuth providers. + :type connection_manager: :class:`microsoft_agents.hosting.core.authorization.Connections` :param auth_handlers: Configuration for OAuth providers. :type auth_handlers: dict[str, :class:`microsoft_agents.hosting.core.app.oauth.auth_handler.AuthHandler`], Optional :raises ValueError: When storage is None or no auth handlers provided. From b65805831996d4715dacb4c2aaa8ec1c327b7a30 Mon Sep 17 00:00:00 2001 From: rodrigobr-msft Date: Wed, 1 Jul 2026 09:49:57 -0700 Subject: [PATCH 43/46] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- tests/hosting_msteams/helpers.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/hosting_msteams/helpers.py b/tests/hosting_msteams/helpers.py index b6c2f62c1..0b5dbbff9 100644 --- a/tests/hosting_msteams/helpers.py +++ b/tests/hosting_msteams/helpers.py @@ -58,6 +58,7 @@ def _make_context( activity.members_added = members_added activity.members_removed = members_removed context.activity = activity + context.turn_state = {} context.send_activity = AsyncMock() mock_adapter = MagicMock() From f49f748bdcc52d37d1c2d8175b5f3343633e60cc Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Wed, 1 Jul 2026 10:17:16 -0700 Subject: [PATCH 44/46] Small fixes to typing annotations --- .../message_extension/message_extension.py | 27 ++++++++----------- .../message_extension/route_handlers.py | 4 +-- .../test_message_extensions.py | 8 ++++++ 3 files changed, 21 insertions(+), 18 deletions(-) diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/message_extension/message_extension.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/message_extension/message_extension.py index bb4c347d9..fa5e2d1f6 100644 --- a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/message_extension/message_extension.py +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/message_extension/message_extension.py @@ -41,8 +41,8 @@ SelectItemHandler, QueryLinkHandler, QueryUrlSettingHandler, - ConfigureSettingsHandler, CardButtonClickedHandler, + SettingHandler, ) @@ -537,25 +537,20 @@ async def __handler(context: TurnContext, state: StateT) -> None: return __call @overload - def setting( - self, handler: ConfigureSettingsHandler[StateT] - ) -> ConfigureSettingsHandler[StateT]: ... + def setting(self, handler: SettingHandler[StateT]) -> SettingHandler[StateT]: ... @overload def setting( - self, *, auth_handlers: Optional[list[str]] = ..., rank: RouteRank = ... - ) -> _RouteDecorator[ConfigureSettingsHandler[StateT]]: ... + self, *, auth_handlers: list[str] | None = ..., rank: RouteRank = ... + ) -> _RouteDecorator[SettingHandler[StateT]]: ... def setting( self, - handler: Optional[Callable] = None, + handler: SettingHandler[StateT] | None = None, *, - auth_handlers: Optional[list[str]] = None, + auth_handlers: list[str] | None = None, rank: RouteRank = RouteRank.DEFAULT, - ) -> ( - ConfigureSettingsHandler[StateT] - | _RouteDecorator[ConfigureSettingsHandler[StateT]] - ): + ) -> SettingHandler[StateT] | _RouteDecorator[SettingHandler[StateT]]: """Register a handler for composeExtension/setting invokes. :param handler: Optional handler receiving the context, state, and settings query payload. @@ -570,7 +565,7 @@ def __selector(context: TurnContext) -> bool: and context.activity.name == "composeExtension/setting" ) - def __call(func: Callable) -> Callable: + def __call(func: SettingHandler[StateT]) -> SettingHandler[StateT]: async def __handler(context: TurnContext, state: StateT) -> None: teams_context = TeamsTurnContext(context, self._app) query = MessagingExtensionQuery.model_validate( @@ -599,14 +594,14 @@ def card_button_clicked( @overload def card_button_clicked( - self, *, auth_handlers: Optional[list[str]] = ..., rank: RouteRank = ... + self, *, auth_handlers: list[str] | None = ..., rank: RouteRank = ... ) -> _RouteDecorator[CardButtonClickedHandler[StateT]]: ... def card_button_clicked( self, - handler: Optional[CardButtonClickedHandler[StateT]] = None, + handler: CardButtonClickedHandler[StateT] | None = None, *, - auth_handlers: Optional[list[str]] = None, + auth_handlers: list[str] | None = None, rank: RouteRank = RouteRank.DEFAULT, ) -> ( CardButtonClickedHandler[StateT] diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/message_extension/route_handlers.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/message_extension/route_handlers.py index 07627a787..5cac32c1b 100644 --- a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/message_extension/route_handlers.py +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/message_extension/route_handlers.py @@ -183,7 +183,7 @@ def __call__( ... -class ConfigureSettingsHandler(Protocol[_StateContra]): +class SettingHandler(Protocol[_StateContra]): """Protocol for a handler invoked on composeExtension/setting activities.""" def __call__( @@ -193,7 +193,7 @@ def __call__( query: MessagingExtensionQuery, /, ) -> Awaitable[MessagingExtensionResponse]: - """Handle a configure settings invoke. + """Handle a setting invoke. :param context: Teams-aware turn context. :param state: The current turn state. diff --git a/tests/hosting_msteams/test_message_extensions.py b/tests/hosting_msteams/test_message_extensions.py index b5474b972..ec2cf149b 100644 --- a/tests/hosting_msteams/test_message_extensions.py +++ b/tests/hosting_msteams/test_message_extensions.py @@ -24,6 +24,9 @@ MessagingExtensionResponse, ) from microsoft_agents.hosting.msteams import TeamsAgentExtension + from microsoft_agents.hosting.msteams.message_extension import ( + route_handlers as message_extension_route_handlers, + ) _PATCH = "microsoft_agents.hosting.msteams.message_extension.message_extension._send_invoke_response" @@ -468,6 +471,11 @@ def setup_method(self): self.app = _make_app() self.ext = TeamsAgentExtension(self.app) + def test_setting_handler_protocol_name(self): + assert ( + message_extension_route_handlers.SettingHandler.__name__ == "SettingHandler" + ) + def test_setting_selector(self): @self.ext.message_extensions.setting() async def handler(ctx, state, settings): ... From 4bd55eb8c45487af4ae3ff9a6f81e7067eada901 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Mon, 6 Jul 2026 12:45:21 -0700 Subject: [PATCH 45/46] Better sync of TeamsTurnContext --- .../hosting/msteams/teams_turn_context.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_turn_context.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_turn_context.py index 5f9f95f19..9685bfb6c 100644 --- a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_turn_context.py +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_turn_context.py @@ -36,7 +36,9 @@ def __init__(self, context: TurnContext, app: AgentApplication) -> None: """ super().__init__(context) self._app = app - self._turn_state.update(context.turn_state) + self._turn_state = context.turn_state + + self._original = context self._set_teams_activity() @@ -60,6 +62,18 @@ def _set_teams_activity(self) -> None: self._activity.__class__ = TeamsActivity self._teams_activity = cast(TeamsActivity, self._activity) + @property + def responded(self) -> bool: + return self._original.responded + + @responded.setter + def responded(self, value: bool): + self._original.responded = value + + @property + def streaming_response(self): + return self._original.streaming_response + @property def activity(self) -> TeamsActivity: """The current activity, typed as a :class:`TeamsActivity`. From 5bcc591c1293ee0a37c91014edea1315d723b5e6 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Mon, 6 Jul 2026 13:42:43 -0700 Subject: [PATCH 46/46] Replacing ChannelData with TeamInfo and ChannelInfo in route handlers --- .../hosting/msteams/_utils.py | 30 ++++++++- .../hosting/msteams/channel/channel.py | 14 ++--- .../hosting/msteams/channel/route_handlers.py | 8 +-- .../hosting/msteams/team/route_handlers.py | 6 +- .../hosting/msteams/team/team.py | 6 +- .../readme.md | 12 ++-- .../conversation-agent/src/agent.py | 23 ++++--- tests/hosting_msteams/test_channels.py | 63 ++++++++++++++++--- tests/hosting_msteams/test_team_lifecycle.py | 39 ++++++++++++ tests/hosting_msteams/test_utils.py | 48 +++++++++++++- 10 files changed, 206 insertions(+), 43 deletions(-) diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/_utils.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/_utils.py index 664731ab3..570e1889e 100644 --- a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/_utils.py +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/_utils.py @@ -9,7 +9,7 @@ from http import HTTPStatus -from microsoft_teams.api.models.channel_data import ChannelData +from microsoft_teams.api.models.channel_data import ChannelData, ChannelInfo, TeamInfo from microsoft_agents.activity import ( Activity, @@ -49,6 +49,34 @@ def _get_channel_data(activity: Activity) -> ChannelData: return channel_data +def _get_channel_info(activity: Activity) -> ChannelInfo: + """Extract Teams channel info from the activity's channel_data. + + :param activity: The current activity. + :return: The parsed :class:`ChannelInfo` from channel_data. + :raises ValueError: If channel_data or channel_data.channel is absent. + :raises pydantic.ValidationError: If channel_data cannot be deserialized. + """ + channel_data = _get_channel_data(activity) + if channel_data.channel is None: + raise ValueError("channel_data.channel is required") + return channel_data.channel + + +def _get_team_info(activity: Activity) -> TeamInfo: + """Extract Teams team info from the activity's channel_data. + + :param activity: The current activity. + :return: The parsed :class:`TeamInfo` from channel_data. + :raises ValueError: If channel_data or channel_data.team is absent. + :raises pydantic.ValidationError: If channel_data cannot be deserialized. + """ + channel_data = _get_channel_data(activity) + if channel_data.team is None: + raise ValueError("channel_data.team is required") + return channel_data.team + + def _match_selector(selector: CommandSelector, value: str | None) -> bool: """Return True if *value* matches *selector*. diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/channel/channel.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/channel/channel.py index a3f91d1e4..dac06cab3 100644 --- a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/channel/channel.py +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/channel/channel.py @@ -19,8 +19,8 @@ StateT, ) from microsoft_agents.hosting.msteams._utils import ( - _get_channel_data, _get_channel_event_type, + _get_channel_info, ) from .route_handlers import ChannelUpdateHandler @@ -68,8 +68,8 @@ def __selector(context: TurnContext) -> bool: def __call(func: ChannelUpdateHandler[StateT]) -> ChannelUpdateHandler[StateT]: async def __handler(context: TurnContext, state: StateT) -> None: teams_context = TeamsTurnContext(context, self._app) - channel_data = _get_channel_data(teams_context.activity) - await func(teams_context, state, channel_data) + channel_info = _get_channel_info(teams_context.activity) + await func(teams_context, state, channel_info) self._app.add_route( __selector, __handler, rank=rank, auth_handlers=auth_handlers @@ -331,8 +331,8 @@ def __selector(context: TurnContext) -> bool: def __call(func: ChannelUpdateHandler[StateT]) -> ChannelUpdateHandler[StateT]: async def __func(context: TurnContext, state: StateT) -> None: teams_context = TeamsTurnContext(context, self._app) - channel_data = _get_channel_data(teams_context.activity) - await func(teams_context, state, channel_data) + channel_info = _get_channel_info(teams_context.activity) + await func(teams_context, state, channel_info) self._app.add_route( __selector, __func, rank=rank, auth_handlers=auth_handlers @@ -379,8 +379,8 @@ def __selector(context: TurnContext) -> bool: def __call(func: ChannelUpdateHandler[StateT]) -> ChannelUpdateHandler[StateT]: async def __func(context: TurnContext, state: StateT) -> None: teams_context = TeamsTurnContext(context, self._app) - channel_data = _get_channel_data(teams_context.activity) - await func(teams_context, state, channel_data) + channel_info = _get_channel_info(teams_context.activity) + await func(teams_context, state, channel_info) self._app.add_route( __selector, __func, rank=rank, auth_handlers=auth_handlers diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/channel/route_handlers.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/channel/route_handlers.py index e018c45c8..54dd30606 100644 --- a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/channel/route_handlers.py +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/channel/route_handlers.py @@ -5,7 +5,7 @@ from typing import Awaitable, Protocol -from microsoft_teams.api.models.channel_data import ChannelData +from microsoft_teams.api.models.channel_data import ChannelInfo from microsoft_agents.hosting.msteams.teams_turn_context import TeamsTurnContext from microsoft_agents.hosting.msteams.type_defs import _StateContra @@ -15,21 +15,21 @@ class ChannelUpdateHandler(Protocol[_StateContra]): """Protocol for a handler invoked on Teams channel update events. Handlers receive the Teams turn context, the current turn state, and the - parsed :class:`ChannelData` from the activity. + parsed :class:`ChannelInfo` from the activity's channel data. """ def __call__( self, context: TeamsTurnContext, state: _StateContra, - data: ChannelData, + data: ChannelInfo, /, ) -> Awaitable[None]: """Handle a channel update event. :param context: Teams-aware turn context. :param state: The current turn state. - :param data: Parsed channel data from the incoming activity. + :param data: Parsed channel info from the incoming activity. :return: An awaitable that completes when the handler finishes. """ ... diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/team/route_handlers.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/team/route_handlers.py index e8f0ad376..aa89f387c 100644 --- a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/team/route_handlers.py +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/team/route_handlers.py @@ -5,7 +5,7 @@ from typing import Awaitable, Protocol -from microsoft_teams.api.models.channel_data import ChannelData +from microsoft_teams.api.models.channel_data import TeamInfo from microsoft_agents.hosting.msteams.teams_turn_context import TeamsTurnContext from microsoft_agents.hosting.msteams.type_defs import _StateContra @@ -18,13 +18,13 @@ def __call__( self, context: TeamsTurnContext, state: _StateContra, - data: ChannelData, + data: TeamInfo, /, ) -> Awaitable[None]: """Handle a team update event. :param context: Teams-aware turn context. :param state: The current turn state. - :param data: Parsed team data from the incoming activity's channel_data. + :param data: Parsed team info from the incoming activity's channel data. """ ... diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/team/team.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/team/team.py index 723ec179d..a95da7381 100644 --- a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/team/team.py +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/team/team.py @@ -19,8 +19,8 @@ StateT, ) from microsoft_agents.hosting.msteams._utils import ( - _get_channel_data, _get_channel_event_type, + _get_team_info, ) from .route_handlers import TeamUpdateHandler @@ -75,8 +75,8 @@ def __selector(context: TurnContext) -> bool: def __call(func: TeamUpdateHandler[StateT]) -> TeamUpdateHandler[StateT]: async def __handler(context: TurnContext, state: StateT) -> None: teams_context = TeamsTurnContext(context, self._app) - team_data = _get_channel_data(teams_context.activity) - await func(teams_context, state, team_data) + team_info = _get_team_info(teams_context.activity) + await func(teams_context, state, team_info) self._app.add_route( __selector, __handler, rank=rank, auth_handlers=auth_handlers diff --git a/libraries/microsoft-agents-hosting-msteams/readme.md b/libraries/microsoft-agents-hosting-msteams/readme.md index ee1838104..f4b2dfb02 100644 --- a/libraries/microsoft-agents-hosting-msteams/readme.md +++ b/libraries/microsoft-agents-hosting-msteams/readme.md @@ -301,14 +301,14 @@ async def on_participants_leave(context: TeamsTurnContext, state, details: Meeti ### Channel Events ```python -from microsoft_teams.api.models import ChannelData +from microsoft_teams.api.models import ChannelInfo @teams.channels.created -async def on_channel_created(context: TeamsTurnContext, state, data: ChannelData): - print(f"New channel: {data.channel.name}") +async def on_channel_created(context: TeamsTurnContext, state, channel: ChannelInfo): + print(f"New channel: {channel.name}") @teams.channels.renamed -async def on_channel_renamed(context: TeamsTurnContext, state, data: ChannelData): +async def on_channel_renamed(context: TeamsTurnContext, state, channel: ChannelInfo): ... # Also available: deleted, shared, unshared, restored, members_added, members_removed @@ -319,8 +319,10 @@ async def on_channel_renamed(context: TeamsTurnContext, state, data: ChannelData ### Team Events ```python +from microsoft_teams.api.models import TeamInfo + @teams.teams.renamed -async def on_team_renamed(context: TeamsTurnContext, state, data: ChannelData): ... +async def on_team_renamed(context: TeamsTurnContext, state, team: TeamInfo): ... # Also available: archived, unarchived, deleted, hard_deleted, restored ``` diff --git a/test_samples/hosting_msteams/conversation-agent/src/agent.py b/test_samples/hosting_msteams/conversation-agent/src/agent.py index 0e6f0bddc..119dc1133 100644 --- a/test_samples/hosting_msteams/conversation-agent/src/agent.py +++ b/test_samples/hosting_msteams/conversation-agent/src/agent.py @@ -16,7 +16,12 @@ from dotenv import load_dotenv -from microsoft_teams.api.models import ChannelData, TeamsChannelAccount +from microsoft_teams.api.models import ( + ChannelData, + ChannelInfo, + TeamInfo, + TeamsChannelAccount, +) from microsoft_agents.activity import ( ActionTypes, @@ -230,27 +235,27 @@ async def on_members_removed(context: TeamsTurnContext, state: TurnState) -> Non @teams.channels.created async def on_channel_created( - context: TeamsTurnContext, state: TurnState, channel_data: ChannelData + context: TeamsTurnContext, state: TurnState, channel_info: ChannelInfo ) -> None: - name = channel_data.channel.name if channel_data.channel else "Unknown" + name = channel_info.name or "Unknown" card = HeroCard(text=f"{name} is the Channel created") await context.send_activity(MessageFactory.attachment(CardFactory.hero_card(card))) @teams.channels.renamed async def on_channel_renamed( - context: TeamsTurnContext, state: TurnState, channel_data: ChannelData + context: TeamsTurnContext, state: TurnState, channel_info: ChannelInfo ) -> None: - name = channel_data.channel.name if channel_data.channel else "Unknown" + name = channel_info.name or "Unknown" card = HeroCard(text=f"{name} is the new Channel name") await context.send_activity(MessageFactory.attachment(CardFactory.hero_card(card))) @teams.channels.deleted async def on_channel_deleted( - context: TeamsTurnContext, state: TurnState, channel_data: ChannelData + context: TeamsTurnContext, state: TurnState, channel_info: ChannelInfo ) -> None: - name = channel_data.channel.name if channel_data.channel else "Unknown" + name = channel_info.name or "Unknown" card = HeroCard(text=f"{name} is the Channel deleted") await context.send_activity(MessageFactory.attachment(CardFactory.hero_card(card))) @@ -260,9 +265,9 @@ async def on_channel_deleted( @teams.teams.renamed async def on_team_renamed( - context: TeamsTurnContext, state: TurnState, channel_data: ChannelData + context: TeamsTurnContext, state: TurnState, team_info: TeamInfo ) -> None: - name = channel_data.team.name if channel_data.team else "Unknown" + name = team_info.name or "Unknown" card = HeroCard(text=f"{name} is the new Team name") await context.send_activity(MessageFactory.attachment(CardFactory.hero_card(card))) diff --git a/tests/hosting_msteams/test_channels.py b/tests/hosting_msteams/test_channels.py index d1887d4f0..b8e8b81b5 100644 --- a/tests/hosting_msteams/test_channels.py +++ b/tests/hosting_msteams/test_channels.py @@ -16,6 +16,8 @@ ) if is_supported_version: + from microsoft_teams.api.models.channel_data import ChannelInfo + from microsoft_agents.hosting.msteams import TeamsAgentExtension @@ -182,6 +184,28 @@ async def handler(ctx, state, data): ... assert self.app._routes[0]["is_invoke"] is False + @pytest.mark.asyncio + async def test_created_handler_passes_channel_info(self): + user_handler = AsyncMock() + + @self.ext.channels.created() + async def handler(ctx, state, data): + await user_handler(ctx, state, data) + + route_handler = self.app._routes[0]["handler"] + ctx = _make_context( + ActivityTypes.conversation_update, + channel_data={ + "eventType": "channelCreated", + "channel": {"id": "c1"}, + }, + ) + + await route_handler(ctx, MagicMock()) + + assert isinstance(user_handler.call_args[0][2], ChannelInfo) + assert user_handler.call_args[0][2].id == "c1" + class TestChannelMembers: @@ -262,9 +286,7 @@ async def handler(ctx, state, data): ... ) @pytest.mark.asyncio - async def test_members_added_handler_passes_channel_data(self): - from microsoft_teams.api.models.channel_data import ChannelData - + async def test_members_added_handler_passes_channel_info(self): user_handler = AsyncMock() @self.ext.channels.members_added() @@ -276,15 +298,17 @@ async def handler(ctx, state, data): ctx = _make_context( ActivityTypes.conversation_update, members_added=[member], - channel_data={"eventType": "membersAdded"}, + channel_data={ + "eventType": "membersAdded", + "channel": {"id": "c1"}, + }, ) await route_handler(ctx, MagicMock()) - assert isinstance(user_handler.call_args[0][2], ChannelData) + assert isinstance(user_handler.call_args[0][2], ChannelInfo) + assert user_handler.call_args[0][2].id == "c1" @pytest.mark.asyncio - async def test_members_removed_handler_passes_channel_data(self): - from microsoft_teams.api.models.channel_data import ChannelData - + async def test_members_removed_handler_passes_channel_info(self): user_handler = AsyncMock() @self.ext.channels.members_removed() @@ -296,10 +320,14 @@ async def handler(ctx, state, data): ctx = _make_context( ActivityTypes.conversation_update, members_removed=[member], - channel_data={"eventType": "membersRemoved"}, + channel_data={ + "eventType": "membersRemoved", + "channel": {"id": "c1"}, + }, ) await route_handler(ctx, MagicMock()) - assert isinstance(user_handler.call_args[0][2], ChannelData) + assert isinstance(user_handler.call_args[0][2], ChannelInfo) + assert user_handler.call_args[0][2].id == "c1" @pytest.mark.asyncio async def test_members_added_raises_when_channel_data_missing(self): @@ -312,6 +340,21 @@ async def handler(ctx, state, data): ... with pytest.raises(ValueError, match="channel_data"): await route_handler(ctx, MagicMock()) + @pytest.mark.asyncio + async def test_members_added_raises_when_channel_info_missing(self): + @self.ext.channels.members_added() + async def handler(ctx, state, data): ... + + route_handler = self.app._routes[0]["handler"] + member = MagicMock() + ctx = _make_context( + ActivityTypes.conversation_update, + members_added=[member], + channel_data={"eventType": "membersAdded"}, + ) + with pytest.raises(ValueError, match="channel_data.channel"): + await route_handler(ctx, MagicMock()) + class TestChannelEvent: diff --git a/tests/hosting_msteams/test_team_lifecycle.py b/tests/hosting_msteams/test_team_lifecycle.py index dc1a64493..903d85261 100644 --- a/tests/hosting_msteams/test_team_lifecycle.py +++ b/tests/hosting_msteams/test_team_lifecycle.py @@ -4,6 +4,7 @@ """Tests for TeamsAgentExtension.teams (team lifecycle conversation update events).""" import pytest +from unittest.mock import AsyncMock, MagicMock from microsoft_agents.activity import ActivityTypes @@ -15,6 +16,8 @@ ) if is_supported_version: + from microsoft_teams.api.models.channel_data import TeamInfo + from microsoft_agents.hosting.msteams import TeamsAgentExtension @@ -171,6 +174,42 @@ async def handler(ctx, state, data): ... assert self.app._routes[0]["is_invoke"] is False + @pytest.mark.asyncio + async def test_archived_handler_passes_team_info(self): + user_handler = AsyncMock() + + @self.ext.teams.archived() + async def handler(ctx, state, data): + await user_handler(ctx, state, data) + + route_handler = self.app._routes[0]["handler"] + ctx = _make_context( + ActivityTypes.conversation_update, + channel_data={ + "eventType": "teamArchived", + "team": {"id": "t1"}, + }, + ) + + await route_handler(ctx, MagicMock()) + + assert isinstance(user_handler.call_args[0][2], TeamInfo) + assert user_handler.call_args[0][2].id == "t1" + + @pytest.mark.asyncio + async def test_archived_raises_when_team_info_missing(self): + @self.ext.teams.archived() + async def handler(ctx, state, data): ... + + route_handler = self.app._routes[0]["handler"] + ctx = _make_context( + ActivityTypes.conversation_update, + channel_data={"eventType": "teamArchived"}, + ) + + with pytest.raises(ValueError, match="channel_data.team"): + await route_handler(ctx, MagicMock()) + class TestTeamEvent: diff --git a/tests/hosting_msteams/test_utils.py b/tests/hosting_msteams/test_utils.py index fd6290ac6..78320f530 100644 --- a/tests/hosting_msteams/test_utils.py +++ b/tests/hosting_msteams/test_utils.py @@ -16,10 +16,16 @@ if is_supported_version: from microsoft_agents.activity import Activity, ActivityTypes - from microsoft_teams.api.models.channel_data import ChannelData + from microsoft_teams.api.models.channel_data import ( + ChannelData, + ChannelInfo, + TeamInfo, + ) from microsoft_agents.hosting.msteams._utils import ( _get_channel_data, _get_channel_event_type, + _get_channel_info, + _get_team_info, _match_selector, _send_invoke_response, ) @@ -119,6 +125,46 @@ def test_model_validates_camel_case_dict_keys(self): assert isinstance(result, ChannelData) +class TestGetChannelInfo: + + def test_returns_channel_info_from_channel_data(self): + ctx = _make_context( + ActivityTypes.conversation_update, + channel_data={"channel": {"id": "c1"}}, + ) + result = _get_channel_info(ctx.activity) + assert isinstance(result, ChannelInfo) + assert result.id == "c1" + + def test_raises_when_channel_info_is_missing(self): + ctx = _make_context( + ActivityTypes.conversation_update, + channel_data={"team": {"id": "t1"}}, + ) + with pytest.raises(ValueError, match="channel_data.channel"): + _get_channel_info(ctx.activity) + + +class TestGetTeamInfo: + + def test_returns_team_info_from_channel_data(self): + ctx = _make_context( + ActivityTypes.conversation_update, + channel_data={"team": {"id": "t1"}}, + ) + result = _get_team_info(ctx.activity) + assert isinstance(result, TeamInfo) + assert result.id == "t1" + + def test_raises_when_team_info_is_missing(self): + ctx = _make_context( + ActivityTypes.conversation_update, + channel_data={"channel": {"id": "c1"}}, + ) + with pytest.raises(ValueError, match="channel_data.team"): + _get_team_info(ctx.activity) + + class TestSendInvokeResponse: @pytest.mark.asyncio
Version