diff --git a/changelog.md b/changelog.md index 920c8a503..0569ea195 100644 --- a/changelog.md +++ b/changelog.md @@ -7,12 +7,14 @@ - **Slack-Aware Turn Context**: Added `SlackTurnContext`, `SlackActivity`, and `SlackRouteHandler` so Slack handlers receive a typed context with direct access to Slack channel data and the per-turn API client. - **Route Decorators**: Added typed `SlackAgentExtension.message()` and `.event()` decorators. Deprecated `on_message()` and `on_event()` while retaining them as backward-compatible aliases. +- **Slack-Scoped Application Route Hooks**: Added `SlackAgentExtension.conversation_update()`, `.members_added()`, `.members_removed()`, and `.installation_update()`, which delegate to the matching `AgentApplication` hooks filtered to the Slack channel. - **Typed Model Lookups**: Added a `type_` parameter and typed overloads to `SlackModel.get()` for converting and validating nested values with Pydantic. ## 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. +- **New Application Route Hooks**: Added `AgentApplication.event()`, `.invoke()`, `.installation_update()`, `.members_added()`, `.members_removed()`, `.end_of_conversation()`, `.message_reaction_added()`, and `.message_reactions_removed()`; `message()`, `event()`, and `invoke()` accept a string, regex, or list selector, and match everything at `RouteRank.LAST` when the selector is omitted. Added Teams-scoped `TeamsAgentExtension.installation_update()` and `.end_of_conversation()`, plus the Slack-scoped hooks listed above. ## Deprecation diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/channel_id.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/channel_id.py index 270399b63..5072047a1 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/channel_id.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/channel_id.py @@ -136,3 +136,51 @@ def get_channel(channel_id: str | ChannelId | None) -> str | None: channel_id = channel_id.strip() parsed = channel_id.split(":", 1)[0].strip() if ":" in channel_id else None return parsed or channel_id + + def includes(self, other: str | ChannelId | None) -> bool: + """Check whether this channel ID, used as a filter, includes another channel ID. + + The comparison is directional: ``self`` is the filter and ``other`` is the + candidate. Matching rules: + + - The channel must be equal, unless this channel is the wildcard ``*``. + - A filter without a sub-channel (e.g. ``msteams``) includes the bare channel + and every sub-channel of it (e.g. ``msteams:copilot``). + - A filter with the wildcard sub-channel (e.g. ``msteams:*``) behaves the same + as a filter without a sub-channel. + - A filter with a specific sub-channel (e.g. ``msteams:copilot``) includes only + that exact sub-channel. + + Comparisons are case-sensitive. ``None`` and invalid candidates are never + included. + + Example:: + + ChannelId("msteams").includes("msteams:copilot") # True + ChannelId("msteams:copilot").includes("msteams") # False + ChannelId("*").includes("slack") # True + + :param other: The candidate channel ID to check. + :return: True if the candidate is included by this channel ID, False otherwise. + """ + + if other is None: + return False + + candidate: ChannelId + try: + candidate = ChannelId(other) + except Exception: + return False + + if self.channel != "*" and self.channel != candidate.channel: + return False + + if ( + self.sub_channel + and self.sub_channel != "*" + and self.sub_channel != candidate.sub_channel + ): + return False + + return True 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 38eb033e7..8961737da 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 @@ -3,10 +3,10 @@ import re - from microsoft_agents.activity import ActivityTypes, ChannelId from microsoft_agents.hosting.core import TurnContext -from ._type_defs import RouteSelector +from ._type_defs import RouteSelector, TextSelector +from ._utils import _matches_text def _text_selector( @@ -22,38 +22,42 @@ def _text_selector( 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 + return _matches_text(select, context.activity.text or "") + + return __selector + - if isinstance(select, re.Pattern): - return re.fullmatch(select, text) is not None +def _event_selector(event_selector: TextSelector | None) -> RouteSelector: + """Creates a route selector that matches the given event name. - return text == select + :param event_selector: The text selector for the event name to match against. + :return: A route selector function that matches the specified event name. + """ + + def __selector(context: TurnContext) -> bool: + """Selector function that checks if the given event name matches the selection criteria.""" + if not context.activity.type == ActivityTypes.event: + return False + return event_selector is None or _matches_text( + event_selector, context.activity.name or "" + ) return __selector -def _invoke_event_selector(event_name: str) -> RouteSelector: +def _invoke_event_selector(event_selector: TextSelector | None) -> RouteSelector: """Creates a route selector that matches the given invoke event name. - :param event_name: The name of the invoke event to match against. + :param event_selector: The text selector for the invoke event name 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: + if not context.activity.type == ActivityTypes.invoke: return False - return ( - context.activity.type == ActivityTypes.invoke - and context.activity.name == event_name + return event_selector is None or _matches_text( + event_selector, context.activity.name or "" ) return __selector @@ -87,17 +91,7 @@ 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 norm_channel_id.includes(context.activity.channel_id) return __selector 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 51dd88c20..32f439755 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 @@ -1,6 +1,7 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. +import re from typing import Callable, TypeVar, Awaitable, Protocol from ..turn_context import TurnContext @@ -11,6 +12,7 @@ StateT = TypeVar("StateT", bound=TurnState) _StateContra = TypeVar("_StateContra", bound=TurnState, contravariant=True) _RouteHandlerT = TypeVar("_RouteHandlerT", bound=Callable) +TextSelector = str | re.Pattern[str] | list[str | re.Pattern[str]] class _RouteDecorator(Protocol[_RouteHandlerT]): diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/_utils.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/_utils.py index f6715087e..50d87d197 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/_utils.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/_utils.py @@ -1,9 +1,13 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. +import re + from email.message import Message from urllib.parse import urlparse +from ._type_defs import TextSelector + _CONTENT_TYPE = "Content-Type" @@ -34,3 +38,29 @@ def _basic_url_check(url: str) -> bool: return parsed.scheme == "https" or ( parsed.scheme == "http" and parsed.hostname == "localhost" ) + + +def _matches_text(select: TextSelector | None = None, text: str | None = None) -> bool: + """Checks if the given text matches the specified text selector. + + :param select: The text selector to match against. Can be a string, a compiled regex pattern, or a list of these. + :param text: The text to check. Defaults to an empty string if None is provided. + :return: True if the text matches the selector, False otherwise. + """ + if select is None: + return True + + text = 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 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 e073b7db2..c3802357f 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 @@ -50,19 +50,21 @@ from .telemetry import spans from ._type_defs import ( - RouteHandler, HandoffHandler, - RouteSelector, _RouteDecorator, + RouteHandler, + RouteSelector, + TextSelector, ) 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, + _channel_selector, + _event_selector, + _invoke_event_selector, ) logger = logging.getLogger(__name__) @@ -426,9 +428,113 @@ def __call(func: RouteHandler[StateT]) -> RouteHandler[StateT]: return __call + def installation_update( + self, + *, + channel_id: str | ChannelId | None = None, + auth_handlers: list[str] | None = None, + **kwargs, + ) -> _RouteDecorator[RouteHandler[StateT]]: + """ + Register a handler for installation update activities as either a decorator or a method. + + :param channel_id: Optional channel ID to match the incoming installation update. + :type channel_id: str | ChannelId | None + :param auth_handlers: Optional list of authorization handler IDs for the route. + :type auth_handlers: list[str] | None + :param kwargs: Additional route configuration passed to :meth:`microsoft_agents.hosting.core.AgentApplication.add_route`. + """ + return self.activity( + ActivityTypes.installation_update, + channel_id=channel_id, + auth_handlers=auth_handlers, + **kwargs, + ) + + def event( + self, + select: TextSelector | None = None, + *, + channel_id: str | ChannelId | None = None, + auth_handlers: list[str] | None = None, + **kwargs, + ) -> _RouteDecorator[RouteHandler[StateT]]: + """ + Register a handler for event activities as either a decorator or a method. + + :param select: Literal text, compiled regex, or list of either used to match the incoming event name. When ``None``, every event matches. + :type select: str | re.Pattern[str] | list[str | re.Pattern[str]] | None + :param channel_id: Optional channel ID to match the incoming event. + :type channel_id: str | ChannelId | None + :param auth_handlers: Optional list of authorization handler IDs for the route. + :type auth_handlers: list[str] | None + :param kwargs: Additional route configuration passed to :meth:`microsoft_agents.hosting.core.AgentApplication.add_route`. + """ + + if select is None and "rank" not in kwargs: + kwargs["rank"] = RouteRank.LAST + + selector = _all_selector( + _event_selector(select), + _channel_selector(channel_id), + ) + + def __call(func: RouteHandler[StateT]) -> RouteHandler[StateT]: + logger.debug( + f"Registering event handler for route handler {func.__name__} with select: {select} with auth handlers: {auth_handlers}" + ) + self.add_route(selector, func, auth_handlers=auth_handlers, **kwargs) + return func + + return __call + + def invoke( + self, + select: TextSelector | None = None, + *, + channel_id: str | ChannelId | None = None, + auth_handlers: list[str] | None = None, + **kwargs, + ) -> _RouteDecorator[RouteHandler[StateT]]: + """ + Register a handler for invoke activities as either a decorator or a method. + + :param select: Literal text, compiled regex, or list of either used to match the incoming invoke event name. When ``None``, every invoke event matches. + :type select: str | re.Pattern[str] | list[str | re.Pattern[str]] | None + :param channel_id: Optional channel ID to match the incoming invoke event. + :type channel_id: str | ChannelId | None + :param auth_handlers: Optional list of authorization handler IDs for the route. + :type auth_handlers: list[str] | None + :param kwargs: Additional route configuration passed to :meth:`microsoft_agents.hosting.core.AgentApplication.add_route`. + """ + + if "is_invoke" in kwargs: + if kwargs["is_invoke"] is False: + raise ValueError("Cannot set is_invoke to False for an invoke handler.") + del kwargs["is_invoke"] + + if select is None and "rank" not in kwargs: + kwargs["rank"] = RouteRank.LAST + + selector = _all_selector( + _invoke_event_selector(select), + _channel_selector(channel_id), + ) + + def __call(func: RouteHandler[StateT]) -> RouteHandler[StateT]: + logger.debug( + f"Registering invoke handler for route handler {func.__name__} with select: {select} with auth handlers: {auth_handlers}" + ) + self.add_route( + selector, func, auth_handlers=auth_handlers, is_invoke=True, **kwargs + ) + return func + + return __call + def message( self, - select: str | re.Pattern[str] | list[str | re.Pattern[str]], + select: TextSelector | None = None, *, channel_id: str | ChannelId | None = None, auth_handlers: list[str] | None = None, @@ -445,14 +551,17 @@ async def on_hi_message(context: TurnContext, state: TurnState): print("hello!") return True - :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 select: Literal text, compiled regex, or list of either used to match the incoming message. When ``None``, every message matches. + :type select: str | re.Pattern[str] | list[str | re.Pattern[str]] | None :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: list[str] | None :param kwargs: Additional route configuration passed to :meth:`microsoft_agents.hosting.core.AgentApplication.add_route`. """ + if select is None and "rank" not in kwargs: + kwargs["rank"] = RouteRank.LAST + selector = _SimpleRouteSelector( channel_id=channel_id, activity_type=ActivityTypes.message, @@ -532,6 +641,75 @@ def __call(func: RouteHandler[StateT]) -> RouteHandler[StateT]: return __call + def members_added( + self, + *, + channel_id: str | ChannelId | None = None, + auth_handlers: list[str] | None = None, + **kwargs, + ) -> _RouteDecorator[RouteHandler[StateT]]: + """ + Register a handler for members added conversation update activities as either a decorator or a method. + + :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: list[str] | None + :param kwargs: Additional route configuration passed to :meth:`microsoft_agents.hosting.core.AgentApplication.add_route`. + """ + return self.conversation_update( + type=ConversationUpdateTypes.MEMBERS_ADDED, + channel_id=channel_id, + auth_handlers=auth_handlers, + **kwargs, + ) + + def members_removed( + self, + *, + channel_id: str | ChannelId | None = None, + auth_handlers: list[str] | None = None, + **kwargs, + ) -> _RouteDecorator[RouteHandler[StateT]]: + """ + Register a handler for members removed conversation update activities as either a decorator or a method. + + :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: list[str] | None + :param kwargs: Additional route configuration passed to :meth:`microsoft_agents.hosting.core.AgentApplication.add_route`. + """ + return self.conversation_update( + type=ConversationUpdateTypes.MEMBERS_REMOVED, + channel_id=channel_id, + auth_handlers=auth_handlers, + **kwargs, + ) + + def end_of_conversation( + self, + *, + channel_id: str | ChannelId | None = None, + auth_handlers: list[str] | None = None, + **kwargs, + ) -> _RouteDecorator[RouteHandler[StateT]]: + """ + Register a handler for end of conversation activities as either a decorator or a method. + + :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: list[str] | None + :param kwargs: Additional route configuration passed to :meth:`microsoft_agents.hosting.core.AgentApplication.add_route`. + """ + return self.activity( + activity_type=ActivityTypes.end_of_conversation, + channel_id=channel_id, + auth_handlers=auth_handlers, + **kwargs, + ) + def message_reaction( self, type: MessageReactionTypes | str, @@ -592,6 +770,50 @@ def __call(func: RouteHandler[StateT]) -> RouteHandler[StateT]: return __call + def message_reaction_added( + self, + channel_id: str | ChannelId | None = None, + auth_handlers: list[str] | None = None, + **kwargs, + ) -> _RouteDecorator[RouteHandler[StateT]]: + """ + Register a handler specifically for the "reactionsAdded" message reaction as either a decorator or a method. + + :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: list[str] | None + :param kwargs: Additional route configuration passed to :meth:`microsoft_agents.hosting.core.AgentApplication.add_route`. + """ + return self.message_reaction( + "reactionsAdded", + channel_id=channel_id, + auth_handlers=auth_handlers, + **kwargs, + ) + + def message_reactions_removed( + self, + channel_id: str | ChannelId | None = None, + auth_handlers: list[str] | None = None, + **kwargs, + ) -> _RouteDecorator[RouteHandler[StateT]]: + """ + Register a handler specifically for the "reactionsRemoved" message reaction as either a decorator or a method. + + :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: list[str] | None + :param kwargs: Additional route configuration passed to :meth:`microsoft_agents.hosting.core.AgentApplication.add_route`. + """ + return self.message_reaction( + "reactionsRemoved", + channel_id=channel_id, + auth_handlers=auth_handlers, + **kwargs, + ) + def message_update( self, type: MessageUpdateTypes | str, @@ -711,6 +933,10 @@ async def on_handoff(context: TurnContext, state: TurnState, continuation: str): :param kwargs: Additional route configuration passed to :meth:`microsoft_agents.hosting.core.AgentApplication.add_route`. """ + if kwargs.get("is_invoke") is False: + raise ValueError("Cannot set is_invoke to False for an invoke handler.") + kwargs["is_invoke"] = True + selector = _all_selector( _invoke_event_selector("handoff/action"), _channel_selector(channel_id), 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 0f17366b0..beebfbc68 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 @@ -231,21 +231,6 @@ def add(self, agent_state: AgentState) -> "TurnState": self._scopes[agent_state.__class__.__name__] = agent_state return self - async def load(self, turn_context: TurnContext, force: bool = False) -> None: - """ - Loads all agent state records in parallel. - - Args: - turn_context: The turn context. - force: Whether data should be forced into cache. - """ - tasks = [ - bs.load(turn_context, force) - for bs in self._scopes.values() - if hasattr(bs, "load") - ] - await asyncio.gather(*tasks) - def clear(self, turn_context: TurnContext, scope: str = None) -> None: """ Clears a state scope. 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 559fc9ed1..7ad931048 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 @@ -14,7 +14,6 @@ from typing import ( Callable, Generic, - Optional, Pattern, Protocol, ) @@ -34,9 +33,10 @@ TurnContext, ) from microsoft_agents.hosting.core.app._type_defs import ( - RouteHandler, HandoffHandler, + RouteHandler, StateT, + TextSelector, ) from .channel import Channel @@ -204,7 +204,7 @@ def activity( self, activity_type: str | ActivityTypes | list[str | ActivityTypes], *, - auth_handlers: Optional[list[str]] = None, + auth_handlers: list[str] | None = None, **kwargs, ) -> _AppRouteDecorator[StateT]: """Register a handler for one or more activity types. @@ -226,7 +226,7 @@ def message( self, select: str | Pattern[str] | list[str | Pattern[str]], *, - auth_handlers: Optional[list[str]] = None, + auth_handlers: list[str] | None = None, **kwargs, ) -> _AppRouteDecorator[StateT]: """Register a handler for message activities matching *select*. @@ -245,11 +245,34 @@ def message( ) ) + def event( + self, + select: TextSelector | None = None, + *, + auth_handlers: list[str] | None = None, + **kwargs, + ) -> _AppRouteDecorator[StateT]: + """Register a handler for event activities matching *select*. + + :param select: A literal string, regex pattern, or list of either to match against + the event name. + :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.event( + select, + auth_handlers=auth_handlers, + channel_id=Channels.msteams, + **kwargs, + ) + ) + def conversation_update( self, type: ConversationUpdateTypes, *, - auth_handlers: Optional[list[str]] = None, + auth_handlers: list[str] | None = None, **kwargs, ) -> _AppRouteDecorator[StateT]: """Register a handler for a specific conversation update event type. @@ -268,7 +291,7 @@ def message_reaction( self, type: MessageReactionTypes, *, - auth_handlers: Optional[list[str]] = None, + auth_handlers: list[str] | None = None, **kwargs, ) -> _AppRouteDecorator[StateT]: """Register a handler for a specific message reaction type. @@ -287,7 +310,7 @@ def message_update( self, type: MessageUpdateTypes, *, - auth_handlers: Optional[list[str]] = None, + auth_handlers: list[str] | None = None, **kwargs, ) -> _AppRouteDecorator[StateT]: """Register a handler for a specific message update event type. @@ -302,10 +325,44 @@ def message_update( ) ) + def installation_update( + self, + *, + auth_handlers: list[str] | None = None, + **kwargs, + ) -> _AppRouteDecorator[StateT]: + """Register a handler for installation update activities. + + :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.installation_update( + auth_handlers=auth_handlers, channel_id=Channels.msteams, **kwargs + ) + ) + + def end_of_conversation( + self, + *, + auth_handlers: list[str] | None = None, + **kwargs, + ) -> _AppRouteDecorator[StateT]: + """Register a handler for end of conversation activities. + + :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.end_of_conversation( + auth_handlers=auth_handlers, channel_id=Channels.msteams, **kwargs + ) + ) + def handoff( self, *, - auth_handlers: Optional[list[str]] = None, + auth_handlers: list[str] | None = None, **kwargs, ) -> Callable[[TeamsHandoffHandler[StateT]], HandoffHandler[StateT]]: """Register a Teams handoff handler. 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 aa6542f09..bb92d7863 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 @@ -5,15 +5,22 @@ from __future__ import annotations -import re -from typing import Any, Generic, Pattern, TypeVar +import warnings +from typing import Any, Callable, Generic, TypeVar from typing_extensions import deprecated -from microsoft_agents.activity import ActivityTypes, Channels +from microsoft_agents.activity import ( + Channels, + ConversationUpdateTypes, +) from microsoft_agents.hosting.core import TurnContext -from microsoft_agents.hosting.core.app import AgentApplication, RouteRank -from microsoft_agents.hosting.core.app._type_defs import _RouteDecorator +from microsoft_agents.hosting.core.app import AgentApplication +from microsoft_agents.hosting.core.app._type_defs import ( + RouteHandler, + _RouteDecorator, + TextSelector, +) from microsoft_agents.hosting.core.app.state import TurnState from .api import ( @@ -26,18 +33,6 @@ StateT = TypeVar("StateT", bound=TurnState) -TextSelector = str | Pattern[str] | None - - -def _matches_text(selector: TextSelector, text: str | None) -> bool: - if selector is None: - return True - if text is None: - return False - if isinstance(selector, Pattern): - return re.fullmatch(selector, text) is not None - return text == selector - def _is_slack_channel(context: TurnContext) -> bool: return context.activity.channel_id == Channels.slack.value @@ -48,8 +43,11 @@ class SlackAgentExtension(Generic[StateT]): Slack-specific route registration and helpers for an :class:`~microsoft_agents.hosting.core.app.AgentApplication`. - Handlers registered with :meth:`message` or :meth:`event` receive a - :class:`SlackTurnContext` with a typed :class:`SlackActivity`. + Handlers registered with :meth:`message`, :meth:`event`, + :meth:`conversation_update`, :meth:`members_added`, + :meth:`members_removed`, or :meth:`installation_update` match only Slack + activities and receive a :class:`SlackTurnContext` with a typed + :class:`SlackActivity`. Pass ``slack_api=...`` to configure the extension's default API client. Before each Slack turn, this client is registered in turn services only if no :class:`SlackApi` is already present. Clients supplied by middleware @@ -182,116 +180,189 @@ async def create_stream( def message( self, - select: TextSelector = None, + select: TextSelector | None = None, *, auth_handlers: list[str] | None = None, - rank: RouteRank = RouteRank.DEFAULT, + **kwargs, ) -> _RouteDecorator[SlackRouteHandler[StateT]]: """Register a handler for Slack message activities. - When ``select`` is ``None``, every Slack message matches; otherwise the - activity's ``text`` must equal ``select`` (case-sensitive string) or - fully match it (compiled regex). Non-Slack activities never match. - The handler receives a :class:`SlackTurnContext` and the turn state. - - ``rank`` defaults to :attr:`RouteRank.DEFAULT`; when ``select`` is - ``None`` the rank is downgraded to :attr:`RouteRank.LAST` so explicit - text routes win — matching the C# behavior. - - ``auth_handlers`` selects the authentication handlers for the route. + :param select: Optional text selector to match the incoming message text. + :type select: TextSelector | None + :param auth_handlers: Optional list of authorization handler IDs for the route. + :type auth_handlers: list[str] | None + :param kwargs: Additional route configuration passed to :meth:`microsoft_agents.hosting.core.AgentApplication.add_route`. """ - effective_rank = ( - RouteRank.LAST if (select is None and rank == RouteRank.DEFAULT) else rank - ) - - def __selector(context: TurnContext) -> bool: - if context.activity.type != ActivityTypes.message or not _is_slack_channel( - context - ): - return False - return _matches_text(select, context.activity.text) - - def __call( - func: SlackRouteHandler[StateT], - ) -> SlackRouteHandler[StateT]: - self._app.add_route( - __selector, - wrap_slack_route_handler(func, self._app), - rank=effective_rank, - auth_handlers=auth_handlers, + return self._wrap_decorator( + self._app.message( + select, auth_handlers=auth_handlers, channel_id=Channels.slack, **kwargs ) - return func - - return __call + ) @deprecated( "SlackAgentExtension.on_message is deprecated; use SlackAgentExtension.message instead." ) def on_message( self, - select: TextSelector = None, + select: TextSelector | None = None, *, auth_handlers: list[str] | None = None, - rank: RouteRank = RouteRank.DEFAULT, + **kwargs, ) -> _RouteDecorator[SlackRouteHandler[StateT]]: """Deprecated alias for :meth:`message`; emits ``DeprecationWarning``.""" - return self.message(select, auth_handlers=auth_handlers, rank=rank) + return self.message(select, auth_handlers=auth_handlers, **kwargs) # ── event routes ─────────────────────────────────────────────────────── def event( self, - event_name: TextSelector = None, + select: TextSelector | None = None, *, + event_name: str | None = None, auth_handlers: list[str] | None = None, - rank: RouteRank = RouteRank.DEFAULT, + **kwargs, ) -> _RouteDecorator[SlackRouteHandler[StateT]]: """Register a handler for Slack event activities. - When ``event_name`` is ``None``, every Slack event matches; otherwise - the activity's ``name`` must equal it (case-sensitive string) or fully - match it (compiled regex). Non-Slack activities never match. - The handler receives a :class:`SlackTurnContext` and the turn state. - - Omitting ``event_name`` with the default rank uses - :attr:`RouteRank.LAST` so specific event routes win. An explicit rank - is preserved. ``auth_handlers`` selects authentication handlers. + :param select: Optional text selector to match the incoming event name. + :type select: TextSelector | None + :param event_name: (Deprecated) Optional specific event name to match. + :type event_name: str | None + :param auth_handlers: Optional list of authorization handler IDs for the route. + :type auth_handlers: list[str] | None + :param kwargs: Additional route configuration passed to :meth:`microsoft_agents.hosting.core.AgentApplication.add_route`. """ - effective_rank = ( - RouteRank.LAST - if (event_name is None and rank == RouteRank.DEFAULT) - else rank - ) - - def __selector(context: TurnContext) -> bool: - if context.activity.type != ActivityTypes.event or not _is_slack_channel( - context - ): - return False - return _matches_text(event_name, context.activity.name) - - def __call( - func: SlackRouteHandler[StateT], - ) -> SlackRouteHandler[StateT]: - self._app.add_route( - __selector, - wrap_slack_route_handler(func, self._app), - rank=effective_rank, - auth_handlers=auth_handlers, + if event_name is not None: + warnings.warn( + "The `event_name` parameter is deprecated; use `select` instead.", + DeprecationWarning, + stacklevel=2, ) - return func - - return __call + return self._wrap_decorator( + self._app.event( + select, auth_handlers=auth_handlers, channel_id=Channels.slack, **kwargs + ) + ) @deprecated( "SlackAgentExtension.on_event is deprecated; use SlackAgentExtension.event instead." ) def on_event( self, - event_name: TextSelector = None, + event_name: TextSelector | None = None, *, auth_handlers: list[str] | None = None, - rank: RouteRank = RouteRank.DEFAULT, + **kwargs, ) -> _RouteDecorator[SlackRouteHandler[StateT]]: """Deprecated alias for :meth:`event`; emits ``DeprecationWarning``.""" - return self.event(event_name, auth_handlers=auth_handlers, rank=rank) + return self.event(event_name, auth_handlers=auth_handlers, **kwargs) + + # ── AgentApplication route hooks ─────────────────────────────────────── + + def _wrap_decorator( + self, decorator: Callable[[RouteHandler[StateT]], RouteHandler[StateT]] + ) -> _RouteDecorator[SlackRouteHandler[StateT]]: + """Wrap a core route decorator so it accepts a :class:`SlackRouteHandler`. + + The handler is converted via :func:`wrap_slack_route_handler` before being + passed to *decorator*, and the original handler is returned. + + :param decorator: A core route decorator from :class:`AgentApplication`. + :return: A decorator that registers a :class:`SlackRouteHandler`. + """ + + def __call(func: SlackRouteHandler[StateT]) -> SlackRouteHandler[StateT]: + decorator(wrap_slack_route_handler(func, self._app)) + return func + + return __call + + def conversation_update( + self, + type: ConversationUpdateTypes | str, + *, + auth_handlers: list[str] | None = None, + **kwargs: Any, + ) -> _RouteDecorator[SlackRouteHandler[StateT]]: + """Register a handler for a Slack conversation update event type. + + Non-Slack activities never match. The handler receives a + :class:`SlackTurnContext` and the turn state. + + :param type: The :class:`ConversationUpdateTypes` value to match (e.g. ``membersAdded``). + :param auth_handlers: Optional list of auth handler names to run before the route. + :param kwargs: Additional route configuration, such as ``rank``, passed to + :meth:`AgentApplication.add_route`. + :return: A decorator that registers the handler and returns it. + """ + return self._wrap_decorator( + self._app.conversation_update( + type, auth_handlers=auth_handlers, channel_id=Channels.slack, **kwargs + ) + ) + + def members_added( + self, + *, + auth_handlers: list[str] | None = None, + **kwargs: Any, + ) -> _RouteDecorator[SlackRouteHandler[StateT]]: + """Register a handler for the Slack ``membersAdded`` event. + + Non-Slack activities never match. The handler receives a + :class:`SlackTurnContext` and the turn state. + + :param auth_handlers: Optional list of auth handler names to run before the route. + :param kwargs: Additional route configuration, such as ``rank``, passed to + :meth:`AgentApplication.add_route`. + :return: A decorator that registers the handler and returns it. + """ + return self._wrap_decorator( + self._app.members_added( + auth_handlers=auth_handlers, channel_id=Channels.slack, **kwargs + ) + ) + + def members_removed( + self, + *, + auth_handlers: list[str] | None = None, + **kwargs: Any, + ) -> _RouteDecorator[SlackRouteHandler[StateT]]: + """Register a handler for the Slack ``membersRemoved`` event. + + Non-Slack activities never match. The handler receives a + :class:`SlackTurnContext` and the turn state. + + :param auth_handlers: Optional list of auth handler names to run before the route. + :param kwargs: Additional route configuration, such as ``rank``, passed to + :meth:`AgentApplication.add_route`. + :return: A decorator that registers the handler and returns it. + """ + return self._wrap_decorator( + self._app.members_removed( + auth_handlers=auth_handlers, channel_id=Channels.slack, **kwargs + ) + ) + + def installation_update( + self, + *, + auth_handlers: list[str] | None = None, + **kwargs: Any, + ) -> _RouteDecorator[SlackRouteHandler[StateT]]: + """Register a handler for the Slack ``installationUpdate`` event. + + Non-Slack activities never match. The handler receives a + :class:`SlackTurnContext` and the turn state. + + :param auth_handlers: Optional list of auth handler names to run before the route. + :param kwargs: Additional route configuration, such as ``rank``, passed to + :meth:`AgentApplication.add_route`. + :return: A decorator that registers the handler and returns it. + """ + return self._wrap_decorator( + self._app.installation_update( + auth_handlers=auth_handlers, channel_id=Channels.slack, **kwargs + ) + ) diff --git a/libraries/microsoft-agents-hosting-slack/readme.md b/libraries/microsoft-agents-hosting-slack/readme.md index 386d387e7..7a723d6ea 100644 --- a/libraries/microsoft-agents-hosting-slack/readme.md +++ b/libraries/microsoft-agents-hosting-slack/readme.md @@ -56,8 +56,14 @@ async def on_slack_message(context: SlackTurnContext, state: TurnState) -> None: ``` Use `@slack.message("hello")` for a specific message or `@slack.event("reaction_added")` -for a specific event. Both accept compiled regular expressions and match only Slack -activities. `on_message()` and `on_event()` are deprecated aliases. +for a specific event. Both accept a string, a compiled regular expression, or a list of +either, and match only Slack activities. When called without a selector, they match every +Slack message or event and register at `RouteRank.LAST`, so more specific routes win. +`on_message()` and `on_event()` are deprecated aliases. + +`@slack.conversation_update(...)`, `@slack.members_added()`, `@slack.members_removed()`, +and `@slack.installation_update()` mirror the corresponding `AgentApplication` route +hooks, but match only Slack activities and pass a `SlackTurnContext` to the handler. Handlers receive a `SlackTurnContext` with typed `activity.slack_channel_data` and access to the per-turn Slack API client through `context.client`. Envelope, payload, diff --git a/test_samples/extensions/extension-starter/src/extension/extension.py b/test_samples/extensions/extension-starter/src/extension/extension.py index 98d7c61da..83fc6b972 100644 --- a/test_samples/extensions/extension-starter/src/extension/extension.py +++ b/test_samples/extensions/extension-starter/src/extension/extension.py @@ -1,4 +1,5 @@ import logging +import re from typing import Callable, Generic, TypeVar from microsoft_agents.activity import Activity, ActivityTypes, InvokeResponse @@ -35,48 +36,37 @@ def __init__(self, app: AgentApplication[StateT]): """ self.app = app - # Allowing event decorators to accept **kwargs and passing - # **kwargs to app.add_route is recommended. + # Forward route options to the application's decorators. def _on_message_has_hello_event( self, **kwargs, ) -> Callable[[CustomRouteHandler[StateT]], RouteHandler[StateT]]: """Decorator for message activities that contain the word 'hello'. - This demonstrates a custom event selector and handler that extracts - additional data from the activity and passes it to the handler. + This uses the application's regex message selector and wraps the handler + to extract additional data from the activity. - :param kwargs: Additional keyword arguments to pass to app.add_route. + :param kwargs: Additional keyword arguments to pass to app.message. :return: A decorator that registers the route handler. Usage: @extension_agent.on_message_has_hello_event() async def handle_hello_event(context: TurnContext, state: StateT, event_data: CustomEventData): - await context.send_activity(f"Hello! You said: {event_data.text}") + await context.send_activity(f"Hello! User ID: {event_data.user_id}") """ - # the function the AgentApplication uses to determine if this route should be called - def route_selector(context: TurnContext) -> bool: - return ( - context.activity.type == ActivityTypes.message - and "hello" in context.activity.text.lower() - ) + def __call(func: CustomRouteHandler[StateT]) -> RouteHandler[StateT]: + logger.debug("Registering route for message has hello event") - # the function that wraps the user's handler to extract custom data - def create_handler(handler: CustomRouteHandler[StateT]) -> RouteHandler[StateT]: + @self.app.message( + re.compile(r".*hello.*", re.IGNORECASE | re.DOTALL), **kwargs + ) async def route_handler(context: TurnContext, state: StateT): custom_event_data = CustomEventData.from_context(context) - await handler(context, state, custom_event_data) + await func(context, state, custom_event_data) return route_handler - # the decorator that registers the route handler with the app - def __call(func: CustomRouteHandler[StateT]) -> RouteHandler[StateT]: - logger.debug("Registering route for message has hello event") - handler = create_handler(func) - self.app.add_route(route_selector, handler, **kwargs) - return handler - return __call def on_message_has_hello_event( @@ -99,33 +89,27 @@ def on_invoke_custom_event( ) -> Callable[[CustomRouteHandler[StateT]], RouteHandler[StateT]]: """Decorator for invoke activities with a specific custom event type. - This demonstrates a custom event selector and handler that extracts - additional data from the activity and passes it to the handler. + This uses the application's channel-scoped invoke selector and wraps + the handler to extract custom data and send an invoke response. :param custom_event_type: The custom event type to listen for. - :param kwargs: Additional keyword arguments to pass to app.add_route. + :param kwargs: Additional keyword arguments to pass to app.invoke. :return: A decorator that registers the route handler. Usage: @extension_agent.on_invoke_custom_event("my_custom_event") async def handle_custom_event(context: TurnContext, state: StateT, event_data: CustomEventData): - await context.send_activity(f"Received custom event with data: {event_data.data}") - return CustomEventResult(success=True) + return CustomEventResult(user_id=event_data.user_id, field=event_data.field) """ - # the function the AgentApplication uses to determine if this route should be called - def route_selector(context: TurnContext) -> bool: - return ( - context.activity.type == ActivityTypes.invoke - and context.activity.channel_id == MY_CHANNEL - and context.activity.name == f"invoke/{custom_event_type}" - ) + def __call(func: CustomRouteHandler[StateT]) -> RouteHandler[StateT]: + logger.debug("Registering route for custom event") - # the function that wraps the user's handler to extract custom data - # it also sends back an invoke response with the handler's result - def create_handler(handler: CustomRouteHandler) -> RouteHandler[StateT]: + @self.app.invoke( + f"invoke/{custom_event_type}", channel_id=MY_CHANNEL, **kwargs + ) async def route_handler(context: TurnContext, state: StateT): custom_event_data = CustomEventData.from_context(context) - result = await handler(context, state, custom_event_data) + result = await func(context, state, custom_event_data) if not result: result = CustomEventResult() @@ -141,11 +125,4 @@ async def route_handler(context: TurnContext, state: StateT): return route_handler - # the decorator that registers the route handler with the app - def __call(func: CustomRouteHandler[StateT]) -> RouteHandler[StateT]: - logger.debug("Registering route for custom event") - handler = create_handler(func) - self.app.add_route(route_selector, handler, is_invoke=True, **kwargs) - return handler - return __call diff --git a/test_samples/extensions/extension-starter/src/sample/app.py b/test_samples/extensions/extension-starter/src/sample/app.py index 0ad4b8fc4..62bc02d44 100644 --- a/test_samples/extensions/extension-starter/src/sample/app.py +++ b/test_samples/extensions/extension-starter/src/sample/app.py @@ -11,7 +11,6 @@ MemoryStorage, AgentApplication, TurnState, - MemoryStorage, RouteRank, TurnContext, ) diff --git a/tests/activity/test_channel_id.py b/tests/activity/test_channel_id.py index 78adb52a9..04747699d 100644 --- a/tests/activity/test_channel_id.py +++ b/tests/activity/test_channel_id.py @@ -75,3 +75,72 @@ def test_init_errors(self): ChannelId("", channel=" ", sub_channel="support") with pytest.raises(Exception): ChannelId(channel="a:b", sub_channel="support") + + +class TestChannelIdIncludes: + @pytest.mark.parametrize( + ("filter_id", "candidate", "expected"), + [ + # Exact channel + ("msteams", "msteams", True), + ("msteams", "webchat", False), + # Parent filter includes every sub-channel + ("msteams", "msteams:copilot", True), + ("msteams", "msteams:meeting", True), + # Specific sub-channel filter is exact + ("msteams:copilot", "msteams:copilot", True), + ("msteams:copilot", "msteams:meeting", False), + ("msteams:copilot", "msteams", False), + ("msteams:copilot", "slack:copilot", False), + # Wildcard sub-channel behaves like the parent filter + ("msteams:*", "msteams", True), + ("msteams:*", "msteams:copilot", True), + ("msteams:*", "slack:copilot", False), + # Wildcard channel + ("*", "msteams", True), + ("*", "msteams:copilot", True), + ("*:copilot", "slack:copilot", True), + ("*:copilot", "msteams:copilot", True), + ("*:copilot", "msteams:meeting", False), + ("*:copilot", "msteams", False), + ("*:*", "slack:anything", True), + # Case-sensitive + ("msteams", "MSTeams", False), + ("msteams:copilot", "msteams:COPILOT", False), + # Whitespace is normalized by ChannelId parsing + ("msteams", " msteams:copilot ", True), + ], + ) + def test_includes(self, filter_id, candidate, expected): + assert ChannelId(filter_id).includes(candidate) is expected + + @pytest.mark.parametrize( + ("filter_id", "candidate", "expected"), + [ + ("msteams", ChannelId("msteams:copilot"), True), + ("msteams:copilot", ChannelId(channel="msteams"), False), + ("msteams", ChannelId(channel="msteams", sub_channel="copilot"), True), + ], + ) + def test_includes_accepts_channel_id_candidate( + self, filter_id, candidate, expected + ): + assert ChannelId(filter_id).includes(candidate) is expected + + @pytest.mark.parametrize("candidate", [None, "", " ", ":copilot"]) + def test_includes_rejects_missing_or_invalid_candidate(self, candidate): + assert ChannelId("msteams").includes(candidate) is False + assert ChannelId("*").includes(candidate) is False + + def test_includes_is_directional(self): + parent = ChannelId("msteams") + child = ChannelId("msteams:copilot") + + assert parent.includes(child) + assert not child.includes(parent) + + def test_includes_does_not_change_equality(self): + parent = ChannelId("msteams") + + assert parent.includes("msteams:copilot") + assert parent != "msteams:copilot" diff --git a/tests/hosting_core/app/test_agent_application_routes.py b/tests/hosting_core/app/test_agent_application_routes.py index 9edb606db..5ba0720f0 100644 --- a/tests/hosting_core/app/test_agent_application_routes.py +++ b/tests/hosting_core/app/test_agent_application_routes.py @@ -12,6 +12,7 @@ from microsoft_agents.hosting.core.app import ( AgentApplication, ApplicationOptions, + RouteRank, TurnState, ) from microsoft_agents.hosting.core.app.oauth import Authorization @@ -181,6 +182,31 @@ async def handler(context: TurnContext, state: TurnState): ) assert not self.called + @pytest.mark.asyncio + @pytest.mark.parametrize("text", ["anything", None]) + async def test_none_select_matches_any_message(self, text): + @self.app.message(None) + async def handler(context: TurnContext, state: TurnState): + self.called = True + + await self.app._on_activity( + _make_context(_make_activity(type=ActivityTypes.message, text=text)), + TurnState(), + ) + assert self.called + + @pytest.mark.asyncio + async def test_none_select_ignores_non_message_activity(self): + @self.app.message(None) + async def handler(context: TurnContext, state: TurnState): + self.called = True + + await self.app._on_activity( + _make_context(_make_activity(type="event", text="hello")), + TurnState(), + ) + assert not self.called + @pytest.mark.asyncio async def test_pattern_match(self): @self.app.message(re.compile(r"hello.*")) @@ -352,6 +378,7 @@ async def handler(context, state, continuation): result = self.app.handoff()(handler) assert result is handler + _only_route(self.app) def test_direct_style_returns_original_handler(self): async def handler(context, state, continuation): @@ -359,6 +386,7 @@ async def handler(context, state, continuation): result = self.app.handoff(handler) assert result is handler + _only_route(self.app) class TestRouteOrdering: @@ -561,3 +589,265 @@ async def handler(context, state, continuation): TurnState(), ) assert self.called is expected + + +def _only_route(app: AgentApplication): + routes = list(app._route_list) + assert len(routes) == 1 + return routes[0] + + +async def _noop(context: TurnContext, state: TurnState): + pass + + +class TestEventRoute: + def setup_method(self): + self.app = _make_app() + + @pytest.mark.parametrize( + ("select", "name", "expected"), + [ + ("ping", "ping", True), + ("ping", "pong", False), + (re.compile(r"app_.*"), "app_mention", True), + (re.compile(r"app_.*"), "message", False), + (["ping", re.compile(r"app_.*")], "app_mention", True), + (["ping", re.compile(r"app_.*")], "pong", False), + (None, "anything", True), + ], + ) + def test_selector_matches_event_name(self, select, name, expected): + self.app.event(select)(_noop) + + selector = _only_route(self.app).selector + assert ( + selector(_make_context(_make_activity(type="event", name=name))) is expected + ) + + def test_selector_ignores_non_event_activity(self): + self.app.event("ping")(_noop) + + selector = _only_route(self.app).selector + assert not selector( + _make_context(_make_activity(type=ActivityTypes.message, name="ping")) + ) + + @pytest.mark.parametrize( + "activity_type", [ActivityTypes.message, ActivityTypes.invoke] + ) + def test_none_select_ignores_non_event_activity(self, activity_type): + self.app.event()(_noop) + + selector = _only_route(self.app).selector + assert not selector( + _make_context(_make_activity(type=activity_type, name="ping")) + ) + + @pytest.mark.parametrize( + ("select", "expected_rank"), + [(None, RouteRank.LAST), ("ping", RouteRank.DEFAULT)], + ) + def test_default_rank(self, select, expected_rank): + self.app.event(select)(_noop) + + assert _only_route(self.app).rank == expected_rank + + def test_explicit_rank_is_preserved_without_select(self): + self.app.event(rank=RouteRank.FIRST)(_noop) + + assert _only_route(self.app).rank == RouteRank.FIRST + + @pytest.mark.asyncio + async def test_dispatches_matching_event(self): + called = [] + + @self.app.event("ping") + async def handler(context: TurnContext, state: TurnState): + called.append(context.activity.name) + + await self.app._on_activity( + _make_context(_make_activity(type="event", name="ping")), TurnState() + ) + assert called == ["ping"] + + +class TestInvokeRoute: + def setup_method(self): + self.app = _make_app() + + @pytest.mark.parametrize( + ("select", "name", "expected"), + [ + ("task/fetch", "task/fetch", True), + ("task/fetch", "task/submit", False), + (re.compile(r"task/.*"), "task/submit", True), + (["a", "task/submit"], "task/submit", True), + (None, "anything", True), + ], + ) + def test_selector_matches_invoke_name(self, select, name, expected): + self.app.invoke(select)(_noop) + + selector = _only_route(self.app).selector + assert ( + selector( + _make_context(_make_activity(type=ActivityTypes.invoke, name=name)) + ) + is expected + ) + + def test_selector_ignores_non_invoke_activity(self): + self.app.invoke("task/fetch")(_noop) + + selector = _only_route(self.app).selector + assert not selector( + _make_context(_make_activity(type="event", name="task/fetch")) + ) + + @pytest.mark.parametrize( + "activity_type", [ActivityTypes.message, ActivityTypes.event] + ) + def test_none_select_ignores_non_invoke_activity(self, activity_type): + self.app.invoke()(_noop) + + selector = _only_route(self.app).selector + assert not selector( + _make_context(_make_activity(type=activity_type, name="task/fetch")) + ) + + @pytest.mark.parametrize( + ("select", "expected_rank"), + [(None, RouteRank.LAST), ("task/fetch", RouteRank.DEFAULT)], + ) + def test_default_rank(self, select, expected_rank): + self.app.invoke(select)(_noop) + + assert _only_route(self.app).rank == expected_rank + + +class TestMessageRouteRank: + @pytest.mark.parametrize( + ("select", "kwargs", "expected_rank"), + [ + (None, {}, RouteRank.LAST), + ("hello", {}, RouteRank.DEFAULT), + (None, {"rank": RouteRank.FIRST}, RouteRank.FIRST), + ], + ) + def test_rank(self, select, kwargs, expected_rank): + app = _make_app() + app.message(select, **kwargs)(_noop) + + assert _only_route(app).rank == expected_rank + + @pytest.mark.asyncio + async def test_catch_all_registered_first_does_not_shadow_specific_route(self): + app = _make_app() + calls = [] + + @app.message() + async def catch_all(context: TurnContext, state: TurnState): + calls.append("catch_all") + + @app.message("hello") + async def specific(context: TurnContext, state: TurnState): + calls.append("specific") + + await app._on_activity( + _make_context(_make_activity(type=ActivityTypes.message, text="hello")), + TurnState(), + ) + assert calls == ["specific"] + + +def _members_added_activity(**kwargs) -> Activity: + return _make_activity( + type=ActivityTypes.conversation_update, members_added=[{"id": "m"}], **kwargs + ) + + +def _members_removed_activity(**kwargs) -> Activity: + return _make_activity( + type=ActivityTypes.conversation_update, members_removed=[{"id": "m"}], **kwargs + ) + + +def _reactions_added_activity(**kwargs) -> Activity: + return _make_activity( + type=ActivityTypes.message_reaction, + reactions_added=[{"type": "like"}], + **kwargs, + ) + + +def _reactions_removed_activity(**kwargs) -> Activity: + return _make_activity( + type=ActivityTypes.message_reaction, + reactions_removed=[{"type": "like"}], + **kwargs, + ) + + +_SHORTCUT_ROUTE_CASES = [ + ("members_added", _members_added_activity, _members_removed_activity), + ("members_removed", _members_removed_activity, _members_added_activity), + ("message_reaction_added", _reactions_added_activity, _reactions_removed_activity), + ( + "message_reactions_removed", + _reactions_removed_activity, + _reactions_added_activity, + ), + ( + "installation_update", + lambda **kw: _make_activity(type=ActivityTypes.installation_update, **kw), + lambda **kw: _make_activity(type=ActivityTypes.message, **kw), + ), + ( + "end_of_conversation", + lambda **kw: _make_activity(type=ActivityTypes.end_of_conversation, **kw), + lambda **kw: _make_activity(type=ActivityTypes.message, **kw), + ), +] + + +class TestShortcutRoutes: + @pytest.mark.parametrize(("route_name", "matching", "other"), _SHORTCUT_ROUTE_CASES) + def test_selector_matches_only_its_activity(self, route_name, matching, other): + app = _make_app() + getattr(app, route_name)()(_noop) + + selector = _only_route(app).selector + assert selector(_make_context(matching())) + assert not selector(_make_context(other())) + + @pytest.mark.parametrize(("route_name", "matching", "other"), _SHORTCUT_ROUTE_CASES) + def test_selector_filters_by_channel_id(self, route_name, matching, other): + app = _make_app() + getattr(app, route_name)(channel_id="msteams")(_noop) + + selector = _only_route(app).selector + assert selector(_make_context(matching(channel_id="msteams:copilot"))) + assert not selector(_make_context(matching(channel_id="webchat"))) + + @pytest.mark.parametrize(("route_name", "matching", "other"), _SHORTCUT_ROUTE_CASES) + def test_forwards_auth_handlers_and_rank(self, route_name, matching, other): + app = _make_app() + getattr(app, route_name)(auth_handlers=["graph"], rank=RouteRank.FIRST)(_noop) + + route = _only_route(app) + assert route.auth_handlers == ["graph"] + assert route.rank == RouteRank.FIRST + + @pytest.mark.asyncio + @pytest.mark.parametrize(("route_name", "matching", "other"), _SHORTCUT_ROUTE_CASES) + async def test_dispatches_matching_activity(self, route_name, matching, other): + app = _make_app() + calls = [] + + @getattr(app, route_name)() + async def handler(context: TurnContext, state: TurnState): + calls.append(context.activity.type) + + await app._on_activity(_make_context(matching()), TurnState()) + assert calls == [matching().type] diff --git a/tests/hosting_msteams/test_routing_integration.py b/tests/hosting_msteams/test_routing_integration.py index e21dffe4c..6f78f0445 100644 --- a/tests/hosting_msteams/test_routing_integration.py +++ b/tests/hosting_msteams/test_routing_integration.py @@ -289,6 +289,8 @@ def setup_method(self): "conversation_update", "message_reaction", "message_update", + "installation_update", + "end_of_conversation", "handoff", ], ) @@ -327,6 +329,12 @@ async def handler(context, state, continuation=None): "type": ActivityTypes.message_update, "channel_data": {"event_type": "editMessage"}, } + elif route_name == "installation_update": + self.ext.installation_update()(handler) + route_values = {"type": ActivityTypes.installation_update} + elif route_name == "end_of_conversation": + self.ext.end_of_conversation()(handler) + route_values = {"type": ActivityTypes.end_of_conversation} else: self.ext.handoff()(handler) route_values = { diff --git a/tests/hosting_slack/test_slack_agent_extension.py b/tests/hosting_slack/test_slack_agent_extension.py index 2b3f57848..fea080a4c 100644 --- a/tests/hosting_slack/test_slack_agent_extension.py +++ b/tests/hosting_slack/test_slack_agent_extension.py @@ -16,6 +16,7 @@ ChannelAccount, ChannelId, ConversationAccount, + ConversationUpdateTypes, DeliveryModes, ) from microsoft_agents.hosting.core import ( @@ -34,9 +35,13 @@ from microsoft_agents.hosting.slack.api import SlackApi, SlackResponse -def _make_app() -> MagicMock: - app = MagicMock(spec=AgentApplication) +def _make_app() -> AgentApplication: + app = AgentApplication( + storage=MemoryStorage(), + connection_manager=MagicMock(spec=Connections), + ) app.routes = [] + original_add_route = app.add_route def _add_route( selector, handler, is_invoke=False, rank=RouteRank.DEFAULT, auth_handlers=None @@ -50,8 +55,15 @@ def _add_route( auth_handlers=auth_handlers, ) ) + original_add_route( + selector, + handler, + is_invoke=is_invoke, + rank=rank, + auth_handlers=auth_handlers, + ) - app.add_route.side_effect = _add_route + app.add_route = _add_route return app @@ -104,6 +116,9 @@ async def handler(context, state): sel = self.app.routes[-1]["selector"] assert sel(_make_context(ActivityTypes.message, text="hello")) + assert sel( + _make_context(ActivityTypes.message, channel_id="slack:sub", text="hello") + ) assert not sel(_make_context(ActivityTypes.message, text="bye")) def test_regex_text_match(self): @@ -246,6 +261,28 @@ def test_alias_forwards_registration_options(self, route_name, select): assert self.app.routes[-1]["auth_handlers"] == auth_handlers assert self.app.routes[-1]["rank"] == RouteRank.FIRST + @pytest.mark.parametrize("route_name", ["message", "event"]) + def test_alias_without_select_defaults_to_last_rank(self, route_name): + register = ( + self.slack.on_message if route_name == "message" else self.slack.on_event + ) + + with pytest.deprecated_call(): + register()(AsyncMock()) + + assert self.app.routes[-1]["rank"] == RouteRank.LAST + + def test_on_event_accepts_event_name_keyword(self): + with pytest.deprecated_call(): + + @self.slack.on_event(event_name="app_mention") + async def handler(context, state): + return True + + sel = self.app.routes[-1]["selector"] + assert sel(_make_context(ActivityTypes.event, name="app_mention")) + assert not sel(_make_context(ActivityTypes.event, name="other")) + class TestCall: @pytest.mark.asyncio @@ -655,3 +692,146 @@ async def test_create_stream_accepts_explicit_timestamp_without_event_timestamp( }, "", ) + + +def _make_activity_context(channel_id: str = "slack", **activity_fields) -> TurnContext: + activity = Activity( + channel_id=channel_id, + service_url="https://example.invalid", + conversation=ConversationAccount(id="conversation"), + from_property=ChannelAccount(id="user"), + recipient=ChannelAccount(id="bot"), + **activity_fields, + ) + return TurnContext(ChannelServiceAdapter(None), activity) + + +_APP_ROUTE_CASES = [ + ( + "conversation_update", + (ConversationUpdateTypes.MEMBERS_ADDED,), + dict( + type=ActivityTypes.conversation_update, + members_added=[ChannelAccount(id="new-user")], + ), + ), + ( + "members_added", + (), + dict( + type=ActivityTypes.conversation_update, + members_added=[ChannelAccount(id="new-user")], + ), + ), + ( + "members_removed", + (), + dict( + type=ActivityTypes.conversation_update, + members_removed=[ChannelAccount(id="old-user")], + ), + ), + ( + "installation_update", + (), + dict(type=ActivityTypes.installation_update), + ), +] + + +class TestAppRouteHooks: + @pytest.mark.parametrize("route_name, route_args, fields", _APP_ROUTE_CASES) + def test_selector_matches_only_slack(self, route_name, route_args, fields): + app = _make_app() + slack = SlackAgentExtension(app) + + async def handler(context, state): + pass + + assert getattr(slack, route_name)(*route_args)(handler) is handler + + selector = app.routes[-1]["selector"] + assert selector(_make_activity_context(**fields)) + assert selector(_make_activity_context(channel_id="slack:sub", **fields)) + assert not selector(_make_activity_context(channel_id="msteams", **fields)) + + @pytest.mark.parametrize("route_name, route_args, fields", _APP_ROUTE_CASES) + def test_selector_rejects_non_matching_activity( + self, route_name, route_args, fields + ): + app = _make_app() + slack = SlackAgentExtension(app) + + @getattr(slack, route_name)(*route_args) + async def handler(context, state): + pass + + selector = app.routes[-1]["selector"] + assert not selector(_make_activity_context(type=ActivityTypes.message)) + + @pytest.mark.parametrize( + "route_name, other_fields", + [ + ( + "members_added", + dict( + type=ActivityTypes.conversation_update, + members_removed=[ChannelAccount(id="old-user")], + ), + ), + ( + "members_removed", + dict( + type=ActivityTypes.conversation_update, + members_added=[ChannelAccount(id="new-user")], + ), + ), + ], + ) + def test_member_routes_reject_opposite_update(self, route_name, other_fields): + app = _make_app() + slack = SlackAgentExtension(app) + + @getattr(slack, route_name)() + async def handler(context, state): + pass + + selector = app.routes[-1]["selector"] + assert not selector(_make_activity_context(**other_fields)) + + @pytest.mark.parametrize("route_name, route_args, fields", _APP_ROUTE_CASES) + def test_registration_forwards_auth_handlers_and_rank( + self, route_name, route_args, fields + ): + app = _make_app() + slack = SlackAgentExtension(app) + + @getattr(slack, route_name)( + *route_args, auth_handlers=["graph"], rank=RouteRank.FIRST + ) + async def handler(context, state): + pass + + route = app.routes[-1] + assert route["auth_handlers"] == ["graph"] + assert route["rank"] == RouteRank.FIRST + + @pytest.mark.asyncio + @pytest.mark.parametrize("route_name, route_args, fields", _APP_ROUTE_CASES) + async def test_handler_receives_slack_context(self, route_name, route_args, fields): + app = _make_app() + slack = SlackAgentExtension(app) + context = _make_activity_context(**fields) + state = TurnState() + + async def handler(slack_context, received_state): + assert isinstance(slack_context, SlackTurnContext) + assert isinstance(slack_context.activity, SlackActivity) + assert received_state is state + + delegate = AsyncMock(side_effect=handler) + getattr(slack, route_name)(*route_args)(delegate) + + await app.routes[-1]["handler"](context, state) + + delegate.assert_awaited_once()