From ef8b33ab0a7044c698a5aea0a1e93f829f3dcc82 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Wed, 14 Oct 2026 10:58:59 -0700 Subject: [PATCH 1/5] Refactoring existing selector logic and allowing specification of channel id for various route hooks --- .../hosting/core/app/_selector.py | 147 ++++++++++++ .../hosting/core/app/agent_application.py | 144 ++++++------ .../hosting/msteams/config/config.py | 8 +- .../hosting/msteams/message/message.py | 8 +- .../message_extension/message_extension.py | 37 +--- .../hosting/msteams/teams_agent_extension.py | 32 ++- test_samples/README.md | 209 ++++++++++-------- .../app/test_agent_application_routes.py | 149 ++++++++++++- tests/hosting_core/app/test_selector.py | 153 +++++++++++++ 9 files changed, 676 insertions(+), 211 deletions(-) create mode 100644 libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/_selector.py create mode 100644 tests/hosting_core/app/test_selector.py diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/_selector.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/_selector.py new file mode 100644 index 000000000..ad75e5c83 --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/_selector.py @@ -0,0 +1,147 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import re + + +from microsoft_agents.activity import ActivityTypes, ChannelId +from microsoft_agents.hosting.core import TurnContext +from ._type_defs import RouteSelector + + +def _text_selector( + select: str | re.Pattern[str] | list[str | re.Pattern[str]], +) -> RouteSelector: + """Checks if the given text matches the selection criteria. + + :param select: A string, regex pattern, or a list of strings/regex patterns to match against. + :return: True if the text matches any of the selection criteria, False otherwise. + """ + + def __selector(context: TurnContext) -> bool: + """Selector function that checks if the given text matches the selection criteria.""" + text = context.activity.text or "" + if isinstance(select, list): + for item in select: + if isinstance(item, re.Pattern): + if re.fullmatch(item, text) is not None: + return True + elif text == item: + return True + return False + + if isinstance(select, re.Pattern): + return re.fullmatch(select, text) is not None + + return text == select + + return __selector + + +def _invoke_event_selector(event_name: str) -> RouteSelector: + """Creates a route selector that matches the given invoke event name. + + :param event_name: The name of the invoke event to match against. + :return: A route selector function that matches the specified invoke event name. + """ + + def __selector(context: TurnContext) -> bool: + """Selector function that checks if the given invoke event name matches the selection criteria.""" + if context.activity.name is None: + return False + return ( + context.activity.type == ActivityTypes.invoke + and context.activity.name == event_name + ) + + return __selector + + +def _activity_type_selector(activity_type: str | list[str]) -> RouteSelector: + """Creates a route selector that matches the given activity type. + + :param activity_type: The activity type or list of activity types to match against. + :return: A route selector function that matches the specified activity type. + """ + if isinstance(activity_type, list): + return lambda context: context.activity.type in activity_type + return lambda context: context.activity.type == activity_type + + +def _channel_selector(channel_id: str | ChannelId | None) -> RouteSelector: + """Creates a route selector that matches the given channel ID. + + :param channel_id: The channel ID to match against. + :return: A route selector function that matches the specified channel ID. + """ + + norm_channel_id = ChannelId(channel_id) if channel_id is not None else None + + if norm_channel_id is None: + return lambda context: True + else: + + def __selector(context: TurnContext) -> bool: + """Selector function that checks if the given channel ID matches the selection criteria.""" + if context.activity.channel_id is None: + return False + if norm_channel_id.sub_channel: + # if passed in a sub-channel, the incoming activity must also come from + # the sub-channel + return context.activity.channel_id == norm_channel_id + else: + # if passed in only a parent channel, only check the parent channel of + # the incoming activity + return ( + ChannelId.get_channel(context.activity.channel_id) + == norm_channel_id.channel + ) + + return __selector + + +def _all_selector(*selectors: RouteSelector) -> RouteSelector: + """Combines multiple route selectors into one that only passes if all selectors pass. + + :param selectors: A variable number of route selector functions to combine. + :return: A new route selector function that only passes if all provided selectors pass. + """ + + def selector(context: TurnContext) -> bool: + return all(sel(context) for sel in selectors) + + return selector + + +class _SimpleRouteSelector: + """A simple route selector that combines multiple selection criteria such as channel ID, activity type, and text pattern.""" + + def __init__( + self, + *, + channel_id: str | ChannelId | None = None, + activity_type: str | list[str] | None = None, + text_pattern: str | re.Pattern[str] | list[str | re.Pattern[str]] | None = None, + ) -> None: + """Initializes a simple route selector with optional channel ID, activity type, and text pattern. + + :param channel_id: The channel ID to match against, or None to match any channel. + :param activity_type: The activity type to match against, or None to match any activity type. + :param text_pattern: A text pattern or list of patterns to match against the activity text, or None to match any text. + """ + + self._selectors = [] + if channel_id is not None: + self._selectors.append(_channel_selector(channel_id)) + if activity_type is not None: + self._selectors.append(_activity_type_selector(activity_type)) + if text_pattern is not None: + self._selectors.append(_text_selector(text_pattern)) + + def __call__(self, context: TurnContext) -> bool: + """Calls the route selector with the given context. + + :param context: The turn context to evaluate. + :return: True if all selectors pass for the given context, False otherwise. + """ + return all(selector(context) for selector in self._selectors) 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 248caba1a..e5b6a4b2d 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 @@ -23,12 +23,12 @@ TypeVar, cast, overload, - Optional, ) from microsoft_agents.activity import ( Activity, ActivityTypes, + ChannelId, ConversationUpdateTypes, MessageReactionTypes, MessageUpdateTypes, @@ -58,6 +58,12 @@ from ._routes import _RouteList, _Route, RouteRank, _agentic_selector from .proactive import Proactive from .adaptive_card import AdaptiveCard +from ._selector import ( + _SimpleRouteSelector, + _invoke_event_selector, + _channel_selector, + _all_selector, +) logger = logging.getLogger(__name__) @@ -103,11 +109,11 @@ def __init__( Creates a new AgentApplication instance. :param options: Configuration options for the application. - :type options: Optional[:class:`microsoft_agents.hosting.core.app.app_options.ApplicationOptions`] + :type options: :class:`microsoft_agents.hosting.core.app.app_options.ApplicationOptions` | None :param connection_manager: OAuth connection manager. - :type connection_manager: Optional[:class:`microsoft_agents.hosting.core.authorization.Connections`] + :type connection_manager: :class:`microsoft_agents.hosting.core.authorization.Connections` | None :param authorization: Authorization manager for handling authentication flows. - :type authorization: Optional[:class:`microsoft_agents.hosting.core.app.oauth.Authorization`] + :type authorization: :class:`microsoft_agents.hosting.core.app.oauth.Authorization` | None :param kwargs: Additional configuration parameters. :type kwargs: Any """ @@ -346,7 +352,7 @@ def add_route( is_invoke: bool = False, is_agentic: bool = False, rank: RouteRank = RouteRank.DEFAULT, - auth_handlers: Optional[list[str]] = None, + auth_handlers: list[str] | None = None, ) -> None: """Adds a new route to the application. @@ -364,7 +370,7 @@ def add_route( :param rank: The rank of the route, defaults to RouteRank.DEFAULT :type rank: :class:`microsoft_agents.hosting.core.app._routes.route_rank.RouteRank`, Optional :param auth_handlers: A list of authentication handler IDs to use for this route, defaults to None - :type auth_handlers: Optional[list[str]], Optional + :type auth_handlers: list[str] | None, Optional :raises ApplicationError: If the selector or handler are not valid. """ if not selector or not handler: @@ -385,7 +391,8 @@ def activity( self, activity_type: str | ActivityTypes | list[str | ActivityTypes], *, - auth_handlers: Optional[list[str]] = None, + channel_id: str | ChannelId | None = None, + auth_handlers: list[str] | None = None, **kwargs, ) -> _RouteDecorator[RouteHandler[StateT]]: """ @@ -402,20 +409,19 @@ async def on_event(context: TurnContext, state: TurnState): :param activity_type: Activity type or collection of types that should trigger the handler. :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]] + :type auth_handlers: list[str] | None :param kwargs: Additional route configuration passed to :meth:`microsoft_agents.hosting.core.AgentApplication.add_route`. """ - def __selector(context: TurnContext): - if isinstance(activity_type, list): - return context.activity.type in activity_type - return activity_type == context.activity.type + selector = _SimpleRouteSelector( + activity_type=activity_type, channel_id=channel_id + ) def __call(func: RouteHandler[StateT]) -> RouteHandler[StateT]: logger.debug( f"Registering activity handler for route handler {func.__name__} with type: {activity_type} with auth handlers: {auth_handlers}" ) - self.add_route(__selector, func, auth_handlers=auth_handlers, **kwargs) + self.add_route(selector, func, auth_handlers=auth_handlers, **kwargs) return func return __call @@ -424,7 +430,8 @@ def message( self, select: str | re.Pattern[str] | list[str | re.Pattern[str]], *, - auth_handlers: Optional[list[str]] = None, + channel_id: str | ChannelId | None = None, + auth_handlers: list[str] | None = None, **kwargs, ) -> _RouteDecorator[RouteHandler[StateT]]: """ @@ -440,36 +447,23 @@ async def on_hi_message(context: TurnContext, state: TurnState): :param select: Literal text, compiled regex, or list of either used to match the incoming message. :type select: str | re.Pattern[str] | list[str | re.Pattern[str]] + :param channel_id: Optional channel ID to match the incoming message. + :type channel_id: str | ChannelId | None :param auth_handlers: Optional list of authorization handler IDs for the route. - :type auth_handlers: Optional[list[str]] + :type auth_handlers: list[str] | None :param kwargs: Additional route configuration passed to :meth:`microsoft_agents.hosting.core.AgentApplication.add_route`. """ - - def __selector(context: TurnContext): - if context.activity.type != ActivityTypes.message: - return False - - text = context.activity.text if context.activity.text else "" - - if isinstance(select, list): - for item in select: - if isinstance(item, re.Pattern): - if re.fullmatch(item, text) is not None: - return True - elif text == item: - return True - return False - - if isinstance(select, re.Pattern): - return re.fullmatch(select, text) is not None - - return text == select + selector = _SimpleRouteSelector( + channel_id=channel_id, + activity_type=ActivityTypes.message, + text_pattern=select, + ) def __call(func: RouteHandler[StateT]) -> RouteHandler[StateT]: logger.debug( f"Registering message handler for route handler {func.__name__} with select: {select} with auth handlers: {auth_handlers}" ) - self.add_route(__selector, func, auth_handlers=auth_handlers, **kwargs) + self.add_route(selector, func, auth_handlers=auth_handlers, **kwargs) return func return __call @@ -478,7 +472,8 @@ def conversation_update( self, type: ConversationUpdateTypes | str, *, - auth_handlers: Optional[list[str]] = None, + channel_id: str | ChannelId | None = None, + auth_handlers: list[str] | None = None, **kwargs, ) -> _RouteDecorator[RouteHandler[StateT]]: """ @@ -494,15 +489,22 @@ async def on_channel_created(context: TurnContext, state: TurnState): :param type: Conversation update category that must match the incoming activity. :type type: microsoft_agents.activity.ConversationUpdateTypes | str + :param channel_id: Optional channel ID to match the incoming message. + :type channel_id: str | ChannelId | None :param auth_handlers: Optional list of authorization handler IDs for the route. - :type auth_handlers: Optional[list[str]] + :type auth_handlers: list[str] | None :param kwargs: Additional route configuration passed to :meth:`microsoft_agents.hosting.core.AgentApplication.add_route`. """ update_type = type.value if isinstance(type, ConversationUpdateTypes) else type + _simple_selector = _SimpleRouteSelector( + activity_type=ActivityTypes.conversation_update, + channel_id=channel_id, + ) + def __selector(context: TurnContext): - if context.activity.type != ActivityTypes.conversation_update: + if not _simple_selector(context): return False if update_type == "membersAdded": @@ -534,7 +536,8 @@ def message_reaction( self, type: MessageReactionTypes | str, *, - auth_handlers: Optional[list[str]] = None, + channel_id: str | ChannelId | None = None, + auth_handlers: list[str] | None = None, **kwargs, ) -> _RouteDecorator[RouteHandler[StateT]]: """ @@ -550,15 +553,22 @@ async def on_reactions_added(context: TurnContext, state: TurnState): :param type: Reaction category that must match the incoming activity. :type type: microsoft_agents.activity.MessageReactionTypes | str + :param channel_id: Optional channel ID to match the incoming message. + :type channel_id: str | ChannelId | None :param auth_handlers: Optional list of authorization handler IDs for the route. - :type auth_handlers: Optional[list[str]] + :type auth_handlers: list[str] | None :param kwargs: Additional route configuration passed to :meth:`microsoft_agents.hosting.core.AgentApplication.add_route`. """ reaction_type = type.value if isinstance(type, MessageReactionTypes) else type + _simple_selector = _SimpleRouteSelector( + channel_id=channel_id, + activity_type=ActivityTypes.message_reaction, + ) + def __selector(context: TurnContext): - if context.activity.type != ActivityTypes.message_reaction: + if not _simple_selector(context): return False if reaction_type == "reactionsAdded": @@ -586,7 +596,8 @@ def message_update( self, type: MessageUpdateTypes | str, *, - auth_handlers: Optional[list[str]] = None, + channel_id: str | ChannelId | None = None, + auth_handlers: list[str] | None = None, **kwargs, ) -> _RouteDecorator[RouteHandler[StateT]]: """ @@ -602,14 +613,21 @@ async def on_edit_message(context: TurnContext, state: TurnState): :param type: Message update category that must match the incoming activity. :type type: microsoft_agents.activity.MessageUpdateTypes | str + :param channel_id: Optional channel ID to filter the incoming activities. + :type channel_id: str | ChannelId | None :param auth_handlers: Optional list of authorization handler IDs for the route. - :type auth_handlers: Optional[list[str]] + :type auth_handlers: list[str] | None :param kwargs: Additional route configuration passed to :meth:`microsoft_agents.hosting.core.AgentApplication.add_route`. """ update_type = type.value if isinstance(type, MessageUpdateTypes) else type + check_channel = _channel_selector(channel_id) + def __selector(context: TurnContext): + if not check_channel(context): + return False + if update_type == "editMessage": if ( context.activity.type == ActivityTypes.message_update @@ -652,7 +670,7 @@ def handoff( self, func: HandoffHandler[StateT], *, - auth_handlers: Optional[list[str]] = None, + auth_handlers: list[str] | None = None, **kwargs, ) -> HandoffHandler[StateT]: ... @@ -660,15 +678,16 @@ def handoff( def handoff( self, *, - auth_handlers: Optional[list[str]] = None, + auth_handlers: list[str] | None = None, **kwargs: Any, ) -> _RouteDecorator[HandoffHandler[StateT]]: ... def handoff( self, - func: Optional[HandoffHandler[StateT]] = None, + func: HandoffHandler[StateT] | None = None, *, - auth_handlers: Optional[list[str]] = None, + channel_id: str | ChannelId | None = None, + auth_handlers: list[str] | None = None, **kwargs, ) -> HandoffHandler[StateT] | _RouteDecorator[HandoffHandler[StateT]]: """ @@ -682,17 +701,18 @@ async def on_handoff(context: TurnContext, state: TurnState, continuation: str): print(continuation) :param func: Optional handler to register directly without using decorator syntax. - :type func: Optional[HandoffHandler[StateT]] + :type func: HandoffHandler[StateT] | None + :param channel_id: Optional channel ID to filter the incoming activities. + :type channel_id: str | ChannelId | None :param auth_handlers: Optional list of authorization handler IDs for the route. - :type auth_handlers: Optional[list[str]] + :type auth_handlers: list[str] | None :param kwargs: Additional route configuration passed to :meth:`microsoft_agents.hosting.core.AgentApplication.add_route`. """ - def __selector(context: TurnContext) -> bool: - return ( - context.activity.type == ActivityTypes.invoke - and context.activity.name == "handoff/action" - ) + selector = _all_selector( + _invoke_event_selector("handoff/action"), + _channel_selector(channel_id), + ) def __call( func: HandoffHandler[StateT], @@ -716,7 +736,7 @@ async def __handler(context: TurnContext, state: StateT): f"Registering handoff handler for route handler {func.__name__} with auth handlers: {auth_handlers}" ) - self.add_route(__selector, __handler, auth_handlers=auth_handlers, **kwargs) + self.add_route(selector, __handler, auth_handlers=auth_handlers, **kwargs) return func if func is not None: @@ -724,8 +744,8 @@ async def __handler(context: TurnContext, state: StateT): return __call def on_sign_in_success( - self, func: Callable[[TurnContext, StateT, Optional[str]], Awaitable[None]] - ) -> Callable[[TurnContext, StateT, Optional[str]], Awaitable[None]]: + self, func: Callable[[TurnContext, StateT, str | None], Awaitable[None]] + ) -> Callable[[TurnContext, StateT, str | None], Awaitable[None]]: """ Register a callback that executes when a user successfully signs in. @@ -737,7 +757,7 @@ async def sign_in_success(context: TurnContext, state: TurnState, connection_id: print("sign-in succeeded") :param func: Callable that handles the sign-in success event. - :type func: Callable[[TurnContext, StateT, Optional[str]], Awaitable[None]] + :type func: Callable[[TurnContext, StateT, str | None], Awaitable[None]] :raises ApplicationError: If authorization services are not configured. """ @@ -758,8 +778,8 @@ async def sign_in_success(context: TurnContext, state: TurnState, connection_id: return func def on_sign_in_failure( - self, func: Callable[[TurnContext, StateT, Optional[str]], Awaitable[None]] - ) -> Callable[[TurnContext, StateT, Optional[str]], Awaitable[None]]: + self, func: Callable[[TurnContext, StateT, str | None], Awaitable[None]] + ) -> Callable[[TurnContext, StateT, str | None], Awaitable[None]]: """ Register a callback that executes when a user fails to sign in. @@ -771,7 +791,7 @@ async def sign_in_failure(context: TurnContext, state: TurnState, connection_id: print("sign-in failed") :param func: Callable that handles the sign-in failure event. - :type func: Callable[[TurnContext, StateT, Optional[str]], Awaitable[None]] + :type func: Callable[[TurnContext, StateT, str | None], Awaitable[None]] :raises ApplicationError: If authorization services are not configured. """ 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 8f55e9f19..b840f7ebf 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 @@ -5,7 +5,6 @@ from typing import Generic, Optional, overload -from microsoft_agents.activity import ActivityTypes from microsoft_agents.hosting.core import ( AgentApplication, RouteRank, @@ -15,6 +14,7 @@ StateT, _RouteDecorator, ) +from microsoft_agents.hosting.core.app._selector import _invoke_event_selector from microsoft_agents.hosting.msteams.teams_turn_context import TeamsTurnContext from microsoft_agents.hosting.msteams._utils import _send_invoke_response @@ -50,11 +50,7 @@ def _create_decorator( :return: A decorator that registers the handler. """ - def __selector(context: TurnContext) -> bool: - return ( - context.activity.type == ActivityTypes.invoke - and context.activity.name == name - ) + __selector = _invoke_event_selector(name) def __call(func: ConfigHandler[StateT]) -> ConfigHandler[StateT]: async def __handler(context: TurnContext, state: StateT) -> None: 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 d807b1918..1c37412a5 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 @@ -17,6 +17,7 @@ StateT, _RouteDecorator, ) +from microsoft_agents.hosting.core.app._selector import _invoke_event_selector from microsoft_agents.hosting.msteams.route_handlers import ( TeamsRouteHandler, @@ -261,12 +262,7 @@ def execute_action( :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" - ) + __selector = _invoke_event_selector("actionableMessage/executeAction") def __call(func: ExecuteActionHandler[StateT]) -> ExecuteActionHandler[StateT]: """Register the supplied execute action 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 102fded13..1c75c305a 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 @@ -22,6 +22,7 @@ StateT, _RouteDecorator, ) +from microsoft_agents.hosting.core.app._selector import _invoke_event_selector from microsoft_agents.hosting.msteams.teams_turn_context import TeamsTurnContext @@ -151,11 +152,7 @@ def select_item( :return: The registered handler when provided, otherwise a decorator that registers it. """ - def __selector(context: TurnContext) -> bool: - return ( - context.activity.type == ActivityTypes.invoke - and context.activity.name == "composeExtension/selectItem" - ) + __selector = _invoke_event_selector("composeExtension/selectItem") def __call(func: SelectItemHandler[StateT]) -> SelectItemHandler[StateT]: async def __handler(context: TurnContext, state: StateT) -> None: @@ -400,11 +397,7 @@ def query_link( :return: The registered handler when provided, otherwise a decorator that registers it. """ - def __selector(context: TurnContext) -> bool: - return ( - context.activity.type == ActivityTypes.invoke - and context.activity.name == "composeExtension/queryLink" - ) + __selector = _invoke_event_selector("composeExtension/queryLink") def __call(func: QueryLinkHandler[StateT]) -> QueryLinkHandler[StateT]: async def __handler(context: TurnContext, state: StateT) -> None: @@ -452,11 +445,7 @@ def anonymous_query_link( :return: The registered handler when provided, otherwise a decorator that registers it. """ - def __selector(context: TurnContext) -> bool: - return ( - context.activity.type == ActivityTypes.invoke - and context.activity.name == "composeExtension/anonymousQueryLink" - ) + __selector = _invoke_event_selector("composeExtension/anonymousQueryLink") def __call(func: QueryLinkHandler[StateT]) -> QueryLinkHandler[StateT]: async def __handler(context: TurnContext, state: StateT) -> None: @@ -506,11 +495,7 @@ def query_setting_url( :return: The registered handler when provided, otherwise a decorator that registers it. """ - def __selector(context: TurnContext) -> bool: - return ( - context.activity.type == ActivityTypes.invoke - and context.activity.name == "composeExtension/querySettingUrl" - ) + __selector = _invoke_event_selector("composeExtension/querySettingUrl") def __call( func: QueryUrlSettingHandler[StateT], @@ -560,11 +545,7 @@ def setting( :return: The registered handler when provided, otherwise a decorator that registers it. """ - def __selector(context: TurnContext) -> bool: - return ( - context.activity.type == ActivityTypes.invoke - and context.activity.name == "composeExtension/setting" - ) + __selector = _invoke_event_selector("composeExtension/setting") def __call(func: SettingHandler[StateT]) -> SettingHandler[StateT]: async def __handler(context: TurnContext, state: StateT) -> None: @@ -616,11 +597,7 @@ def card_button_clicked( :return: The registered handler when provided, otherwise a decorator that registers it. """ - def __selector(context: TurnContext) -> bool: - return ( - context.activity.type == ActivityTypes.invoke - and context.activity.name == "composeExtension/onCardButtonClicked" - ) + __selector = _invoke_event_selector("composeExtension/onCardButtonClicked") def __call( func: CardButtonClickedHandler[StateT], 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 8d48690ce..559fc9ed1 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 @@ -214,7 +214,12 @@ def activity( :return: A decorator that registers the handler and returns it. """ return self._wrap_decorator( - self._app.activity(activity_type, auth_handlers=auth_handlers, **kwargs) + self._app.activity( + activity_type, + auth_handlers=auth_handlers, + channel_id=Channels.msteams, + **kwargs, + ) ) def message( @@ -232,7 +237,12 @@ def message( :return: A decorator that registers the handler and returns it. """ return self._wrap_decorator( - self._app.message(select, auth_handlers=auth_handlers, **kwargs) + self._app.message( + select, + auth_handlers=auth_handlers, + channel_id=Channels.msteams, + **kwargs, + ) ) def conversation_update( @@ -249,7 +259,9 @@ def conversation_update( :return: A decorator that registers the handler and returns it. """ return self._wrap_decorator( - self._app.conversation_update(type, auth_handlers=auth_handlers, **kwargs) + self._app.conversation_update( + type, auth_handlers=auth_handlers, channel_id=Channels.msteams, **kwargs + ) ) def message_reaction( @@ -266,7 +278,9 @@ def message_reaction( :return: A decorator that registers the handler and returns it. """ return self._wrap_decorator( - self._app.message_reaction(type, auth_handlers=auth_handlers, **kwargs) + self._app.message_reaction( + type, auth_handlers=auth_handlers, channel_id=Channels.msteams, **kwargs + ) ) def message_update( @@ -283,7 +297,9 @@ def message_update( :return: A decorator that registers the handler and returns it. """ return self._wrap_decorator( - self._app.message_update(type, auth_handlers=auth_handlers, **kwargs) + self._app.message_update( + type, auth_handlers=auth_handlers, channel_id=Channels.msteams, **kwargs + ) ) def handoff( @@ -299,9 +315,9 @@ def handoff( """ def __call(func: TeamsHandoffHandler[StateT]) -> HandoffHandler[StateT]: - return self._app.handoff(auth_handlers=auth_handlers, **kwargs)( - wrap_teams_handoff_handler(func, self._app) - ) + return self._app.handoff( + auth_handlers=auth_handlers, channel_id=Channels.msteams, **kwargs + )(wrap_teams_handoff_handler(func, self._app)) return __call diff --git a/test_samples/README.md b/test_samples/README.md index fd035498a..1b04e49ec 100644 --- a/test_samples/README.md +++ b/test_samples/README.md @@ -1,97 +1,112 @@ -# Setting Up Virtual Environment and installing the SDK - -This guide explains how to create and activate a Python virtual environment using `venv` for Python versions 3.10 to 3.14. - -## What is a Virtual Environment? - -A virtual environment is an isolated Python environment that allows you to install packages for a specific project without affecting your system's global Python installation. This helps avoid package conflicts between different projects. - -## Prerequisites - -- Python 3.10, 3.11, 3.12, 3.13, or 3.14 installed on your system -- Basic knowledge of command line operations - -## Creating a Virtual Environment - -### On Linux/macOS - -1. Open a terminal window -2. Navigate to your project directory: - ```bash - cd the-root-of-this-project - ``` -3. Create a virtual environment: - ```bash - python3 -m venv venv - ``` - -### On Windows - -1. Open Command Prompt or PowerShell -2. Navigate to your project directory: - ``` - cd the-root-of-this-project - ``` -3. Create a virtual environment: - ``` - python -m venv venv - ``` - -## Activating the Virtual Environment - -### On Linux/macOS - -```bash -source venv/bin/activate -``` - -### On Windows - -#### Command Prompt -``` -venv\Scripts\activate.bat -``` - -#### PowerShell -``` -venv\Scripts\Activate.ps1 -``` - -Once activated, you'll notice your command prompt changes to show the name of the activated environment. For example: -``` -(venv) $ -``` - -## Deactivating the Virtual Environment - -When you're done working in the virtual environment, you can deactivate it by running: - -```bash -deactivate -``` - -## Verifying Python Version - -To verify the Python version in your virtual environment: - -```bash -python --version -``` - -Make sure it shows a version of at least 3.10. - -## Installing Test Packages - -After activating your virtual environment, you can install packages from pypi: - -```bash -pip install microsoft-agents-activity -pip install microsoft-agents-authorization -pip install microsoft-agents-connector -pip install microsoft-agents-client -pip install microsoft-agents-hosting-core -pip install microsoft-agents-authentication-msal -pip install microsoft-agents-copilotstudio-client -pip install microsoft-agents-hosting-core-aiohttp -pip install microsoft-agents-storage-core -``` \ No newline at end of file +# Python SDK test samples + +This directory contains runnable examples, compatibility ports, and tests for +the Microsoft Agents SDK for Python. Start with the linked sample guide when +one is available; it has the most specific setup and configuration details. +Unless stated otherwise, run commands from the sample directory. + +## Common setup + +- Python samples generally require Python 3.10 or later. Check the sample + guide for any additional version or service requirements. +- From the repository root, create and activate a virtual environment, then + install the repository's SDK libraries: + + ```powershell + python -m venv .venv + .\.venv\Scripts\Activate.ps1 + .\scripts\dev_setup.ps1 + ``` + + On Linux or macOS, activate with `source .venv/bin/activate` and run + `./scripts/dev_setup.sh`. +- When a sample has a `requirements.txt`, install it from the repository root, + for example: + `python -m pip install -r test_samples/proactive/requirements.txt`. + For samples with a `pyproject.toml`, follow that sample's `uv sync` + instructions instead. +- Where an `env.TEMPLATE` is provided, copy it to `.env` and fill in the + sample's required settings. Do not commit credentials. Most bot-hosted + samples need an Azure Bot/Entra app registration; Teams invoke features may + also need Teams and a public development tunnel. OAuth, Graph, and cloud + identity examples have additional requirements noted below or in their + guides. + +The distribution names in this repository's `libraries/` projects are +`microsoft-agents-activity`, +`microsoft-agents-authentication-entra-auth-sidecar`, +`microsoft-agents-authentication-msal`, +`microsoft-agents-copilotstudio-client`, +`microsoft-agents-hosting-aiohttp`, +`microsoft-agents-hosting-core`, +`microsoft-agents-hosting-dialogs`, +`microsoft-agents-hosting-fastapi`, +`microsoft-agents-hosting-msteams`, +`microsoft-agents-hosting-teams`, +`microsoft-agents-storage-blob`, +`microsoft-agents-storage-cosmos`, and +`microsoft-agents-testing`. Use a sample's requirements or project setup to +select the needed packages; not every sample needs every library. + +## Agent and channel samples + +| Sample | Purpose and additional prerequisites | Run / details | +| --- | --- | --- | +| [`app_style/`](app_style/README.md) | App-style aiohttp agents: empty, authorization, Copilot Studio, proactive echo, and streaming examples. Configure the credentials/connections needed by the selected agent. | From `test_samples/app_style`: `python empty_agent.py`, `python authorization_agent.py`, `python mcs_agent.py`, `python echo_proactive_agent.py`, or `python streaming_agent.py`. | +| [`fastapi/`](fastapi/README.md) | FastAPI versions of the empty and authorization agents; the authorization example uses Microsoft Graph and GitHub. | From `test_samples/fastapi`: `python empty_agent.py` or `python authorization_agent.py`. | +| [`cards/`](cards/README.md) | Adaptive Cards, submit/execute actions, dynamic search, and media cards. `Action.Execute` and dynamic search require Teams. | From `test_samples/cards`: `python agent.py`. | +| [`handling_attachments/`](handling_attachments/README.md) | Sends and receives inline, URL, and uploaded attachments; uploaded attachments require Teams. | From `test_samples/handling_attachments`: `python -m src.main`. | +| [`proactive/`](proactive/README.md) | Proactive conversation continuation, stored references, OAuth, and an external notification endpoint. Requires an Azure Bot OAuth connection and a tunnel or Teams/Agents Playground for interaction. | From the repository root: `python test_samples/proactive/proactive_agent.py`. | +| [`bizchat-sso/`](bizchat-sso/) | Echo agent with sign-in and sign-out handlers. Configure the Azure Bot OAuth connection named for the sample's SSO handler. | From `test_samples/bizchat-sso`: `python src/main.py`; install `requirements.txt` first. | +| [`agentic-test/`](agentic-test/) | Acquires an agentic user token and displays token claims in an Adaptive Card. Requires the configured `AGENTIC` authentication connection. | From `test_samples/agentic-test`: `python src/main.py`; install `requirements.txt` first. | +| [`copilot_studio_client_sample/`](copilot_studio_client_sample/app.py) | Console client for a Copilot Studio agent using MSAL interactive sign-in and the Copilot Studio client library. Requires a configured Entra app and Copilot Studio connection. | `python app.py`. No sample README or `requirements.txt` is present; install its SDK and MSAL dependencies in the environment. | +| [`copilot_studio_connector/`](copilot_studio_connector/README.md) | Receives Copilot Studio connector requests and uses OAuth/OBO to call Graph. Requires an Azure Bot app, Copilot Studio connector, and Graph OAuth permissions. | From `test_samples/copilot_studio_connector`: `pip install -r requirements.txt`, then `python app.py`. | +| [`compat/weather-agent-open-ai/`](compat/weather-agent-open-ai/app.py) | Compatibility weather agent backed by Azure OpenAI. Requires Azure OpenAI configuration as well as bot credentials. | From `test_samples/compat/weather-agent-open-ai`: install `requirements.txt`, then `python app.py`. | +| [`extensions/extension-starter/`](extensions/extension-starter/README.md) | Minimal example of extending the SDK; the guide recommends Microsoft 365 Agents Playground for interaction. | From `test_samples/extensions/extension-starter`: `python -m src.sample.main`. | +| [`tethered-agent/`](tethered-agent/src/main.py) | Echo agent using `tethered` to limit outbound network access to the configured login and Bot Framework hosts. Requires the normal agent configuration and the `tethered` dependency. | From `test_samples/tethered-agent`: `python -m src.main`. The directory's README is empty; the entry point and purpose are identified from the source. | + +## Teams samples + +| Sample | Purpose and additional prerequisites | Run / details | +| --- | --- | --- | +| [`hosting_msteams/conversation-agent/`](hosting_msteams/conversation-agent/README.md) | Teams conversation lifecycle events, member lookups, mentions, and proactive messages. Requires a Teams app and bot registration. | From the sample directory: `python -m src.main`. | +| [`hosting_msteams/graph-clients/`](hosting_msteams/graph-clients/README.md) | Teams commands using app-only and delegated Microsoft Graph clients; requires Graph application/delegated permissions and consent. | From the sample directory: `python -m src.main`. | +| [`hosting_msteams/message-extensions/`](hosting_msteams/message-extensions/README.md) | Teams message-extension search, actions, link previews, and settings. Requires a Teams app and a reachable tunnel for settings. | From the sample directory: `python -m src.main`. | +| [`hosting_msteams/task-modules/`](hosting_msteams/task-modules/README.md) | Teams task modules with card forms, a webpage dialog, and multi-step submits. Requires a Teams app and a reachable tunnel for the webpage dialog. | From the sample directory: `python -m src.main`. | +| [`teams_extension/`](teams_extension/README.md) | Three `TeamsAgentExtension` examples: message extensions, task modules, and meeting events. Requires Teams; install `microsoft-teams-api` as described in the guide. | From `test_samples/teams_extension`, run `python message_extensions_agent.py`, `python task_modules_agent.py`, or `python meeting_events_agent.py`. | + +## Authentication and cloud identity + +| Sample | Purpose and additional prerequisites | Run / details | +| --- | --- | --- | +| [`entra_sidecar/`](entra_sidecar/README.md) | Credential-free outbound token acquisition through the Entra ID Agent Container sidecar. Requires the sidecar and its downstream API configuration. | Follow the guide to install requirements and run `python agent.py` from `test_samples/entra_sidecar`. | +| [`fic/`](fic/FIC_README.md) | Federated identity credentials for an Azure App Service agent using a user-assigned managed identity. Requires Azure CLI, an Azure subscription, and an Entra app registration. | From the repository root, provision with `.\test_samples\fic\setup.ps1 -ResourceGroup -AppName `, then deploy with `.\test_samples\fic\deploy.ps1`. | +| [`workload_identity/`](workload_identity/README.md) | End-to-end AKS Workload Identity deployment and test. Requires Azure CLI, `kubectl`, an Azure subscription, and an AKS cluster or permission to create one. | Follow the guide's setup and deployment steps; run `.\test_samples\workload_identity\test.ps1` for the in-cluster test. | +| [`mi/`](mi/) | Echo-agent sample with Azure resource templates under `infra/`. Requires Azure identity/bot configuration and the resources described by those templates. | From `test_samples/mi`: install `requirements.txt`, then run `python src/main.py`. | + +## Compatibility samples + +| Sample | Purpose | Run / details | +| --- | --- | --- | +| [`compat/dialogs/complex_dialog/`](compat/dialogs/complex_dialog/README.md) | BotBuilder complex-dialog port. | From the sample directory: install `requirements.txt`, then run `python src/main.py`. | +| [`compat/dialogs/custom_dialogs/`](compat/dialogs/custom_dialogs/README.md) | BotBuilder custom-dialogs port. | From the sample directory: install `requirements.txt`, then run `python src/main.py`. | +| [`compat/dialogs/multi_turn/`](compat/dialogs/multi_turn/README.md) | BotBuilder multi-turn-prompt port. | From the sample directory: install `requirements.txt`, then run `python src/main.py`. | +| [`compat/dialogs/oauth_prompt/`](compat/dialogs/oauth_prompt/README.md) | OAuth prompt sample. Requires an Azure Bot OAuth connection. | From the sample directory: install `requirements.txt`, then run `python src/main.py`. | +| [`compat/dialogs/user_auth/`](compat/dialogs/user_auth/README.md) | User-authentication sample that accesses Graph; requires the corresponding OAuth connection and permissions. | From the sample directory: install `requirements.txt`, then run `python src/main.py`. | +| [`compat/teams_agent/`](compat/teams_agent/app.py) | Legacy Teams handler examples for SSO and other Teams features. | Purpose is identifiable from the source, but a runnable command could not be verified: its imports reference `test_samples.activity_handler`, which is not present here. | +| [`compat/agent_to_agent/`](compat/agent_to_agent/) | Two-agent channel/agent communication example. | The source imports also reference `test_samples.activity_handler`; a runnable command could not be verified against this checkout. | + +## Observability and testing + +| Sample | Purpose and prerequisites | Run / test | +| --- | --- | --- | +| [`otel/quickstart/`](otel/quickstart/README.md) | In-code OpenTelemetry configuration and instrumentation; `uv` and the configured agent connections are required. | In the sample directory: `uv sync`, then `uv run python -m src.main`. The guide also starts the Aspire dashboard with `.\start_dashboard.ps1`. | +| [`otel/zero-code/`](otel/zero-code/README.md) | OpenTelemetry auto-instrumentation configured through environment variables; `uv` and the configured agent connections are required. | In the sample directory: `uv sync`, then run `.\start_dashboard.ps1` and `.\start_agent.ps1`. | +| [`testing/`](testing/README.md) | Tests an `AgentApplication` in memory with `TestAdapter` and `TestFlow`; no live server is needed for the tests. | From `test_samples/testing`: `uv sync` then `uv run pytest`. To run the aiohttp host, use `uv run python src/app.py`. | +| [`streaming_activity_lifecycle_net/`](streaming_activity_lifecycle_net/README.md) | .NET diagnostic for updating/deleting finalized streaming activities in Teams. Requires .NET 10, a sibling `Agents-for-net` checkout, and Teams/Bot setup. | From the repository root: `dotnet run --project test_samples\streaming_activity_lifecycle_net\StreamingActivityLifecycle.csproj`. | + +## Entries without verifiable sample instructions + +The `a2a/` and `demo/` directories currently have no sample README or runnable +source files in this checkout, so their purpose and run/test commands could +not be verified. diff --git a/tests/hosting_core/app/test_agent_application_routes.py b/tests/hosting_core/app/test_agent_application_routes.py index fcb6548db..9edb606db 100644 --- a/tests/hosting_core/app/test_agent_application_routes.py +++ b/tests/hosting_core/app/test_agent_application_routes.py @@ -38,9 +38,9 @@ def _make_app() -> AgentApplication[TurnState]: ) -def _make_activity(**kwargs) -> Activity: +def _make_activity(channel_id: str = "test", **kwargs) -> Activity: return Activity( - channel_id="test", + channel_id=channel_id, conversation={"id": "conv1"}, from_property={"id": "user1"}, recipient={"id": "bot1"}, @@ -416,3 +416,148 @@ async def evt_handler(context: TurnContext, state: TurnState): TurnState(), ) assert self.call_order == ["event", "message"] + + +class TestChannelIdRouteFiltering: + def setup_method(self): + self.app = _make_app() + self.called = False + + @pytest.mark.parametrize( + ("incoming_channel_id", "expected"), + [("msteams:copilot", True), ("webchat", False)], + ) + @pytest.mark.asyncio + async def test_activity_route_filters_by_channel_id( + self, incoming_channel_id: str, expected: bool + ): + @self.app.activity("event", channel_id="msteams") + async def handler(context: TurnContext, state: TurnState): + self.called = True + + await self.app._on_activity( + _make_context(_make_activity(channel_id=incoming_channel_id, type="event")), + TurnState(), + ) + assert self.called is expected + + @pytest.mark.parametrize( + ("incoming_channel_id", "expected"), + [("msteams:copilot", True), ("msteams:meeting", False)], + ) + @pytest.mark.asyncio + async def test_message_route_filters_by_exact_subchannel( + self, incoming_channel_id: str, expected: bool + ): + @self.app.message("hello", channel_id="msteams:copilot") + async def handler(context: TurnContext, state: TurnState): + self.called = True + + await self.app._on_activity( + _make_context( + _make_activity( + channel_id=incoming_channel_id, + type=ActivityTypes.message, + text="hello", + ) + ), + TurnState(), + ) + assert self.called is expected + + @pytest.mark.parametrize( + ("incoming_channel_id", "expected"), + [("msteams:copilot", True), ("webchat", False)], + ) + @pytest.mark.asyncio + async def test_conversation_update_route_filters_by_channel_id( + self, incoming_channel_id: str, expected: bool + ): + @self.app.conversation_update("membersAdded", channel_id="msteams") + async def handler(context: TurnContext, state: TurnState): + self.called = True + + await self.app._on_activity( + _make_context( + _make_activity( + channel_id=incoming_channel_id, + type=ActivityTypes.conversation_update, + members_added=[{"id": "new-member"}], + ) + ), + TurnState(), + ) + assert self.called is expected + + @pytest.mark.parametrize( + ("incoming_channel_id", "expected"), + [("msteams:copilot", True), ("msteams:meeting", False)], + ) + @pytest.mark.asyncio + async def test_message_reaction_route_filters_by_exact_subchannel( + self, incoming_channel_id: str, expected: bool + ): + @self.app.message_reaction("reactionsAdded", channel_id="msteams:copilot") + async def handler(context: TurnContext, state: TurnState): + self.called = True + + await self.app._on_activity( + _make_context( + _make_activity( + channel_id=incoming_channel_id, + type=ActivityTypes.message_reaction, + reactions_added=[{"type": "like"}], + ) + ), + TurnState(), + ) + assert self.called is expected + + @pytest.mark.parametrize( + ("incoming_channel_id", "expected"), + [("msteams:copilot", True), ("webchat", False)], + ) + @pytest.mark.asyncio + async def test_message_update_route_filters_by_channel_id( + self, incoming_channel_id: str, expected: bool + ): + @self.app.message_update("editMessage", channel_id="msteams") + async def handler(context: TurnContext, state: TurnState): + self.called = True + + await self.app._on_activity( + _make_context( + _make_activity( + channel_id=incoming_channel_id, + type=ActivityTypes.message_update, + channel_data={"event_type": "editMessage"}, + ) + ), + TurnState(), + ) + assert self.called is expected + + @pytest.mark.parametrize( + ("incoming_channel_id", "expected"), + [("msteams:copilot", True), ("msteams:meeting", False)], + ) + @pytest.mark.asyncio + async def test_handoff_route_filters_by_exact_subchannel( + self, incoming_channel_id: str, expected: bool + ): + @self.app.handoff(channel_id="msteams:copilot") + async def handler(context, state, continuation): + self.called = True + + await self.app._on_activity( + _make_context( + _make_activity( + channel_id=incoming_channel_id, + type=ActivityTypes.invoke, + name="handoff/action", + value={"continuation": "cont123"}, + ) + ), + TurnState(), + ) + assert self.called is expected diff --git a/tests/hosting_core/app/test_selector.py b/tests/hosting_core/app/test_selector.py new file mode 100644 index 000000000..58ffaa2a7 --- /dev/null +++ b/tests/hosting_core/app/test_selector.py @@ -0,0 +1,153 @@ +"""Tests for AgentApplication route selector helpers.""" + +import re +from types import SimpleNamespace + +from microsoft_agents.activity import Activity, ActivityTypes, ChannelId +from microsoft_agents.hosting.core.app._selector import ( + _SimpleRouteSelector, + _activity_type_selector, + _all_selector, + _channel_selector, + _invoke_event_selector, + _text_selector, +) + + +def _context(**activity_values): + activity_values.setdefault("type", ActivityTypes.message) + return SimpleNamespace(activity=Activity(**activity_values)) + + +def test_text_selector_matches_exact_text(): + selector = _text_selector("hello") + + assert selector(_context(text="hello")) + assert not selector(_context(text="hello world")) + assert not selector(_context()) + + +def test_text_selector_uses_regex_fullmatch(): + selector = _text_selector(re.compile(r"hello.*")) + + assert selector(_context(text="hello world")) + assert not selector(_context(text="well, hello")) + + +def test_text_selector_matches_any_value_in_mixed_list(): + selector = _text_selector(["hello", re.compile(r"goodbye.*")]) + + assert selector(_context(text="hello")) + assert selector(_context(text="goodbye everyone")) + assert not selector(_context(text="good morning")) + + +def test_text_selector_treats_missing_text_as_empty_string(): + assert _text_selector("")(_context()) + assert not _text_selector("hello")(_context()) + + +def test_invoke_event_selector_requires_matching_invoke_name(): + selector = _invoke_event_selector("handoff/action") + + assert selector(_context(type=ActivityTypes.invoke, name="handoff/action")) + assert not selector(_context(type=ActivityTypes.invoke, name="other/action")) + assert not selector(_context(type=ActivityTypes.message, name="handoff/action")) + assert not selector(_context(type=ActivityTypes.invoke)) + + +def test_activity_type_selector_matches_single_type_or_list(): + assert _activity_type_selector("event")(_context(type="event")) + assert not _activity_type_selector("event")(_context(type="message")) + + selector = _activity_type_selector(["event", ActivityTypes.message]) + assert selector(_context(type="event")) + assert selector(_context(type=ActivityTypes.message)) + assert not selector(_context(type=ActivityTypes.invoke)) + + +def test_channel_selector_without_filter_matches_any_channel(): + selector = _channel_selector(None) + + assert selector(_context(channel_id="msteams")) + assert selector(_context()) + + +def test_parent_channel_selector_matches_any_subchannel_of_parent(): + selector = _channel_selector("msteams") + + assert selector(_context(channel_id="msteams:copilot")) + assert selector(_context(channel_id="msteams:meeting")) + assert not selector(_context(channel_id="webchat")) + assert not selector(_context()) + + +def test_subchannel_selector_requires_exact_subchannel(): + selector = _channel_selector(ChannelId("msteams:copilot")) + + assert selector(_context(channel_id="msteams:copilot")) + assert not selector(_context(channel_id="msteams:meeting")) + assert not selector(_context(channel_id="msteams")) + + +def test_all_selector_requires_every_selector_to_match(): + selector = _all_selector( + _activity_type_selector(ActivityTypes.message), + _channel_selector("msteams"), + _text_selector("hello"), + ) + + assert selector( + _context( + type=ActivityTypes.message, + channel_id="msteams:copilot", + text="hello", + ) + ) + assert not selector( + _context(type=ActivityTypes.message, channel_id="webchat", text="hello") + ) + + +def test_simple_route_selector_combines_channel_type_and_text(): + selector = _SimpleRouteSelector( + channel_id="msteams", + activity_type=[ActivityTypes.message, ActivityTypes.event], + text_pattern=["hello", re.compile(r"goodbye.*")], + ) + + assert selector( + _context( + type=ActivityTypes.message, + channel_id="msteams:copilot", + text="hello", + ) + ) + assert selector( + _context( + type=ActivityTypes.event, + channel_id="msteams", + text="goodbye everyone", + ) + ) + assert not selector( + _context( + type=ActivityTypes.invoke, + channel_id="msteams", + text="hello", + ) + ) + assert not selector( + _context( + type=ActivityTypes.message, + channel_id="webchat", + text="hello", + ) + ) + assert not selector( + _context( + type=ActivityTypes.message, + channel_id="msteams", + text="unmatched", + ) + ) From abc6feab0a80900b267421aa9293777ab14aacf4 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Wed, 14 Oct 2026 11:05:28 -0700 Subject: [PATCH 2/5] More tests --- .../test_routing_integration.py | 70 +++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/tests/hosting_msteams/test_routing_integration.py b/tests/hosting_msteams/test_routing_integration.py index f5c2cd5d6..e21dffe4c 100644 --- a/tests/hosting_msteams/test_routing_integration.py +++ b/tests/hosting_msteams/test_routing_integration.py @@ -271,3 +271,73 @@ async def on_event(context, state): _make_context(_make_activity(type=ActivityTypes.message, text="hello")) ) assert self.call_order == ["event", "message"] + + +class TestDirectRouteHooksChannelFiltering: + """Direct hooks must only route Teams activities at core dispatch.""" + + def setup_method(self): + self.app = _make_app() + self.ext = TeamsAgentExtension(self.app) + self.calls: list[str] = [] + + @pytest.mark.parametrize( + "route_name", + [ + "activity", + "message", + "conversation_update", + "message_reaction", + "message_update", + "handoff", + ], + ) + @pytest.mark.parametrize( + ("channel_id", "expected"), + [("msteams", True), ("msteams:copilot", True), ("webchat", False)], + ) + @pytest.mark.asyncio + async def test_direct_route_only_matches_msteams_channel( + self, route_name: str, channel_id: str, expected: bool + ): + async def handler(context, state, continuation=None): + self.calls.append("called") + + if route_name == "activity": + self.ext.activity(ActivityTypes.event)(handler) + route_values = {"type": ActivityTypes.event} + elif route_name == "message": + self.ext.message("hello")(handler) + route_values = {"type": ActivityTypes.message, "text": "hello"} + elif route_name == "conversation_update": + self.ext.conversation_update("membersAdded")(handler) + route_values = { + "type": ActivityTypes.conversation_update, + "members_added": [{"id": "new-member"}], + } + elif route_name == "message_reaction": + self.ext.message_reaction("reactionsAdded")(handler) + route_values = { + "type": ActivityTypes.message_reaction, + "reactions_added": [{"type": "like"}], + } + elif route_name == "message_update": + self.ext.message_update("editMessage")(handler) + route_values = { + "type": ActivityTypes.message_update, + "channel_data": {"event_type": "editMessage"}, + } + else: + self.ext.handoff()(handler) + route_values = { + "type": ActivityTypes.invoke, + "name": "handoff/action", + "value": {"continuation": "continuation"}, + } + + await self.app._on_activity( + _make_context(_make_activity(channel_id=channel_id, **route_values)), + TurnState(), + ) + + assert self.calls == (["called"] if expected else []) From 800106028483a205852e053a7d2e01c22b023eb4 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Wed, 14 Oct 2026 11:09:32 -0700 Subject: [PATCH 3/5] Updating changelog --- changelog.md | 1 + 1 file changed, 1 insertion(+) diff --git a/changelog.md b/changelog.md index 6da07dbe1..920c8a503 100644 --- a/changelog.md +++ b/changelog.md @@ -12,6 +12,7 @@ ## Major Features & Enhancements - **Outbound Host Validation Hardening**: Enhanced `OutboundHostValidator` with HTTPS-only URL enforcement, DNS resolution validation for public and optionally private network addresses, and an SSRF-aware `aiohttp` connector that validates resolved IPv4 and IPv6 addresses before connecting. +- **Channel-Aware Route Selectors**: Added reusable route selectors, optional `channel_id` filtering to `AgentApplication` route hooks, and Teams-channel scoping for direct `TeamsAgentExtension` routes. ## Deprecation From 91783988d195e3e1d87321c210d793a642a70aa3 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Wed, 14 Oct 2026 11:16:42 -0700 Subject: [PATCH 4/5] Updating test_sampels\README.md --- test_samples/README.md | 21 +++++---------------- 1 file changed, 5 insertions(+), 16 deletions(-) diff --git a/test_samples/README.md b/test_samples/README.md index 1b04e49ec..9fd26a840 100644 --- a/test_samples/README.md +++ b/test_samples/README.md @@ -57,7 +57,6 @@ select the needed packages; not every sample needs every library. | [`cards/`](cards/README.md) | Adaptive Cards, submit/execute actions, dynamic search, and media cards. `Action.Execute` and dynamic search require Teams. | From `test_samples/cards`: `python agent.py`. | | [`handling_attachments/`](handling_attachments/README.md) | Sends and receives inline, URL, and uploaded attachments; uploaded attachments require Teams. | From `test_samples/handling_attachments`: `python -m src.main`. | | [`proactive/`](proactive/README.md) | Proactive conversation continuation, stored references, OAuth, and an external notification endpoint. Requires an Azure Bot OAuth connection and a tunnel or Teams/Agents Playground for interaction. | From the repository root: `python test_samples/proactive/proactive_agent.py`. | -| [`bizchat-sso/`](bizchat-sso/) | Echo agent with sign-in and sign-out handlers. Configure the Azure Bot OAuth connection named for the sample's SSO handler. | From `test_samples/bizchat-sso`: `python src/main.py`; install `requirements.txt` first. | | [`agentic-test/`](agentic-test/) | Acquires an agentic user token and displays token claims in an Adaptive Card. Requires the configured `AGENTIC` authentication connection. | From `test_samples/agentic-test`: `python src/main.py`; install `requirements.txt` first. | | [`copilot_studio_client_sample/`](copilot_studio_client_sample/app.py) | Console client for a Copilot Studio agent using MSAL interactive sign-in and the Copilot Studio client library. Requires a configured Entra app and Copilot Studio connection. | `python app.py`. No sample README or `requirements.txt` is present; install its SDK and MSAL dependencies in the environment. | | [`copilot_studio_connector/`](copilot_studio_connector/README.md) | Receives Copilot Studio connector requests and uses OAuth/OBO to call Graph. Requires an Azure Bot app, Copilot Studio connector, and Graph OAuth permissions. | From `test_samples/copilot_studio_connector`: `pip install -r requirements.txt`, then `python app.py`. | @@ -80,19 +79,16 @@ select the needed packages; not every sample needs every library. | Sample | Purpose and additional prerequisites | Run / details | | --- | --- | --- | | [`entra_sidecar/`](entra_sidecar/README.md) | Credential-free outbound token acquisition through the Entra ID Agent Container sidecar. Requires the sidecar and its downstream API configuration. | Follow the guide to install requirements and run `python agent.py` from `test_samples/entra_sidecar`. | -| [`fic/`](fic/FIC_README.md) | Federated identity credentials for an Azure App Service agent using a user-assigned managed identity. Requires Azure CLI, an Azure subscription, and an Entra app registration. | From the repository root, provision with `.\test_samples\fic\setup.ps1 -ResourceGroup -AppName `, then deploy with `.\test_samples\fic\deploy.ps1`. | -| [`workload_identity/`](workload_identity/README.md) | End-to-end AKS Workload Identity deployment and test. Requires Azure CLI, `kubectl`, an Azure subscription, and an AKS cluster or permission to create one. | Follow the guide's setup and deployment steps; run `.\test_samples\workload_identity\test.ps1` for the in-cluster test. | -| [`mi/`](mi/) | Echo-agent sample with Azure resource templates under `infra/`. Requires Azure identity/bot configuration and the resources described by those templates. | From `test_samples/mi`: install `requirements.txt`, then run `python src/main.py`. | ## Compatibility samples | Sample | Purpose | Run / details | | --- | --- | --- | -| [`compat/dialogs/complex_dialog/`](compat/dialogs/complex_dialog/README.md) | BotBuilder complex-dialog port. | From the sample directory: install `requirements.txt`, then run `python src/main.py`. | -| [`compat/dialogs/custom_dialogs/`](compat/dialogs/custom_dialogs/README.md) | BotBuilder custom-dialogs port. | From the sample directory: install `requirements.txt`, then run `python src/main.py`. | -| [`compat/dialogs/multi_turn/`](compat/dialogs/multi_turn/README.md) | BotBuilder multi-turn-prompt port. | From the sample directory: install `requirements.txt`, then run `python src/main.py`. | -| [`compat/dialogs/oauth_prompt/`](compat/dialogs/oauth_prompt/README.md) | OAuth prompt sample. Requires an Azure Bot OAuth connection. | From the sample directory: install `requirements.txt`, then run `python src/main.py`. | -| [`compat/dialogs/user_auth/`](compat/dialogs/user_auth/README.md) | User-authentication sample that accesses Graph; requires the corresponding OAuth connection and permissions. | From the sample directory: install `requirements.txt`, then run `python src/main.py`. | +| [`compat/dialogs/complex_dialog/`](compat/dialogs/complex_dialog/README.md) | BotBuilder complex-dialog port. | From the sample directory: install `requirements.txt`, then run `python -m src.main`. | +| [`compat/dialogs/custom_dialogs/`](compat/dialogs/custom_dialogs/README.md) | BotBuilder custom-dialogs port. | From the sample directory: install `requirements.txt`, then run `python -m src.main`. | +| [`compat/dialogs/multi_turn/`](compat/dialogs/multi_turn/README.md) | BotBuilder multi-turn-prompt port. | From the sample directory: install `requirements.txt`, then run `python -m src.main`. | +| [`compat/dialogs/oauth_prompt/`](compat/dialogs/oauth_prompt/README.md) | OAuth prompt sample. Requires an Azure Bot OAuth connection. | From the sample directory: install `requirements.txt`, then run `python -m src.main`. | +| [`compat/dialogs/user_auth/`](compat/dialogs/user_auth/README.md) | User-authentication sample that accesses Graph; requires the corresponding OAuth connection and permissions. | From the sample directory: install `requirements.txt`, then run `python -m src.main`. | | [`compat/teams_agent/`](compat/teams_agent/app.py) | Legacy Teams handler examples for SSO and other Teams features. | Purpose is identifiable from the source, but a runnable command could not be verified: its imports reference `test_samples.activity_handler`, which is not present here. | | [`compat/agent_to_agent/`](compat/agent_to_agent/) | Two-agent channel/agent communication example. | The source imports also reference `test_samples.activity_handler`; a runnable command could not be verified against this checkout. | @@ -103,10 +99,3 @@ select the needed packages; not every sample needs every library. | [`otel/quickstart/`](otel/quickstart/README.md) | In-code OpenTelemetry configuration and instrumentation; `uv` and the configured agent connections are required. | In the sample directory: `uv sync`, then `uv run python -m src.main`. The guide also starts the Aspire dashboard with `.\start_dashboard.ps1`. | | [`otel/zero-code/`](otel/zero-code/README.md) | OpenTelemetry auto-instrumentation configured through environment variables; `uv` and the configured agent connections are required. | In the sample directory: `uv sync`, then run `.\start_dashboard.ps1` and `.\start_agent.ps1`. | | [`testing/`](testing/README.md) | Tests an `AgentApplication` in memory with `TestAdapter` and `TestFlow`; no live server is needed for the tests. | From `test_samples/testing`: `uv sync` then `uv run pytest`. To run the aiohttp host, use `uv run python src/app.py`. | -| [`streaming_activity_lifecycle_net/`](streaming_activity_lifecycle_net/README.md) | .NET diagnostic for updating/deleting finalized streaming activities in Teams. Requires .NET 10, a sibling `Agents-for-net` checkout, and Teams/Bot setup. | From the repository root: `dotnet run --project test_samples\streaming_activity_lifecycle_net\StreamingActivityLifecycle.csproj`. | - -## Entries without verifiable sample instructions - -The `a2a/` and `demo/` directories currently have no sample README or runnable -source files in this checkout, so their purpose and run/test commands could -not be verified. From bd68be1e1c25aeb8d1ed039cd458af89d24dd7b5 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Wed, 14 Oct 2026 11:24:05 -0700 Subject: [PATCH 5/5] Updating overload signature and docstrings --- .../microsoft_agents/hosting/core/app/_selector.py | 6 ++++-- .../microsoft_agents/hosting/core/app/agent_application.py | 2 ++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/_selector.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/_selector.py index ad75e5c83..38eb033e7 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/_selector.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/_selector.py @@ -12,10 +12,12 @@ def _text_selector( select: str | re.Pattern[str] | list[str | re.Pattern[str]], ) -> RouteSelector: - """Checks if the given text matches the selection criteria. + """Creates a route selector that matches the activity text against the selection criteria. + + Strings must match the text exactly; regex patterns must fully match it. :param select: A string, regex pattern, or a list of strings/regex patterns to match against. - :return: True if the text matches any of the selection criteria, False otherwise. + :return: A route selector function that returns True if the activity text matches any of the selection criteria. """ def __selector(context: TurnContext) -> bool: 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 e5b6a4b2d..e073b7db2 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 @@ -670,6 +670,7 @@ def handoff( self, func: HandoffHandler[StateT], *, + channel_id: str | ChannelId | None = None, auth_handlers: list[str] | None = None, **kwargs, ) -> HandoffHandler[StateT]: ... @@ -678,6 +679,7 @@ def handoff( def handoff( self, *, + channel_id: str | ChannelId | None = None, auth_handlers: list[str] | None = None, **kwargs: Any, ) -> _RouteDecorator[HandoffHandler[StateT]]: ...